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