Files
Panama/config/dot/quickshell/modules/settings/DateTimePage.qml
T

439 lines
17 KiB
QML

// Date, Time & Region.
//
// One tab, because they are one question: what does this machine consider
// local. It used to be two -- Date & Time here, Region & Language one tab over
// -- and the second was mostly a button that opened GNOME.
//
// Nothing on this page is stored in Panama's settings file except 24-hour time.
// The timezone and network time belong to timedatectl; the language and the
// per-category formats belong to localectl. A preference here would be a second
// answer to a question the system already answers, and the two would drift the
// moment anything else changed one of them.
//
// Changing any of it needs privilege. Both helpers ask polkit, and a canceled
// dialog surfaces as an error rather than as a value that appears to have been
// accepted.
//
// The preview at the bottom is rendered by Qt from the *chosen* locales rather
// than described in prose, because "1,234,567.89" settles in one glance what a
// sentence about separators does not.
import QtQuick
import Quickshell
import qs.config
import qs.services
import qs.modules.clipboard
SettingsPage {
id: root
objectName: "datetime"
title: "Date, Time & Region"
lede: SystemLocale.pendingRestart
? "Your new language applies to programs started after you sign out and back in."
: root.nowLine
Component.onCompleted: if (SystemLocale.locales.length === 0) SystemLocale.refresh()
readonly property string nowLine:
Qt.formatDateTime(clock.date, Settings.use24Hour ? "HH:mm" : "h:mm AP")
+ " · " + Qt.formatDate(clock.date, "dddd, MMMM d")
+ (DateTime.timezone === "" ? "" : " · " + DateTime.timezone) + "."
// ── The clock ────────────────────────────────────────────────────────────
// What the system itself says the time is, in the shape `timedatectl
// set-time` accepts, so the manual field starts from the truth rather than
// from Qt's idea of it. Falls back to the shell's own clock on a machine
// whose timedatectl phrases TimeUSec differently.
function seedTime(): string {
const match = /(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})/.exec(String(DateTime.localTime ?? ""));
return match
? match[1] + " " + match[2]
: Qt.formatDateTime(clock.date, "yyyy-MM-dd HH:mm");
}
SettingsCard {
title: "Clock"
SettingRow {
label: "Set automatically"
detail: DateTime.ntpEnabled
? (DateTime.ntpSynchronized ? "Synchronized with a time server" : "Waiting to synchronize")
: "The clock is set by hand"
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: DateTime.ntpEnabled
enabled: !DateTime.busy
onToggled: value => DateTime.setNtp(value)
}
}
// Only offered when there is something to do: with a time server in
// charge, setting the clock by hand is refused by timedatectl anyway,
// and a field that always fails is worse than no field.
FieldActionRow {
id: manualClock
visible: !DateTime.ntpEnabled
label: "Set the clock"
detail: "Date and time as YYYY-MM-DD HH:MM"
placeholder: "2026-01-31 09:00"
action: "Set"
fieldWidth: 160
enabled: !DateTime.busy
onSubmitted: value => DateTime.setTime(value)
// Seeded when the row appears rather than bound, so typing is not
// yanked out from under anyone by the next status read.
onVisibleChanged: if (visible && text === "") text = root.seedTime()
}
// Not just the bar's. The same choice reads out in the date menu,
// every notification's timestamp, and the lock screen, so it belongs
// beside the clock the whole machine shares rather than on a page
// about one surface.
ToggleRow { setting: "use24Hour"; divider: false }
}
SettingsCard {
title: "Timezone"
PickerRow {
id: zonePicker
label: "Time zone"
detail: DateTime.timezone === ""
? "Reading the system clock"
: DateTime.cityOf(DateTime.timezone) + " — " + DateTime.regionOf(DateTime.timezone)
value: DateTime.timezone === "" ? "Unknown" : DateTime.cityOf(DateTime.timezone)
divider: false
SearchField {
id: zoneSearch
width: parent.width
placeholder: "Search timezones"
}
Repeater {
model: root.matchingZones
TextRow {
required property var modelData
required property int index
label: DateTime.cityOf(modelData)
detail: DateTime.regionOf(modelData)
value: modelData === DateTime.timezone ? "Current" : ""
controlWidth: 90
divider: index < root.matchingZones.length - 1
activatable: true
onActivated: {
DateTime.setTimezone(modelData);
zonePicker.collapse();
}
}
}
TextRow {
visible: root.matchingZones.length === 0
label: "No timezone matches that"
detail: "Try a city or a region, such as \"Denver\" or \"Europe\""
divider: false
}
}
}
SettingsCard {
visible: DateTime.lastError !== ""
title: "The system did not accept that"
subtitle: DateTime.lastError
}
// ── Language and formats ─────────────────────────────────────────────────
// Each category may follow the language or override it. An empty override
// is what "Match language" means, and it is what localectl stores: no
// LC_TIME line at all, rather than a copy of LANG that stops tracking it.
function categoryValue(category: string): string {
return String(SystemLocale.categoryValue(category) ?? "");
}
// The locale actually in force for a category, which is what the preview
// must be rendered from.
function effectiveLocale(category: string): string {
const override = root.categoryValue(category);
return override === "" ? SystemLocale.current : override;
}
function localeLabel(value: string): string {
const match = (SystemLocale.locales ?? []).find(entry => entry.value === value);
return match ? match.label : value;
}
// "Match language" first, then everything installed. The first entry has an
// empty value because that is literally what it sets.
readonly property var categoryChoices: [{
value: "",
label: "Match language",
detail: root.localeLabel(SystemLocale.current)
}].concat(SystemLocale.locales ?? [])
// Qt wants "en_US"; localectl deals in "en_US.UTF-8". QLocale tolerates the
// codeset, but the modifier forms do not all round-trip, so it is trimmed.
function qtLocale(value: string): var {
return Qt.locale(String(value ?? "").split(".")[0].split("@")[0]);
}
readonly property var timeLocale: root.qtLocale(root.effectiveLocale("LC_TIME"))
readonly property var numberLocale: root.qtLocale(root.effectiveLocale("LC_NUMERIC"))
readonly property var currencyLocale: root.qtLocale(root.effectiveLocale("LC_MONETARY"))
readonly property var measurementLocale: root.qtLocale(root.effectiveLocale("LC_MEASUREMENT"))
readonly property var weekdayNames: [
"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"
]
// Measurement and paper are chosen together by one row, because glibc ships
// them from the same country and nobody sets one without the other. When
// they have been set apart by hand the row says so rather than hiding it.
readonly property bool paperDiffers:
root.categoryValue("LC_PAPER") !== root.categoryValue("LC_MEASUREMENT")
SettingsCard {
title: "Language & formats"
subtitle: "Changing any of these needs your password, and takes effect for programs started afterwards."
PickerRow {
id: languagePicker
label: "Language"
detail: SystemLocale.pendingRestart
? "Chosen, but not in use until you sign out and back in"
: "Used by programs that ask the system what language to speak"
value: SystemLocale.currentLabel || "Reading…"
SearchPicker {
width: parent.width
items: SystemLocale.locales
current: SystemLocale.current
placeholder: "Search languages and regions"
emptyText: SystemLocale.scanning ? "Reading installed locales…" : "No locales are installed"
onPicked: value => {
SystemLocale.set(value);
languagePicker.collapse();
}
}
}
PickerRow {
id: timePicker
label: "Dates and times"
detail: "LC_TIME — the order of day and month, and the shape of the clock"
value: SystemLocale.categoryLabel("LC_TIME")
SearchPicker {
width: parent.width
items: root.categoryChoices
current: root.categoryValue("LC_TIME")
placeholder: "Search locales"
onPicked: value => {
SystemLocale.setCategory("LC_TIME", value);
timePicker.collapse();
}
}
}
PickerRow {
id: numberPicker
label: "Numbers"
detail: "LC_NUMERIC — the decimal mark and the thousands separator"
value: SystemLocale.categoryLabel("LC_NUMERIC")
SearchPicker {
width: parent.width
items: root.categoryChoices
current: root.categoryValue("LC_NUMERIC")
placeholder: "Search locales"
onPicked: value => {
SystemLocale.setCategory("LC_NUMERIC", value);
numberPicker.collapse();
}
}
}
PickerRow {
id: currencyPicker
label: "Currency"
detail: "LC_MONETARY — which symbol, and which side of the number it sits on"
value: SystemLocale.categoryLabel("LC_MONETARY")
SearchPicker {
width: parent.width
items: root.categoryChoices
current: root.categoryValue("LC_MONETARY")
placeholder: "Search locales"
onPicked: value => {
SystemLocale.setCategory("LC_MONETARY", value);
currencyPicker.collapse();
}
}
}
PickerRow {
id: measurementPicker
label: "Measurements and paper"
detail: root.paperDiffers
? "LC_MEASUREMENT and LC_PAPER — set apart from each other on this machine; choosing here sets both"
: "LC_MEASUREMENT and LC_PAPER — metric or imperial, and the default page size"
value: SystemLocale.categoryLabel("LC_MEASUREMENT")
SearchPicker {
width: parent.width
items: root.categoryChoices
current: root.categoryValue("LC_MEASUREMENT")
placeholder: "Search locales"
onPicked: value => {
SystemLocale.setCategory("LC_MEASUREMENT", value);
SystemLocale.setCategory("LC_PAPER", value);
measurementPicker.collapse();
}
}
}
// A fact, not a switch: glibc ships the first day of the week with the
// date format, and there is no separate thing to set.
TextRow {
label: "First day of the week"
detail: "Comes with the date format — glibc owns it, so this is a readout rather than a choice"
value: root.weekdayNames[root.timeLocale.firstDayOfWeek] ?? ""
}
// ── The preview ──────────────────────────────────────────────────────
Item {
width: parent.width
implicitHeight: previewFrame.implicitHeight + 20
Rectangle {
id: previewFrame
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.topMargin: 6
implicitHeight: previewGrid.implicitHeight + 22
radius: Theme.cardRadius
color: Theme.alpha(Theme.bgDark, 0.55)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.07)
Grid {
id: previewGrid
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 11
columns: previewFrame.width >= 520 ? 2 : 1
columnSpacing: 18
rowSpacing: 5
Repeater {
model: root.previewCells
Row {
required property var modelData
width: (previewGrid.width - (previewGrid.columns - 1) * previewGrid.columnSpacing)
/ previewGrid.columns
spacing: 10
Text {
width: 78
text: modelData.label
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
Text {
width: parent.width - 88
text: modelData.value
color: Theme.fg
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
elide: Text.ElideRight
}
}
}
}
}
}
// Paper size is deliberately absent above. Qt reports a locale's
// measurement system and not its page size, and deriving Letter from a
// country list would be Panama inventing an answer glibc already holds
// -- a preview that guesses is worse than a preview that stops.
NotMeasuredRow {
label: "Paper size is not previewed"
because: "Qt can report a locale's measurement system but not its page size, and guessing it from the country would be this page making something up"
divider: false
}
}
SettingsCard {
visible: SystemLocale.lastError !== ""
title: "The system did not accept that"
subtitle: SystemLocale.lastError
}
readonly property var previewCells: {
const sample = clock.date;
return [
{ label: "Today", value: sample.toLocaleDateString(root.timeLocale, Locale.LongFormat) },
{ label: "Time", value: sample.toLocaleTimeString(root.timeLocale, Locale.ShortFormat) },
{ label: "Number", value: (1234567.89).toLocaleString(root.numberLocale) },
{ label: "Currency", value: (1234.56).toLocaleCurrencyString(root.currencyLocale) },
{ label: "Units", value: root.measurementLocale.measurementSystem === Locale.MetricSystem
? "Metric — °C, km, kg"
: "Imperial — °F, miles, lb" },
{ label: "Short date", value: sample.toLocaleDateString(root.timeLocale, Locale.ShortFormat) }
];
}
// Bounded so a blank search does not try to lay out six hundred rows. The
// current zone is always included, so the card never looks empty when the
// field is untouched.
readonly property var matchingZones: {
const needle = zoneSearch.text.trim().toLowerCase();
const all = DateTime.zones;
if (needle === "") {
// The zone in effect goes first. Listing a region alphabetically
// means the current setting is usually off the bottom of the card,
// which makes the list look like it does not know what is set.
const current = DateTime.timezone;
const nearby = all
.filter(zone => zone !== current && DateTime.regionOf(zone) === DateTime.regionOf(current))
.slice(0, 11);
return current === "" ? nearby : [current].concat(nearby);
}
return all.filter(zone => zone.toLowerCase().replace(/_/g, " ").indexOf(needle) >= 0).slice(0, 40);
}
SystemClock {
id: clock
precision: SystemClock.Minutes
}
}