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:
@@ -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
|
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
|
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
|
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
|
`tests/quickshell/keybinds-contract` fails if any bind lacks a description, so
|
||||||
this cannot regress silently.
|
this cannot regress silently.
|
||||||
|
|
||||||
|
|||||||
@@ -1473,7 +1473,7 @@ Singleton {
|
|||||||
// each chord and falls back to the shipped one, so a hand-edited file
|
// each chord and falls back to the shipped one, so a hand-edited file
|
||||||
// cannot cost you a keymap.
|
// 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.
|
// internal.
|
||||||
{
|
{
|
||||||
key: "keybindOverrides", type: "json", def: ({}), group: "input",
|
key: "keybindOverrides", type: "json", def: ({}), group: "input",
|
||||||
|
|||||||
@@ -55,6 +55,6 @@ one I have".
|
|||||||
|
|
||||||
## Changing them
|
## Changing them
|
||||||
|
|
||||||
Settings has a Shortcuts page listing every bind, each of which can be
|
Settings has a Keyboard page, under Input, listing every bind, each of which
|
||||||
reassigned. A rebind moves the shortcut and cannot change what it does, so
|
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.
|
there is no way to make a key do something unexpected by editing it.
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ somewhere without following it.
|
|||||||
On a machine with more than one display, workspaces belong to whichever
|
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
|
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
|
main display and give the second screen its own, that is a switch on the
|
||||||
Desktop settings page.
|
Desktop & Dock settings page.
|
||||||
|
|
||||||
## Projects
|
## Projects
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
## Start here
|
## Start here
|
||||||
|
|
||||||
Run **Check System Health** from the launcher, or open Settings and go to
|
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.
|
installed, and it can repair several things itself.
|
||||||
|
|
||||||
From a terminal, the same check is `panama doctor`.
|
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
|
## A shortcut stopped working
|
||||||
|
|
||||||
Check `Super + /` first: if the shortcut shows a different chord than you
|
Check `Super + /` first: if the shortcut shows a different chord than you
|
||||||
expect, something rebound it. The Shortcuts settings page can reset any bind,
|
expect, something rebound it. The Keyboard page, under Input in Settings, can
|
||||||
or all of them at once.
|
reset any bind, or all of them at once.
|
||||||
|
|
||||||
If the whole keymap is missing from that list, the compositor config failed
|
If the whole keymap is missing from that list, the compositor config failed
|
||||||
to load. `Hyprland --verify-config` says why without touching your session.
|
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
|
`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.
|
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
|
If an update went badly, the Snapshots tab under System in Settings can roll
|
||||||
where btrfs snapshots are configured.
|
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
|
## Reporting it
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,16 @@
|
|||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
|
|
||||||
`Super + I`. Thirty-odd pages covering displays, sound, network, appearance,
|
`Super + I`. Fifteen categories down the side, covering appearance, displays,
|
||||||
power, printers, users, sharing, storage, updates and the rest. The search
|
sound, input, network, power, accounts and the rest. A category with more than
|
||||||
box at the top searches the settings themselves rather than just page names,
|
one subject in it opens a row of tabs above the page, which is where the
|
||||||
so if you know what you want to change you can type it.
|
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
|
## Appearance
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Network & Devices.
|
// Connections — wired, Wi-Fi, and Bluetooth.
|
||||||
//
|
//
|
||||||
// Wi-Fi and Bluetooth are handled here rather than delegated. Everything goes
|
// Wi-Fi and Bluetooth are handled here rather than delegated. Everything goes
|
||||||
// through Quickshell.Networking and Quickshell.Bluetooth -- NetworkManager and
|
// through Quickshell.Networking and Quickshell.Bluetooth -- NetworkManager and
|
||||||
@@ -20,7 +20,7 @@ SettingsPage {
|
|||||||
id: root
|
id: root
|
||||||
|
|
||||||
|
|
||||||
title: "Network & Devices"
|
title: "Connections"
|
||||||
lede: Connectivity.activeNetwork
|
lede: Connectivity.activeNetwork
|
||||||
? "Connected to " + Connectivity.activeNetwork.name
|
? "Connected to " + Connectivity.activeNetwork.name
|
||||||
: "Wi-Fi, Bluetooth, and the things Fedora owns."
|
: "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
|
hardware, accounts, printers — is delegated to GNOME Settings and labeled as
|
||||||
such rather than half-reimplemented.
|
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
|
## System Health
|
||||||
|
|
||||||
The stable internal `services` route renders **System Health**. It is reachable
|
The stable internal `services` route renders **System Health**, a tab of
|
||||||
from the Settings sidebar and its live 54px footer, the degraded-only bar
|
**System**. It is reachable from there, from its live 54px footer, from the
|
||||||
indicator, and Vicinae's **Check System Health** command. Healthy scans
|
degraded-only bar indicator, and from Vicinae's **Check System Health**
|
||||||
reserve no bar space and produce no notification.
|
command. Healthy scans reserve no bar space and produce no notification.
|
||||||
|
|
||||||
`services/Health.qml` owns the last accepted redacted snapshot. It invokes
|
`services/Health.qml` owns the last accepted redacted snapshot. It invokes
|
||||||
`scripts/panama-doctor` for scans and bounded repairs, `wl-copy` only for an
|
`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)
|
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 {
|
Loader {
|
||||||
id: pageLoader
|
id: pageLoader
|
||||||
anchors.left: sidebar.right
|
anchors.left: sidebar.right
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.top: titlebar.bottom
|
anchors.top: categoryStrip.bottom
|
||||||
anchors.bottom: parent.bottom
|
anchors.bottom: parent.bottom
|
||||||
sourceComponent: {
|
sourceComponent: {
|
||||||
switch (ShellState.settingsPage) {
|
switch (ShellState.settingsPage) {
|
||||||
@@ -114,6 +142,7 @@ Rectangle {
|
|||||||
case "screen-intelligence": return screenIntelligencePage;
|
case "screen-intelligence": return screenIntelligencePage;
|
||||||
case "shortcuts": return shortcutsPage;
|
case "shortcuts": return shortcutsPage;
|
||||||
case "mouse": return mousePage;
|
case "mouse": return mousePage;
|
||||||
|
case "dictation": return dictationPage;
|
||||||
case "privacy": return privacyPage;
|
case "privacy": return privacyPage;
|
||||||
case "region": return regionPage;
|
case "region": return regionPage;
|
||||||
case "accounts": return onlineAccountsPage;
|
case "accounts": return onlineAccountsPage;
|
||||||
@@ -192,6 +221,7 @@ Rectangle {
|
|||||||
Component { id: screenIntelligencePage; ScreenIntelligencePage {} }
|
Component { id: screenIntelligencePage; ScreenIntelligencePage {} }
|
||||||
Component { id: shortcutsPage; ShortcutsPage {} }
|
Component { id: shortcutsPage; ShortcutsPage {} }
|
||||||
Component { id: mousePage; MousePage {} }
|
Component { id: mousePage; MousePage {} }
|
||||||
|
Component { id: dictationPage; DictationPage {} }
|
||||||
Component { id: privacyPage; PrivacyPage {} }
|
Component { id: privacyPage; PrivacyPage {} }
|
||||||
Component { id: regionPage; RegionPage {} }
|
Component { id: regionPage; RegionPage {} }
|
||||||
Component { id: onlineAccountsPage; OnlineAccountsPage {} }
|
Component { id: onlineAccountsPage; OnlineAccountsPage {} }
|
||||||
|
|||||||
@@ -16,60 +16,15 @@ Rectangle {
|
|||||||
sidebarScroll.contentY = 0;
|
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 {
|
function pageLabel(page: string): string {
|
||||||
const found = root.destinations.find(item => item.page === page);
|
return SettingsRoutes.breadcrumb(page);
|
||||||
return found ? found.label : "Settings";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
readonly property var destinations: [
|
// One row per category. SettingsRoutes owns the taxonomy; a category with
|
||||||
{ page: "home", label: "Home", icon: "\u{F02DC}" },
|
// tabs is opened at its first available tab by ShellState's resolution.
|
||||||
{ page: "appearance", label: "Appearance", icon: "\u{F0E0D}" },
|
readonly property var destinations: SettingsRoutes.categories
|
||||||
{ 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
width: 272
|
width: 272
|
||||||
color: Theme.alpha(Theme.bgDark, 0.96)
|
color: Theme.alpha(Theme.bgDark, 0.96)
|
||||||
@@ -252,14 +207,16 @@ Rectangle {
|
|||||||
Rectangle {
|
Rectangle {
|
||||||
id: navItem
|
id: navItem
|
||||||
required property var modelData
|
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
|
width: parent.width
|
||||||
height: 40
|
height: 40
|
||||||
radius: 10
|
radius: 10
|
||||||
color: modelData.page === root.selectedPage
|
color: navItem.active
|
||||||
? Theme.alpha(Theme.accent, 0.17)
|
? Theme.alpha(Theme.accent, 0.17)
|
||||||
: (navMouse.containsMouse ? Theme.alpha(Theme.fg, Theme.hoverAlpha * 0.55) : Theme.alpha(Theme.fg, 0))
|
: (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)
|
border.color: Theme.alpha(Theme.accent, 0.26)
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
@@ -268,7 +225,7 @@ Rectangle {
|
|||||||
radius: 1
|
radius: 1
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
visible: navItem.modelData.page === root.selectedPage
|
visible: navItem.active
|
||||||
gradient: Gradient {
|
gradient: Gradient {
|
||||||
GradientStop { position: 0; color: Theme.accent }
|
GradientStop { position: 0; color: Theme.accent }
|
||||||
GradientStop { position: 1; color: Theme.accentSecondary }
|
GradientStop { position: 1; color: Theme.accentSecondary }
|
||||||
@@ -281,7 +238,7 @@ Rectangle {
|
|||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
width: 25
|
width: 25
|
||||||
text: navItem.modelData.icon
|
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.family: Theme.fontMono
|
||||||
font.pixelSize: 15
|
font.pixelSize: 15
|
||||||
}
|
}
|
||||||
@@ -293,10 +250,10 @@ Rectangle {
|
|||||||
anchors.rightMargin: 9
|
anchors.rightMargin: 9
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
text: navItem.modelData.label
|
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.family: Theme.fontFamily
|
||||||
font.pixelSize: Theme.fontSize
|
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
|
elide: Text.ElideRight
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,10 @@
|
|||||||
// sat below a wallpaper grid and an entire lock screen. Tabs are not a way to
|
// 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
|
// 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.
|
// 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 QtQuick
|
||||||
import qs.config
|
import qs.config
|
||||||
@@ -27,55 +31,110 @@ Item {
|
|||||||
width: parent ? parent.width : 620
|
width: parent ? parent.width : 620
|
||||||
implicitHeight: 40
|
implicitHeight: 40
|
||||||
|
|
||||||
Row {
|
Flickable {
|
||||||
id: strip
|
id: flick
|
||||||
anchors.left: parent.left
|
|
||||||
anchors.bottom: parent.bottom
|
|
||||||
spacing: 2
|
|
||||||
|
|
||||||
Repeater {
|
anchors.fill: parent
|
||||||
model: root.tabs
|
contentWidth: strip.implicitWidth
|
||||||
|
contentHeight: height
|
||||||
|
flickableDirection: Flickable.HorizontalFlick
|
||||||
|
boundsBehavior: Flickable.StopAtBounds
|
||||||
|
interactive: contentWidth > width
|
||||||
|
clip: true
|
||||||
|
|
||||||
delegate: Item {
|
function reveal(item: Item): void {
|
||||||
id: tab
|
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
|
delegate: Item {
|
||||||
implicitHeight: 38
|
id: tab
|
||||||
|
|
||||||
Text {
|
required property var modelData
|
||||||
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 {
|
readonly property bool active: String(tab.modelData.value) === root.current
|
||||||
anchors.left: parent.left
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.bottom: parent.bottom
|
|
||||||
height: 2
|
|
||||||
radius: 1
|
|
||||||
visible: tab.active
|
|
||||||
color: Theme.accent
|
|
||||||
}
|
|
||||||
|
|
||||||
HoverHandler {
|
implicitWidth: caption.implicitWidth + 30
|
||||||
id: hover
|
implicitHeight: 38
|
||||||
cursorShape: Qt.PointingHandCursor
|
|
||||||
}
|
|
||||||
|
|
||||||
TapHandler {
|
onActiveChanged: if (active) flick.reveal(tab)
|
||||||
onTapped: root.selected(String(tab.modelData.value))
|
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 {
|
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
|
// 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
|
// here. The previous version was a hand-maintained array of nineteen entries
|
||||||
@@ -46,11 +46,11 @@ SettingsPage {
|
|||||||
SystemSettings.commitPreference("keyboardOptions", options.join(","));
|
SystemSettings.commitPreference("keyboardOptions", options.join(","));
|
||||||
}
|
}
|
||||||
|
|
||||||
title: "Input & Shortcuts"
|
title: "Keyboard"
|
||||||
lede: "The Forge mental model, carried forward into native tiling."
|
lede: "The Forge mental model, carried forward into native tiling."
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Keyboard"
|
title: "Layout & typing"
|
||||||
|
|
||||||
// These were read-only text, on the grounds that a layout change needed
|
// These were read-only text, on the grounds that a layout change needed
|
||||||
// a compositor reload. It does not: setting input:kb_variant through
|
// 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 {
|
SettingsCard {
|
||||||
title: "Applications"
|
title: "Applications"
|
||||||
subtitle: "Control each application currently playing through PipeWire."
|
subtitle: "Control each application currently playing through PipeWire."
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ TimeOfDayRow 1.0 TimeOfDayRow.qml
|
|||||||
LocationPicker 1.0 LocationPicker.qml
|
LocationPicker 1.0 LocationPicker.qml
|
||||||
FontPicker 1.0 FontPicker.qml
|
FontPicker 1.0 FontPicker.qml
|
||||||
MousePage 1.0 MousePage.qml
|
MousePage 1.0 MousePage.qml
|
||||||
|
DictationPage 1.0 DictationPage.qml
|
||||||
TextEntryRow 1.0 TextEntryRow.qml
|
TextEntryRow 1.0 TextEntryRow.qml
|
||||||
PrivacyPage 1.0 PrivacyPage.qml
|
PrivacyPage 1.0 PrivacyPage.qml
|
||||||
RegionPage 1.0 RegionPage.qml
|
RegionPage 1.0 RegionPage.qml
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// The first thing a new machine shows.
|
// The first thing a new machine shows.
|
||||||
//
|
//
|
||||||
// Panama has thirty settings pages, which is the opposite of the usual
|
// Panama has fifteen settings categories and thirty-odd pages inside them,
|
||||||
// problem: somebody arriving from GNOME, macOS or Windows cannot tell which
|
// which is the opposite of the usual problem: somebody arriving from GNOME,
|
||||||
// four things matter. This is those four things, once, on the first start.
|
// 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
|
// 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
|
// 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.
|
// have -- and so this cannot drift the way a hand-written list would.
|
||||||
//
|
//
|
||||||
// Shown once, tracked by the `welcomeSeen` preference. It stays reachable
|
// Shown once, tracked by the `welcomeSeen` preference. It stays reachable
|
||||||
// afterwards from the launcher and from Settings > About, because the moment
|
// afterwards from the launcher and from Settings > System > About, because
|
||||||
// somebody wants it again is exactly the moment a one-shot has thrown it away.
|
// the moment somebody wants it again is exactly the moment a one-shot has
|
||||||
|
// thrown it away.
|
||||||
|
|
||||||
import Quickshell
|
import Quickshell
|
||||||
import Quickshell.Wayland
|
import Quickshell.Wayland
|
||||||
|
|||||||
@@ -8,11 +8,16 @@ searching there. These commands close that gap, so typing "night light" into the
|
|||||||
launcher opens the page that owns it.
|
launcher opens the page that owns it.
|
||||||
|
|
||||||
The vocabulary is derived from the same sources the in-app search uses --
|
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
|
and the labels in PreferenceSchema.qml -- so a setting that is searchable inside
|
||||||
Settings is searchable from the launcher without anyone maintaining a second
|
Settings is searchable from the launcher without anyone maintaining a second
|
||||||
list.
|
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 write the commands
|
||||||
panama-settings-commands --check fail if what is on disk is stale
|
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]
|
REPO = Path(__file__).resolve().parents[4]
|
||||||
SHELL = REPO / "config/dot/quickshell"
|
SHELL = REPO / "config/dot/quickshell"
|
||||||
SIDEBAR = SHELL / "modules/settings/SettingsSidebar.qml"
|
ROUTES = SHELL / "services/SettingsRoutes.qml"
|
||||||
SEARCH = SHELL / "services/SettingsSearch.qml"
|
SEARCH = SHELL / "services/SettingsSearch.qml"
|
||||||
SCHEMA = SHELL / "config/PreferenceSchema.qml"
|
SCHEMA = SHELL / "config/PreferenceSchema.qml"
|
||||||
OUTPUT_DIR = REPO / "config/local/share/vicinae/scripts"
|
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
|
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]]:
|
def pages() -> list[tuple[str, str]]:
|
||||||
"""The settings pages, in sidebar order, as (id, label)."""
|
"""The leaf pages, in sidebar order, as (id, label).
|
||||||
source = read(SIDEBAR)
|
|
||||||
found = re.findall(r'\{ page: "([a-z-]+)", label: "([^"]+)"', source)
|
A category with tabs is a group, not somewhere anyone lands: its tabs are
|
||||||
if not found:
|
the pages, and each tab is named by its own label rather than the group's.
|
||||||
raise ParseError("no pages found in SettingsSidebar.qml")
|
A category without tabs is a leaf itself.
|
||||||
return [(page, label) for page, label in found if page not in SKIP_PAGES]
|
"""
|
||||||
|
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]:
|
def group_pages() -> dict[str, str]:
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ Usage:
|
|||||||
panama-settings-docs --check exit 1 if the committed copy is stale
|
panama-settings-docs --check exit 1 if the committed copy is stale
|
||||||
panama-settings-docs --stdout print without writing
|
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
|
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
|
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.
|
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]
|
ROOT = pathlib.Path(__file__).resolve().parents[4]
|
||||||
SCHEMA = ROOT / "config/dot/quickshell/config/PreferenceSchema.qml"
|
SCHEMA = ROOT / "config/dot/quickshell/config/PreferenceSchema.qml"
|
||||||
SEARCH = ROOT / "config/dot/quickshell/services/SettingsSearch.qml"
|
SEARCH = ROOT / "config/dot/quickshell/services/SettingsSearch.qml"
|
||||||
|
ROUTES = ROOT / "config/dot/quickshell/services/SettingsRoutes.qml"
|
||||||
OUTPUT = ROOT / "docs/settings.md"
|
OUTPUT = ROOT / "docs/settings.md"
|
||||||
|
|
||||||
# Page id -> the name a person sees in the sidebar.
|
CATEGORY = re.compile(
|
||||||
PAGE_TITLES = {
|
r'\{\s*page:\s*"([a-z-]+)",\s*label:\s*"([^"]+)",\s*icon:\s*"[^"]*",\s*tabs:\s*\[([^\]]*)\]\s*\}',
|
||||||
"home": "Home", "appearance": "Appearance", "displays": "Displays",
|
re.S)
|
||||||
"connectivity": "Network & Devices", "home-phone": "Home & Phone",
|
TAB = re.compile(r'\{\s*page:\s*"([a-z-]+)",\s*label:\s*"([^"]+)"\s*\}')
|
||||||
"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",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class SchemaError(RuntimeError):
|
class SchemaError(RuntimeError):
|
||||||
"""The schema stopped looking the way this reader expects."""
|
"""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():
|
def read_routes():
|
||||||
"""group -> page id, from SettingsSearch."""
|
"""group -> page id, from SettingsSearch."""
|
||||||
text = SEARCH.read_text()
|
text = SEARCH.read_text()
|
||||||
@@ -139,7 +165,7 @@ def read_entries():
|
|||||||
return entries
|
return entries
|
||||||
|
|
||||||
|
|
||||||
def render(entries, routes):
|
def render(entries, routes, titles):
|
||||||
lines = [
|
lines = [
|
||||||
"# Settings reference",
|
"# Settings reference",
|
||||||
"",
|
"",
|
||||||
@@ -163,18 +189,19 @@ def render(entries, routes):
|
|||||||
if not visible:
|
if not visible:
|
||||||
continue
|
continue
|
||||||
page = routes.get(group)
|
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
|
# the group "gaming" documented itself as "Found on **gaming**" while
|
||||||
# every other group named a real page. The staleness contract could not
|
# every other group named a real page. The staleness contract could not
|
||||||
# see it: regenerating reproduced the same wrong file, so the copy was
|
# 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
|
# current and wrong at once. Refusing to render is what makes a group
|
||||||
# page added here impossible to miss.
|
# routed at a page that no longer exists impossible to miss.
|
||||||
if page is not None and page not in PAGE_TITLES:
|
if page is not None and page not in titles:
|
||||||
raise SchemaError(
|
raise SchemaError(
|
||||||
f"group '{group}' routes to page '{page}', which has no entry in "
|
f"group '{group}' routes to page '{page}', which is not a leaf page "
|
||||||
"PAGE_TITLES; add one rather than letting the id be printed as a name"
|
"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 += [f"## {group}", "", f"Found on **{title}**.", ""]
|
||||||
lines += ["| Setting | Default | What it does |", "|---|---|---|"]
|
lines += ["| Setting | Default | What it does |", "|---|---|---|"]
|
||||||
for entry in visible:
|
for entry in visible:
|
||||||
@@ -210,7 +237,7 @@ def main():
|
|||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
rendered = render(read_entries(), read_routes())
|
rendered = render(read_entries(), read_routes(), read_titles())
|
||||||
except SchemaError as error:
|
except SchemaError as error:
|
||||||
# Loudly, and without writing. A partial reference is worse than a stale
|
# Loudly, and without writing. A partial reference is worse than a stale
|
||||||
# one: stale is caught by --check, partial reads as complete.
|
# one: stale is caught by --check, partial reads as complete.
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// The settings taxonomy: which categories the sidebar shows, and which leaf
|
||||||
|
// pages live inside each as tabs.
|
||||||
|
//
|
||||||
|
// The sidebar used to be a flat list of thirty-one pages, which made finding
|
||||||
|
// Printers a scan of the whole column. Categories group them the way macOS
|
||||||
|
// groups System Settings; the tab strip above a page switches between the
|
||||||
|
// leaves of its category. ShellState.settingsPage always holds a *leaf* id —
|
||||||
|
// the ids callers have always used — so every existing deep link, IPC call,
|
||||||
|
// and search result keeps working unchanged.
|
||||||
|
//
|
||||||
|
// This file is the only place the taxonomy is written down. The sidebar, the
|
||||||
|
// tab strip, ShellState's route validation, and two generators —
|
||||||
|
// scripts/panama-settings-commands and scripts/panama-settings-docs, which
|
||||||
|
// regex-parse the categories array below (keep its literal shape) — all
|
||||||
|
// derive from it.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
// A category with no tabs is a leaf itself. A category with tabs is
|
||||||
|
// addressed by its first available tab; its own page id is accepted as an
|
||||||
|
// alias. Three category ids ("applications", "users", "privacy") double as
|
||||||
|
// the id of their first tab, which resolves to the same place either way.
|
||||||
|
readonly property var categories: [
|
||||||
|
{ page: "home", label: "Home", icon: "\u{F02DC}", tabs: [] },
|
||||||
|
{ page: "appearance", label: "Appearance", icon: "\u{F0E0D}", tabs: [] },
|
||||||
|
{ page: "desktop", label: "Desktop & Dock", icon: "\u{F04A4}", tabs: [] },
|
||||||
|
{ page: "displays", label: "Displays", icon: "\u{F0379}", tabs: [] },
|
||||||
|
{ page: "sound", label: "Sound", icon: "\u{F057E}", tabs: [] },
|
||||||
|
{ page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}", tabs: [] },
|
||||||
|
{ page: "input", label: "Input", icon: "\u{F030C}", tabs: [
|
||||||
|
{ page: "shortcuts", label: "Keyboard" },
|
||||||
|
{ page: "mouse", label: "Mouse & Touchpad" },
|
||||||
|
{ page: "dictation", label: "Dictation" }
|
||||||
|
] },
|
||||||
|
{ page: "network", label: "Network & Sharing", icon: "\u{F08D4}", tabs: [
|
||||||
|
{ page: "connectivity", label: "Connections" },
|
||||||
|
{ page: "firewall", label: "Firewall" },
|
||||||
|
{ page: "sharing", label: "Sharing" },
|
||||||
|
{ page: "printers", label: "Printers" }
|
||||||
|
] },
|
||||||
|
{ page: "home-phone", label: "Home & Phone", icon: "\u{F02DC}", tabs: [] },
|
||||||
|
{ page: "applications", label: "Applications", icon: "\u{F003B}", tabs: [
|
||||||
|
{ page: "applications", label: "Applications" },
|
||||||
|
{ page: "gaming", label: "Gaming" },
|
||||||
|
{ page: "screen-intelligence", label: "Screen Intelligence" }
|
||||||
|
] },
|
||||||
|
{ page: "users", label: "Users & Accounts", icon: "\u{F0004}", tabs: [
|
||||||
|
{ page: "users", label: "Users" },
|
||||||
|
{ page: "accounts", label: "Online Accounts" }
|
||||||
|
] },
|
||||||
|
{ page: "privacy", label: "Privacy & Security", icon: "\u{F0483}", tabs: [
|
||||||
|
{ page: "privacy", label: "Privacy" },
|
||||||
|
{ page: "ssh-keys", label: "SSH Keys" }
|
||||||
|
] },
|
||||||
|
{ page: "power", label: "Power & Lock", icon: "\u{F0425}", tabs: [] },
|
||||||
|
{ page: "accessibility", label: "Accessibility", icon: "\u{F0208}", tabs: [] },
|
||||||
|
{ page: "system", label: "System", icon: "\u{F02FD}", tabs: [
|
||||||
|
{ page: "about", label: "About" },
|
||||||
|
{ page: "updates", label: "Software Update" },
|
||||||
|
{ page: "services", label: "System Health" },
|
||||||
|
{ page: "storage", label: "Storage" },
|
||||||
|
{ page: "snapshots", label: "Snapshots" },
|
||||||
|
{ page: "containers", label: "Containers" },
|
||||||
|
{ page: "datetime", label: "Date & Time" },
|
||||||
|
{ page: "region", label: "Region & Language" },
|
||||||
|
{ page: "manual", label: "Manual" }
|
||||||
|
] }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Pages whose entire backing stack can be absent hide once a scan has
|
||||||
|
// proven it absent: a Containers tab with no podman and a Snapshots tab
|
||||||
|
// with no snapper configuration render permanently empty, which reads as
|
||||||
|
// broken rather than inapplicable. Until the scan lands they stay visible,
|
||||||
|
// so machines that have the stack never see a blink — both scans fire when
|
||||||
|
// Settings opens (HomePage.onCompleted).
|
||||||
|
function pageAvailable(page: string): bool {
|
||||||
|
switch (page) {
|
||||||
|
case "containers":
|
||||||
|
return !Containers.scanned || Containers.available;
|
||||||
|
case "snapshots":
|
||||||
|
return !Snapshots.scanned || Snapshots.configs.length > 0;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The category owning a leaf. Tab membership is checked before category
|
||||||
|
// ids so the three doubled ids land on their category either way.
|
||||||
|
function categoryOf(leaf: string): var {
|
||||||
|
const byTab = root.categories.find(cat => cat.tabs.some(tab => tab.page === leaf));
|
||||||
|
if (byTab)
|
||||||
|
return byTab;
|
||||||
|
return root.categories.find(cat => cat.page === leaf) ?? root.categories[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any id a caller may hold — leaf, category, or garbage — to the leaf that
|
||||||
|
// should render: a leaf resolves to itself, a category to its first
|
||||||
|
// available tab, anything unknown to home.
|
||||||
|
function resolve(id: string): string {
|
||||||
|
const asLeaf = root.categories.some(cat => (cat.page === id && cat.tabs.length === 0)
|
||||||
|
|| cat.tabs.some(tab => tab.page === id));
|
||||||
|
if (asLeaf)
|
||||||
|
return id;
|
||||||
|
const category = root.categories.find(cat => cat.page === id);
|
||||||
|
if (category) {
|
||||||
|
const tab = category.tabs.find(t => root.pageAvailable(t.page));
|
||||||
|
if (tab)
|
||||||
|
return tab.page;
|
||||||
|
}
|
||||||
|
return "home";
|
||||||
|
}
|
||||||
|
|
||||||
|
// The available tabs of the category owning a leaf, for the strip above
|
||||||
|
// the page. One entry (or none) means no strip is worth rendering.
|
||||||
|
function tabsFor(leaf: string): var {
|
||||||
|
return root.categoryOf(leaf).tabs.filter(tab => root.pageAvailable(tab.page));
|
||||||
|
}
|
||||||
|
|
||||||
|
// "System › Storage" for a tabbed leaf, "Displays" for a lone one. This is
|
||||||
|
// what search results show, so a hit says where it will land.
|
||||||
|
function breadcrumb(leaf: string): string {
|
||||||
|
const category = root.categoryOf(leaf);
|
||||||
|
const tab = category.tabs.find(t => t.page === leaf);
|
||||||
|
return tab ? category.label + " › " + tab.label : category.label;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -136,6 +136,8 @@ Singleton {
|
|||||||
{ label: "Desktop version", detail: "Which Hyprland and Quickshell this session runs", page: "about" },
|
{ label: "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: "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: "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: "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: "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" },
|
{ label: "Lock screen background", detail: "Choose a blurred desktop, wallpaper, or solid color", page: "appearance" },
|
||||||
|
|||||||
@@ -118,8 +118,10 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showSettings(page: string): void {
|
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"];
|
// SettingsRoutes owns the route table: a leaf id passes through, a
|
||||||
root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home";
|
// 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);
|
DesktopPreferences.set("lastPage", root.settingsPage);
|
||||||
root.settingsOpen = true;
|
root.settingsOpen = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Generated by scripts/panama-settings-commands -- do not edit by hand.
|
# Generated by scripts/panama-settings-commands -- do not edit by hand.
|
||||||
# @vicinae.schemaVersion 1
|
# @vicinae.schemaVersion 1
|
||||||
# @vicinae.title Settings: Network & Devices
|
# @vicinae.title Settings: Connections
|
||||||
# @vicinae.mode silent
|
# @vicinae.mode silent
|
||||||
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
# @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"]
|
# @vicinae.keywords ["settings", "wi-fi", "bluetooth", "printers"]
|
||||||
|
|
||||||
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page connectivity
|
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page connectivity
|
||||||
|
|||||||
+10
@@ -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
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Generated by scripts/panama-settings-commands -- do not edit by hand.
|
# Generated by scripts/panama-settings-commands -- do not edit by hand.
|
||||||
# @vicinae.schemaVersion 1
|
# @vicinae.schemaVersion 1
|
||||||
# @vicinae.title Settings: Privacy & Security
|
# @vicinae.title Settings: Privacy
|
||||||
# @vicinae.mode silent
|
# @vicinae.mode silent
|
||||||
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
# @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"]
|
# @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
|
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page privacy
|
||||||
|
|||||||
+6
-6
@@ -42,7 +42,7 @@ Found on **Power & Lock**.
|
|||||||
|
|
||||||
## capture
|
## capture
|
||||||
|
|
||||||
Found on **Screen Intelligence**.
|
Found on **Applications › Screen Intelligence**.
|
||||||
|
|
||||||
| Setting | Default | What it does |
|
| Setting | Default | What it does |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -127,7 +127,7 @@ Found on **Desktop & Dock**.
|
|||||||
|
|
||||||
## gaming
|
## gaming
|
||||||
|
|
||||||
Found on **Gaming**.
|
Found on **Applications › Gaming**.
|
||||||
|
|
||||||
| Setting | Default | What it does |
|
| Setting | Default | What it does |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -157,7 +157,7 @@ Found on **Power & Lock**.
|
|||||||
|
|
||||||
## input
|
## input
|
||||||
|
|
||||||
Found on **Keyboard**.
|
Found on **Input › Keyboard**.
|
||||||
|
|
||||||
| Setting | Default | What it does |
|
| Setting | Default | What it does |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -244,7 +244,7 @@ Found on **Notifications & Focus**.
|
|||||||
|
|
||||||
## pointer
|
## pointer
|
||||||
|
|
||||||
Found on **Mouse & Touchpad**.
|
Found on **Input › Mouse & Touchpad**.
|
||||||
|
|
||||||
| Setting | Default | What it does |
|
| Setting | Default | What it does |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -259,7 +259,7 @@ Found on **Mouse & Touchpad**.
|
|||||||
|
|
||||||
## search
|
## search
|
||||||
|
|
||||||
Found on **Applications**.
|
Found on **Applications › Applications**.
|
||||||
|
|
||||||
| Setting | Default | What it does |
|
| Setting | Default | What it does |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -286,7 +286,7 @@ Found on **Appearance**.
|
|||||||
|
|
||||||
## touchpad
|
## touchpad
|
||||||
|
|
||||||
Found on **Mouse & Touchpad**.
|
Found on **Input › Mouse & Touchpad**.
|
||||||
|
|
||||||
| Setting | Default | What it does |
|
| Setting | Default | What it does |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -12,10 +12,10 @@
|
|||||||
# longer true, and no test could notice because none of them knew what Panama
|
# longer true, and no test could notice because none of them knew what Panama
|
||||||
# had come to own in the meantime.
|
# had come to own in the meantime.
|
||||||
#
|
#
|
||||||
# This reads the sidebar for the pages that exist and the pages for the panels
|
# This reads SettingsRoutes for the pages that exist and the pages for the
|
||||||
# they hand off, and fails on any overlap. It is deliberately derived from both
|
# panels they hand off, and fails on any overlap. It is deliberately derived
|
||||||
# sides rather than from a hand-kept list, so absorbing the next page cannot
|
# from both sides rather than from a hand-kept list, so absorbing the next page
|
||||||
# leave a stale door behind.
|
# cannot leave a stale door behind.
|
||||||
#
|
#
|
||||||
# Read-only.
|
# Read-only.
|
||||||
|
|
||||||
@@ -23,14 +23,14 @@ set -uo pipefail
|
|||||||
|
|
||||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
settings_dir="$repo_dir/config/dot/quickshell/modules/settings"
|
settings_dir="$repo_dir/config/dot/quickshell/modules/settings"
|
||||||
sidebar="$settings_dir/SettingsSidebar.qml"
|
routes="$repo_dir/config/dot/quickshell/services/SettingsRoutes.qml"
|
||||||
|
|
||||||
fail() {
|
fail() {
|
||||||
printf 'gnome handoff contract: %s\n' "$1" >&2
|
printf 'gnome handoff contract: %s\n' "$1" >&2
|
||||||
exit 1
|
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
|
# 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
|
# 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"
|
["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)"
|
# The leaves: a tabless category is a page in its own right, and every tab is
|
||||||
[[ -n "$pages" ]] || fail 'no pages could be read from the sidebar, so this proves nothing'
|
# 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() {
|
has_page() {
|
||||||
grep -qx "$1" <<<"$pages"
|
grep -qx "$1" <<<"$pages"
|
||||||
|
|||||||
@@ -42,8 +42,11 @@ rg -Fq 'HealthCheckRow 1.0 HealthCheckRow.qml' "$settings_dir/qmldir" \
|
|||||||
! rg -Fq 'ServicesPage 1.0 ServicesPage.qml' "$settings_dir/qmldir" \
|
! rg -Fq 'ServicesPage 1.0 ServicesPage.qml' "$settings_dir/qmldir" \
|
||||||
|| fail 'retired ServicesPage remains registered in the Settings QML module'
|
|| fail 'retired ServicesPage remains registered in the Settings QML module'
|
||||||
|
|
||||||
rg -Fq 'label: "System Health"' "$settings_dir/SettingsSidebar.qml" \
|
# The route is named in SettingsRoutes now that it is a tab of System rather
|
||||||
|| fail 'sidebar does not label the stable services route System Health'
|
# 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" \
|
rg -Fq 'onTapped: root.pageRequested("services")' "$settings_dir/SettingsSidebar.qml" \
|
||||||
|| fail 'health footer does not open the stable services route'
|
|| fail 'health footer does not open the stable services route'
|
||||||
rg -Fq 'height: 54' "$settings_dir/SettingsSidebar.qml" \
|
rg -Fq 'height: 54' "$settings_dir/SettingsSidebar.qml" \
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ set -uo pipefail
|
|||||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
manual_dir="$repo_dir/config/dot/quickshell/manual"
|
manual_dir="$repo_dir/config/dot/quickshell/manual"
|
||||||
page="$repo_dir/config/dot/quickshell/modules/settings/ManualPage.qml"
|
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"
|
shell_ui="$repo_dir/config/dot/quickshell/modules/settings/SettingsShell.qml"
|
||||||
qmldir="$repo_dir/config/dot/quickshell/modules/settings/qmldir"
|
qmldir="$repo_dir/config/dot/quickshell/modules/settings/qmldir"
|
||||||
state="$repo_dir/config/dot/quickshell/services/ShellState.qml"
|
state="$repo_dir/config/dot/quickshell/services/ShellState.qml"
|
||||||
@@ -79,13 +79,27 @@ grep -q 'Quickshell.shellDir + "/manual/"' "$page" \
|
|||||||
grep -q '\.\./\.\./\.\.' "$page" \
|
grep -q '\.\./\.\./\.\.' "$page" \
|
||||||
&& note 'the manual path walks upward out of the shell directory, which is only correct by accident'
|
&& 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 '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 '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 '^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'
|
grep -q 'page: "manual"' "$search" || note 'the manual is not searchable from the settings search box'
|
||||||
|
|
||||||
if (( ${#findings[@]} > 0 )); then
|
if (( ${#findings[@]} > 0 )); then
|
||||||
|
|||||||
@@ -38,16 +38,24 @@ declare -A expected=(
|
|||||||
[restart-shell]=restart-shell
|
[restart-shell]=restart-shell
|
||||||
)
|
)
|
||||||
|
|
||||||
# The per-page settings commands are generated from the settings page list, so
|
# The per-page settings commands are generated from the settings taxonomy, so
|
||||||
# they are enumerated from that list rather than restated here -- a hand-written
|
# they are enumerated from it rather than restated here -- a hand-written copy
|
||||||
# copy would have to be edited every time a page is added, which is exactly the
|
# would have to be edited every time a page is added, which is exactly the kind
|
||||||
# kind of second list this generator exists to avoid.
|
# of second list this generator exists to avoid.
|
||||||
sidebar="$repo_dir/config/dot/quickshell/modules/settings/SettingsSidebar.qml"
|
#
|
||||||
|
# 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
|
while read -r page; do
|
||||||
[[ -n "$page" ]] || continue
|
[[ -n "$page" ]] || continue
|
||||||
[[ "$page" == "home" ]] && continue
|
[[ "$page" == "home" ]] && continue
|
||||||
expected[settings-$page]="settings-page $page"
|
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'
|
(( ${#expected[@]} > 18 )) || fail 'no generated per-page commands were found; run scripts/panama-settings-commands'
|
||||||
|
|
||||||
|
|||||||
@@ -2,19 +2,24 @@
|
|||||||
|
|
||||||
# Every "open the settings for this" jump must land somewhere real.
|
# Every "open the settings for this" jump must land somewhere real.
|
||||||
#
|
#
|
||||||
# ShellState.openSettings() validates its argument against an allow-list and
|
# ShellState.openSettings() hands its argument to SettingsRoutes.resolve(),
|
||||||
# falls back to Home for anything unknown. That fallback is sensible and it is
|
# which falls back to Home for anything it does not recognise. That fallback is
|
||||||
# also completely silent: a typo, or a page renamed later, turns a right-click
|
# sensible and it is also completely silent: a typo, or a page renamed later,
|
||||||
# into "opens Settings on the wrong page" with nothing logged and no error.
|
# 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
|
# 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
|
# the risk was small. The bar now offers a jump on every widget, which makes the
|
||||||
# fallback worth guarding.
|
# 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
|
set -uo pipefail
|
||||||
|
|
||||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
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"
|
modules="$repo_dir/config/dot/quickshell/modules"
|
||||||
dock_menu="$modules/dock/DockContextMenu.qml"
|
dock_menu="$modules/dock/DockContextMenu.qml"
|
||||||
notification_card="$modules/notifications/NotificationCard.qml"
|
notification_card="$modules/notifications/NotificationCard.qml"
|
||||||
@@ -25,8 +30,12 @@ fail() {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
allowed_line="$(grep -m1 'const allowed = \[' "$shell_state")" \
|
[[ -r "$routes" ]] || fail "cannot read $routes"
|
||||||
|| fail 'could not find the allow-list in ShellState'
|
|
||||||
|
# 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 \
|
jumps="$(grep -rhoE 'openSettings\("[a-z-]+"\)' "$modules" 2>/dev/null \
|
||||||
| sed 's/openSettings("//; s/")//' | sort -u)"
|
| sed 's/openSettings("//; s/")//' | sort -u)"
|
||||||
@@ -35,8 +44,8 @@ jumps="$(grep -rhoE 'openSettings\("[a-z-]+"\)' "$modules" 2>/dev/null \
|
|||||||
count=0
|
count=0
|
||||||
while read -r page; do
|
while read -r page; do
|
||||||
[[ -n "$page" ]] || continue
|
[[ -n "$page" ]] || continue
|
||||||
grep -qF "\"$page\"" <<<"$allowed_line" \
|
grep -qx "$page" <<<"$known" \
|
||||||
|| 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"
|
|| 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))
|
count=$((count + 1))
|
||||||
done <<<"$jumps"
|
done <<<"$jumps"
|
||||||
|
|
||||||
|
|||||||
@@ -3,23 +3,32 @@
|
|||||||
# Adding a settings page means editing four separate files, and missing one
|
# Adding a settings page means editing four separate files, and missing one
|
||||||
# fails quietly rather than loudly:
|
# fails quietly rather than loudly:
|
||||||
#
|
#
|
||||||
# SettingsSidebar.qml the row you click
|
# services/SettingsRoutes.qml the taxonomy: which category the page belongs
|
||||||
# SettingsShell.qml the case that maps that row to a component, AND the
|
# 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
|
# 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
|
# modules/settings/qmldir the component registration -- without it the page
|
||||||
# is "not a type" and the whole settings window fails
|
# is "not a type" and the whole settings window fails
|
||||||
# to load, taking every other page with it
|
# 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
|
set -euo pipefail
|
||||||
|
|
||||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
settings_dir="$repo_dir/config/dot/quickshell/modules/settings"
|
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"
|
shell_file="$settings_dir/SettingsShell.qml"
|
||||||
qmldir="$settings_dir/qmldir"
|
qmldir="$settings_dir/qmldir"
|
||||||
shell_state="$repo_dir/config/dot/quickshell/services/ShellState.qml"
|
shell_state="$repo_dir/config/dot/quickshell/services/ShellState.qml"
|
||||||
@@ -29,17 +38,49 @@ fail() {
|
|||||||
exit 1
|
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"
|
[[ -r "$required" ]] || fail "cannot read $required"
|
||||||
done
|
done
|
||||||
|
|
||||||
# ── Every sidebar row resolves everywhere ────────────────────────────────────
|
# ── Reading the taxonomy ─────────────────────────────────────────────────────
|
||||||
pages="$(grep -oE '\{ page: "[a-z-]+"' "$sidebar" | sed 's/.*"\(.*\)"/\1/')"
|
# A category line carries an icon; a tab line does not. A category whose tabs
|
||||||
[[ -n "$pages" ]] || fail 'no pages found in the sidebar -- this contract is not reading it correctly'
|
# 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")" \
|
[[ -n "$category_ids" ]] || fail 'no categories found in SettingsRoutes -- this contract is not reading it correctly'
|
||||||
|| fail 'could not find the allow-list in ShellState'
|
[[ -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
|
while read -r page; do
|
||||||
[[ -n "$page" ]] || continue
|
[[ -n "$page" ]] || continue
|
||||||
|
|
||||||
@@ -47,12 +88,15 @@ while read -r page; do
|
|||||||
# where an unknown page falls back to.
|
# where an unknown page falls back to.
|
||||||
if [[ "$page" != "home" ]]; then
|
if [[ "$page" != "home" ]]; then
|
||||||
grep -qE "case \"$page\": return [a-zA-Z]+;" "$shell_file" \
|
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
|
fi
|
||||||
|
done <<<"$leaves"
|
||||||
|
|
||||||
grep -qF "\"$page\"" <<<"$allowed_line" \
|
# ── ShellState defers to the taxonomy instead of restating it ────────────────
|
||||||
|| fail "\"$page\" is missing from ShellState's allow-list, so openSettings(\"$page\") silently redirects to Home"
|
grep -q 'SettingsRoutes\.resolve(' "$shell_state" \
|
||||||
done <<<"$pages"
|
|| 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 ────────────────────────
|
# ── Every routed component is declared and registered ────────────────────────
|
||||||
# The case arms name a Component id; each must have a declaration, and the type
|
# 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"
|
|| fail "$type_name.qml exists but nothing in SettingsShell instantiates it"
|
||||||
done < <(find "$settings_dir" -maxdepth 1 -name '*Page.qml')
|
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")"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ fail() {
|
|||||||
exit 1
|
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
|
for page in "${pages[@]}"; do
|
||||||
page_file="$repo_dir/config/dot/quickshell/modules/settings/${page}Page.qml"
|
page_file="$repo_dir/config/dot/quickshell/modules/settings/${page}Page.qml"
|
||||||
[[ -f "$page_file" ]] || fail "${page}Page.qml is missing"
|
[[ -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="$harness_pid"
|
||||||
[[ "$shell_pid" =~ ^[0-9]+$ ]] || fail 'could not identify the branch shell process'
|
[[ "$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
|
for page in "${pages[@]}"; do
|
||||||
qs_for_test ipc call settings page "$page" >/dev/null
|
qs_for_test ipc call settings page "$page" >/dev/null
|
||||||
for _ in $(seq 1 20); do
|
for _ in $(seq 1 20); do
|
||||||
|
|||||||
@@ -104,12 +104,20 @@ EXTRACT
|
|||||||
)
|
)
|
||||||
[[ "$missing" -eq 0 ]] || fail "$missing schema label(s) are not findable by search"
|
[[ "$missing" -eq 0 ]] || fail "$missing schema label(s) are not findable by search"
|
||||||
|
|
||||||
# ── Results only ever route to pages the shell can open ──────────────────────
|
# ── Results only ever route to leaves the shell can open ─────────────────────
|
||||||
allowed="$(grep -oE 'const allowed = \[[^]]*\]' "$repo_dir/config/dot/quickshell/services/ShellState.qml" \
|
# A search result carries a leaf id, never a category: it has to land on the
|
||||||
| grep -oE '"[a-z-]+"' | tr -d '"' | sort -u)"
|
# exact tab holding the setting, not on whichever tab that category opens
|
||||||
for query in wallpaper blur timezone screenshot pointer lock volume gaps; do
|
# 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)"
|
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
|
done
|
||||||
|
|
||||||
trap - EXIT
|
trap - EXIT
|
||||||
|
|||||||
@@ -3,11 +3,19 @@
|
|||||||
# The Sound page is a first-class PipeWire control surface, not a launcher for
|
# 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
|
# another settings app. This contract keeps the real device plumbing shared
|
||||||
# with Quick Settings and verifies the controls that must remain available.
|
# 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
|
set -euo pipefail
|
||||||
|
|
||||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
sound_page="$repo_dir/config/dot/quickshell/modules/settings/SoundPage.qml"
|
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_list="$repo_dir/config/dot/quickshell/modules/settings/SoundDeviceList.qml"
|
||||||
device_row="$repo_dir/config/dot/quickshell/modules/settings/SoundDeviceRow.qml"
|
device_row="$repo_dir/config/dot/quickshell/modules/settings/SoundDeviceRow.qml"
|
||||||
application_mixer="$repo_dir/config/dot/quickshell/modules/settings/ApplicationMixer.qml"
|
application_mixer="$repo_dir/config/dot/quickshell/modules/settings/ApplicationMixer.qml"
|
||||||
@@ -36,7 +44,7 @@ cleanup() {
|
|||||||
}
|
}
|
||||||
trap cleanup EXIT
|
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/"}"
|
[[ -f "$file" ]] || fail "missing ${file#"$repo_dir/"}"
|
||||||
done
|
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 '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'
|
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
|
# Native bindings are the supported path. Shelling out would race the service
|
||||||
# that owns these same objects and regress Quick Settings coherence.
|
# 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
|
if rg -q '\b(Process|pactl|wpctl)\b' "$audio_devices" "$device_list" "$device_row" "$balance"; then
|
||||||
|
|||||||
Reference in New Issue
Block a user