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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user