Add effective desktop style controls
This commit is contained in:
@@ -548,6 +548,12 @@ Singleton {
|
||||
detail: "Swap the primary and secondary buttons",
|
||||
hypr: { path: ["input", "left_handed"], option: "input:left_handed", readAs: "bool" }
|
||||
},
|
||||
{
|
||||
key: "middleClickPaste", type: "bool", def: true, group: "pointer",
|
||||
label: "Middle-click paste",
|
||||
detail: "Paste the primary selection in GTK and native Wayland applications",
|
||||
hypr: { path: ["misc", "middle_click_paste"], option: "misc:middle_click_paste", readAs: "bool" }
|
||||
},
|
||||
|
||||
// ── Touchpad ────────────────────────────────────────────────────────
|
||||
//
|
||||
@@ -806,6 +812,24 @@ Singleton {
|
||||
]
|
||||
},
|
||||
|
||||
// ── Application themes ─────────────────────────────────────────────
|
||||
// ColorScheme owns GTK's light/dark theme. These are the two theme
|
||||
// choices GNOME applications expose independently of that palette:
|
||||
// their icons and pointer. DesktopStyle only accepts names found in
|
||||
// the read-only XDG catalog before storing them.
|
||||
{
|
||||
key: "cursorTheme", type: "string", def: "oreo_blue_cursors", group: "themes",
|
||||
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
|
||||
label: "Pointer theme",
|
||||
detail: "The pointer design used by applications and Hyprland"
|
||||
},
|
||||
{
|
||||
key: "iconTheme", type: "string", def: "Adwaita", group: "themes",
|
||||
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
|
||||
label: "Application icons",
|
||||
detail: "The icon set used by GTK applications"
|
||||
},
|
||||
|
||||
// ── Typography ──────────────────────────────────────────────────────
|
||||
// The single largest thing in this desktop that used to be changeable
|
||||
// only by editing Theme.qml.
|
||||
@@ -835,6 +859,91 @@ Singleton {
|
||||
label: "Interface text size",
|
||||
detail: "The base size the rest of the shell's type scales from"
|
||||
},
|
||||
{
|
||||
key: "applicationFont", type: "string", def: "Adwaita Sans", group: "typography",
|
||||
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
|
||||
label: "Application font",
|
||||
detail: "Used by menus, controls, and labels in applications"
|
||||
},
|
||||
{
|
||||
key: "applicationFontSize", type: "int", def: 11, min: 6, max: 32, step: 1,
|
||||
unit: "pt", group: "typography",
|
||||
label: "Application text size",
|
||||
detail: "The base text size used by applications"
|
||||
},
|
||||
{
|
||||
key: "documentFont", type: "string", def: "Adwaita Sans", group: "typography",
|
||||
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
|
||||
label: "Document font",
|
||||
detail: "Used for document content when an application follows the system choice"
|
||||
},
|
||||
{
|
||||
key: "documentFontSize", type: "int", def: 12, min: 6, max: 32, step: 1,
|
||||
unit: "pt", group: "typography",
|
||||
label: "Document text size",
|
||||
detail: "The default text size for document content"
|
||||
},
|
||||
{
|
||||
key: "monospaceFont", type: "string", def: "VictorMono Nerd Font", group: "typography",
|
||||
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
|
||||
label: "Monospace font",
|
||||
detail: "Used by terminals, editors, and code fields that follow the system choice"
|
||||
},
|
||||
{
|
||||
key: "monospaceFontSize", type: "int", def: 10, min: 6, max: 32, step: 1,
|
||||
unit: "pt", group: "typography",
|
||||
label: "Monospace text size",
|
||||
detail: "The default text size for terminals and code"
|
||||
},
|
||||
{
|
||||
key: "fontHinting", type: "enum", def: "slight", group: "typography",
|
||||
label: "Font hinting",
|
||||
detail: "How strongly text aligns to the pixel grid",
|
||||
options: [
|
||||
{ value: "none", label: "None" },
|
||||
{ value: "slight", label: "Slight" },
|
||||
{ value: "medium", label: "Medium" },
|
||||
{ value: "full", label: "Full" }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: "fontAntialiasing", type: "enum", def: "rgba", group: "typography",
|
||||
label: "Text smoothing",
|
||||
detail: "How application text softens its edges",
|
||||
options: [
|
||||
{ value: "none", label: "None" },
|
||||
{ value: "grayscale", label: "Grayscale" },
|
||||
{ value: "rgba", label: "Subpixel" }
|
||||
]
|
||||
},
|
||||
|
||||
// ── Application titlebars ──────────────────────────────────────────
|
||||
// These affect applications that honour GNOME's window preferences.
|
||||
// Hyprland itself has no server-side titlebar buttons, so minimize is
|
||||
// deliberately absent rather than presented as a switch that lies.
|
||||
{
|
||||
key: "titlebarButtonSide", type: "enum", def: "right", group: "titlebar",
|
||||
label: "Button side",
|
||||
detail: "Place application titlebar buttons on the left or right",
|
||||
options: [
|
||||
{ value: "left", label: "Left" },
|
||||
{ value: "right", label: "Right" }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: "titlebarMaximizeButton", type: "bool", def: false, group: "titlebar",
|
||||
label: "Maximize button",
|
||||
detail: "Show a maximize button in application titlebars that support it"
|
||||
},
|
||||
{
|
||||
key: "titlebarDoubleClick", type: "enum", def: "toggle-maximize", group: "titlebar",
|
||||
label: "Double-click titlebar",
|
||||
detail: "Choose what a double-click on an application titlebar does",
|
||||
options: [
|
||||
{ value: "toggle-maximize", label: "Toggle maximize" },
|
||||
{ value: "none", label: "Do nothing" }
|
||||
]
|
||||
},
|
||||
|
||||
// ── Accessibility ───────────────────────────────────────────────────
|
||||
// Backed by gsettings so GTK applications agree with the shell, and
|
||||
|
||||
@@ -17,8 +17,10 @@ import qs.services
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
property string expandedPicker: ""
|
||||
|
||||
title: "Appearance"
|
||||
lede: "Drag anything below. The preview above is your real geometry, to scale."
|
||||
lede: "Tune the Prism shell and the applications that live inside it. The preview above is your real geometry, to scale."
|
||||
|
||||
header: Component {
|
||||
Column {
|
||||
@@ -83,7 +85,7 @@ SettingsPage {
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Typography"
|
||||
title: "Shell typography"
|
||||
subtitle: Fonts.lastError !== ""
|
||||
? Fonts.lastError
|
||||
: "Every piece of text in the shell. Samples are drawn in the font they name."
|
||||
@@ -123,6 +125,137 @@ SettingsPage {
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Application typography"
|
||||
subtitle: DesktopStyle.lastError !== ""
|
||||
? DesktopStyle.lastError
|
||||
: "Fonts used by applications that follow the desktop defaults. Open one family at a time to keep the page calm."
|
||||
|
||||
ActionRow {
|
||||
label: "Application font"
|
||||
detail: DesktopStyle.applicationFont
|
||||
action: root.expandedPicker === "application-font" ? "Close" : "Choose"
|
||||
onTriggered: root.expandedPicker = root.expandedPicker === "application-font" ? "" : "application-font"
|
||||
}
|
||||
|
||||
FontPicker {
|
||||
visible: root.expandedPicker === "application-font"
|
||||
width: parent.width
|
||||
families: Fonts.interfaceFonts
|
||||
current: DesktopStyle.applicationFont
|
||||
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No application fonts found"
|
||||
onPicked: family => {
|
||||
if (DesktopStyle.setApplicationFont(family))
|
||||
root.expandedPicker = "";
|
||||
}
|
||||
}
|
||||
|
||||
SliderRow { setting: "applicationFontSize" }
|
||||
|
||||
ActionRow {
|
||||
label: "Document font"
|
||||
detail: DesktopStyle.documentFont
|
||||
action: root.expandedPicker === "document-font" ? "Close" : "Choose"
|
||||
onTriggered: root.expandedPicker = root.expandedPicker === "document-font" ? "" : "document-font"
|
||||
}
|
||||
|
||||
FontPicker {
|
||||
visible: root.expandedPicker === "document-font"
|
||||
width: parent.width
|
||||
families: Fonts.interfaceFonts
|
||||
current: DesktopStyle.documentFont
|
||||
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No document fonts found"
|
||||
onPicked: family => {
|
||||
if (DesktopStyle.setDocumentFont(family))
|
||||
root.expandedPicker = "";
|
||||
}
|
||||
}
|
||||
|
||||
SliderRow { setting: "documentFontSize" }
|
||||
|
||||
ActionRow {
|
||||
label: "Monospace font"
|
||||
detail: DesktopStyle.monospaceFont
|
||||
action: root.expandedPicker === "monospace-font" ? "Close" : "Choose"
|
||||
onTriggered: root.expandedPicker = root.expandedPicker === "monospace-font" ? "" : "monospace-font"
|
||||
}
|
||||
|
||||
FontPicker {
|
||||
visible: root.expandedPicker === "monospace-font"
|
||||
width: parent.width
|
||||
families: Fonts.monospaceFonts
|
||||
current: DesktopStyle.monospaceFont
|
||||
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No monospace fonts found"
|
||||
onPicked: family => {
|
||||
if (DesktopStyle.setMonospaceFont(family))
|
||||
root.expandedPicker = "";
|
||||
}
|
||||
}
|
||||
|
||||
SliderRow { setting: "monospaceFontSize" }
|
||||
ChoiceRow { setting: "fontHinting" }
|
||||
ChoiceRow { setting: "fontAntialiasing"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Icons & pointer"
|
||||
subtitle: DesktopStyle.lastError !== ""
|
||||
? DesktopStyle.lastError
|
||||
: "Installed themes only. The pointer updates in applications and Hyprland together."
|
||||
|
||||
ActionRow {
|
||||
label: "Application icons"
|
||||
detail: DesktopStyle.iconTheme
|
||||
action: root.expandedPicker === "icon-theme" ? "Close" : "Choose"
|
||||
enabled: DesktopStyle.catalogLoaded
|
||||
onTriggered: root.expandedPicker = root.expandedPicker === "icon-theme" ? "" : "icon-theme"
|
||||
}
|
||||
|
||||
SearchPicker {
|
||||
visible: root.expandedPicker === "icon-theme"
|
||||
width: parent.width
|
||||
items: DesktopStyle.iconThemes
|
||||
current: DesktopStyle.iconTheme
|
||||
placeholder: "Search icon themes"
|
||||
emptyText: DesktopStyle.scanning ? "Reading installed icon themes…" : "No icon themes found"
|
||||
onPicked: value => {
|
||||
if (DesktopStyle.setIconTheme(value))
|
||||
root.expandedPicker = "";
|
||||
}
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "Pointer theme"
|
||||
detail: DesktopStyle.cursorTheme
|
||||
action: root.expandedPicker === "cursor-theme" ? "Close" : "Choose"
|
||||
enabled: DesktopStyle.catalogLoaded
|
||||
divider: false
|
||||
onTriggered: root.expandedPicker = root.expandedPicker === "cursor-theme" ? "" : "cursor-theme"
|
||||
}
|
||||
|
||||
SearchPicker {
|
||||
visible: root.expandedPicker === "cursor-theme"
|
||||
width: parent.width
|
||||
items: DesktopStyle.cursorThemes
|
||||
current: DesktopStyle.cursorTheme
|
||||
placeholder: "Search pointer themes"
|
||||
emptyText: DesktopStyle.scanning ? "Reading installed pointer themes…" : "No pointer themes found"
|
||||
onPicked: value => {
|
||||
if (DesktopStyle.setCursorTheme(value))
|
||||
root.expandedPicker = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Titlebars"
|
||||
subtitle: "For applications that draw GNOME-compatible titlebars. Hyprland itself does not add titlebar buttons to tiled windows."
|
||||
|
||||
ChoiceRow { setting: "titlebarButtonSide" }
|
||||
ToggleRow { setting: "titlebarMaximizeButton" }
|
||||
ChoiceRow { setting: "titlebarDoubleClick"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Windows"
|
||||
subtitle: "Spacing and shape of tiled windows. Each change is applied to the compositor and confirmed before it is saved."
|
||||
@@ -187,11 +320,4 @@ SettingsPage {
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Theme"
|
||||
subtitle: "This desktop has one curated visual identity rather than a matrix of partially compatible themes. The controls above adjust its parameters — how much space, how soft, how much motion — without replacing it."
|
||||
|
||||
TextRow { label: "Color palette"; detail: "Tokyo Night Moon"; value: "Prism" }
|
||||
TextRow { label: "Interface type"; detail: "Adwaita Sans"; value: "System"; divider: false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,12 @@ SettingsPage {
|
||||
ChoiceRow { setting: "accelProfile" }
|
||||
ToggleRow { setting: "naturalScroll" }
|
||||
SliderRow { setting: "scrollFactor" }
|
||||
ToggleRow { setting: "leftHanded"; divider: false }
|
||||
ToggleRow { setting: "leftHanded" }
|
||||
ToggleRow {
|
||||
setting: "middleClickPaste"
|
||||
detail: "Paste the primary selection in GTK and native Wayland applications; individual apps may choose not to support it"
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Report installed cursor and icon themes from the standard XDG roots.
|
||||
|
||||
This helper is intentionally read-only and argument-free. Theme paths come
|
||||
only from XDG_DATA_HOME and XDG_DATA_DIRS; directory symlinks are not followed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def icon_roots() -> list[Path]:
|
||||
home = Path(os.environ.get("HOME") or "/nonexistent")
|
||||
data_home = Path(os.environ.get("XDG_DATA_HOME") or home / ".local/share")
|
||||
data_dirs = os.environ.get("XDG_DATA_DIRS") or "/usr/local/share:/usr/share"
|
||||
|
||||
roots = [data_home / "icons"]
|
||||
roots.extend(Path(directory) / "icons" for directory in data_dirs.split(":") if directory)
|
||||
# XDG paths are required to be absolute. Ignoring malformed relative
|
||||
# entries also prevents the helper's working directory becoming an
|
||||
# accidental caller-controlled search root.
|
||||
return [root for root in roots if root.is_absolute()]
|
||||
|
||||
|
||||
def is_real_directory(path: Path) -> bool:
|
||||
try:
|
||||
return stat.S_ISDIR(path.lstat().st_mode)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def is_real_file(path: Path) -> bool:
|
||||
try:
|
||||
return stat.S_ISREG(path.lstat().st_mode)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def has_icon_directories(index_path: Path) -> bool:
|
||||
try:
|
||||
with index_path.open(encoding="utf-8", errors="replace") as handle:
|
||||
for raw_line in handle:
|
||||
line = raw_line.strip()
|
||||
if line.startswith(("#", ";")) or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
if key.strip() == "Directories":
|
||||
return bool(value.strip())
|
||||
except OSError:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def catalog() -> dict[str, list[str]]:
|
||||
cursor_themes: set[str] = set()
|
||||
icon_themes: set[str] = set()
|
||||
|
||||
for root in icon_roots():
|
||||
if not is_real_directory(root):
|
||||
continue
|
||||
try:
|
||||
entries = list(os.scandir(root))
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
for entry in entries:
|
||||
if not entry.is_dir(follow_symlinks=False):
|
||||
continue
|
||||
theme = Path(entry.path)
|
||||
if is_real_directory(theme / "cursors"):
|
||||
cursor_themes.add(entry.name)
|
||||
|
||||
index_path = theme / "index.theme"
|
||||
if is_real_file(index_path) and has_icon_directories(index_path):
|
||||
icon_themes.add(entry.name)
|
||||
|
||||
return {
|
||||
"cursorThemes": sorted(cursor_themes, key=str.casefold),
|
||||
"iconThemes": sorted(icon_themes, key=str.casefold),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) != 1:
|
||||
print("panama-desktop-style takes no arguments", file=sys.stderr)
|
||||
return 2
|
||||
print(json.dumps(catalog(), ensure_ascii=False, separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -25,25 +25,14 @@ import qs.config
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property string cursorTheme: ""
|
||||
property string lastError: ""
|
||||
|
||||
readonly property bool busy: themeQuery.running || runner.running || root.pending.length > 0
|
||||
readonly property bool busy: runner.running || root.pending.length > 0
|
||||
|
||||
readonly property string cursorTheme: DesktopPreferences.get("cursorTheme")
|
||||
readonly property int cursorSize: DesktopPreferences.get("cursorSize")
|
||||
readonly property real textScale: DesktopPreferences.get("textScale")
|
||||
|
||||
Process {
|
||||
id: themeQuery
|
||||
command: ["gsettings", "get", "org.gnome.desktop.interface", "cursor-theme"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
// gsettings quotes strings: 'oreo_blue_cursors'
|
||||
root.cursorTheme = this.text.trim().replace(/^'|'$/g, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A short queue, because applying one setting takes several commands and
|
||||
// Process runs one at a time.
|
||||
property var pending: []
|
||||
@@ -71,7 +60,6 @@ Singleton {
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
themeQuery.running = true;
|
||||
settle.restart();
|
||||
}
|
||||
|
||||
@@ -102,10 +90,7 @@ Singleton {
|
||||
["gsettings", "set", "org.gnome.desktop.interface", "cursor-size", size],
|
||||
["gsettings", "set", "org.gnome.desktop.interface", "text-scaling-factor", String(root.textScale)]
|
||||
];
|
||||
// setcursor needs a theme name; skip it rather than guess if gsettings
|
||||
// has not answered yet. The next change will catch up.
|
||||
if (root.cursorTheme !== "")
|
||||
commands.push(["hyprctl", "setcursor", root.cursorTheme, size]);
|
||||
commands.push(["hyprctl", "setcursor", root.cursorTheme, size]);
|
||||
root.enqueue(commands);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
pragma Singleton
|
||||
|
||||
// Application-facing desktop style.
|
||||
//
|
||||
// Panama owns the durable choices; gsettings is an output boundary for GTK
|
||||
// and applications that follow GNOME's desktop schemas. Commands are arrays,
|
||||
// values are validated before storage, and no user text is ever sent through a
|
||||
// shell. Hyprland's pointer setting stays live through Accessibility.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-desktop-style"
|
||||
|
||||
// SearchPicker consumes [{ value, label, detail }]. Keep the raw names as
|
||||
// a separate allow-list so a caller cannot smuggle a display label into a
|
||||
// stored theme name.
|
||||
property var cursorThemes: []
|
||||
property var iconThemes: []
|
||||
property var cursorThemeNames: []
|
||||
property var iconThemeNames: []
|
||||
property bool catalogLoaded: false
|
||||
property bool scanning: false
|
||||
property bool startupApplied: false
|
||||
property string lastError: ""
|
||||
|
||||
property var pending: []
|
||||
readonly property bool busy: root.scanning || catalogProcess.running
|
||||
|| runner.running || root.pending.length > 0
|
||||
readonly property int preferenceRevision: DesktopPreferences.revision
|
||||
|
||||
readonly property string cursorTheme: DesktopPreferences.get("cursorTheme")
|
||||
readonly property string iconTheme: DesktopPreferences.get("iconTheme")
|
||||
readonly property string applicationFont: DesktopPreferences.get("applicationFont")
|
||||
readonly property string documentFont: DesktopPreferences.get("documentFont")
|
||||
readonly property string monospaceFont: DesktopPreferences.get("monospaceFont")
|
||||
|
||||
Process {
|
||||
id: catalogProcess
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.acceptCatalog(this.text)
|
||||
}
|
||||
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
root.scanning = false;
|
||||
if (exitCode !== 0) {
|
||||
root.catalogLoaded = false;
|
||||
root.lastError = "Installed icon and pointer themes could not be read.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: runner
|
||||
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (exitCode !== 0)
|
||||
root.lastError = "One desktop style setting could not be applied.";
|
||||
root.drain();
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
root.ensureStarted();
|
||||
// Accessing the singleton here keeps its existing hyprctl setcursor
|
||||
// path alive for cursor-theme changes as well as cursor-size changes.
|
||||
Accessibility.applyAll();
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: startupApply
|
||||
interval: 1200
|
||||
onTriggered: {
|
||||
root.startupApplied = true;
|
||||
root.applyAll();
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: DesktopPreferences
|
||||
function onRevisionChanged(): void { applyCoalesce.restart(); }
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: applyCoalesce
|
||||
interval: 180
|
||||
onTriggered: root.applyAll()
|
||||
}
|
||||
|
||||
function ensureStarted(): void {
|
||||
if (!root.catalogLoaded && !root.scanning)
|
||||
root.refreshCatalog();
|
||||
if (!root.startupApplied && !startupApply.running)
|
||||
startupApply.restart();
|
||||
}
|
||||
|
||||
function refreshCatalog(): void {
|
||||
if (catalogProcess.running)
|
||||
return;
|
||||
root.scanning = true;
|
||||
catalogProcess.exec([root.helperPath]);
|
||||
}
|
||||
|
||||
function acceptCatalog(text: string): void {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (!parsed || !Array.isArray(parsed.cursorThemes) || !Array.isArray(parsed.iconThemes))
|
||||
throw new Error("invalid catalog shape");
|
||||
|
||||
const cursors = parsed.cursorThemes.filter(name =>
|
||||
typeof name === "string" && PreferenceSchema.coerce("cursorTheme", name) !== undefined);
|
||||
const icons = parsed.iconThemes.filter(name =>
|
||||
typeof name === "string" && PreferenceSchema.coerce("iconTheme", name) !== undefined);
|
||||
root.cursorThemeNames = cursors;
|
||||
root.iconThemeNames = icons;
|
||||
root.cursorThemes = cursors.map(name => ({
|
||||
value: name,
|
||||
label: name,
|
||||
detail: "Pointer theme"
|
||||
}));
|
||||
root.iconThemes = icons.map(name => ({
|
||||
value: name,
|
||||
label: name,
|
||||
detail: "Application icon theme"
|
||||
}));
|
||||
root.catalogLoaded = true;
|
||||
root.lastError = "";
|
||||
} catch (error) {
|
||||
root.cursorThemes = [];
|
||||
root.iconThemes = [];
|
||||
root.cursorThemeNames = [];
|
||||
root.iconThemeNames = [];
|
||||
root.catalogLoaded = false;
|
||||
root.lastError = "Installed icon and pointer themes could not be read.";
|
||||
}
|
||||
root.scanning = false;
|
||||
}
|
||||
|
||||
function drain(): void {
|
||||
if (runner.running || root.pending.length === 0)
|
||||
return;
|
||||
const next = root.pending[0];
|
||||
root.pending = root.pending.slice(1);
|
||||
runner.exec(next);
|
||||
}
|
||||
|
||||
function enqueue(commands: var): void {
|
||||
// A fresh revision supersedes commands that have not started yet. The
|
||||
// currently running command is allowed to finish, then the newest full
|
||||
// state is replayed in a deterministic order.
|
||||
root.pending = commands;
|
||||
root.drain();
|
||||
}
|
||||
|
||||
// GVariant accepts JSON-style quoted strings. JSON.stringify escapes every
|
||||
// quote, backslash, and control character, and the schema patterns further
|
||||
// constrain stored font/theme names. Arguments still travel directly to
|
||||
// gsettings rather than through a shell.
|
||||
function gvariant(value: var): string {
|
||||
if (typeof value === "boolean")
|
||||
return value ? "true" : "false";
|
||||
if (typeof value === "number")
|
||||
return String(value);
|
||||
return JSON.stringify(String(value));
|
||||
}
|
||||
|
||||
function fontName(familyKey: string, sizeKey: string): string {
|
||||
return `${DesktopPreferences.get(familyKey)} ${DesktopPreferences.get(sizeKey)}`;
|
||||
}
|
||||
|
||||
function buttonLayout(): string {
|
||||
const side = DesktopPreferences.get("titlebarButtonSide");
|
||||
const maximize = DesktopPreferences.get("titlebarMaximizeButton") === true;
|
||||
|
||||
// Tokens are fixed. Only their side and whether maximize is present
|
||||
// vary, so preference data can never become command syntax.
|
||||
if (side === "left")
|
||||
return (maximize ? "close,maximize" : "close") + ":appmenu";
|
||||
return "appmenu:" + (maximize ? "maximize,close" : "close");
|
||||
}
|
||||
|
||||
function setting(schema: string, key: string, value: var): var {
|
||||
return ["gsettings", "set", schema, key, root.gvariant(value)];
|
||||
}
|
||||
|
||||
function applyAll(): void {
|
||||
root.lastError = "";
|
||||
root.enqueue([
|
||||
root.setting("org.gnome.desktop.interface", "icon-theme", root.iconTheme),
|
||||
root.setting("org.gnome.desktop.interface", "cursor-theme", root.cursorTheme),
|
||||
root.setting("org.gnome.desktop.interface", "font-name",
|
||||
root.fontName("applicationFont", "applicationFontSize")),
|
||||
root.setting("org.gnome.desktop.interface", "document-font-name",
|
||||
root.fontName("documentFont", "documentFontSize")),
|
||||
root.setting("org.gnome.desktop.interface", "monospace-font-name",
|
||||
root.fontName("monospaceFont", "monospaceFontSize")),
|
||||
root.setting("org.gnome.desktop.interface", "font-hinting",
|
||||
DesktopPreferences.get("fontHinting")),
|
||||
root.setting("org.gnome.desktop.interface", "font-antialiasing",
|
||||
DesktopPreferences.get("fontAntialiasing")),
|
||||
root.setting("org.gnome.desktop.interface", "gtk-enable-primary-paste",
|
||||
DesktopPreferences.get("middleClickPaste")),
|
||||
root.setting("org.gnome.desktop.wm.preferences", "button-layout", root.buttonLayout()),
|
||||
root.setting("org.gnome.desktop.wm.preferences", "action-double-click-titlebar",
|
||||
DesktopPreferences.get("titlebarDoubleClick"))
|
||||
]);
|
||||
}
|
||||
|
||||
function storeCatalogChoice(key: string, value: string, allowed: var, kind: string): bool {
|
||||
if (!root.catalogLoaded || allowed.indexOf(value) < 0) {
|
||||
root.lastError = `That ${kind} theme is not installed.`;
|
||||
return false;
|
||||
}
|
||||
if (!DesktopPreferences.set(key, value)) {
|
||||
root.lastError = `That ${kind} theme name could not be saved.`;
|
||||
return false;
|
||||
}
|
||||
root.lastError = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
function setCursorTheme(value: string): bool {
|
||||
return root.storeCatalogChoice("cursorTheme", value, root.cursorThemeNames, "pointer");
|
||||
}
|
||||
|
||||
function setIconTheme(value: string): bool {
|
||||
return root.storeCatalogChoice("iconTheme", value, root.iconThemeNames, "icon");
|
||||
}
|
||||
|
||||
function storeFont(key: string, family: string, allowed: var, kind: string): bool {
|
||||
if (allowed.indexOf(family) < 0) {
|
||||
root.lastError = `That ${kind} font is not installed.`;
|
||||
return false;
|
||||
}
|
||||
if (!DesktopPreferences.set(key, family)) {
|
||||
root.lastError = `That ${kind} font name could not be saved.`;
|
||||
return false;
|
||||
}
|
||||
root.lastError = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
function setApplicationFont(family: string): bool {
|
||||
return root.storeFont("applicationFont", family, Fonts.interfaceFonts, "application");
|
||||
}
|
||||
|
||||
function setDocumentFont(family: string): bool {
|
||||
return root.storeFont("documentFont", family, Fonts.interfaceFonts, "document");
|
||||
}
|
||||
|
||||
function setMonospaceFont(family: string): bool {
|
||||
return root.storeFont("monospaceFont", family, Fonts.monospaceFonts, "monospace");
|
||||
}
|
||||
}
|
||||
@@ -17,12 +17,20 @@ import qs.config
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// SettingsSearch is part of the always-constructed sidebar. Touching the
|
||||
// desktop-style service here gives application preferences their startup
|
||||
// replay even when Appearance is not the page that opens first.
|
||||
Component.onCompleted: DesktopStyle.ensureStarted()
|
||||
|
||||
// Which page shows the settings in a given schema group. A group with no
|
||||
// entry here still appears in results and routes to Home rather than being
|
||||
// dropped, so adding a group can never make a setting unreachable.
|
||||
readonly property var groupPages: ({
|
||||
"clock": "appearance",
|
||||
"vitals": "appearance",
|
||||
"typography": "appearance",
|
||||
"themes": "appearance",
|
||||
"titlebar": "appearance",
|
||||
"windows": "appearance",
|
||||
"effects": "appearance",
|
||||
"wallpaper": "appearance",
|
||||
|
||||
Reference in New Issue
Block a user