Make typography choosable instead of hardcoded
Theme.qml was the largest remaining thing in this desktop that could only be changed by editing a file, and typeface is the first thing someone changes when they want a desktop to feel like theirs. Interface font, icon font, and the base text size are settings now. The two font choices are deliberately separate lists. Theme.fontMono is used only to draw glyphs -- workspace pills, the status cluster, search icons -- so a plain monospace family there replaces every icon in the shell with tofu. The picker offers only Nerd Fonts for that slot and says why. Candidates are rendered in the family they name. A list of font names set in the current font tells you nothing about what you are choosing. The four type sizes derive from the base rather than being stored separately, so the relationship between body, caption, heading and title survives a change instead of four numbers drifting apart. hypr/looks.lua reads the same key. Following the preference only on the QML side would leave the compositor and the shell disagreeing about the interface font, which nobody notices until a tooltip renders in a different typeface. Only a family this machine reports is accepted: the value reaches hl.config as a string, and a settings file moved between machines will name fonts that are not installed. A missing family is reported rather than silently substituted by fontconfig. Also collapses the wallpaper grid to two rows. Sixty tiles is two screens of pictures on a page that also holds typography, window geometry and effects -- everything below it was unreachable without scrolling past all of them. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+56
@@ -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'
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user