Files
Panama/config/dot/quickshell/services/SystemLocale.qml
T

253 lines
9.7 KiB
QML

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 = "";
}
}
}