.pragma library // Copyright (C) 2026 Danilo M. // // 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(""); }