Finish the wonderland: System told truthfully, in eight tabs instead of ten
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -1,16 +1,22 @@
|
||||
// Date & Time.
|
||||
// Date, Time & Region.
|
||||
//
|
||||
// These belong to the machine rather than to Panama, so nothing here is stored
|
||||
// in Panama's settings file -- it would be a second answer to a question the
|
||||
// system already answers. Timezone and network time are read from and written
|
||||
// to timedatectl directly. 24-hour time is the one presentation choice that
|
||||
// belongs here rather than on Shell › Bar: it drives the date menu,
|
||||
// notification timestamps and the lock screen as well as the bar. The rest of
|
||||
// the bar clock's presentation -- seconds, weekday -- stays with the bar.
|
||||
// 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.
|
||||
//
|
||||
// Changing the timezone or network time needs privilege. timedatectl asks
|
||||
// polkit, and a canceled dialog surfaces as an error rather than as a value
|
||||
// that appears to have been accepted.
|
||||
// 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
|
||||
@@ -21,29 +27,42 @@ import qs.modules.clipboard
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
title: "Date & Time"
|
||||
lede: "Timezone and network time, shared with the whole machine."
|
||||
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"
|
||||
|
||||
TextRow {
|
||||
label: "Current time"
|
||||
detail: DateTime.timezone === "" ? "Reading the system clock" : DateTime.timezone
|
||||
value: Qt.formatDateTime(clock.date, Settings.use24Hour ? "ddd d MMM HH:mm" : "ddd d MMM h:mm AP")
|
||||
}
|
||||
// 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" }
|
||||
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
|
||||
divider: false
|
||||
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
@@ -53,6 +72,32 @@ SettingsPage {
|
||||
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 {
|
||||
@@ -109,6 +154,265 @@ SettingsPage {
|
||||
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.
|
||||
TextRow {
|
||||
label: "Paper size is not previewed"
|
||||
detail: "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."
|
||||
value: ""
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user