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,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