diff --git a/config/dot/hypr/README.md b/config/dot/hypr/README.md index a762f76..65d8d74 100644 --- a/config/dot/hypr/README.md +++ b/config/dot/hypr/README.md @@ -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. diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index e2cc491..7b61ec1 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -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", diff --git a/config/dot/quickshell/manual/02-the-keyboard.md b/config/dot/quickshell/manual/02-the-keyboard.md index 15ecef8..384c6ce 100644 --- a/config/dot/quickshell/manual/02-the-keyboard.md +++ b/config/dot/quickshell/manual/02-the-keyboard.md @@ -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. diff --git a/config/dot/quickshell/manual/03-windows-and-workspaces.md b/config/dot/quickshell/manual/03-windows-and-workspaces.md index d9761fe..b3a6e87 100644 --- a/config/dot/quickshell/manual/03-windows-and-workspaces.md +++ b/config/dot/quickshell/manual/03-windows-and-workspaces.md @@ -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 diff --git a/config/dot/quickshell/manual/04-when-something-breaks.md b/config/dot/quickshell/manual/04-when-something-breaks.md index af0c09d..064d5dc 100644 --- a/config/dot/quickshell/manual/04-when-something-breaks.md +++ b/config/dot/quickshell/manual/04-when-something-breaks.md @@ -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 diff --git a/config/dot/quickshell/manual/05-making-it-yours.md b/config/dot/quickshell/manual/05-making-it-yours.md index aa18346..a0f7121 100644 --- a/config/dot/quickshell/manual/05-making-it-yours.md +++ b/config/dot/quickshell/manual/05-making-it-yours.md @@ -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 diff --git a/config/dot/quickshell/modules/settings/ConnectivityPage.qml b/config/dot/quickshell/modules/settings/ConnectivityPage.qml index 43ec75c..6a97d43 100644 --- a/config/dot/quickshell/modules/settings/ConnectivityPage.qml +++ b/config/dot/quickshell/modules/settings/ConnectivityPage.qml @@ -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." diff --git a/config/dot/quickshell/modules/settings/DictationPage.qml b/config/dot/quickshell/modules/settings/DictationPage.qml new file mode 100644 index 0000000..1dd6641 --- /dev/null +++ b/config/dot/quickshell/modules/settings/DictationPage.qml @@ -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") + } + } +} diff --git a/config/dot/quickshell/modules/settings/README.md b/config/dot/quickshell/modules/settings/README.md index 24e222c..9926bab 100644 --- a/config/dot/quickshell/modules/settings/README.md +++ b/config/dot/quickshell/modules/settings/README.md @@ -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 diff --git a/config/dot/quickshell/modules/settings/SettingsShell.qml b/config/dot/quickshell/modules/settings/SettingsShell.qml index 7ad6dff..19aedf7 100644 --- a/config/dot/quickshell/modules/settings/SettingsShell.qml +++ b/config/dot/quickshell/modules/settings/SettingsShell.qml @@ -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 {} } diff --git a/config/dot/quickshell/modules/settings/SettingsSidebar.qml b/config/dot/quickshell/modules/settings/SettingsSidebar.qml index ce5f214..648ed5d 100644 --- a/config/dot/quickshell/modules/settings/SettingsSidebar.qml +++ b/config/dot/quickshell/modules/settings/SettingsSidebar.qml @@ -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 } diff --git a/config/dot/quickshell/modules/settings/SettingsTabs.qml b/config/dot/quickshell/modules/settings/SettingsTabs.qml index 7bbddea..e1cd282 100644 --- a/config/dot/quickshell/modules/settings/SettingsTabs.qml +++ b/config/dot/quickshell/modules/settings/SettingsTabs.qml @@ -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 { diff --git a/config/dot/quickshell/modules/settings/ShortcutsPage.qml b/config/dot/quickshell/modules/settings/ShortcutsPage.qml index c49b0be..0990d97 100644 --- a/config/dot/quickshell/modules/settings/ShortcutsPage.qml +++ b/config/dot/quickshell/modules/settings/ShortcutsPage.qml @@ -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 diff --git a/config/dot/quickshell/modules/settings/SoundPage.qml b/config/dot/quickshell/modules/settings/SoundPage.qml index da3170d..0f430e6 100644 --- a/config/dot/quickshell/modules/settings/SoundPage.qml +++ b/config/dot/quickshell/modules/settings/SoundPage.qml @@ -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." diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index 23fa8c5..27868a9 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -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 diff --git a/config/dot/quickshell/modules/welcome/Welcome.qml b/config/dot/quickshell/modules/welcome/Welcome.qml index 37c53d5..13b545c 100644 --- a/config/dot/quickshell/modules/welcome/Welcome.qml +++ b/config/dot/quickshell/modules/welcome/Welcome.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 diff --git a/config/dot/quickshell/scripts/panama-settings-commands b/config/dot/quickshell/scripts/panama-settings-commands index 0437cea..034d7e1 100755 --- a/config/dot/quickshell/scripts/panama-settings-commands +++ b/config/dot/quickshell/scripts/panama-settings-commands @@ -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]: diff --git a/config/dot/quickshell/scripts/panama-settings-docs b/config/dot/quickshell/scripts/panama-settings-docs index 3811825..e8e236d 100755 --- a/config/dot/quickshell/scripts/panama-settings-docs +++ b/config/dot/quickshell/scripts/panama-settings-docs @@ -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. diff --git a/config/dot/quickshell/services/SettingsRoutes.qml b/config/dot/quickshell/services/SettingsRoutes.qml new file mode 100644 index 0000000..351d6d6 --- /dev/null +++ b/config/dot/quickshell/services/SettingsRoutes.qml @@ -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; + } +} diff --git a/config/dot/quickshell/services/SettingsSearch.qml b/config/dot/quickshell/services/SettingsSearch.qml index 1e19641..c0dc30a 100644 --- a/config/dot/quickshell/services/SettingsSearch.qml +++ b/config/dot/quickshell/services/SettingsSearch.qml @@ -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" }, diff --git a/config/dot/quickshell/services/ShellState.qml b/config/dot/quickshell/services/ShellState.qml index b699fb3..07570fa 100644 --- a/config/dot/quickshell/services/ShellState.qml +++ b/config/dot/quickshell/services/ShellState.qml @@ -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; } diff --git a/config/local/share/vicinae/scripts/settings-connectivity b/config/local/share/vicinae/scripts/settings-connectivity index 2bdac3b..3d39046 100755 --- a/config/local/share/vicinae/scripts/settings-connectivity +++ b/config/local/share/vicinae/scripts/settings-connectivity @@ -1,10 +1,10 @@ #!/usr/bin/env bash # Generated by scripts/panama-settings-commands -- do not edit by hand. # @vicinae.schemaVersion 1 -# @vicinae.title Settings: Network & Devices +# @vicinae.title Settings: Connections # @vicinae.mode silent # @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg -# @vicinae.description Open Network & Devices in Settings. +# @vicinae.description Open Connections in Settings. # @vicinae.keywords ["settings", "wi-fi", "bluetooth", "printers"] exec "$HOME/.config/quickshell/scripts/panama-action" settings-page connectivity diff --git a/config/local/share/vicinae/scripts/settings-dictation b/config/local/share/vicinae/scripts/settings-dictation new file mode 100755 index 0000000..9b9e791 --- /dev/null +++ b/config/local/share/vicinae/scripts/settings-dictation @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Generated by scripts/panama-settings-commands -- do not edit by hand. +# @vicinae.schemaVersion 1 +# @vicinae.title Settings: Dictation +# @vicinae.mode silent +# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg +# @vicinae.description Open Dictation in Settings. +# @vicinae.keywords ["settings", "dictation", "speech to text"] + +exec "$HOME/.config/quickshell/scripts/panama-action" settings-page dictation diff --git a/config/local/share/vicinae/scripts/settings-privacy b/config/local/share/vicinae/scripts/settings-privacy index 7852506..49aa7dc 100755 --- a/config/local/share/vicinae/scripts/settings-privacy +++ b/config/local/share/vicinae/scripts/settings-privacy @@ -1,10 +1,10 @@ #!/usr/bin/env bash # Generated by scripts/panama-settings-commands -- do not edit by hand. # @vicinae.schemaVersion 1 -# @vicinae.title Settings: Privacy & Security +# @vicinae.title Settings: Privacy # @vicinae.mode silent # @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg -# @vicinae.description Open Privacy & Security in Settings. +# @vicinae.description Open Privacy in Settings. # @vicinae.keywords ["settings", "saved passwords", "camera and microphone", "screen sharing", "file history and trash", "device security"] exec "$HOME/.config/quickshell/scripts/panama-action" settings-page privacy diff --git a/docs/settings.md b/docs/settings.md index d4a4392..0f4cc28 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -42,7 +42,7 @@ Found on **Power & Lock**. ## capture -Found on **Screen Intelligence**. +Found on **Applications › Screen Intelligence**. | Setting | Default | What it does | |---|---|---| @@ -127,7 +127,7 @@ Found on **Desktop & Dock**. ## gaming -Found on **Gaming**. +Found on **Applications › Gaming**. | Setting | Default | What it does | |---|---|---| @@ -157,7 +157,7 @@ Found on **Power & Lock**. ## input -Found on **Keyboard**. +Found on **Input › Keyboard**. | Setting | Default | What it does | |---|---|---| @@ -244,7 +244,7 @@ Found on **Notifications & Focus**. ## pointer -Found on **Mouse & Touchpad**. +Found on **Input › Mouse & Touchpad**. | Setting | Default | What it does | |---|---|---| @@ -259,7 +259,7 @@ Found on **Mouse & Touchpad**. ## search -Found on **Applications**. +Found on **Applications › Applications**. | Setting | Default | What it does | |---|---|---| @@ -286,7 +286,7 @@ Found on **Appearance**. ## touchpad -Found on **Mouse & Touchpad**. +Found on **Input › Mouse & Touchpad**. | Setting | Default | What it does | |---|---|---| diff --git a/docs/superpowers/specs/2026-08-23-settings-navigation-redesign.md b/docs/superpowers/specs/2026-08-23-settings-navigation-redesign.md new file mode 100644 index 0000000..7acf888 --- /dev/null +++ b/docs/superpowers/specs/2026-08-23-settings-navigation-redesign.md @@ -0,0 +1,69 @@ +# Settings navigation redesign — 31 flat pages become 15 categories + +Approved 2026-08-23. The sidebar's 31 entries collapse into 15 categories. Multi-subject +categories render an Appearance-style tab strip above the page; single-subject categories +render no strip. Every old page id keeps working forever. + +## The taxonomy + +| Category (sidebar) | Tabs (leaf page ids) | +|---|---| +| Home | — (`home`) | +| Appearance | — (`appearance`, keeps its internal tabs) | +| Desktop & Dock | — (`desktop`) | +| Displays | — (`displays`) | +| Sound | — (`sound`, Dictation card moves out) | +| Notifications & Focus | — (`notifications`) | +| Input | Keyboard (`shortcuts`), Mouse & Touchpad (`mouse`), Dictation (`dictation`, new) | +| Network & Sharing | Connections (`connectivity`), Firewall (`firewall`), Sharing (`sharing`), Printers (`printers`) | +| Home & Phone | — (`home-phone`) | +| Applications | Applications (`applications`), Gaming (`gaming`), Screen Intelligence (`screen-intelligence`) | +| Users & Accounts | Users (`users`), Online Accounts (`accounts`) | +| Privacy & Security | Privacy (`privacy`), SSH Keys (`ssh-keys`) | +| Power & Lock | — (`power`) | +| Accessibility | — (`accessibility`) | +| System | About (`about`), Software Update (`updates`), System Health (`services`), Storage (`storage`), Snapshots (`snapshots`), Containers (`containers`), Date & Time (`datetime`), Region & Language (`region`), Manual (`manual`) | + +## Architecture + +`ShellState.settingsPage` keeps holding the **leaf** id, exactly as today. The page loader's +switch, the ownership contracts, HomePage's direct jumps, and every external caller are +untouched by the grouping itself. What changes: + +- **`services/SettingsRoutes.qml` (new singleton)** — the one source of truth for the + taxonomy: `categories` (page, label, icon, tabs), `resolve(id)` (category id → first + available leaf; leaf → itself; unknown → home), `categoryOf(leaf)`, `tabsFor(leaf)` + (availability-filtered), `breadcrumb(leaf)`, `pageAvailable(leaf)` (the podman/snapper + gating, moved from the sidebar). +- **`SettingsSidebar.qml`** — destinations come from `SettingsRoutes.categories`; a row + highlights when it is the category owning the current leaf; clicking requests the + category id and `ShellState` resolves it. +- **`ShellState.showSettings`** — the literal allow-list becomes `SettingsRoutes.resolve()`. +- **`SettingsShell.qml`** — renders a `SettingsTabs` strip between the titlebar and the + page loader whenever the current leaf's category has more than one available tab. + Tab click is just `openSettings(leafId)`. Adds the `dictation` case. +- **`SettingsTabs.qml`** — the strip becomes horizontally scrollable when it overflows + (System has nine tabs; the window tiles down to 900px). +- **`SoundPage.qml` / `DictationPage.qml` (new)** — the Dictation card moves to its own + page under Input, with a labeled handoff noting the microphone is chosen in Sound. +- **`SettingsSearch.qml`** — dictation `extraEntries` re-route to `dictation`; the search + result subtitle becomes the `SettingsRoutes` breadcrumb ("System › Storage"). Because + results carry leaf ids, search now lands on the exact tab with no further work. + +## Blast radius owned by this change + +- `scripts/panama-settings-commands` regex-parses the sidebar's destinations array; it + moves to parsing `SettingsRoutes.qml` and regenerates the Vicinae commands (one per + leaf; `home` still skipped; `dictation` gained). `scripts/panama-settings-docs` checked + the same way. +- Test contracts updated in the same change, not after: `settings-nav-contract`, + `settings-jump-contract`, `search-routing-contract`, `gnome-handoff-contract`, + `manual-contract`, `sound-page-contract`, plus any contract found grepping page ids. +- `modules/settings/README.md` gains the navigation model; manual chapter + `05-making-it-yours.md` updated where it describes the sidebar. + +## Non-goals + +- No per-tab deep links into Appearance's internal tabs from search (unchanged today). +- No persistence of last-selected tab per category (Appearance already resets; same rule). +- No renaming of leaf ids, files, or schema groups. diff --git a/tests/quickshell/gnome-handoff-contract b/tests/quickshell/gnome-handoff-contract index b592db4..202ed0a 100755 --- a/tests/quickshell/gnome-handoff-contract +++ b/tests/quickshell/gnome-handoff-contract @@ -12,10 +12,10 @@ # longer true, and no test could notice because none of them knew what Panama # had come to own in the meantime. # -# This reads the sidebar for the pages that exist and the pages for the panels -# they hand off, and fails on any overlap. It is deliberately derived from both -# sides rather than from a hand-kept list, so absorbing the next page cannot -# leave a stale door behind. +# This reads SettingsRoutes for the pages that exist and the pages for the +# panels they hand off, and fails on any overlap. It is deliberately derived +# from both sides rather than from a hand-kept list, so absorbing the next page +# cannot leave a stale door behind. # # Read-only. @@ -23,14 +23,14 @@ set -uo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" settings_dir="$repo_dir/config/dot/quickshell/modules/settings" -sidebar="$settings_dir/SettingsSidebar.qml" +routes="$repo_dir/config/dot/quickshell/services/SettingsRoutes.qml" fail() { printf 'gnome handoff contract: %s\n' "$1" >&2 exit 1 } -[[ -r "$sidebar" ]] || fail "missing $sidebar" +[[ -r "$routes" ]] || fail "missing $routes" # GNOME panel names that correspond to a Panama page. Only entries whose panel # genuinely duplicates a Panama page belong here: "network" stays off it because @@ -52,8 +52,14 @@ declare -A ALLOWED=( ["UsersPage.qml:system-users"]="fingerprint enrollment requires fprintd's guided capture flow, and GNOME's Users panel carries the only good dialog for it" ) -pages="$(grep -oE '\{ *page: "[a-z-]+"' "$sidebar" | sed 's/.*"\(.*\)"/\1/' | sort -u)" -[[ -n "$pages" ]] || fail 'no pages could be read from the sidebar, so this proves nothing' +# The leaves: a tabless category is a page in its own right, and every tab is +# a page. A category that only groups tabs owns no controls itself, so it is +# not something GNOME could be handing a duplicate of. +pages="$( { + grep -oE '\{ page: "[a-z-]+", label: "[^"]*", icon: "[^"]*", tabs: \[\] \}' "$routes" + grep -oE '\{ page: "[a-z-]+", label: "[^"]*" \}' "$routes" +} | sed -E 's/\{ page: "([a-z-]+)".*/\1/' | sort -u)" +[[ -n "$pages" ]] || fail 'no pages could be read from SettingsRoutes, so this proves nothing' has_page() { grep -qx "$1" <<<"$pages" diff --git a/tests/quickshell/health-ui-contract b/tests/quickshell/health-ui-contract index a27875c..4be8e72 100755 --- a/tests/quickshell/health-ui-contract +++ b/tests/quickshell/health-ui-contract @@ -42,8 +42,11 @@ rg -Fq 'HealthCheckRow 1.0 HealthCheckRow.qml' "$settings_dir/qmldir" \ ! rg -Fq 'ServicesPage 1.0 ServicesPage.qml' "$settings_dir/qmldir" \ || fail 'retired ServicesPage remains registered in the Settings QML module' -rg -Fq 'label: "System Health"' "$settings_dir/SettingsSidebar.qml" \ - || fail 'sidebar does not label the stable services route System Health' +# The route is named in SettingsRoutes now that it is a tab of System rather +# than a sidebar row; the sidebar reads its labels from there. +rg -Fq '{ page: "services", label: "System Health" }' \ + "$repo_dir/config/dot/quickshell/services/SettingsRoutes.qml" \ + || fail 'the settings taxonomy does not label the stable services route System Health' rg -Fq 'onTapped: root.pageRequested("services")' "$settings_dir/SettingsSidebar.qml" \ || fail 'health footer does not open the stable services route' rg -Fq 'height: 54' "$settings_dir/SettingsSidebar.qml" \ diff --git a/tests/quickshell/manual-contract b/tests/quickshell/manual-contract index 2cd5656..7753c59 100755 --- a/tests/quickshell/manual-contract +++ b/tests/quickshell/manual-contract @@ -24,7 +24,7 @@ set -uo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" manual_dir="$repo_dir/config/dot/quickshell/manual" page="$repo_dir/config/dot/quickshell/modules/settings/ManualPage.qml" -sidebar="$repo_dir/config/dot/quickshell/modules/settings/SettingsSidebar.qml" +routes="$repo_dir/config/dot/quickshell/services/SettingsRoutes.qml" shell_ui="$repo_dir/config/dot/quickshell/modules/settings/SettingsShell.qml" qmldir="$repo_dir/config/dot/quickshell/modules/settings/qmldir" state="$repo_dir/config/dot/quickshell/services/ShellState.qml" @@ -79,13 +79,27 @@ grep -q 'Quickshell.shellDir + "/manual/"' "$page" \ grep -q '\.\./\.\./\.\.' "$page" \ && note 'the manual path walks upward out of the shell directory, which is only correct by accident' -# ── 4. Registered in all five places ───────────────────────────────────────── +# ── 4. Registered everywhere a settings page has to be ─────────────────────── +# The manual is a tab of the System category rather than a sidebar row of its +# own -- it is reference material, not a control surface, and it belongs beside +# About for the same reason. SettingsRoutes is what makes it reachable at all: +# a leaf missing from the taxonomy cannot be opened, searched, or linked to. -grep -q '{ page: "manual"' "$sidebar" || note 'the manual has no sidebar entry' +grep -q '{ page: "manual", label: ' "$routes" || note 'the manual is not a leaf in SettingsRoutes, so nothing can navigate to it' +python3 - "$routes" <<'PY' || note 'the manual is no longer a tab of the System category, so it has drifted out of the group it belongs to' +import re, sys + +text = open(sys.argv[1], encoding="utf-8").read() +block = re.search(r'\{ page: "system",.*?tabs: \[(.*?)\] \}', text, re.S) +raise SystemExit(0 if block and '{ page: "manual"' in block.group(1) else 1) +PY grep -q 'case "manual": return manualPage;' "$shell_ui" || note 'SettingsShell does not route to the manual' grep -q 'Component { id: manualPage; ManualPage {} }' "$shell_ui" || note 'SettingsShell never declares the manual component' grep -q '^ManualPage 1.0 ManualPage.qml$' "$qmldir" || note 'ManualPage is not registered in the settings qmldir' -grep -q '"manual"' "$state" || note 'the manual is not in the allowed settings pages, so openSettings would redirect to Home' +# ShellState keeps no page list of its own any more; it resolves whatever it is +# handed through SettingsRoutes. That is what makes the taxonomy check above +# sufficient, so it is worth pinning that it stays that way. +grep -q 'SettingsRoutes.resolve(' "$state" || note 'ShellState does not resolve pages through SettingsRoutes, so openSettings("manual") has no defined destination' grep -q 'page: "manual"' "$search" || note 'the manual is not searchable from the settings search box' if (( ${#findings[@]} > 0 )); then diff --git a/tests/quickshell/panama-commands-contract b/tests/quickshell/panama-commands-contract index f526e84..6bd1176 100755 --- a/tests/quickshell/panama-commands-contract +++ b/tests/quickshell/panama-commands-contract @@ -38,16 +38,24 @@ declare -A expected=( [restart-shell]=restart-shell ) -# The per-page settings commands are generated from the settings page list, so -# they are enumerated from that list rather than restated here -- a hand-written -# copy would have to be edited every time a page is added, which is exactly the -# kind of second list this generator exists to avoid. -sidebar="$repo_dir/config/dot/quickshell/modules/settings/SettingsSidebar.qml" +# The per-page settings commands are generated from the settings taxonomy, so +# they are enumerated from it rather than restated here -- a hand-written copy +# would have to be edited every time a page is added, which is exactly the kind +# of second list this generator exists to avoid. +# +# One command per leaf, not per sidebar row: a launcher entry that opened a +# category would land on whichever tab that category happens to open first, +# which is not what somebody typing "firewall" asked for. Home is skipped +# because open-settings already goes there. +routes="$repo_dir/config/dot/quickshell/services/SettingsRoutes.qml" while read -r page; do [[ -n "$page" ]] || continue [[ "$page" == "home" ]] && continue expected[settings-$page]="settings-page $page" -done < <(grep -oE '\{ page: "[a-z-]+"' "$sidebar" | sed 's/.*"\([a-z-]*\)"/\1/') +done < <( { + grep -oE '\{ page: "[a-z-]+", label: "[^"]*", icon: "[^"]*", tabs: \[\] \}' "$routes" + grep -oE '\{ page: "[a-z-]+", label: "[^"]*" \}' "$routes" +} | sed -E 's/\{ page: "([a-z-]+)".*/\1/' | sort -u) (( ${#expected[@]} > 18 )) || fail 'no generated per-page commands were found; run scripts/panama-settings-commands' diff --git a/tests/quickshell/settings-jump-contract b/tests/quickshell/settings-jump-contract index d591064..99239b6 100755 --- a/tests/quickshell/settings-jump-contract +++ b/tests/quickshell/settings-jump-contract @@ -2,19 +2,24 @@ # Every "open the settings for this" jump must land somewhere real. # -# ShellState.openSettings() validates its argument against an allow-list and -# falls back to Home for anything unknown. That fallback is sensible and it is -# also completely silent: a typo, or a page renamed later, turns a right-click -# into "opens Settings on the wrong page" with nothing logged and no error. +# ShellState.openSettings() hands its argument to SettingsRoutes.resolve(), +# which falls back to Home for anything it does not recognise. That fallback is +# sensible and it is also completely silent: a typo, or a page renamed later, +# turns a right-click into "opens Settings on the wrong page" with nothing +# logged and no error. # # Before this, exactly four places in the entire shell could reach Settings, so # the risk was small. The bar now offers a jump on every widget, which makes the # fallback worth guarding. +# +# A jump may name either a leaf ("firewall") or a category ("network"), since +# resolve() opens a category at its first available tab. Anything else is a +# typo, and SettingsRoutes.qml is the only place that knows which is which. set -uo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -shell_state="$repo_dir/config/dot/quickshell/services/ShellState.qml" +routes="$repo_dir/config/dot/quickshell/services/SettingsRoutes.qml" modules="$repo_dir/config/dot/quickshell/modules" dock_menu="$modules/dock/DockContextMenu.qml" notification_card="$modules/notifications/NotificationCard.qml" @@ -25,8 +30,12 @@ fail() { exit 1 } -allowed_line="$(grep -m1 'const allowed = \[' "$shell_state")" \ - || fail 'could not find the allow-list in ShellState' +[[ -r "$routes" ]] || fail "cannot read $routes" + +# Every id SettingsRoutes recognises: the categories (which carry an icon) and +# the tabs inside them (which do not). +known="$(grep -oE '\{ page: "[a-z-]+"' "$routes" | sed 's/.*"\(.*\)"/\1/' | sort -u)" +[[ -n "$known" ]] || fail 'no pages found in SettingsRoutes -- this contract is not reading it correctly' jumps="$(grep -rhoE 'openSettings\("[a-z-]+"\)' "$modules" 2>/dev/null \ | sed 's/openSettings("//; s/")//' | sort -u)" @@ -35,8 +44,8 @@ jumps="$(grep -rhoE 'openSettings\("[a-z-]+"\)' "$modules" 2>/dev/null \ count=0 while read -r page; do [[ -n "$page" ]] || continue - grep -qF "\"$page\"" <<<"$allowed_line" \ - || fail "a jump opens \"$page\", which ShellState does not allow -- openSettings falls back to Home silently, so this reads as a right-click that goes to the wrong page" + grep -qx "$page" <<<"$known" \ + || fail "a jump opens \"$page\", which is neither a leaf nor a category in SettingsRoutes -- resolve() falls back to Home silently, so this reads as a right-click that goes to the wrong page" count=$((count + 1)) done <<<"$jumps" diff --git a/tests/quickshell/settings-nav-contract b/tests/quickshell/settings-nav-contract index 1eaff61..d46e717 100755 --- a/tests/quickshell/settings-nav-contract +++ b/tests/quickshell/settings-nav-contract @@ -3,23 +3,32 @@ # Adding a settings page means editing four separate files, and missing one # fails quietly rather than loudly: # -# SettingsSidebar.qml the row you click -# SettingsShell.qml the case that maps that row to a component, AND the +# services/SettingsRoutes.qml the taxonomy: which category the page belongs +# to, and whether it is a tab inside one or a category +# of its own. This is the single source of truth -- the +# sidebar, the tab strip, ShellState's route +# resolution and the launcher command generator all +# derive from it, so a page absent here is a page that +# exists nowhere +# SettingsShell.qml the case that maps a leaf to a component, AND the # Component declaration itself -# ShellState.qml the allow-list openSettings() checks -- a page missing -# here silently redirects to Home, so a deep link or a -# search result lands on the wrong page with no error # modules/settings/qmldir the component registration -- without it the page # is "not a type" and the whole settings window fails # to load, taking every other page with it +# the .qml file itself # -# Nothing at runtime cross-checks the four. This does, statically. +# ShellState no longer keeps its own allow-list: it asks SettingsRoutes to +# resolve whatever id it is handed. That removed a fifth place to forget, and +# this contract pins that it stays removed -- a literal list reappearing there +# would silently disagree with the taxonomy again. +# +# Nothing at runtime cross-checks any of this. This does, statically. set -euo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" settings_dir="$repo_dir/config/dot/quickshell/modules/settings" -sidebar="$settings_dir/SettingsSidebar.qml" +routes="$repo_dir/config/dot/quickshell/services/SettingsRoutes.qml" shell_file="$settings_dir/SettingsShell.qml" qmldir="$settings_dir/qmldir" shell_state="$repo_dir/config/dot/quickshell/services/ShellState.qml" @@ -29,17 +38,49 @@ fail() { exit 1 } -for required in "$sidebar" "$shell_file" "$qmldir" "$shell_state"; do +for required in "$routes" "$shell_file" "$qmldir" "$shell_state"; do [[ -r "$required" ]] || fail "cannot read $required" done -# ── Every sidebar row resolves everywhere ──────────────────────────────────── -pages="$(grep -oE '\{ page: "[a-z-]+"' "$sidebar" | sed 's/.*"\(.*\)"/\1/')" -[[ -n "$pages" ]] || fail 'no pages found in the sidebar -- this contract is not reading it correctly' +# ── Reading the taxonomy ───────────────────────────────────────────────────── +# A category line carries an icon; a tab line does not. A category whose tabs +# are empty is a leaf itself, which is why the two shapes are read separately. +category_ids="$(grep -oE '\{ page: "[a-z-]+", label: "[^"]*", icon:' "$routes" \ + | sed -E 's/\{ page: "([a-z-]+)".*/\1/')" +tabless_ids="$(grep -oE '\{ page: "[a-z-]+", label: "[^"]*", icon: "[^"]*", tabs: \[\] \}' "$routes" \ + | sed -E 's/\{ page: "([a-z-]+)".*/\1/')" +tab_ids="$(grep -oE '\{ page: "[a-z-]+", label: "[^"]*" \}' "$routes" \ + | sed -E 's/\{ page: "([a-z-]+)".*/\1/')" -allowed_line="$(grep -m1 'const allowed = \[' "$shell_state")" \ - || fail 'could not find the allow-list in ShellState' +[[ -n "$category_ids" ]] || fail 'no categories found in SettingsRoutes -- this contract is not reading it correctly' +[[ -n "$tab_ids" ]] || fail 'no tabs found in SettingsRoutes -- this contract is not reading it correctly' +# The leaves: every page a person can actually land on. +leaves="$(printf '%s\n%s\n' "$tabless_ids" "$tab_ids" | sed '/^$/d')" + +# ── The taxonomy addresses each leaf exactly once ───────────────────────────── +# ShellState.settingsPage holds a leaf id, and the sidebar highlights the +# category that owns it. A leaf in two categories makes "which row lights up" +# depend on iteration order, and makes the breadcrumb a coin flip. +duplicate_tabs="$(sort <<<"$tab_ids" | uniq -d)" +[[ -z "$duplicate_tabs" ]] \ + || fail "these tab pages appear in more than one category, so the sidebar highlight and the breadcrumb become ambiguous: $(tr '\n' ' ' <<<"$duplicate_tabs")" + +# A category with tabs may share its id with its first tab -- "applications", +# "users" and "privacy" do, and both readings land on the same category. A +# category *without* tabs is a leaf, so sharing an id with a tab elsewhere +# would put one page in two places. +while read -r page; do + [[ -n "$page" ]] || continue + grep -qx "$page" <<<"$tab_ids" \ + && fail "\"$page\" is a category with no tabs and also a tab of another category, so the same page id names two different places" +done <<<"$tabless_ids" + +duplicate_categories="$(sort <<<"$category_ids" | uniq -d)" +[[ -z "$duplicate_categories" ]] \ + || fail "these category ids are declared twice: $(tr '\n' ' ' <<<"$duplicate_categories")" + +# ── Every leaf resolves everywhere ─────────────────────────────────────────── while read -r page; do [[ -n "$page" ]] || continue @@ -47,12 +88,15 @@ while read -r page; do # where an unknown page falls back to. if [[ "$page" != "home" ]]; then grep -qE "case \"$page\": return [a-zA-Z]+;" "$shell_file" \ - || fail "the sidebar offers \"$page\" but SettingsShell has no case for it, so clicking it shows Home" + || fail "SettingsRoutes offers \"$page\" but SettingsShell has no case for it, so opening it shows Home" fi +done <<<"$leaves" - grep -qF "\"$page\"" <<<"$allowed_line" \ - || fail "\"$page\" is missing from ShellState's allow-list, so openSettings(\"$page\") silently redirects to Home" -done <<<"$pages" +# ── ShellState defers to the taxonomy instead of restating it ──────────────── +grep -q 'SettingsRoutes\.resolve(' "$shell_state" \ + || fail 'showSettings() does not route through SettingsRoutes.resolve, so a category id or an unknown page has no defined destination' +grep -q 'const allowed = \[' "$shell_state" \ + && fail 'ShellState has grown a literal allow-list again -- it will drift from SettingsRoutes, and a page missing from it silently redirects to Home' # ── Every routed component is declared and registered ──────────────────────── # The case arms name a Component id; each must have a declaration, and the type @@ -86,4 +130,5 @@ while read -r page_file; do || fail "$type_name.qml exists but nothing in SettingsShell instantiates it" done < <(find "$settings_dir" -maxdepth 1 -name '*Page.qml') -printf 'settings nav contract: PASS\n' +printf 'settings nav contract: PASS (%d categories, %d leaves)\n' \ + "$(grep -c . <<<"$category_ids")" "$(grep -c . <<<"$leaves")" diff --git a/tests/quickshell/settings-pages-contract b/tests/quickshell/settings-pages-contract index 4a1f54a..27bd8d7 100755 --- a/tests/quickshell/settings-pages-contract +++ b/tests/quickshell/settings-pages-contract @@ -9,7 +9,7 @@ fail() { exit 1 } -pages=(Home Displays Connectivity Sound Notifications ScreenIntelligence Health About) +pages=(Home Displays Connectivity Sound Dictation Notifications ScreenIntelligence Health About) for page in "${pages[@]}"; do page_file="$repo_dir/config/dot/quickshell/modules/settings/${page}Page.qml" [[ -f "$page_file" ]] || fail "${page}Page.qml is missing" @@ -303,7 +303,11 @@ qs_for_test ipc call home-assistant fixture ready >/dev/null shell_pid="$harness_pid" [[ "$shell_pid" =~ ^[0-9]+$ ]] || fail 'could not identify the branch shell process' -pages=(home appearance displays connectivity home-phone desktop sound notifications screen-intelligence shortcuts services about) +# A spread of leaves rather than all of them: tabless categories, tabs from +# four different categories, and the page the tab strip was introduced for. +# Routing to a tab must land on that tab, not on whatever its category opens +# first, which is the failure the SettingsRoutes resolution could introduce. +pages=(home appearance displays connectivity home-phone desktop sound dictation notifications screen-intelligence shortcuts services manual about) for page in "${pages[@]}"; do qs_for_test ipc call settings page "$page" >/dev/null for _ in $(seq 1 20); do diff --git a/tests/quickshell/settings-search-contract b/tests/quickshell/settings-search-contract index b2d08be..7cfc9d4 100755 --- a/tests/quickshell/settings-search-contract +++ b/tests/quickshell/settings-search-contract @@ -104,12 +104,20 @@ EXTRACT ) [[ "$missing" -eq 0 ]] || fail "$missing schema label(s) are not findable by search" -# ── Results only ever route to pages the shell can open ────────────────────── -allowed="$(grep -oE 'const allowed = \[[^]]*\]' "$repo_dir/config/dot/quickshell/services/ShellState.qml" \ - | grep -oE '"[a-z-]+"' | tr -d '"' | sort -u)" -for query in wallpaper blur timezone screenshot pointer lock volume gaps; do +# ── Results only ever route to leaves the shell can open ───────────────────── +# A search result carries a leaf id, never a category: it has to land on the +# exact tab holding the setting, not on whichever tab that category opens +# first. SettingsRoutes is where the leaves are declared -- a tabless category +# is one, and so is every tab. +routes="$repo_dir/config/dot/quickshell/services/SettingsRoutes.qml" +leaves="$( { + grep -oE '\{ page: "[a-z-]+", label: "[^"]*", icon: "[^"]*", tabs: \[\] \}' "$routes" + grep -oE '\{ page: "[a-z-]+", label: "[^"]*" \}' "$routes" +} | sed -E 's/\{ page: "([a-z-]+)".*/\1/' | sort -u)" +[[ -n "$leaves" ]] || fail 'no leaf pages could be read from SettingsRoutes, so this proves nothing' +for query in wallpaper blur timezone screenshot pointer lock volume gaps dictation; do page="$(find_top "$query" | jq -r .topPage)" - grep -qx "$page" <<<"$allowed" || fail "search routed '$query' to unknown page '$page'" + grep -qx "$page" <<<"$leaves" || fail "search routed '$query' to '$page', which is not a page anyone can land on" done trap - EXIT diff --git a/tests/quickshell/sound-page-contract b/tests/quickshell/sound-page-contract index a267c99..cd257e3 100755 --- a/tests/quickshell/sound-page-contract +++ b/tests/quickshell/sound-page-contract @@ -3,11 +3,19 @@ # The Sound page is a first-class PipeWire control surface, not a launcher for # another settings app. This contract keeps the real device plumbing shared # with Quick Settings and verifies the controls that must remain available. +# +# It also owns the line between Sound and Dictation. Dictation used to be a +# card on this page, because it listens through the input device chosen here. +# It is an input method, so it now sits under Input with the keyboard -- and +# the thing that made the old arrangement legible, that the microphone and the +# dictation setup were visibly the same subject, has to survive the move as an +# explicit handoff rather than as a second device picker. set -euo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" sound_page="$repo_dir/config/dot/quickshell/modules/settings/SoundPage.qml" +dictation_page="$repo_dir/config/dot/quickshell/modules/settings/DictationPage.qml" device_list="$repo_dir/config/dot/quickshell/modules/settings/SoundDeviceList.qml" device_row="$repo_dir/config/dot/quickshell/modules/settings/SoundDeviceRow.qml" application_mixer="$repo_dir/config/dot/quickshell/modules/settings/ApplicationMixer.qml" @@ -36,7 +44,7 @@ cleanup() { } trap cleanup EXIT -for file in "$sound_page" "$device_list" "$device_row" "$application_mixer" "$application_row" "$balance" "$audio_devices" "$sound_feedback" "$quick_devices"; do +for file in "$sound_page" "$dictation_page" "$device_list" "$device_row" "$application_mixer" "$application_row" "$balance" "$audio_devices" "$sound_feedback" "$quick_devices"; do [[ -f "$file" ]] || fail "missing ${file#"$repo_dir/"}" done @@ -71,6 +79,35 @@ rg -Fq 'SoundFeedback.setEventSounds(checked)' "$sound_page" || fail 'event soun rg -Fq 'SoundFeedback.setInputFeedback(checked)' "$sound_page" || fail 'input feedback sounds are not controllable' rg -Fq 'org.gnome.desktop.sound' "$sound_feedback" || fail 'sound feedback does not use the desktop sound schema' +# ── Dictation lives on its own page under Input ────────────────────────────── +# One page owns the dictation controls. Two would mean two setup buttons +# driving the same one-time install, and whichever one someone found second +# would report state it did not cause. +! rg -Fq 'Dictation.' "$sound_page" \ + || fail 'the Sound page reads Dictation state again -- dictation belongs to DictationPage, and two pages showing the same setup is how one of them goes stale' +rg -Fq 'label: "Set up dictation"' "$dictation_page" \ + || fail 'DictationPage has no setup action, so the one-time install cannot be started from Settings' +rg -Fq 'onTriggered: Dictation.setup()' "$dictation_page" \ + || fail 'the dictation setup action does not call the helper that installs both the speech server and the model' +rg -Fq 'label: "Speech server"' "$dictation_page" \ + || fail 'DictationPage does not report whether the speech server is installed' +rg -Fq 'label: "Speech model"' "$dictation_page" \ + || fail 'DictationPage does not report whether the speech model is installed' +rg -Fq 'Dictation.typingAvailable' "$dictation_page" \ + || fail 'DictationPage does not say when wtype is missing, so dictated text would silently go to the clipboard' +rg -Fq 'Dictation.lastError' "$dictation_page" \ + || fail 'DictationPage never surfaces a setup failure' + +# The microphone is chosen on the Sound page, and dictation listens through it. +# Saying so, with a way to get there, is what replaces the two cards having sat +# side by side. +rg -Fq 'title: "Microphone"' "$dictation_page" \ + || fail 'DictationPage does not name the input device it listens through' +rg -Fq 'ShellState.openSettings("sound")' "$dictation_page" \ + || fail 'DictationPage does not hand off to Sound, so the device it listens through is unreachable from it' +! rg -Fq 'SoundDeviceList {' "$dictation_page" \ + || fail 'DictationPage grew its own device picker -- there is one input device, and two places to change it disagree' + # Native bindings are the supported path. Shelling out would race the service # that owns these same objects and regress Quick Settings coherence. if rg -q '\b(Process|pactl|wpctl)\b' "$audio_devices" "$device_list" "$device_row" "$balance"; then