summaryrefslogtreecommitdiffstats
path: root/desktop
diff options
context:
space:
mode:
Diffstat (limited to 'desktop')
-rw-r--r--desktop/README.md1
-rw-r--r--desktop/modules/fonts/Fonts.js152
-rw-r--r--desktop/modules/fonts/FontsModule.qml180
-rw-r--r--desktop/modules/fonts/FontsPage.qml212
-rw-r--r--desktop/modules/fonts/README.md49
-rw-r--r--desktop/modules/fonts/test_fonts.js67
-rw-r--r--desktop/shell.qml2
7 files changed, 663 insertions, 0 deletions
diff --git a/desktop/README.md b/desktop/README.md
index 7716aed..0f82445 100644
--- a/desktop/README.md
+++ b/desktop/README.md
@@ -94,6 +94,7 @@ and flashes it, so a page needs no highlight code of its own.
modules/clipboard/ cliphist history: search, copy, delete, clear all
modules/git/ the global git config, in the settings window
modules/autostart/ Hyprland's autostart.lua, in the settings window
+ modules/fonts/ one font size for Qt, GTK, kitty, rofi, hyprlock and the shells
modules/hyprland/ Hyprland's keyboard shortcuts and options, in the settings window
modules/assistant/ desktop-assistant's config, tools, and Ask presets
modules/qtmaildir/ qtmaildir.conf: general, compose, accounts, tags and keys
diff --git a/desktop/modules/fonts/Fonts.js b/desktop/modules/fonts/Fonts.js
new file mode 100644
index 0000000..5b7d303
--- /dev/null
+++ b/desktop/modules/fonts/Fonts.js
@@ -0,0 +1,152 @@
+.pragma library
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+// Pure helpers for the fonts page, tested by test_fonts.js under node.
+//
+// The model is fonts.json: {base, override}. base is one pixel size for the
+// whole desktop; override maps a system ("qt") or one entry of a section
+// ("rofi:kf-launcher-entry") to its own pixel size. Everything is stored in
+// px and converted where a system wants points: at scale 1 Wayland's logical
+// DPI is 96 (gtk-xft-dpi reads 98304, 96 * 1024), so px = pt * 96 / 72.
+
+function toPx(pt) { return pt * 4 / 3; }
+function toPt(px) { return px * 3 / 4; }
+function round(x) { return Math.round(x * 10) / 10; }
+
+// The systems with one size each. unit is what the system's own config is
+// written in; when says when a change is seen.
+const SYSTEMS = [
+ { id: "quickshell", label: "Quickshell", unit: "px", where: "every shell's Theme.fontSize", when: "at once" },
+ { id: "qt", label: "Qt", unit: "pt", where: "qt6ct.conf, qt5ct.conf", when: "when an app starts" },
+ { id: "gtk", label: "GTK", unit: "pt", where: "gtk-3.0 and gtk-4.0 settings.ini, .gtkrc-2.0, gsettings", when: "at once for GTK 3 and 4" },
+ { id: "kitty", label: "kitty", unit: "pt", where: "kitty.conf, through an include", when: "at once" },
+ { id: "groupbar", label: "Hyprland groupbar", unit: "px", where: "keystone.lua, group:groupbar:font_size", when: "at once" },
+];
+
+// A section is a set of designed sizes that scale together: rofi's fonts,
+// hyprlock's labels. Its own size is that of its smallest entry, its body
+// text; every other entry keeps its ratio to it unless overridden.
+const SECTIONS = [
+ { id: "rofi", label: "rofi", unit: "pt", when: "next time rofi opens" },
+ { id: "hyprlock", label: "hyprlock", unit: "pt", when: "next lock" },
+];
+
+// A system's or a section's size in px.
+function size(m, id) {
+ const o = m.override[id];
+ return o !== undefined ? o : m.base;
+}
+
+// A section's body size in its designed unit, pt for both.
+function bodyPt(entries) { return Math.min.apply(null, entries.map(e => e.pt)); }
+
+// An entry's size in px: its override, else its designed size scaled by the
+// section's size over the designed body.
+function entryPx(m, section, entries, e) {
+ const o = m.override[section + ":" + e.name];
+ if (o !== undefined) return o;
+ return size(m, section) * e.pt / bodyPt(entries);
+}
+
+// The model with id set to px, or back to the base when px is null.
+function withOverride(m, id, px) {
+ const o = {};
+ for (const k in m.override) if (k !== id) o[k] = m.override[k];
+ if (px !== null) o[id] = px;
+ return { base: m.base, override: o };
+}
+
+function parseModel(text) {
+ try {
+ const j = JSON.parse(text);
+ if (typeof j.base === "number") return { base: j.base, override: j.override || {} };
+ } catch (e) {}
+ return null;
+}
+
+// A first model from what the files say now, so the first save changes
+// nothing: the base is Quickshell's 16px, and every system that differs
+// from it is an override at its current size. Kept to 0.01px: at 0.1, an
+// 11pt body stored as 14.7px scaled rofi's 72pt glyph to 72.2.
+function seed(now) {
+ const m = { base: 16, override: {} };
+ for (const id in now) if (now[id] !== null && Math.abs(now[id] - 16) > 0.05) m.override[id] = Math.round(now[id] * 100) / 100;
+ return m;
+}
+
+// ---- reading ----
+
+function qtPt(t) { const m = /^general="[^,]*,([\d.]+)/m.exec(t); return m ? Number(m[1]) : null; }
+function gtkPt(t) { const m = /^gtk-font-name="?[^"\n]*?([\d.]+)"?\s*$/m.exec(t); return m ? Number(m[1]) : null; }
+function kittyPt(t) { const m = /^font_size\s+([\d.]+)/m.exec(t); return m ? Number(m[1]) : null; }
+
+// rofi entries: every `var(kf-name, "Family Size")` in the .rasi texts, as
+// {name, family, pt}. The fallback in the var is the designed size.
+function rofiEntries(texts) {
+ const out = [], seen = {};
+ const re = /var\(\s*(kf-[\w-]+)\s*,\s*"([^"]*?)\s+([\d.]+)"\s*\)/g;
+ for (const t of texts) {
+ let m;
+ while ((m = re.exec(t)) !== null)
+ if (!seen[m[1]]) { seen[m[1]] = true; out.push({ name: m[1], family: m[2], pt: Number(m[3]) }); }
+ }
+ return out;
+}
+
+// hyprlock entries: every `$kf_name = size` in hyprlock.conf, the inline
+// fallbacks the generated file overrides, as {name, pt}.
+function hyprlockEntries(t) {
+ const out = [];
+ const re = /^\$(kf_\w+)\s*=\s*([\d.]+)\s*$/gm;
+ let m;
+ while ((m = re.exec(t)) !== null) out.push({ name: m[1], pt: Number(m[2]) });
+ return out;
+}
+
+// ---- writing ----
+
+// qt5ct/qt6ct: the point size, the second field of general= and fixed=.
+// null when neither key is there.
+function setQt(t, pt) {
+ const re = /^((?:general|fixed)="[^,]*,)[\d.]+/gm;
+ return re.test(t) ? t.replace(re, "$1" + round(pt)) : null;
+}
+
+// settings.ini and .gtkrc-2.0: the size at the end of gtk-font-name.
+function setGtk(t, pt) {
+ const re = /^(gtk-font-name="?[^"\n]*?)[\d.]+("?\s*)$/m;
+ return re.test(t) ? t.replace(re, "$1" + round(pt) + "$2") : null;
+}
+
+// The family gsettings is given, from the same key.
+function gtkFamily(t) {
+ const m = /^gtk-font-name="?([^",\n]*?),?\s+[\d.]+"?\s*$/m.exec(t);
+ return m ? m[1] : "Noto Sans";
+}
+
+const HEAD = "Generated by the keystone settings window, Appearance > Fonts. Rewritten on every save.";
+
+function kittyFile(m) {
+ return "# " + HEAD + "\nfont_size " + round(toPt(size(m, "kitty"))) + "\n";
+}
+
+function rofiFile(m, entries) {
+ return "/* " + HEAD + " */\n* {\n"
+ + entries.map(e => " " + e.name + ": \"" + e.family + " " + round(toPt(entryPx(m, "rofi", entries, e))) + "\";\n").join("")
+ + "}\n";
+}
+
+// hyprlock's font_size is an integer, in points.
+function hyprlockFile(m, entries) {
+ return "# " + HEAD + "\n"
+ + entries.map(e => "$" + e.name + " = " + Math.round(toPt(entryPx(m, "hyprlock", entries, e))) + "\n").join("");
+}
diff --git a/desktop/modules/fonts/FontsModule.qml b/desktop/modules/fonts/FontsModule.qml
new file mode 100644
index 0000000..016d85d
--- /dev/null
+++ b/desktop/modules/fonts/FontsModule.qml
@@ -0,0 +1,180 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import Quickshell
+import Quickshell.Io
+import QtQuick
+import "../.."
+import "Fonts.js" as F
+import "../hyprland/Hypr.js" as H
+
+// One pixel size for the desktop, and where a system needs its own, an
+// override. Saved as ~/.config/keystone/fonts.json, then written into each
+// system in its own unit: Theme.fontSize reads the json itself; qt5ct/qt6ct
+// and the GTK settings files are edited in place, size only; kitty, rofi
+// and hyprlock read generated files under ~/.config/keystone that their
+// configs include over designed fallbacks; the groupbar is a keystone.lua
+// line. unified-desktop-theme no longer sets the Qt and GTK sizes, so a
+// re-install does not undo this page.
+Module {
+ id: fm
+ name: "fonts"
+ label: "Fonts"
+ // nf-fa-font, present in Inconsolata Nerd Font.
+ icon: ""
+ settings: true
+ group: "Appearance"
+ dirty: fm.touched
+ keywords: ["font", "size", "text", "px", "pt", "qt6ct", "gtk", "kitty", "rofi", "hyprlock", "groupbar", "scale"]
+
+ readonly property string home: Quickshell.env("HOME")
+ readonly property string config: Quickshell.env("XDG_CONFIG_HOME") || fm.home + "/.config"
+ readonly property var paths: ({
+ model: fm.config + "/keystone/fonts.json",
+ qt6: fm.config + "/qt6ct/qt6ct.conf",
+ qt5: fm.config + "/qt5ct/qt5ct.conf",
+ gtk3: fm.config + "/gtk-3.0/settings.ini",
+ gtk4: fm.config + "/gtk-4.0/settings.ini",
+ gtk2: fm.home + "/.gtkrc-2.0",
+ kittyConf: fm.config + "/kitty/kitty.conf",
+ kitty: fm.config + "/keystone/kitty-fonts.conf",
+ rofi: fm.config + "/keystone/rofi-fonts.rasi",
+ hyprlockConf: fm.config + "/hypr/hyprlock.conf",
+ hyprlock: fm.config + "/keystone/hyprlock-fonts.conf",
+ keystone: fm.config + "/hypr/sections/keystone.lua",
+ })
+
+ // key -> text as last read or written: FileView.text() lags setText().
+ property var texts: ({})
+ // Bumped on every load, so bindings over texts recompute.
+ property int version: 0
+ property var rofiEntries: []
+ property real groupbarNow: 0
+ readonly property var hyprlockEntries: { fm.version; return F.hyprlockEntries(fm.texts.hyprlockConf ?? ""); }
+
+ // What the files say now, in px, for the first model.
+ function now() {
+ const t = fm.texts;
+ const pt = (v) => v === null ? null : F.toPx(v);
+ return {
+ quickshell: Theme.fontSize,
+ qt: pt(F.qtPt(t.qt6 ?? "")),
+ gtk: pt(F.gtkPt(t.gtk3 ?? "")),
+ kitty: pt(F.kittyPt(t.kitty ?? "") ?? F.kittyPt(t.kittyConf ?? "")),
+ groupbar: fm.groupbarNow || null,
+ rofi: fm.rofiEntries.length ? F.toPx(Math.min.apply(null, fm.rofiEntries.map(e => e.pt))) : null,
+ hyprlock: fm.hyprlockEntries.length ? F.toPx(Math.min.apply(null, fm.hyprlockEntries.map(e => e.pt))) : null,
+ };
+ }
+
+ readonly property var saved: { fm.version; return F.parseModel(fm.texts.model ?? "") ?? F.seed(fm.now()); }
+ property var draft: ({ base: 16, override: {} })
+ // A reload replaces the draft only while it holds no edits.
+ onSavedChanged: if (!fm.touched) fm.draft = fm.saved
+ property bool touched: false
+ property string notice: ""
+ property string error: ""
+
+ function setBase(px) { fm.touched = true; fm.draft = { base: px, override: fm.draft.override }; }
+ function setOverride(id, px) { fm.touched = true; fm.draft = F.withOverride(fm.draft, id, px); }
+ function discard() { fm.touched = false; fm.draft = fm.saved; fm.error = ""; }
+
+ function refresh() {
+ for (const v of views.instances) v.reload();
+ rofiProc.running = false;
+ rofiProc.running = true;
+ groupbarProc.running = false;
+ groupbarProc.running = true;
+ }
+
+ property Variants views: Variants {
+ model: Object.keys(fm.paths)
+ FileView {
+ required property string modelData
+ path: fm.paths[modelData]
+ atomicWrites: true
+ watchChanges: true
+ printErrors: false
+ onFileChanged: reload()
+ onLoaded: { fm.texts[modelData] = text(); fm.version++; }
+ onLoadFailed: { fm.texts[modelData] = ""; fm.version++; }
+ }
+ }
+
+ function write(key, t) {
+ if (t === null || t === (fm.texts[key] ?? null)) return;
+ const v = fm.views.instances.find(x => x.modelData === key);
+ fm.texts[key] = t;
+ v.setText(t);
+ }
+
+ // The designed sizes: every var(kf-..., "...") in the udt rofi files.
+ property Process rofiProc: Process {
+ command: ["sh", "-c", 'cat "$1"/*.rasi', "sh", fm.config + "/rofi/udt"]
+ stdout: StdioCollector { onStreamFinished: { fm.rofiEntries = F.rofiEntries([this.text]); fm.version++; } }
+ }
+
+ property Process groupbarProc: Process {
+ command: ["hyprctl", "getoption", "group:groupbar:font_size", "-j"]
+ stdout: StdioCollector {
+ onStreamFinished: { try { fm.groupbarNow = JSON.parse(this.text).int; } catch (e) {} }
+ }
+ }
+
+ function save() {
+ const m = fm.draft;
+ const skipped = [];
+ const edit = (key, f, what) => {
+ const t = fm.texts[key] ?? "";
+ if (t === "") return;
+ const n = f(t);
+ if (n === null) skipped.push(what); else fm.write(key, n);
+ };
+ const qt = F.toPt(F.size(m, "qt")), gtk = F.toPt(F.size(m, "gtk"));
+ edit("qt6", t => F.setQt(t, qt), "qt6ct.conf");
+ edit("qt5", t => F.setQt(t, qt), "qt5ct.conf");
+ for (const k of ["gtk3", "gtk4", "gtk2"]) edit(k, t => F.setGtk(t, gtk), k);
+ Quickshell.execDetached(["gsettings", "set", "org.gnome.desktop.interface", "font-name",
+ F.gtkFamily(fm.texts.gtk3 ?? "") + " " + F.round(gtk)]);
+ const kitty = F.kittyFile(m);
+ const kittyChanged = kitty !== fm.texts.kitty;
+ fm.write("kitty", kitty);
+ // SIGUSR1 is kitty's reload-config, sent to the kitty processes only.
+ if (kittyChanged) Quickshell.execDetached(["pkill", "-USR1", "-x", "kitty"]);
+ if (fm.rofiEntries.length) fm.write("rofi", F.rofiFile(m, fm.rofiEntries));
+ if (fm.hyprlockEntries.length) fm.write("hyprlock", F.hyprlockFile(m, fm.hyprlockEntries));
+ // ponytail: keystone.lua is also the Options page's; a save on both at
+ // the same moment could drop one line. Hyprland autoreloads on it.
+ if (fm.texts.keystone !== undefined)
+ fm.write("keystone", H.override(fm.texts.keystone, "group:groupbar:font_size", String(Math.round(F.size(m, "groupbar")))));
+ fm.write("model", JSON.stringify(m, null, 2) + "\n");
+ fm.touched = false;
+ fm.version++;
+ fm.error = skipped.length ? "No font size found in " + skipped.join(", ") + "; left alone." : "";
+ fm.notice = "Saved. Qt apps pick it up when they start.";
+ }
+
+ page: Component {
+ Page {
+ title: "Fonts"
+ actions: Component {
+ SaveBar {
+ dirty: fm.dirty
+ notice: fm.notice
+ error: fm.error
+ onSave: fm.save()
+ onDiscard: fm.discard()
+ }
+ }
+ FontsPage { width: parent.width; fonts: fm }
+ }
+ }
+}
diff --git a/desktop/modules/fonts/FontsPage.qml b/desktop/modules/fonts/FontsPage.qml
new file mode 100644
index 0000000..fd2e672
--- /dev/null
+++ b/desktop/modules/fonts/FontsPage.qml
@@ -0,0 +1,212 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import QtQuick
+import "../.."
+import "Fonts.js" as F
+
+// The base size, then a row per system and per section entry: what it is,
+// its size in its own unit and in px, and a switch to give it its own size
+// instead of following the base (or, inside rofi and hyprlock, its share of
+// the section's size). Edits are a draft until Save.
+Column {
+ id: page
+
+ required property QtObject fonts
+ readonly property var m: page.fonts.draft
+ readonly property real minPx: 8
+ readonly property real maxPx: 160
+
+ spacing: 8
+
+ function clamp(px) { return Math.max(page.minPx, Math.min(page.maxPx, px)); }
+ // A step of 1 in the row's own unit, so a pt row moves by whole points.
+ function step(px, unit, d) { return page.clamp(unit === "pt" ? F.toPx(Math.round(F.toPt(px)) + d) : Math.round(px) + d); }
+
+ component Small: Text {
+ color: Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ wrapMode: Text.Wrap
+ }
+
+ component Heading: Text {
+ topPadding: 10
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1; bold: true }
+ }
+
+ // One column of the stepper: px always first, pt second, so every row
+ // lines up; the unit the system's config is written in is the bright one.
+ component Unit: Text {
+ property bool own: false
+ width: 72
+ anchors.verticalCenter: parent.verticalCenter
+ horizontalAlignment: Text.AlignRight
+ color: own ? Theme.accent : Theme.subtext
+ font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 3; bold: own }
+ }
+
+ // − px pt +, the system's own unit highlighted.
+ component Stepper: Row {
+ id: st
+ property real px: 16
+ property string unit: "px"
+ property bool enabled: true
+ signal changed(real px)
+ spacing: 6
+ opacity: st.enabled ? 1 : 0.45
+ Button { text: "−"; enabled: st.enabled; onClicked: st.changed(page.step(st.px, st.unit, -1)) }
+ Unit { text: F.round(st.px) + " px"; own: st.unit === "px" }
+ Unit { text: F.round(F.toPt(st.px)) + " pt"; own: st.unit === "pt" }
+ Button { text: "+"; enabled: st.enabled; onClicked: st.changed(page.step(st.px, st.unit, 1)) }
+ }
+
+ // A system or a section entry. id is its override key; inherited is the
+ // size it has when it follows.
+ component SizeRow: Item {
+ id: sr
+ required property string key
+ required property string title
+ property string detail: ""
+ property string unit: "px"
+ required property real inherited
+ property string follows: "base"
+ readonly property bool own: page.m.override[sr.key] !== undefined
+ readonly property real px: sr.own ? page.m.override[sr.key] : sr.inherited
+ objectName: "font:" + sr.key
+ width: page.width
+ height: Math.max(48, texts.implicitHeight + 12)
+
+ Column {
+ id: texts
+ anchors { left: parent.left; leftMargin: 8; right: controls.left; rightMargin: 12; verticalCenter: parent.verticalCenter }
+ Text {
+ width: parent.width
+ text: sr.title
+ color: Theme.text
+ elide: Text.ElideRight
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ }
+ Small { width: parent.width; visible: sr.detail !== ""; text: sr.detail }
+ }
+
+ Row {
+ id: controls
+ anchors { right: parent.right; rightMargin: 8; verticalCenter: parent.verticalCenter }
+ spacing: 12
+ Small {
+ anchors.verticalCenter: parent.verticalCenter
+ wrapMode: Text.NoWrap
+ text: sr.own ? "own size" : "follows " + sr.follows
+ }
+ Switch {
+ anchors.verticalCenter: parent.verticalCenter
+ checked: sr.own
+ onToggled: on => page.fonts.setOverride(sr.key, on ? F.round(sr.inherited) : null)
+ }
+ Stepper {
+ anchors.verticalCenter: parent.verticalCenter
+ px: sr.px
+ unit: sr.unit
+ enabled: sr.own
+ onChanged: px => page.fonts.setOverride(sr.key, px)
+ }
+ }
+ }
+
+ Small {
+ width: parent.width
+ text: "One size for the whole desktop, in pixels. At scale 1 a point is 4/3 of a pixel (96 dpi), so 16 px is 12 pt. "
+ + "A system with its own size keeps it when the base moves."
+ }
+
+ Item {
+ objectName: "font:base"
+ width: parent.width
+ height: 48
+ Text {
+ anchors { left: parent.left; leftMargin: 8; verticalCenter: parent.verticalCenter }
+ text: "Base size"
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize; bold: true }
+ }
+ Stepper {
+ anchors { right: parent.right; rightMargin: 8; verticalCenter: parent.verticalCenter }
+ px: page.m.base
+ onChanged: px => page.fonts.setBase(px)
+ }
+ }
+
+ Heading { text: "Systems" }
+ Repeater {
+ model: F.SYSTEMS.filter(s => s.id !== "groupbar")
+ SizeRow {
+ required property var modelData
+ key: modelData.id
+ title: modelData.label
+ detail: modelData.where + ". Seen " + modelData.when + "."
+ unit: modelData.unit
+ inherited: page.m.base
+ }
+ }
+
+ Heading { text: "Hyprland groupbar" }
+ SizeRow {
+ key: "groupbar"
+ title: "Group tab titles"
+ detail: "group:groupbar:font_size, a keystone.lua line. Seen at once. Its text_offset is tuned for 14 px."
+ inherited: page.m.base
+ }
+
+ // rofi and hyprlock: the section's size, which is its body text, then
+ // each entry at its designed ratio to the body.
+ Repeater {
+ model: F.SECTIONS
+ Column {
+ id: sec
+ required property var modelData
+ readonly property var entries: sec.modelData.id === "rofi" ? page.fonts.rofiEntries : page.fonts.hyprlockEntries
+ readonly property real body: sec.entries.length ? Math.min.apply(null, sec.entries.map(e => e.pt)) : 0
+ width: page.width
+ spacing: 8
+
+ Heading { text: sec.modelData.label }
+ Small {
+ x: 8
+ width: parent.width - 16
+ visible: sec.entries.length === 0
+ text: "No sizes found to scale: " + (sec.modelData.id === "rofi"
+ ? "no var(kf-..., \"...\") in ~/.config/rofi/udt."
+ : "no $kf_... = size in ~/.config/hypr/hyprlock.conf.")
+ }
+ SizeRow {
+ visible: sec.entries.length > 0
+ key: sec.modelData.id
+ title: sec.modelData.label + " body text"
+ detail: "Designed at " + sec.body + " pt; everything below keeps its ratio to it. Seen " + sec.modelData.when + "."
+ unit: "pt"
+ inherited: page.m.base
+ }
+ Repeater {
+ model: sec.entries
+ SizeRow {
+ required property var modelData
+ key: sec.modelData.id + ":" + modelData.name
+ title: modelData.name.replace(/^kf[-_]/, "")
+ detail: (modelData.family ? modelData.family + ", " : "") + "designed " + modelData.pt + " pt"
+ unit: "pt"
+ follows: sec.modelData.label
+ inherited: F.entryPx(page.m, sec.modelData.id, sec.entries, { name: "", pt: modelData.pt })
+ }
+ }
+ }
+ }
+}
diff --git a/desktop/modules/fonts/README.md b/desktop/modules/fonts/README.md
new file mode 100644
index 0000000..b887060
--- /dev/null
+++ b/desktop/modules/fonts/README.md
@@ -0,0 +1,49 @@
+# fonts
+
+One pixel size for the desktop, in the settings window under Appearance >
+Fonts, and an override wherever a system needs its own. Saved as
+`~/.config/keystone/fonts.json`:
+
+ { "base": 16, "override": { "qt": 18.67, "rofi:kf-launcher-entry": 22 } }
+
+Everything is stored in px. At scale 1 Wayland's logical DPI is 96
+(`gtk-xft-dpi` reads 98304, 96 * 1024), so a point is 4/3 of a pixel and
+16 px is 12 pt. Each system is written in its own unit:
+
+| System | Written to | Unit | Seen |
+| --- | --- | --- | --- |
+| Quickshell | `Theme.fontSize` reads `fonts.json` itself | px | at once |
+| Qt | `qt6ct.conf`, `qt5ct.conf`, `general=` and `fixed=` size field | pt | when an app starts |
+| GTK | gtk-3.0 and gtk-4.0 `settings.ini`, `.gtkrc-2.0`, gsettings `font-name` | pt | at once for GTK 3 and 4 |
+| kitty | `~/.config/keystone/kitty-fonts.conf`, included last by `kitty.conf`; SIGUSR1 reloads | pt | at once |
+| Hyprland groupbar | `group:groupbar:font_size` line in `keystone.lua` | px | at once |
+| rofi | `~/.config/keystone/rofi-fonts.rasi`, imported by udt's `common.rasi` | pt | next open |
+| hyprlock | `~/.config/keystone/hyprlock-fonts.conf`, sourced by `hyprlock.conf` | pt | next lock |
+
+Qt and GTK files are edited in place, size only; the family stays what it
+is. unified-desktop-theme's templates no longer carry those keys, so a
+re-install does not undo the page. Hyprland's text is pixels
+(`pango_font_description_set_absolute_size`), hyprlock's points
+(`pango_font_description_set_size`).
+
+rofi and hyprlock are sections: sets of designed sizes that scale together.
+Every rofi font in the udt files is `var(kf-<file>-<selector>, "Family N")`,
+and every hyprlock label is `font_size = $kf_<label>` over an inline `$kf_...
+= N`. The designed value is the fallback, used when the generated file is
+absent, and it is also where the page reads the design from, so a new entry
+in either file shows up on the page by itself. A section's size is its body
+text, the smallest designed entry; every entry keeps its ratio to that unless
+given its own size.
+
+With no `fonts.json`, the page starts from what the files say: base 16 px,
+and an override for every system at its current size, so the first save
+changes nothing.
+
+Not handled: font families; the hard-coded `pixelSize` values in the shells
+(21 of them), which do not follow `Theme.fontSize`; and a `keystone.lua`
+save from this page and the Options page at the same moment, which can drop
+one line.
+
+## Check
+
+ node desktop/modules/fonts/test_fonts.js
diff --git a/desktop/modules/fonts/test_fonts.js b/desktop/modules/fonts/test_fonts.js
new file mode 100644
index 0000000..947bd42
--- /dev/null
+++ b/desktop/modules/fonts/test_fonts.js
@@ -0,0 +1,67 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+// Run: node desktop/modules/fonts/test_fonts.js
+// Fonts.js is a QML library, so its .pragma line is stripped before node
+// evaluates it.
+
+const fs = require("fs");
+const assert = require("assert");
+
+const src = fs.readFileSync(__dirname + "/Fonts.js", "utf8").replace(/^\.pragma library$/m, "");
+const F = new Function(src + "\nreturn { size, entryPx, withOverride, parseModel, seed, qtPt, gtkPt, kittyPt, "
+ + "rofiEntries, hyprlockEntries, setQt, setGtk, gtkFamily, kittyFile, rofiFile, hyprlockFile };")();
+
+const qt = '[Fonts]\nfixed="Inconsolata Nerd Font Mono,14,-1,5,400"\ngeneral="Noto Sans,14,-1,5,400,Regular"\n';
+const gtk3 = "[Settings]\ngtk-font-name=Noto Sans, 10\ngtk-theme-name=udt\n";
+const gtk2 = 'gtk-font-name="Noto Sans, 10"\n';
+assert.strictEqual(F.qtPt(qt), 14);
+assert.strictEqual(F.gtkPt(gtk3), 10);
+assert.strictEqual(F.gtkPt(gtk2), 10);
+assert.strictEqual(F.kittyPt("font_size 15.0\n"), 15);
+assert.strictEqual(F.gtkFamily(gtk3), "Noto Sans");
+
+assert.strictEqual(F.setQt(qt, 13.5), '[Fonts]\nfixed="Inconsolata Nerd Font Mono,13.5,-1,5,400"\ngeneral="Noto Sans,13.5,-1,5,400,Regular"\n');
+assert.strictEqual(F.setQt("[Appearance]\n", 12), null);
+assert.strictEqual(F.setGtk(gtk3, 12), "[Settings]\ngtk-font-name=Noto Sans, 12\ngtk-theme-name=udt\n");
+assert.strictEqual(F.setGtk(gtk2, 12), 'gtk-font-name="Noto Sans, 12"\n');
+
+// The first save changes nothing: what differs from 16px is an override.
+const m = F.seed({ quickshell: 16, qt: 14 * 4 / 3, gtk: 10 * 4 / 3, kitty: 20 });
+assert.deepStrictEqual(m, { base: 16, override: { qt: 18.67, gtk: 13.33, kitty: 20 } });
+assert.strictEqual(F.size(m, "quickshell"), 16);
+assert.strictEqual(F.size(F.withOverride(m, "qt", null), "qt"), 16);
+assert.strictEqual(F.parseModel("nope"), null);
+assert.deepStrictEqual(F.parseModel(JSON.stringify(m)), m);
+
+const rasi = ['* { font: var(kf-common-font, "Noto Sans 11"); }',
+ 'entry { font: var(kf-launcher-entry, "Noto Sans Bold 15"); }',
+ 'x { font: var(kf-common-font, "Noto Sans 11"); }'];
+const re = F.rofiEntries(rasi);
+assert.deepStrictEqual(re.map(e => [e.name, e.family, e.pt]),
+ [["kf-common-font", "Noto Sans", 11], ["kf-launcher-entry", "Noto Sans Bold", 15]]);
+// rofi at 22px: the 11pt body becomes 16.5pt, the 15pt entry keeps 15/11.
+const r = { base: 16, override: { rofi: 22 } };
+assert.strictEqual(F.rofiFile(r, re).split("\n").slice(2, 4).join("|"),
+ ' kf-common-font: "Noto Sans 16.5";| kf-launcher-entry: "Noto Sans Bold 22.5";');
+const r2 = F.withOverride(r, "rofi:kf-launcher-entry", 40);
+assert.ok(F.rofiFile(r2, re).includes('kf-launcher-entry: "Noto Sans Bold 30"'));
+
+const hl = F.hyprlockEntries("$font = Noto Sans\n$kf_clock = 120\n$kf_body = 16\n");
+assert.deepStrictEqual(hl, [{ name: "kf_clock", pt: 120 }, { name: "kf_body", pt: 16 }]);
+// Following the base 16px: body 12pt, clock 120 * 12 / 16 = 90.
+assert.strictEqual(F.hyprlockFile({ base: 16, override: {} }, hl).split("\n").slice(1).join("|"), "$kf_clock = 90|$kf_body = 12|");
+// A seeded rofi keeps its designed sizes exactly.
+const sr = F.seed({ rofi: 11 * 4 / 3 });
+assert.ok(F.rofiFile(sr, [{ name: "kf-a", family: "M", pt: 11 }, { name: "kf-b", family: "M", pt: 72 }]).includes('kf-b: "M 72"'));
+assert.strictEqual(F.kittyFile(m).split("\n")[1], "font_size 15");
+
+console.log("ok");
diff --git a/desktop/shell.qml b/desktop/shell.qml
index 451dde8..8d9ca2e 100644
--- a/desktop/shell.qml
+++ b/desktop/shell.qml
@@ -27,6 +27,7 @@ import "modules/shells"
import "modules/bluetooth"
import "modules/clipboard"
import "modules/companion"
+import "modules/fonts"
import "modules/git"
import "modules/hyprland"
import "modules/kdeconnect"
@@ -87,6 +88,7 @@ ShellRoot {
ThemeModule {},
UdtModule {},
IconsModule {},
+ FontsModule {},
SunsetModule {},
IdleModule {},
CompanionModule {},