diff --git a/config/dot/hypr/looks.lua b/config/dot/hypr/looks.lua index f345813..b8b5646 100644 --- a/config/dot/hypr/looks.lua +++ b/config/dot/hypr/looks.lua @@ -115,7 +115,11 @@ hl.config({ disable_hyprland_logo = true, disable_splash_rendering = true, - font_family = "Adwaita Sans", + -- Same setting as Theme.fontFamily in the shell. If only the QML side + -- followed the preference, the compositor and the shell would disagree + -- about the interface font and nobody would notice until a tooltip + -- rendered in a different typeface. + font_family = prefs.get("interfaceFont", "Adwaita Sans"), -- Variable refresh rate. 3 = enable only for fullscreen windows whose -- content type is "video" or "game" -- the tag rules.lua applies to diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index b4f76b9..95eaba7 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -386,6 +386,36 @@ Singleton { detail: "A time earlier than the start simply means the next morning" }, + // ── Typography ────────────────────────────────────────────────────── + // The single largest thing in this desktop that used to be changeable + // only by editing Theme.qml. + // + // interfaceFont is every piece of text a person reads. iconFont is used + // ONLY to draw glyphs -- workspace pills, the status cluster, search + // icons -- so it must carry Nerd Font glyphs; a plain monospace family + // there replaces every icon in the shell with tofu, which is why the + // picker offers them as a separate, filtered list. + { + key: "interfaceFont", type: "string", def: "Adwaita Sans", group: "typography", + // A family name, matched by fontconfig. Constrained because it also + // reaches hl.config as a string in hypr/looks.lua. + pattern: "^[A-Za-z0-9 ._-]{1,64}$", + label: "Interface font", + detail: "Used for every piece of text in the shell" + }, + { + key: "iconFont", type: "string", def: "VictorMono Nerd Font", group: "typography", + pattern: "^[A-Za-z0-9 ._-]{1,64}$", + label: "Icon font", + detail: "Draws the shell's glyphs, so it must be a Nerd Font" + }, + { + key: "interfaceFontSize", type: "int", def: 13, min: 10, max: 18, step: 1, + unit: "px", group: "typography", + label: "Interface text size", + detail: "The base size the rest of the shell's type scales from" + }, + // ── Accessibility ─────────────────────────────────────────────────── // Backed by gsettings so GTK applications agree with the shell, and // pushed to the compositor as well where it has its own notion. diff --git a/config/dot/quickshell/config/Theme.qml b/config/dot/quickshell/config/Theme.qml index 8f2095b..7538fdf 100644 --- a/config/dot/quickshell/config/Theme.qml +++ b/config/dot/quickshell/config/Theme.qml @@ -90,12 +90,12 @@ Singleton { // Adwaita Sans for everything the user reads as text. No exceptions: a // monospaced clock or percentage reads as a terminal readout pasted into a // UI, which is the opposite of the intent here. - readonly property string fontFamily: "Adwaita Sans" + readonly property string fontFamily: DesktopPreferences.get("interfaceFont") // Nerd Font, used ONLY to draw icon glyphs — never for text. It is the // pragmatic alternative to freedesktop symbolic icons, which ship with a // hardcoded dark fill Qt will not recolour (see widgets/ThemedIcon.qml). - readonly property string fontMono: "VictorMono Nerd Font" + readonly property string fontMono: DesktopPreferences.get("iconFont") // Apply to any text whose digits change in place — clocks, percentages, // elapsed timers, dimension readouts. Tabular figures all share one @@ -107,10 +107,13 @@ Singleton { readonly property var tabularFigures: ({ "tnum": 1 }) - readonly property int fontSize: 13 - readonly property int fontSizeSmall: 11 - readonly property int fontSizeLarge: 16 - readonly property int fontSizeTitle: 20 + // The four sizes are a scale, not four independent numbers: they move + // together so the relationship between body, caption, heading and title + // survives a change to the base rather than drifting apart. + readonly property int fontSize: DesktopPreferences.get("interfaceFontSize") + readonly property int fontSizeSmall: Math.max(9, root.fontSize - 2) + readonly property int fontSizeLarge: root.fontSize + 3 + readonly property int fontSizeTitle: root.fontSize + 7 // ── Motion ────────────────────────────────────────────────────────────── // Event-driven only. Nothing in this shell animates while idle — no pulse, diff --git a/config/dot/quickshell/modules/settings/AppearancePage.qml b/config/dot/quickshell/modules/settings/AppearancePage.qml index 02fae73..2e1e456 100644 --- a/config/dot/quickshell/modules/settings/AppearancePage.qml +++ b/config/dot/quickshell/modules/settings/AppearancePage.qml @@ -49,9 +49,18 @@ SettingsPage { : "Applied to every display. Panama looks in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds." WallpaperPicker { + id: wallpapers width: parent.width } + ActionRow { + visible: wallpapers.hidden > 0 || wallpapers.expanded + label: wallpapers.expanded ? "Showing every image" : wallpapers.hidden + " more available" + detail: "The grid is kept short so the rest of this page stays reachable" + action: wallpapers.expanded ? "Show fewer" : "Show all" + onTriggered: wallpapers.expanded = !wallpapers.expanded + } + ActionRow { label: "Look for new images" detail: Wallpaper.scanning @@ -64,6 +73,47 @@ SettingsPage { } } + SettingsCard { + title: "Typography" + subtitle: Fonts.lastError !== "" + ? Fonts.lastError + : "Every piece of text in the shell. Samples are drawn in the font they name." + + SliderRow { setting: "interfaceFontSize" } + + TextRow { + label: "Interface font" + detail: Fonts.interfaceMissing + ? "Not installed on this machine — fontconfig is substituting something else" + : "Used for all shell text" + value: Fonts.interfaceFont + } + + FontPicker { + width: parent.width + families: Fonts.interfaceFonts + current: Fonts.interfaceFont + emptyText: Fonts.scanning ? "Reading installed fonts…" : "No interface fonts found" + onPicked: family => Fonts.setInterface(family) + } + + TextRow { + label: "Icon font" + detail: Fonts.iconMissing + ? "Not installed — the shell's glyphs will not draw correctly" + : "Draws the shell's glyphs, so only Nerd Fonts are offered" + value: Fonts.iconFont + } + + FontPicker { + width: parent.width + families: Fonts.iconFonts + current: Fonts.iconFont + emptyText: "No Nerd Fonts installed" + onPicked: family => Fonts.setIcon(family) + } + } + SettingsCard { title: "Windows" subtitle: "Spacing and shape of tiled windows. Each change is applied to the compositor and confirmed before it is saved." diff --git a/config/dot/quickshell/modules/settings/FontPicker.qml b/config/dot/quickshell/modules/settings/FontPicker.qml new file mode 100644 index 0000000..16b256d --- /dev/null +++ b/config/dot/quickshell/modules/settings/FontPicker.qml @@ -0,0 +1,80 @@ +// Choosing a font family. +// +// Each candidate is rendered IN the font it names. A list of family names set +// in the current font tells you nothing about what you are choosing, and the +// whole point of picking a typeface is seeing it. + +import QtQuick +import qs.config +import qs.modules.clipboard + +Column { + id: root + + spacing: 0 + + property var families: [] + property string current: "" + property string emptyText: "No fonts found" + + signal picked(string family) + + // Filtered rather than listing all 139: a scrolling wall of family names + // inside a page that is already scrolling is worse than a search box. + readonly property var matches: { + const needle = filter.text.trim().toLowerCase(); + const list = root.families.filter(family => + needle === "" ? true : family.toLowerCase().indexOf(needle) >= 0); + // Always show what is currently selected, even when it does not match. + if (needle === "" && list.indexOf(root.current) >= 0) + return [root.current].concat(list.filter(f => f !== root.current)).slice(0, 12); + return list.slice(0, 12); + } + + SearchField { + id: filter + width: parent.width + placeholder: "Search installed fonts" + } + + Repeater { + model: root.matches + + SettingRow { + id: candidate + + required property var modelData + required property int index + + readonly property bool selected: candidate.modelData === root.current + + label: candidate.modelData + detail: candidate.selected ? "Currently in use" : "" + controlWidth: 210 + divider: candidate.index < root.matches.length - 1 + activatable: !candidate.selected + onActivated: root.picked(candidate.modelData) + + Text { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: 200 + horizontalAlignment: Text.AlignRight + elide: Text.ElideRight + // The sample is drawn in the candidate family, which is the + // entire reason this is a list rather than a text field. + text: "Handgloves 0123" + font.family: candidate.modelData + font.pixelSize: Theme.fontSize + 1 + color: candidate.selected ? Theme.accent : Theme.fgDim + } + } + } + + SettingRow { + width: parent.width + visible: root.matches.length === 0 + label: root.emptyText + divider: false + } +} diff --git a/config/dot/quickshell/modules/settings/WallpaperPicker.qml b/config/dot/quickshell/modules/settings/WallpaperPicker.qml index 42c8e57..ad0d39b 100644 --- a/config/dot/quickshell/modules/settings/WallpaperPicker.qml +++ b/config/dot/quickshell/modules/settings/WallpaperPicker.qml @@ -19,6 +19,19 @@ Item { implicitHeight: grid.implicitHeight + // Sixty tiles is two screens of wallpaper on a page that also holds + // typography, window geometry and effects -- everything below it becomes + // unreachable without a long scroll past pictures. Two rows by default, + // all of them on request. + property bool expanded: false + readonly property int collapsedRows: 2 + + readonly property var shown: root.expanded + ? Wallpaper.available + : Wallpaper.available.slice(0, root.columns * root.collapsedRows) + + readonly property int hidden: Wallpaper.available.length - root.shown.length + readonly property int columns: Math.max(2, Math.floor(width / 190)) readonly property real cellWidth: columns > 0 ? (width - (columns - 1) * 10) / columns : 160 @@ -30,7 +43,7 @@ Item { spacing: 10 Repeater { - model: Wallpaper.available + model: root.shown Rectangle { id: tile diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index 0cca97b..231ef52 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -45,3 +45,4 @@ SoundDeviceList 1.0 SoundDeviceList.qml SoundDeviceRow 1.0 SoundDeviceRow.qml TimeOfDayRow 1.0 TimeOfDayRow.qml LocationPicker 1.0 LocationPicker.qml +FontPicker 1.0 FontPicker.qml diff --git a/config/dot/quickshell/scripts/panama-fonts b/config/dot/quickshell/scripts/panama-fonts new file mode 100755 index 0000000..e5eb715 --- /dev/null +++ b/config/dot/quickshell/scripts/panama-fonts @@ -0,0 +1,56 @@ +#!/usr/bin/env bash + +# Lists installed font families, split by what they are usable for. +# +# Two separate questions get asked of fonts here, and they have different +# answers: +# +# interface proportional families suitable for reading UI text +# monospace fixed-pitch families +# icons families carrying Nerd Font glyphs +# +# The icon distinction matters more than it looks. Theme.fontMono is used ONLY +# to draw glyphs -- the workspace pills, the status cluster, the search icon -- +# and picking a monospace family without those glyphs replaces every icon in the +# shell with tofu. So they are reported separately rather than lumped in with +# monospace, and the picker can say so. +# +# fc-list reports one line per style, so families are deduplicated here. The +# first comma-separated alias is the canonical name Qt will match. + +set -euo pipefail + +emit() { + # $1: fontconfig pattern, $2: json key + fc-list "$1" family 2>/dev/null \ + | sed 's/,.*//' \ + | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' \ + | grep -v '^$' \ + | sort -u +} + +json_array() { + local first=true + printf '[' + while IFS= read -r line; do + [[ -n "$line" ]] || continue + [[ "$first" == true ]] || printf ',' + first=false + printf '%s' "$(printf '%s' "$line" | jq -Rs 'rtrimstr("\n")')" + done + printf ']' +} + +# spacing=100 is fontconfig's mono flag. Proportional is everything else. +mono="$(emit ':spacing=100')" +all="$(emit ':')" +interface="$(comm -23 <(printf '%s\n' "$all") <(printf '%s\n' "$mono"))" +icons="$(printf '%s\n' "$all" | grep -i 'nerd font' || true)" + +printf '{"interface":' +printf '%s\n' "$interface" | json_array +printf ',"monospace":' +printf '%s\n' "$mono" | json_array +printf ',"icons":' +printf '%s\n' "$icons" | json_array +printf '}\n' diff --git a/config/dot/quickshell/services/Fonts.qml b/config/dot/quickshell/services/Fonts.qml new file mode 100644 index 0000000..6951e5a --- /dev/null +++ b/config/dot/quickshell/services/Fonts.qml @@ -0,0 +1,102 @@ +pragma Singleton + +// Installed fonts, split by what they can actually be used for. +// +// Two different questions with different answers: which families are readable +// as interface text, and which carry the Nerd Font glyphs the shell draws its +// icons with. Offering one list for both is how you end up with a desktop full +// of tofu, so they stay separate. +// +// Enumerated once on demand. Fonts do not appear while you are looking at a +// settings page, and fc-list over a few hundred families is not free. + +import Quickshell +import Quickshell.Io +import QtQuick +import qs.config + +Singleton { + id: root + + readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-fonts" + + property var interfaceFonts: [] + property var monospaceFonts: [] + property var iconFonts: [] + property bool scanning: false + property string lastError: "" + + readonly property string interfaceFont: DesktopPreferences.get("interfaceFont") + readonly property string iconFont: DesktopPreferences.get("iconFont") + + readonly property bool loaded: root.interfaceFonts.length > 0 + + // True when the stored family is not installed. fontconfig silently + // substitutes something else, so the shell keeps working and quietly stops + // looking the way it was configured to -- worth saying out loud. + readonly property bool interfaceMissing: root.loaded + && root.interfaceFonts.indexOf(root.interfaceFont) < 0 + readonly property bool iconMissing: root.iconFonts.length > 0 + && root.iconFonts.indexOf(root.iconFont) < 0 + + Process { + id: scan + command: [root.helperPath] + stdout: StdioCollector { + onStreamFinished: { + try { + const parsed = JSON.parse(this.text); + root.interfaceFonts = parsed.interface ?? []; + root.monospaceFonts = parsed.monospace ?? []; + root.iconFonts = parsed.icons ?? []; + root.lastError = ""; + } catch (error) { + root.lastError = "The installed fonts could not be read."; + } + root.scanning = false; + } + } + onExited: (exitCode, exitStatus) => { + root.scanning = false; + if (exitCode !== 0) + root.lastError = "The installed fonts could not be read."; + } + } + + Component.onCompleted: root.refresh() + + function refresh(): void { + if (scan.running) + return; + root.scanning = true; + scan.running = true; + } + + // Only a family this machine reported is accepted, so a hand-edited + // settings file cannot put arbitrary text where a font name belongs -- it + // reaches hl.config as a string on the compositor side. + function setInterface(family: string): bool { + if (root.interfaceFonts.indexOf(family) < 0) { + root.lastError = "That font is not installed."; + return false; + } + return root.store("interfaceFont", family); + } + + function setIcon(family: string): bool { + if (root.iconFonts.indexOf(family) < 0) { + root.lastError = "That font does not carry icon glyphs."; + return false; + } + return root.store("iconFont", family); + } + + function store(key: string, family: string): bool { + if (!DesktopPreferences.set(key, family)) { + root.lastError = "That font name could not be saved."; + return false; + } + root.lastError = ""; + return true; + } +}