// 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 } }