439 lines
17 KiB
QML
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
|
|
}
|
|
}
|