Fold thirty-one settings pages into fifteen categories with tabs

The sidebar was a flat scan of thirty-one rows; now it reads like a
settings app. Multi-subject categories (Input, Network & Sharing,
Applications, Users & Accounts, Privacy & Security, System) carry an
Appearance-style tab strip above the page, drawn by the shell so the
leaf pages themselves are untouched. The taxonomy lives in one new
file, services/SettingsRoutes.qml; the sidebar, the strip, route
validation, search breadcrumbs, and both generators derive from it.

ShellState.settingsPage still holds leaf ids, so every deep link, IPC
call, and search result keeps working — and now lands on the exact
tab. Dictation moves out of Sound onto its own page under Input, with
a handoff back to Sound for the microphone. The strip scrolls when
System's nine tabs outgrow a tiled window. All 161 contracts pass.

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-23 20:21:31 -04:00
parent 50077a0c31
commit 5490fd285d
35 changed files with 816 additions and 288 deletions
+1 -1
View File
@@ -119,7 +119,7 @@ shipped one, so a hand-edited `settings.json` cannot cost you a keymap.
Every `hl.bind` must pass a `description`. Hyprland reports Lua-defined binds
with dispatcher `__lua` and a bytecode offset as the argument, so a bind without
one has nothing readable beside its chord, and the Settings app drops it from the
Input & Shortcuts page rather than showing a mystery row.
Keyboard page under Input rather than showing a mystery row.
`tests/quickshell/keybinds-contract` fails if any bind lacks a description, so
this cannot regress silently.
@@ -1473,7 +1473,7 @@ Singleton {
// each chord and falls back to the shipped one, so a hand-edited file
// cannot cost you a keymap.
//
// Edited through the Input & Shortcuts page rather than as a row, hence
// Edited through the Keyboard page (under Input) rather than as a row, hence
// internal.
{
key: "keybindOverrides", type: "json", def: ({}), group: "input",
@@ -55,6 +55,6 @@ one I have".
## Changing them
Settings has a Shortcuts page listing every bind, each of which can be
reassigned. A rebind moves the shortcut and cannot change what it does, so
Settings has a Keyboard page, under Input, listing every bind, each of which
can be reassigned. A rebind moves the shortcut and cannot change what it does, so
there is no way to make a key do something unexpected by editing it.
@@ -35,7 +35,7 @@ somewhere without following it.
On a machine with more than one display, workspaces belong to whichever
screen has focus. If you would rather pin workspaces one through ten to your
main display and give the second screen its own, that is a switch on the
Desktop settings page.
Desktop & Dock settings page.
## Projects
@@ -3,7 +3,7 @@
## Start here
Run **Check System Health** from the launcher, or open Settings and go to
System Health. It reports what is actually running rather than what was
System, then the System Health tab. It reports what is actually running rather than what was
installed, and it can repair several things itself.
From a terminal, the same check is `panama doctor`.
@@ -20,8 +20,8 @@ From a terminal: `qs -d` starts the shell again.
## A shortcut stopped working
Check `Super + /` first: if the shortcut shows a different chord than you
expect, something rebound it. The Shortcuts settings page can reset any bind,
or all of them at once.
expect, something rebound it. The Keyboard page, under Input in Settings, can
reset any bind, or all of them at once.
If the whole keymap is missing from that list, the compositor config failed
to load. `Hyprland --verify-config` says why without touching your session.
@@ -47,8 +47,9 @@ harmed by being refused.
`panama migrate` applies repairs this machine has not had yet. It is safe to
run at any time and does nothing when there is nothing to do.
If an update went badly, the Snapshots settings page can roll the system back
where btrfs snapshots are configured.
If an update went badly, the Snapshots tab under System in Settings can roll
the system back. That tab is only there where btrfs snapshots are configured,
so if you cannot see it, this machine has none to roll back to.
## Reporting it
@@ -2,10 +2,16 @@
## Settings
`Super + I`. Thirty-odd pages covering displays, sound, network, appearance,
power, printers, users, sharing, storage, updates and the rest. The search
box at the top searches the settings themselves rather than just page names,
so if you know what you want to change you can type it.
`Super + I`. Fifteen categories down the side, covering appearance, displays,
sound, input, network, power, accounts and the rest. A category with more than
one subject in it opens a row of tabs above the page, which is where the
narrower topics live: Printers is a tab of Network & Sharing, Dictation a tab
of Input, and About, Software Update, Storage, Snapshots and this manual are
tabs of System.
The search box at the top searches the settings themselves rather than page
names, and each result says where it will land, so if you know what you want
to change you can type it and never touch the sidebar.
## Appearance
@@ -1,4 +1,4 @@
// Network & Devices.
// Connections — wired, Wi-Fi, and Bluetooth.
//
// Wi-Fi and Bluetooth are handled here rather than delegated. Everything goes
// through Quickshell.Networking and Quickshell.Bluetooth -- NetworkManager and
@@ -20,7 +20,7 @@ SettingsPage {
id: root
title: "Network & Devices"
title: "Connections"
lede: Connectivity.activeNetwork
? "Connected to " + Connectivity.activeNetwork.name
: "Wi-Fi, Bluetooth, and the things Fedora owns."
@@ -0,0 +1,90 @@
import QtQuick
import qs.config
import qs.services
// Dictation is an input method, so it lives under Input beside the keyboard —
// but it listens through whichever device the Sound page selects, so the
// microphone card below hands off there rather than duplicating the picker.
SettingsPage {
title: "Dictation"
lede: Dictation.ready
? "Hold Super+D, speak, and release. The words are typed where the cursor is."
: "Speech to text, on the GPU, once the one-time setup below has run."
SettingsCard {
title: "Dictation"
subtitle: Dictation.ready
? "Hold Super+D, speak, and release. The words are typed where the cursor is."
: "The one-time setup fetches a speech server and a ~490 MB model — neither ships with Panama, because both are large and want the network."
// Status of the two pieces, read from the machine rather than guessed.
TextRow {
label: "Speech server"
detail: Dictation.serverReady
? "Running"
: (Dictation.imageBuilt ? "Ready — starts on the first dictation" : "Not set up yet")
value: Dictation.imageBuilt ? "Ready" : "Missing"
}
TextRow {
label: "Speech model"
detail: Dictation.modelInstalled
? "Kept across rebuilds of the server"
: "About 490 MB, downloaded once"
value: Dictation.modelInstalled ? Math.round(Dictation.modelBytes / 1048576) + " MB" : "Missing"
}
// ONE action that actually works: panama-dictate setup pulls the
// server image and downloads the model together. This card used to
// offer a Download button wired to a command the helper does not have,
// and told you to build a "panama app whisper-vulkan" that does not
// exist -- so nothing here did anything. It does now.
ActionRow {
visible: !Dictation.ready || Dictation.downloading
label: "Set up dictation"
detail: {
if (!Dictation.downloading)
return "Fetches the speech server and the model. Runs once, keeps both.";
if (Dictation.phase === "pulling")
return "Fetching the speech server…";
if (Dictation.downloadTotalBytes > 0)
return "Downloading model — " + Math.round(Dictation.downloadFraction * 100)
+ "% of " + Math.round(Dictation.downloadTotalBytes / 1048576) + " MB";
return "Setting up…";
}
action: Dictation.downloading ? "Working…" : "Set up"
enabled: !Dictation.downloading
onTriggered: Dictation.setup()
divider: !Dictation.typingAvailable || Dictation.lastError !== ""
}
TextRow {
visible: !Dictation.typingAvailable
label: "Typing"
detail: "wtype is missing, so dictated text would go to the clipboard instead of being typed."
value: "Missing"
divider: Dictation.lastError !== ""
}
TextRow {
visible: Dictation.lastError !== ""
label: "Problem"
detail: Dictation.lastError
value: ""
divider: false
}
}
SettingsCard {
title: "Microphone"
subtitle: AudioDevices.current(false)?.description ?? "No input device"
ActionRow {
label: "Input device"
detail: "Dictation listens through the input device selected in Sound"
divider: false
action: "Open Sound"
onTriggered: ShellState.openSettings("sound")
}
}
}
@@ -4,12 +4,42 @@ The control center for everything Panama owns. Anything the system owns —
hardware, accounts, printers — is delegated to GNOME Settings and labeled as
such rather than half-reimplemented.
## Navigation
The sidebar lists fifteen **categories**, not one row per page. A category
covering several subjects — Input, Network & Sharing, Applications, Users &
Accounts, Privacy & Security, System — draws a tab strip above the page, and
each of its **leaf** pages is one tab; a category with a single subject is a
leaf itself and shows no strip. The column used to be a flat list of thirty-one
pages, which made finding Printers a scan of the whole thing. Grouped, it sits
under Network & Sharing, where somebody looking for it already expects it.
Leaf pages are ordinary `SettingsPage` files and know nothing about this.
`SettingsShell` draws the strip and hosts the page inside it, so moving a page
between categories never touches the page.
`services/SettingsRoutes.qml` is the one place the taxonomy is written down.
The sidebar, the tab strip, `ShellState`'s route validation, the breadcrumb
subtitles on search results ("System Storage"), and
`scripts/panama-settings-commands` all derive from it — so a page moves in one
edit rather than five, and none of them can disagree about where it lives.
`ShellState.settingsPage` still holds a **leaf** id, the same ids callers have
always used, so every existing deep link, IPC call, and search hit keeps
working and now lands on the exact tab. A category id is accepted too, and
resolves to that category's first available tab.
Availability gating belongs to the strip rather than the sidebar:
`SettingsRoutes.pageAvailable()` drops the Containers tab on a machine without
podman and Snapshots without a snapper configuration, but only once a scan has
proven the stack absent, so a machine that has it never sees the tab blink.
## System Health
The stable internal `services` route renders **System Health**. It is reachable
from the Settings sidebar and its live 54px footer, the degraded-only bar
indicator, and Vicinae's **Check System Health** command. Healthy scans
reserve no bar space and produce no notification.
The stable internal `services` route renders **System Health**, a tab of
**System**. It is reachable from there, from its live 54px footer, from the
degraded-only bar indicator, and from Vicinae's **Check System Health**
command. Healthy scans reserve no bar space and produce no notification.
`services/Health.qml` owns the last accepted redacted snapshot. It invokes
`scripts/panama-doctor` for scans and bounded repairs, `wl-copy` only for an
@@ -95,11 +95,39 @@ Rectangle {
color: Theme.alpha(Theme.fg, 0.06)
}
// The tab strip for the current category, rendered above the page so the
// leaf pages themselves stay untouched by the grouping. A category with a
// single subject earns no strip.
readonly property var categoryTabs: SettingsRoutes.tabsFor(ShellState.settingsPage)
Item {
id: categoryStrip
readonly property bool shown: root.categoryTabs.length > 1
anchors.left: sidebar.right
anchors.right: parent.right
anchors.top: titlebar.bottom
anchors.leftMargin: 34
anchors.rightMargin: 34
height: shown ? 48 : 0
visible: shown
SettingsTabs {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
tabs: root.categoryTabs.map(tab => ({ value: tab.page, label: tab.label }))
current: ShellState.settingsPage
onSelected: value => ShellState.openSettings(value)
}
}
Loader {
id: pageLoader
anchors.left: sidebar.right
anchors.right: parent.right
anchors.top: titlebar.bottom
anchors.top: categoryStrip.bottom
anchors.bottom: parent.bottom
sourceComponent: {
switch (ShellState.settingsPage) {
@@ -114,6 +142,7 @@ Rectangle {
case "screen-intelligence": return screenIntelligencePage;
case "shortcuts": return shortcutsPage;
case "mouse": return mousePage;
case "dictation": return dictationPage;
case "privacy": return privacyPage;
case "region": return regionPage;
case "accounts": return onlineAccountsPage;
@@ -192,6 +221,7 @@ Rectangle {
Component { id: screenIntelligencePage; ScreenIntelligencePage {} }
Component { id: shortcutsPage; ShortcutsPage {} }
Component { id: mousePage; MousePage {} }
Component { id: dictationPage; DictationPage {} }
Component { id: privacyPage; PrivacyPage {} }
Component { id: regionPage; RegionPage {} }
Component { id: onlineAccountsPage; OnlineAccountsPage {} }
@@ -16,60 +16,15 @@ Rectangle {
sidebarScroll.contentY = 0;
}
// Search results say where a hit will land ("System Storage"), because
// with tabbed categories the page name alone no longer locates it.
function pageLabel(page: string): string {
const found = root.destinations.find(item => item.page === page);
return found ? found.label : "Settings";
return SettingsRoutes.breadcrumb(page);
}
readonly property var destinations: [
{ page: "home", label: "Home", icon: "\u{F02DC}" },
{ page: "appearance", label: "Appearance", icon: "\u{F0E0D}" },
{ page: "displays", label: "Displays", icon: "\u{F0379}" },
{ page: "connectivity", label: "Network & Devices", icon: "\u{F08D4}" },
{ page: "sharing", label: "Sharing", icon: "\u{F04E6}" },
{ page: "firewall", label: "Firewall", icon: "\u{F0483}" },
{ page: "printers", label: "Printers", icon: "\u{F042A}" },
{ page: "containers", label: "Containers", icon: "\u{F0868}" },
{ page: "ssh-keys", label: "SSH Keys", icon: "\u{F0306}" },
{ page: "home-phone", label: "Home & Phone", icon: "\u{F02DC}" },
{ page: "desktop", label: "Desktop & Dock", icon: "\u{F04A4}" },
{ page: "sound", label: "Sound", icon: "\u{F057E}" },
{ page: "gaming", label: "Gaming", icon: "\u{F0297}" },
{ page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}" },
{ page: "screen-intelligence", label: "Screen Intelligence", icon: "\u{F05A8}" },
{ page: "shortcuts", label: "Keyboard", icon: "\u{F030C}" },
{ page: "mouse", label: "Mouse & Touchpad", icon: "\u{F037D}" },
{ page: "privacy", label: "Privacy & Security", icon: "\u{F0483}" },
{ page: "region", label: "Region & Language", icon: "\u{F0AC2}" },
{ page: "accounts", label: "Online Accounts", icon: "\u{F0004}" },
{ page: "accessibility", label: "Accessibility", icon: "\u{F0208}" },
{ page: "power", label: "Power & Lock", icon: "\u{F0425}" },
{ page: "datetime", label: "Date & Time", icon: "\u{F0954}" },
{ page: "applications", label: "Applications", icon: "\u{F003B}" },
{ page: "updates", label: "Software Update", icon: "\u{F06B0}" },
{ page: "storage", label: "Storage", icon: "\u{F02CA}" },
{ page: "snapshots", label: "Snapshots", icon: "\u{F0954}" },
{ page: "users", label: "Users", icon: "\u{F0004}" },
{ page: "services", label: "System Health", icon: "\u{F0493}" },
{ page: "manual", label: "Manual", icon: "\u{F02D4}" },
{ page: "about", label: "About", icon: "\u{F02FD}" }
]
// Pages whose entire backing stack can be absent hide once a scan has
// proven it absent: a Containers page with no podman and a Snapshots page
// with no snapper configuration render permanently empty, which reads as
// broken rather than inapplicable. Until the scan lands they stay
// visible, so machines that have the stack never see a blink -- and both
// scans fire when Settings opens (HomePage.onCompleted).
function pageAvailable(page: string): bool {
switch (page) {
case "containers":
return !Containers.scanned || Containers.available;
case "snapshots":
return !Snapshots.scanned || Snapshots.configs.length > 0;
}
return true;
}
// One row per category. SettingsRoutes owns the taxonomy; a category with
// tabs is opened at its first available tab by ShellState's resolution.
readonly property var destinations: SettingsRoutes.categories
width: 272
color: Theme.alpha(Theme.bgDark, 0.96)
@@ -252,14 +207,16 @@ Rectangle {
Rectangle {
id: navItem
required property var modelData
visible: root.pageAvailable(modelData.page)
// A row lights up when it owns the current leaf, so
// System stays highlighted while you sit on Storage.
readonly property bool active: SettingsRoutes.categoryOf(root.selectedPage).page === modelData.page
width: parent.width
height: 40
radius: 10
color: modelData.page === root.selectedPage
color: navItem.active
? Theme.alpha(Theme.accent, 0.17)
: (navMouse.containsMouse ? Theme.alpha(Theme.fg, Theme.hoverAlpha * 0.55) : Theme.alpha(Theme.fg, 0))
border.width: modelData.page === root.selectedPage ? 1 : 0
border.width: navItem.active ? 1 : 0
border.color: Theme.alpha(Theme.accent, 0.26)
Rectangle {
@@ -268,7 +225,7 @@ Rectangle {
radius: 1
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
visible: navItem.modelData.page === root.selectedPage
visible: navItem.active
gradient: Gradient {
GradientStop { position: 0; color: Theme.accent }
GradientStop { position: 1; color: Theme.accentSecondary }
@@ -281,7 +238,7 @@ Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: 25
text: navItem.modelData.icon
color: navItem.modelData.page === root.selectedPage ? Theme.accent : Theme.fgDim
color: navItem.active ? Theme.accent : Theme.fgDim
font.family: Theme.fontMono
font.pixelSize: 15
}
@@ -293,10 +250,10 @@ Rectangle {
anchors.rightMargin: 9
anchors.verticalCenter: parent.verticalCenter
text: navItem.modelData.label
color: navItem.modelData.page === root.selectedPage ? Theme.fg : Theme.fgDim
color: navItem.active ? Theme.fg : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: navItem.modelData.page === root.selectedPage ? Font.Medium : Font.Normal
font.weight: navItem.active ? Font.Medium : Font.Normal
elide: Text.ElideRight
}
@@ -11,6 +11,10 @@
// sat below a wallpaper grid and an entire lock screen. Tabs are not a way to
// make a short page look organised; they are for when the page is long enough
// that the order stops being a suggestion and starts being a burial.
//
// The strip scrolls horizontally when it overflows: the Settings window tiles
// down to 900px, and the System category carries nine tabs. The active tab is
// always brought into view, and edge fades say there is more to the side.
import QtQuick
import qs.config
@@ -27,55 +31,110 @@ Item {
width: parent ? parent.width : 620
implicitHeight: 40
Row {
id: strip
anchors.left: parent.left
anchors.bottom: parent.bottom
spacing: 2
Flickable {
id: flick
Repeater {
model: root.tabs
anchors.fill: parent
contentWidth: strip.implicitWidth
contentHeight: height
flickableDirection: Flickable.HorizontalFlick
boundsBehavior: Flickable.StopAtBounds
interactive: contentWidth > width
clip: true
delegate: Item {
id: tab
function reveal(item: Item): void {
if (contentWidth <= width)
return;
const target = item.x - (flick.width - item.width) / 2;
contentX = Math.max(0, Math.min(contentWidth - width, target));
}
required property var modelData
Row {
id: strip
anchors.bottom: parent.bottom
spacing: 2
readonly property bool active: String(tab.modelData.value) === root.current
Repeater {
model: root.tabs
implicitWidth: caption.implicitWidth + 30
implicitHeight: 38
delegate: Item {
id: tab
Text {
id: caption
anchors.centerIn: parent
text: String(tab.modelData.label ?? "")
color: tab.active ? Theme.fg : (hover.hovered ? Theme.fgDim : Theme.fgMuted)
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: tab.active ? Font.DemiBold : Font.Medium
}
required property var modelData
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 2
radius: 1
visible: tab.active
color: Theme.accent
}
readonly property bool active: String(tab.modelData.value) === root.current
HoverHandler {
id: hover
cursorShape: Qt.PointingHandCursor
}
implicitWidth: caption.implicitWidth + 30
implicitHeight: 38
TapHandler {
onTapped: root.selected(String(tab.modelData.value))
onActiveChanged: if (active) flick.reveal(tab)
Component.onCompleted: if (active) flick.reveal(tab)
Text {
id: caption
anchors.centerIn: parent
text: String(tab.modelData.label ?? "")
color: tab.active ? Theme.fg : (hover.hovered ? Theme.fgDim : Theme.fgMuted)
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: tab.active ? Font.DemiBold : Font.Medium
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 2
radius: 1
visible: tab.active
color: Theme.accent
}
HoverHandler {
id: hover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: root.selected(String(tab.modelData.value))
}
}
}
}
WheelHandler {
target: null
onWheel: event => {
const delta = event.angleDelta.x !== 0 ? event.angleDelta.x : event.angleDelta.y;
flick.contentX = Math.max(0, Math.min(flick.contentWidth - flick.width, flick.contentX - delta));
}
}
}
Rectangle {
anchors.left: parent.left
anchors.bottom: parent.bottom
width: 18
height: parent.height
visible: flick.contentX > 1
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0; color: Theme.bg }
GradientStop { position: 1; color: Theme.alpha(Theme.bg, 0) }
}
}
Rectangle {
anchors.right: parent.right
anchors.bottom: parent.bottom
width: 18
height: parent.height
visible: flick.contentWidth > flick.width && flick.contentX < flick.contentWidth - flick.width - 1
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0; color: Theme.alpha(Theme.bg, 0) }
GradientStop { position: 1; color: Theme.bg }
}
}
Rectangle {
@@ -1,4 +1,4 @@
// Input & Shortcuts.
// Keyboard — layout, key behavior, and every compositor shortcut.
//
// The shortcut list is generated from `hyprctl binds -j` rather than typed out
// here. The previous version was a hand-maintained array of nineteen entries
@@ -46,11 +46,11 @@ SettingsPage {
SystemSettings.commitPreference("keyboardOptions", options.join(","));
}
title: "Input & Shortcuts"
title: "Keyboard"
lede: "The Forge mental model, carried forward into native tiling."
SettingsCard {
title: "Keyboard"
title: "Layout & typing"
// These were read-only text, on the grounds that a layout change needed
// a compositor reload. It does not: setting input:kb_variant through
@@ -31,72 +31,6 @@ SettingsPage {
}
}
// Beside Input on purpose: dictation listens through whichever device that
// card selects, and putting the two together is what makes that obvious.
SettingsCard {
title: "Dictation"
subtitle: Dictation.ready
? "Hold Super+D, speak, and release. The words are typed where the cursor is."
: "Speech to text, on the GPU. Hold Super+D once it is set up. The one-time setup below fetches a speech server and a ~490 MB model — neither ships with Panama, because both are large and want the network."
// Status of the two pieces, read from the machine rather than guessed.
TextRow {
label: "Speech server"
detail: Dictation.serverReady
? "Running"
: (Dictation.imageBuilt ? "Ready — starts on the first dictation" : "Not set up yet")
value: Dictation.imageBuilt ? "Ready" : "Missing"
}
TextRow {
label: "Speech model"
detail: Dictation.modelInstalled
? "Kept across rebuilds of the server"
: "About 490 MB, downloaded once"
value: Dictation.modelInstalled ? Math.round(Dictation.modelBytes / 1048576) + " MB" : "Missing"
}
// ONE action that actually works: panama-dictate setup pulls the
// server image and downloads the model together. This card used to
// offer a Download button wired to a command the helper does not have,
// and told you to build a "panama app whisper-vulkan" that does not
// exist -- so nothing here did anything. It does now.
ActionRow {
visible: !Dictation.ready || Dictation.downloading
label: "Set up dictation"
detail: {
if (!Dictation.downloading)
return "Fetches the speech server and the model. Runs once, keeps both.";
if (Dictation.phase === "pulling")
return "Fetching the speech server…";
if (Dictation.downloadTotalBytes > 0)
return "Downloading model — " + Math.round(Dictation.downloadFraction * 100)
+ "% of " + Math.round(Dictation.downloadTotalBytes / 1048576) + " MB";
return "Setting up…";
}
action: Dictation.downloading ? "Working…" : "Set up"
enabled: !Dictation.downloading
onTriggered: Dictation.setup()
divider: !Dictation.typingAvailable || Dictation.lastError !== ""
}
TextRow {
visible: !Dictation.typingAvailable
label: "Typing"
detail: "wtype is missing, so dictated text would go to the clipboard instead of being typed."
value: "Missing"
divider: Dictation.lastError !== ""
}
TextRow {
visible: Dictation.lastError !== ""
label: "Problem"
detail: Dictation.lastError
value: ""
divider: false
}
}
SettingsCard {
title: "Applications"
subtitle: "Control each application currently playing through PipeWire."
@@ -74,6 +74,7 @@ TimeOfDayRow 1.0 TimeOfDayRow.qml
LocationPicker 1.0 LocationPicker.qml
FontPicker 1.0 FontPicker.qml
MousePage 1.0 MousePage.qml
DictationPage 1.0 DictationPage.qml
TextEntryRow 1.0 TextEntryRow.qml
PrivacyPage 1.0 PrivacyPage.qml
RegionPage 1.0 RegionPage.qml
@@ -1,8 +1,9 @@
// The first thing a new machine shows.
//
// Panama has thirty settings pages, which is the opposite of the usual
// problem: somebody arriving from GNOME, macOS or Windows cannot tell which
// four things matter. This is those four things, once, on the first start.
// Panama has fifteen settings categories and thirty-odd pages inside them,
// which is the opposite of the usual problem: somebody arriving from GNOME,
// macOS or Windows cannot tell which four things matter. This is those four
// things, once, on the first start.
//
// Deliberately not a tour. Nobody reads a tour, and a multi-step wizard on a
// desktop somebody just installed is another thing standing between them and
@@ -14,8 +15,9 @@
// have -- and so this cannot drift the way a hand-written list would.
//
// Shown once, tracked by the `welcomeSeen` preference. It stays reachable
// afterwards from the launcher and from Settings > About, because the moment
// somebody wants it again is exactly the moment a one-shot has thrown it away.
// afterwards from the launcher and from Settings > System > About, because
// the moment somebody wants it again is exactly the moment a one-shot has
// thrown it away.
import Quickshell
import Quickshell.Wayland
@@ -8,11 +8,16 @@ searching there. These commands close that gap, so typing "night light" into the
launcher opens the page that owns it.
The vocabulary is derived from the same sources the in-app search uses --
the page list in SettingsSidebar.qml, the group routing in SettingsSearch.qml,
the taxonomy in SettingsRoutes.qml, the group routing in SettingsSearch.qml,
and the labels in PreferenceSchema.qml -- so a setting that is searchable inside
Settings is searchable from the launcher without anyone maintaining a second
list.
One command per *leaf* page, which is what the sidebar's categories bottom out
in: a category with tabs contributes its tabs, a category without them is a leaf
itself. Landing the launcher on a category would mean landing on whichever tab
happened to be first, which is not what the person typing "printers" asked for.
panama-settings-commands write the commands
panama-settings-commands --check fail if what is on disk is stale
@@ -27,7 +32,7 @@ from pathlib import Path
REPO = Path(__file__).resolve().parents[4]
SHELL = REPO / "config/dot/quickshell"
SIDEBAR = SHELL / "modules/settings/SettingsSidebar.qml"
ROUTES = SHELL / "services/SettingsRoutes.qml"
SEARCH = SHELL / "services/SettingsSearch.qml"
SCHEMA = SHELL / "config/PreferenceSchema.qml"
OUTPUT_DIR = REPO / "config/local/share/vicinae/scripts"
@@ -55,13 +60,51 @@ def read(path: Path) -> str:
raise ParseError(f"could not read {path}") from error
CATEGORY = re.compile(
r'\{\s*page:\s*"([a-z-]+)",\s*label:\s*"([^"]+)",\s*icon:\s*"[^"]*",\s*tabs:\s*\[([^\]]*)\]\s*\}',
re.S)
TAB = re.compile(r'\{\s*page:\s*"([a-z-]+)",\s*label:\s*"([^"]+)"\s*\}')
def categories() -> list[tuple[str, str, list[tuple[str, str]]]]:
"""The sidebar taxonomy, in order, as (id, label, tabs)."""
source = read(ROUTES)
block = re.search(r"readonly property var categories: \[(.*?)\n \]", source, re.S)
if not block:
raise ParseError("categories not found in SettingsRoutes.qml")
found = [(page, label, TAB.findall(tabs))
for page, label, tabs in CATEGORY.findall(block.group(1))]
# A category whose literal stops matching -- a reordered field, an icon
# written as anything but a string -- would otherwise go missing without a
# word, taking its pages' commands with it. So every `page:` written in the
# array has to be accounted for by something this reader recognised.
declared = len(re.findall(r'\bpage:\s*"', block.group(1)))
parsed = sum(1 + len(tabs) for _page, _label, tabs in found)
if not found or parsed != declared:
raise ParseError(
f"read {parsed} of the {declared} pages in SettingsRoutes.qml; the "
"categories array no longer looks the way this reader expects")
return found
def pages() -> list[tuple[str, str]]:
"""The settings pages, in sidebar order, as (id, label)."""
source = read(SIDEBAR)
found = re.findall(r'\{ page: "([a-z-]+)", label: "([^"]+)"', source)
if not found:
raise ParseError("no pages found in SettingsSidebar.qml")
return [(page, label) for page, label in found if page not in SKIP_PAGES]
"""The leaf pages, in sidebar order, as (id, label).
A category with tabs is a group, not somewhere anyone lands: its tabs are
the pages, and each tab is named by its own label rather than the group's.
A category without tabs is a leaf itself.
"""
leaves: list[tuple[str, str]] = []
for page, label, tabs in categories():
leaves += tabs or [(page, label)]
ids = [page for page, _label in leaves]
duplicated = sorted({page for page in ids if ids.count(page) > 1})
if duplicated:
# Two leaves sharing an id write one file between them, so the second
# would quietly overwrite the first's title.
raise ParseError("these leaf pages are declared twice in SettingsRoutes.qml: "
+ ", ".join(duplicated))
return [(page, label) for page, label in leaves if page not in SKIP_PAGES]
def group_pages() -> dict[str, str]:
@@ -17,6 +17,10 @@ Usage:
panama-settings-docs --check exit 1 if the committed copy is stale
panama-settings-docs --stdout print without writing
Where each group of settings lives comes from SettingsSearch.qml's routing and
SettingsRoutes.qml's taxonomy, so a page renamed or moved into another category
is renamed here too rather than in a list somebody has to remember.
Parsing rather than importing: the schema is QML, there is no QML interpreter
here, and a regex reader that FAILS LOUDLY when it stops recognising the file is
better than a dependency on a shell that has to start a compositor.
@@ -32,28 +36,50 @@ import sys
ROOT = pathlib.Path(__file__).resolve().parents[4]
SCHEMA = ROOT / "config/dot/quickshell/config/PreferenceSchema.qml"
SEARCH = ROOT / "config/dot/quickshell/services/SettingsSearch.qml"
ROUTES = ROOT / "config/dot/quickshell/services/SettingsRoutes.qml"
OUTPUT = ROOT / "docs/settings.md"
# Page id -> the name a person sees in the sidebar.
PAGE_TITLES = {
"home": "Home", "appearance": "Appearance", "displays": "Displays",
"connectivity": "Network & Devices", "home-phone": "Home & Phone",
"desktop": "Desktop & Dock", "sound": "Sound",
"notifications": "Notifications & Focus",
"screen-intelligence": "Screen Intelligence", "shortcuts": "Keyboard",
"mouse": "Mouse & Touchpad", "privacy": "Privacy & Security",
"region": "Region & Language", "accounts": "Online Accounts",
"accessibility": "Accessibility", "power": "Power & Lock",
"datetime": "Date & Time", "applications": "Applications",
"services": "System Health", "about": "About",
"gaming": "Gaming",
}
CATEGORY = re.compile(
r'\{\s*page:\s*"([a-z-]+)",\s*label:\s*"([^"]+)",\s*icon:\s*"[^"]*",\s*tabs:\s*\[([^\]]*)\]\s*\}',
re.S)
TAB = re.compile(r'\{\s*page:\s*"([a-z-]+)",\s*label:\s*"([^"]+)"\s*\}')
class SchemaError(RuntimeError):
"""The schema stopped looking the way this reader expects."""
def read_titles():
"""Leaf page id -> the name a person sees, from SettingsRoutes.
The sidebar is fifteen categories of tabs rather than a flat list, so a page
is named the way a hit names it in search: "System Storage" for a tab,
"Displays" for a category that is a page by itself.
"""
text = ROUTES.read_text()
block = re.search(r"readonly property var categories: \[(.*?)\n \]", text, re.S)
if not block:
raise SchemaError("could not find the categories array in SettingsRoutes.qml")
titles = {}
read = 0
for page, label, tabs in CATEGORY.findall(block.group(1)):
found = TAB.findall(tabs)
read += 1 + len(found)
if found:
titles.update({tab: f"{label} {tab_label}" for tab, tab_label in found})
else:
titles[page] = label
# A category this reader stopped recognising would take its pages' names
# with it, and the group that routed to one would then be documented as
# living nowhere. Every `page:` in the array has to be accounted for.
declared = len(re.findall(r'\bpage:\s*"', block.group(1)))
if not titles or read != declared:
raise SchemaError(
f"read {read} of the {declared} pages in SettingsRoutes.qml; the "
"categories array no longer looks the way this reader expects")
return titles
def read_routes():
"""group -> page id, from SettingsSearch."""
text = SEARCH.read_text()
@@ -139,7 +165,7 @@ def read_entries():
return entries
def render(entries, routes):
def render(entries, routes, titles):
lines = [
"# Settings reference",
"",
@@ -163,18 +189,19 @@ def render(entries, routes):
if not visible:
continue
page = routes.get(group)
# A routed page with no title used to fall back to the raw page id, so
# A routed page with no name used to fall back to the raw page id, so
# the group "gaming" documented itself as "Found on **gaming**" while
# every other group named a real page. The staleness contract could not
# see it: regenerating reproduced the same wrong file, so the copy was
# current and wrong at once. Refusing to render is what makes the next
# page added here impossible to miss.
if page is not None and page not in PAGE_TITLES:
# current and wrong at once. Refusing to render is what makes a group
# routed at a page that no longer exists impossible to miss.
if page is not None and page not in titles:
raise SchemaError(
f"group '{group}' routes to page '{page}', which has no entry in "
"PAGE_TITLES; add one rather than letting the id be printed as a name"
f"group '{group}' routes to page '{page}', which is not a leaf page "
"in SettingsRoutes.qml; route it at one rather than letting the id "
"be printed as a name"
)
title = PAGE_TITLES.get(page, page or "—")
title = titles.get(page, page or "—")
lines += [f"## {group}", "", f"Found on **{title}**.", ""]
lines += ["| Setting | Default | What it does |", "|---|---|---|"]
for entry in visible:
@@ -210,7 +237,7 @@ def main():
args = parser.parse_args()
try:
rendered = render(read_entries(), read_routes())
rendered = render(read_entries(), read_routes(), read_titles())
except SchemaError as error:
# Loudly, and without writing. A partial reference is worse than a stale
# one: stale is caught by --check, partial reads as complete.
@@ -0,0 +1,131 @@
pragma Singleton
// The settings taxonomy: which categories the sidebar shows, and which leaf
// pages live inside each as tabs.
//
// The sidebar used to be a flat list of thirty-one pages, which made finding
// Printers a scan of the whole column. Categories group them the way macOS
// groups System Settings; the tab strip above a page switches between the
// leaves of its category. ShellState.settingsPage always holds a *leaf* id —
// the ids callers have always used — so every existing deep link, IPC call,
// and search result keeps working unchanged.
//
// This file is the only place the taxonomy is written down. The sidebar, the
// tab strip, ShellState's route validation, and two generators —
// scripts/panama-settings-commands and scripts/panama-settings-docs, which
// regex-parse the categories array below (keep its literal shape) — all
// derive from it.
import Quickshell
import QtQuick
Singleton {
id: root
// A category with no tabs is a leaf itself. A category with tabs is
// addressed by its first available tab; its own page id is accepted as an
// alias. Three category ids ("applications", "users", "privacy") double as
// the id of their first tab, which resolves to the same place either way.
readonly property var categories: [
{ page: "home", label: "Home", icon: "\u{F02DC}", tabs: [] },
{ page: "appearance", label: "Appearance", icon: "\u{F0E0D}", tabs: [] },
{ page: "desktop", label: "Desktop & Dock", icon: "\u{F04A4}", tabs: [] },
{ page: "displays", label: "Displays", icon: "\u{F0379}", tabs: [] },
{ page: "sound", label: "Sound", icon: "\u{F057E}", tabs: [] },
{ page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}", tabs: [] },
{ page: "input", label: "Input", icon: "\u{F030C}", tabs: [
{ page: "shortcuts", label: "Keyboard" },
{ page: "mouse", label: "Mouse & Touchpad" },
{ page: "dictation", label: "Dictation" }
] },
{ page: "network", label: "Network & Sharing", icon: "\u{F08D4}", tabs: [
{ page: "connectivity", label: "Connections" },
{ page: "firewall", label: "Firewall" },
{ page: "sharing", label: "Sharing" },
{ page: "printers", label: "Printers" }
] },
{ page: "home-phone", label: "Home & Phone", icon: "\u{F02DC}", tabs: [] },
{ page: "applications", label: "Applications", icon: "\u{F003B}", tabs: [
{ page: "applications", label: "Applications" },
{ page: "gaming", label: "Gaming" },
{ page: "screen-intelligence", label: "Screen Intelligence" }
] },
{ page: "users", label: "Users & Accounts", icon: "\u{F0004}", tabs: [
{ page: "users", label: "Users" },
{ page: "accounts", label: "Online Accounts" }
] },
{ page: "privacy", label: "Privacy & Security", icon: "\u{F0483}", tabs: [
{ page: "privacy", label: "Privacy" },
{ page: "ssh-keys", label: "SSH Keys" }
] },
{ page: "power", label: "Power & Lock", icon: "\u{F0425}", tabs: [] },
{ page: "accessibility", label: "Accessibility", icon: "\u{F0208}", tabs: [] },
{ page: "system", label: "System", icon: "\u{F02FD}", tabs: [
{ page: "about", label: "About" },
{ page: "updates", label: "Software Update" },
{ page: "services", label: "System Health" },
{ page: "storage", label: "Storage" },
{ page: "snapshots", label: "Snapshots" },
{ page: "containers", label: "Containers" },
{ page: "datetime", label: "Date & Time" },
{ page: "region", label: "Region & Language" },
{ page: "manual", label: "Manual" }
] }
]
// Pages whose entire backing stack can be absent hide once a scan has
// proven it absent: a Containers tab with no podman and a Snapshots tab
// with no snapper configuration render permanently empty, which reads as
// broken rather than inapplicable. Until the scan lands they stay visible,
// so machines that have the stack never see a blink — both scans fire when
// Settings opens (HomePage.onCompleted).
function pageAvailable(page: string): bool {
switch (page) {
case "containers":
return !Containers.scanned || Containers.available;
case "snapshots":
return !Snapshots.scanned || Snapshots.configs.length > 0;
}
return true;
}
// The category owning a leaf. Tab membership is checked before category
// ids so the three doubled ids land on their category either way.
function categoryOf(leaf: string): var {
const byTab = root.categories.find(cat => cat.tabs.some(tab => tab.page === leaf));
if (byTab)
return byTab;
return root.categories.find(cat => cat.page === leaf) ?? root.categories[0];
}
// Any id a caller may hold — leaf, category, or garbage — to the leaf that
// should render: a leaf resolves to itself, a category to its first
// available tab, anything unknown to home.
function resolve(id: string): string {
const asLeaf = root.categories.some(cat => (cat.page === id && cat.tabs.length === 0)
|| cat.tabs.some(tab => tab.page === id));
if (asLeaf)
return id;
const category = root.categories.find(cat => cat.page === id);
if (category) {
const tab = category.tabs.find(t => root.pageAvailable(t.page));
if (tab)
return tab.page;
}
return "home";
}
// The available tabs of the category owning a leaf, for the strip above
// the page. One entry (or none) means no strip is worth rendering.
function tabsFor(leaf: string): var {
return root.categoryOf(leaf).tabs.filter(tab => root.pageAvailable(tab.page));
}
// "System Storage" for a tabbed leaf, "Displays" for a lone one. This is
// what search results show, so a hit says where it will land.
function breadcrumb(leaf: string): string {
const category = root.categoryOf(leaf);
const tab = category.tabs.find(t => t.page === leaf);
return tab ? category.label + " " + tab.label : category.label;
}
}
@@ -136,6 +136,8 @@ Singleton {
{ label: "Desktop version", detail: "Which Hyprland and Quickshell this session runs", page: "about" },
{ label: "Restore defaults", detail: "Return every Panama setting to its shipped value", page: "desktop" },
{ label: "Keyboard shortcuts", detail: "Every shortcut the compositor has bound", page: "shortcuts" },
{ label: "Dictation", detail: "Speech to text with Super+D, typed where the cursor is", page: "dictation" },
{ label: "Speech to text", detail: "Set up the local speech server and model", page: "dictation" },
{ label: "System Health", detail: "Check Panama services, integrations, tools, and recovery actions", page: "services" },
{ label: "Copy health report", detail: "Copy a redacted Panama doctor report", page: "services" },
{ label: "Lock screen background", detail: "Choose a blurred desktop, wallpaper, or solid color", page: "appearance" },
@@ -118,8 +118,10 @@ Singleton {
}
function showSettings(page: string): void {
const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "gaming", "notifications", "screen-intelligence", "shortcuts", "mouse", "privacy", "region", "accounts", "accessibility", "power", "datetime", "applications", "updates", "storage", "snapshots", "users", "sharing", "firewall", "printers", "containers", "ssh-keys", "services", "manual", "about"];
root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home";
// SettingsRoutes owns the route table: a leaf id passes through, a
// category id lands on its first available tab, and anything unknown
// redirects to home rather than rendering a blank loader.
root.settingsPage = SettingsRoutes.resolve(page);
DesktopPreferences.set("lastPage", root.settingsPage);
root.settingsOpen = true;
}