summaryrefslogtreecommitdiffstats
path: root/desktop
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-29 09:55:20 +0100
committerDanilo M. <danix@danix.xyz>2026-10-29 09:55:20 +0100
commit28beb8a9600b6e74cab454401cc94c664d90bfe5 (patch)
treec91a0731af8f3204e2c2b07a96a8bbfeaae68e35 /desktop
parent38cda58f0969fbdda2c33a38356ab43b2869b955 (diff)
downloadquickshell-05173c6d9899416cc76175a7c39b4d7c6f935fa6.tar.gz
quickshell-05173c6d9899416cc76175a7c39b4d7c6f935fa6.zip
fonts: one font size for the desktop, with per-system overridesarchive/companion
A settings page under Appearance > Fonts holds one pixel size for the whole desktop and an override wherever a system needs its own, saved as ~/.config/keystone/fonts.json, all in px. At scale 1 the logical DPI is 96, so a point is 4/3 px, and each system is written in its own unit: Theme.fontSize reads the json itself, so every shell follows live; qt5ct/qt6ct and the GTK settings files are edited in place, size only, plus gsettings; kitty, rofi and hyprlock read generated files under ~/.config/keystone that their configs include over designed fallbacks; the groupbar is a keystone.lua line. rofi and hyprlock are sections of designed sizes that scale together from their body text, the smallest entry, each keeping its ratio unless given its own size. The design is read from the fallbacks themselves, var(kf-..., "Family N") in the rofi files and $kf_... = N in hyprlock.conf, so a new entry appears on the page with no list to keep. With no fonts.json the page starts from what the files say, base 16 and an override per system at its current size, so the first save changes nothing; seeded to 0.01px, since 0.1 scaled rofi's 72pt glyph to 72.2. Each stepper shows px then pt in fixed columns, the system's own unit highlighted. Check: node desktop/modules/fonts/test_fonts.js Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 {},