pragma Singleton // The system locale. // // Named SystemLocale, not Locale: QML has a built-in Locale value type, and a // singleton of that name is silently shadowed by it. Every binding then reads // properties off the wrong thing and the page renders empty with only // "Cannot read property of undefined" to show for it. // // Changing it is privileged: localectl goes through polkit, which prompts // (hyprpolkitagent serves that in this session). It also only applies to // programs started afterwards, so `pendingRestart` goes true once a change is // accepted and the page says a sign-out is needed. Reporting the new locale as // simply "in effect" would be wrong -- almost nothing on screen would be using // it yet. import Quickshell import Quickshell.Io import QtQuick Singleton { id: root readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-locale" // [{ value, label, detail }] property var locales: [] property string current: "" property bool scanning: false property string lastError: "" // True once a change has been accepted but the session has not restarted, // so the UI can stop claiming the new locale is already in use. property bool pendingRestart: false // Guards read the Process objects directly; a derived binding is stale // inside the handler that changes it. See DefaultApps.qml. readonly property bool busy: apply.running || applyCategory.running || root.scanning readonly property string currentLabel: { const match = root.locales.find(locale => locale.value === root.current); return match ? match.label : root.current; } // ── Per-category formats ──────────────────────────────────────────────── // // Reading in one language while writing dates, numbers and currency the way // your country does is the ordinary case. Each category is an OVERRIDE, and // its absence -- "" here -- means "match language", which is not the same // as an override that happens to equal LANG today: the two diverge the // moment the language changes. // // Same restart discipline as the language itself. localectl only affects // programs started afterwards, so an accepted change sets pendingRestart // and the page says a sign-out is needed rather than claiming the new // format is already in use. readonly property var categories: ["LC_TIME", "LC_NUMERIC", "LC_MONETARY", "LC_MEASUREMENT", "LC_PAPER"] property var categoryValues: ({}) // Bumped whenever an override is read or accepted, and read at the top of // categoryValue() so a binding built on that call has something to // invalidate. A bare function call captures no dependency and every reader // would go stale -- see DesktopPreferences.get() for the same reason. property int categoryRevision: 0 // "" means match language. Anything else is an installed locale name. function categoryValue(category: string): string { root.categoryRevision; return String(root.categoryValues[category] ?? ""); } function categoryLabel(category: string): string { const value = root.categoryValue(category); if (value === "") return "Match language"; const match = root.locales.find(locale => locale.value === value); return match ? match.label : value; } // Pass "" to remove the override. Refused for anything that is not one of // the five categories: this reaches a privileged command, and the caller // does not get to name the variable being written. function setCategory(category: string, locale: string): bool { if (root.categories.indexOf(category) < 0) return false; if (locale !== "" && !root.locales.some(entry => entry.value === locale)) return false; if (root.categoryValue(category) === locale) return true; // A click while a change is still applying is queued rather than // fired: assigning running = true to a running Process is a no-op, so // a second setCategory would be silently dropped and applyCategory's // handler would then adopt it as though it had been applied. Same // queue discipline as set() above. root.requestedCategories = Object.assign({}, root.requestedCategories); root.requestedCategories[category] = locale; if (!applyCategory.running) root._applyNextCategory(); return true; } property var requestedCategories: ({}) function _applyNextCategory(): void { const keys = Object.keys(root.requestedCategories); if (keys.length === 0) return; const category = keys[0]; const value = String(root.requestedCategories[category]); const remaining = Object.assign({}, root.requestedCategories); delete remaining[category]; root.requestedCategories = remaining; applyCategory.pendingCategory = category; applyCategory.pendingValue = value; applyCategory.command = [root.helperPath, "set", category, value]; applyCategory.running = true; } function refresh(): void { if (root.scanning) return; root.scanning = true; readCurrent.running = true; readCategories.running = true; list.running = true; } // A picker click while a change is still applying is queued rather than // fired directly: assigning `running = true` to an already-running // Process is a no-op, so a second set() here would otherwise be silently // dropped and apply.onExited would then adopt the second value as if it // had actually been applied. requestedValue always holds the latest // request; apply.onExited re-fires with it once the in-flight apply // settles, mirroring Brightness.qml's pending-write queue. property string requestedValue: "" function set(value: string): void { if (value === root.current) return; root.requestedValue = value; if (!apply.running) root._applyValue(value); } function _applyValue(value: string): void { root.requestedValue = ""; apply.command = [root.helperPath, "set", value]; apply.pendingValue = value; apply.running = true; } Process { id: list command: [root.helperPath, "list"] stdout: StdioCollector { onStreamFinished: { try { const parsed = JSON.parse(this.text); root.locales = Array.isArray(parsed) ? parsed : []; } catch (error) { root.locales = []; console.warn("SystemLocale: could not parse the locale list:", error); } root.scanning = false; } } } Process { id: readCurrent command: [root.helperPath, "get"] stdout: StdioCollector { onStreamFinished: root.current = this.text.trim() } } Process { id: readCategories command: [root.helperPath, "overrides"] stdout: StdioCollector { onStreamFinished: { try { const parsed = JSON.parse(this.text); root.categoryValues = (parsed && typeof parsed === "object") ? parsed : ({}); } catch (error) { root.categoryValues = ({}); console.warn("SystemLocale: could not parse the format overrides:", error); } root.categoryRevision += 1; } } } Process { id: applyCategory property string pendingCategory: "" property string pendingValue: "" // A refused change -- polkit dismissed, or a locale the system does not // have -- must not move the UI. The value is only adopted on a zero // exit, exactly as the language apply above does. onExited: code => { if (code === 0) { const next = Object.assign({}, root.categoryValues); next[applyCategory.pendingCategory] = applyCategory.pendingValue; root.categoryValues = next; root.categoryRevision += 1; root.pendingRestart = true; root.lastError = ""; } else { root.lastError = "The system did not accept that format. It may have needed a password."; } applyCategory.pendingCategory = ""; applyCategory.pendingValue = ""; root._applyNextCategory(); } } Process { id: apply property string pendingValue: "" // A refused change -- polkit dismissed, or an unknown locale -- must not // move the UI. The value is only adopted on a zero exit. onExited: code => { if (code === 0) { root.current = apply.pendingValue; root.pendingRestart = true; root.lastError = ""; } else { root.lastError = "The system did not accept that language. It may have needed a password."; } // A set() call that arrived while this apply was running only // queued itself in requestedValue (see the comment above). Fire // it now if it still names something other than what was just // applied, so the UI never settles on a locale the system was // never actually asked for. if (root.requestedValue !== "" && root.requestedValue !== apply.pendingValue) root._applyValue(root.requestedValue); else root.requestedValue = ""; } } }