253 lines
9.7 KiB
QML
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 = "";
|
|
}
|
|
}
|
|
}
|