diff options
Diffstat (limited to 'desktop')
| -rw-r--r-- | desktop/README.md | 1 | ||||
| -rw-r--r-- | desktop/modules/fonts/Fonts.js | 152 | ||||
| -rw-r--r-- | desktop/modules/fonts/FontsModule.qml | 180 | ||||
| -rw-r--r-- | desktop/modules/fonts/FontsPage.qml | 212 | ||||
| -rw-r--r-- | desktop/modules/fonts/README.md | 49 | ||||
| -rw-r--r-- | desktop/modules/fonts/test_fonts.js | 67 | ||||
| -rw-r--r-- | desktop/shell.qml | 2 |
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 {}, |
