diff --git a/config/dot/hypr/input.lua b/config/dot/hypr/input.lua
index ec40d51..5bd8b53 100644
--- a/config/dot/hypr/input.lua
+++ b/config/dot/hypr/input.lua
@@ -35,6 +35,13 @@ hl.config({
-- Still focus-follows-pointer, just less twitchy.
mouse_refocus = false,
+ -- 0 = NEXT, the compositor's own default: focus goes to the next window
+ -- in the layout order. `hyprctl descriptions` publishes
+ -- map: [{"mru":2},{"cursor":1},{"next":0}], and Settings offers all
+ -- three; 0 is repeated here so nothing changes on a machine with no
+ -- settings file.
+ focus_on_close = prefs.getInt("focusOnClose", 0),
+
-- Flat pointer response by default, no acceleration. Matters for
-- gaming; Settings offers adaptive for people who want it back.
sensitivity = prefs.get("pointerSensitivity", 0),
@@ -44,6 +51,21 @@ hl.config({
scroll_factor = prefs.get("scrollFactor", 1.0),
left_handed = prefs.get("leftHanded", false),
+ -- Empty is a value, not an omission: it is what `hyprctl getoption`
+ -- reports as "[[EMPTY]]" before anything writes the option, and it means
+ -- "let libinput pick per device" -- two fingers on a touchpad, the wheel
+ -- on a mouse. Writing it back explicitly is the same branch a stock
+ -- Hyprland takes, and it keeps the setting reversible: without an empty
+ -- choice in the schema there would be no way back from a scroll method
+ -- once one was picked. Settings offers 2fg / edge / on_button_down /
+ -- no_scroll alongside it, the four words the option's own description
+ -- names (it publishes no map).
+ scroll_method = prefs.get("scrollMethod", ""),
+
+ -- Only consulted while scroll_method is on_button_down. 0 means the
+ -- device's own middle button.
+ scroll_button = prefs.get("scrollButton", 0),
+
-- Clicking a floating window raises and focuses it.
float_switch_override_focus = 2,
@@ -61,9 +83,33 @@ hl.config({
scroll_factor = prefs.get("touchpadScrollFactor", 1.0),
drag_lock = prefs.getInt("touchpadDragLock", 0),
middle_button_emulation = prefs.get("touchpadMiddleButtonEmulation", false),
+ clickfinger_behavior = prefs.get("touchpadClickfinger", false),
+
+ -- Underscores here, hyphens in the option name getoption answers to
+ -- (input:touchpad:tap-and-drag) -- the same split tap_to_click has
+ -- at the top of this table. Hyprland's own default is true; it is
+ -- repeated rather than omitted so the schema, the Lua, and the
+ -- compositor all state the same value.
+ tap_and_drag = prefs.get("touchpadTapAndDrag", true),
},
},
+ -- Pointer BEHAVIOUR, as opposed to pointer appearance: how the cursor reacts
+ -- to typing and to workspace switches. The cursor's looks -- theme, size,
+ -- hardware cursors, the inactivity fade -- are a separate cursor table in
+ -- looks.lua. hl.config calls are additive per option, so the two tables
+ -- coexist; they are split by what a person would go looking for, and these
+ -- two appear on Settings' Mouse & Touchpad page rather than in Appearance.
+ cursor = {
+ hide_on_key_press = prefs.get("cursorHideWhileTyping", false),
+
+ -- An integer with three states, written from a switch: disable = 0,
+ -- enable = 1, force = 2. Settings offers the first two, so getInt is
+ -- what bridges a stored boolean to the number Hyprland wants -- the same
+ -- pairing render.cm_auto_hdr uses in looks.lua.
+ warp_on_change_workspace = prefs.getInt("cursorWarpOnWorkspaceChange", 0),
+ },
+
-- Tuning for the three-finger gestures registered below.
gestures = {
workspace_swipe_distance = prefs.getInt("swipeDistance", 300),
diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml
index 120c807..38faeb5 100644
--- a/config/dot/quickshell/config/PreferenceSchema.qml
+++ b/config/dot/quickshell/config/PreferenceSchema.qml
@@ -757,6 +757,88 @@ Singleton {
detail: "Paste the primary selection in GTK and native Wayland applications",
hypr: { path: ["misc", "middle_click_paste"], option: "misc:middle_click_paste", readAs: "bool" }
},
+ {
+ key: "focusOnClose", type: "enum", def: 0, group: "pointer",
+ label: "Focus after closing",
+ detail: "Which window takes keyboard focus when the focused one goes away",
+ // Designed as a two-way choice; the compositor publishes three.
+ // map: [{"mru":2},{"cursor":1},{"next":0}]
+ // and 0 -- the value this desktop runs on today -- is "next in the
+ // stack", which is neither of the two the design named. Hiding it
+ // would make the shipped default unreachable from its own dropdown,
+ // and enum-hypr-map-contract refuses an enum that drops a published
+ // value for exactly that reason.
+ options: [
+ { value: 0, label: "Next in the stack",
+ detail: "Whichever window Hyprland has next in the layout order" },
+ { value: 1, label: "Under the pointer",
+ detail: "Whatever window the pointer happens to be over" },
+ { value: 2, label: "Most recently used",
+ detail: "The window you were on before this one" }
+ ],
+ hypr: { path: ["input", "focus_on_close"], option: "input:focus_on_close", readAs: "int" }
+ },
+ {
+ key: "scrollMethod", type: "enum", def: "", group: "pointer",
+ label: "Scroll method",
+ detail: "How a pointing device turns movement into scrolling",
+ // No `map` is published for this one -- it is a plain string option,
+ // and the words it accepts live in its description instead:
+ // [2fg/edge/on_button_down/no_scroll].
+ //
+ // Unset is a real state rather than an absence, and it is the state
+ // Panama ships: getoption answers "[[EMPTY]]" until something writes
+ // the option, and an empty value means "whatever libinput picks for
+ // this device", which is the branch every stock Hyprland takes. So
+ // empty is offered as a choice of its own -- without it the setting
+ // would be a one-way door, and its default would be unreachable.
+ // Writing "" reads back as "" with set:true, the same round trip
+ // input:kb_variant has made for as long as it has been empty.
+ options: [
+ { value: "", label: "Whatever suits the device",
+ detail: "Two fingers on a touchpad, the wheel on a mouse" },
+ { value: "2fg", label: "Two fingers" },
+ { value: "edge", label: "Along the edge of the touchpad" },
+ { value: "on_button_down", label: "While a button is held" },
+ { value: "no_scroll", label: "Never scroll" }
+ ],
+ hypr: { path: ["input", "scroll_method"], option: "input:scroll_method", readAs: "str" }
+ },
+ {
+ key: "scrollButton", type: "int", def: 0, min: 0, max: 300, step: 1,
+ group: "pointer",
+ label: "Scroll button",
+ detail: "Which button is held to scroll, as an evdev code; 0 lets the device choose",
+ // The range is the compositor's own rather than a guess: descriptions
+ // gives min 0, max 300. Only meaningful while Scroll method is
+ // "While a button is held", which is a UI condition, not a schema one
+ // -- the value stays valid and stored either way.
+ hypr: { path: ["input", "scroll_button"], option: "input:scroll_button", readAs: "int" }
+ },
+ {
+ key: "cursorHideWhileTyping", type: "bool", def: false, group: "pointer",
+ label: "Hide pointer while typing",
+ detail: "The pointer vanishes on the next keystroke and returns when you move it",
+ // A `cursor:` option rather than an `input:` one, so its read-back in
+ // the Lua sits in a cursor table of its own; see hypr/input.lua.
+ hypr: { path: ["cursor", "hide_on_key_press"], option: "cursor:hide_on_key_press", readAs: "bool" }
+ },
+ {
+ key: "cursorWarpOnWorkspaceChange", type: "bool", def: false, group: "pointer",
+ label: "Jump pointer to the focused display",
+ detail: "Moves the pointer to the last focused window after switching workspace",
+ // A switch here, an integer in the compositor -- the same shape
+ // autoHdr has, and `readAs: "int"` is what keeps the two sides in
+ // agreement. The published map is
+ // map: [{"force":2},{"enable":1},{"disable":0}]
+ // and "force" -- warp even when the pointer is already on that
+ // display -- is deliberately not offered: a third state would turn a
+ // switch into a dropdown for a distinction almost nobody wants.
+ // enum-hypr-map-contract governs enums only, so this is a decision
+ // rather than a violation, but it IS a decision: value 2 is not
+ // reachable from Settings.
+ hypr: { path: ["cursor", "warp_on_change_workspace"], option: "cursor:warp_on_change_workspace", readAs: "int" }
+ },
// ── Touchpad ────────────────────────────────────────────────────────
//
@@ -814,6 +896,28 @@ Singleton {
detail: "Pressing left and right together acts as a middle click",
hypr: { path: ["input", "touchpad", "middle_button_emulation"], option: "input:touchpad:middle_button_emulation", readAs: "bool" }
},
+ {
+ key: "touchpadClickfinger", type: "bool", def: false, group: "touchpad",
+ label: "Two-finger right-click",
+ detail: "One, two, or three fingers pressing down give left, right, and middle click, instead of clicking by which part of the pad you press",
+ hypr: { path: ["input", "touchpad", "clickfinger_behavior"], option: "input:touchpad:clickfinger_behavior", readAs: "bool" }
+ },
+ {
+ key: "touchpadTapAndDrag", type: "bool", def: true, group: "touchpad",
+ label: "Tap and drag",
+ detail: "A tap followed straight away by a tap-and-hold starts a drag, with nothing pressed down",
+ // Hyphens in the option name, underscores in the Lua path -- the same
+ // split tap-to-click documents above, and the only other option in
+ // the touchpad section spelled that way.
+ //
+ // `hyprctl descriptions` contradicts itself here: it reports current
+ // false while `hyprctl getoption` answers bool true with set:false,
+ // meaning nothing has ever written it and it is sitting on
+ // Hyprland's own default of true. getoption is the authority, since
+ // it is what the write path verifies against, so true is what ships
+ // and nothing changes on a machine that has a touchpad.
+ hypr: { path: ["input", "touchpad", "tap_and_drag"], option: "input:touchpad:tap-and-drag", readAs: "bool" }
+ },
// Tuning for the three-finger gestures registered in hypr/input.lua.
// The gestures themselves are not settings: Hyprland reads a gesture
diff --git a/config/dot/quickshell/modules/settings/DictationPage.qml b/config/dot/quickshell/modules/settings/DictationPage.qml
index 1dd6641..f8a45d3 100644
--- a/config/dot/quickshell/modules/settings/DictationPage.qml
+++ b/config/dot/quickshell/modules/settings/DictationPage.qml
@@ -1,68 +1,440 @@
import QtQuick
+import Quickshell.Io
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.
+//
+// Status first. The two questions this page exists to answer are "is it ready"
+// and "which keys" -- both were previously spelled as a pair of rows reading
+// "Ready / Missing" beside a hardcoded sentence about Super+D. The hotkeys are
+// now looked up from the live keymap, so rebinding the dictation key changes
+// what this page says instead of quietly making it wrong.
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."
+ id: root
- 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."
+ // Chords come from the compositor by matching the descriptions
+ // hypr/keybinds.lua gives the dictation binds. The literals are a fallback
+ // for the moment before the keymap has loaded, not a second source of truth.
+ function chordFor(needle: string, fallback: string): string {
+ for (const bind of Keybinds.binds) {
+ if (String(bind.description).toLowerCase().indexOf(needle) >= 0)
+ return String(bind.chord);
+ }
+ return fallback;
+ }
- // Status of the two pieces, read from the machine rather than guessed.
- TextRow {
- label: "Speech server"
+ readonly property string holdChord: root.chordFor("dictate (hold to talk)", "Super + D")
+ readonly property string cancelChord: root.chordFor("cancel dictation", "Super + Shift + D")
+
+ readonly property int modelMegabytes: Math.round(Dictation.modelBytes / 1048576)
+
+ // What the setup is doing, as steps rather than a percentage with no
+ // subject. Driven entirely by the service's existing phase and progress
+ // fields -- nothing new is asked of panama-dictate.
+ readonly property var setupSteps: [
+ {
+ number: 1,
+ title: "Container image",
+ detail: Dictation.imageBuilt
+ ? "The speech server is on this machine"
+ : (Dictation.phase === "pulling" ? "Fetching the speech server…" : "About 1 GB, pulled once"),
+ done: Dictation.imageBuilt,
+ active: Dictation.phase === "pulling",
+ progress: -1
+ },
+ {
+ number: 2,
+ title: "Speech model",
+ detail: Dictation.modelInstalled
+ ? root.modelMegabytes + " MB, kept across rebuilds of the server"
+ : (Dictation.phase === "downloading" && Dictation.downloadTotalBytes > 0
+ ? "Downloading — " + Math.round(Dictation.downloadedBytes / 1048576)
+ + " of " + Math.round(Dictation.downloadTotalBytes / 1048576) + " MB"
+ : "About 490 MB, downloaded once"),
+ done: Dictation.modelInstalled,
+ active: Dictation.phase === "downloading",
+ progress: Dictation.phase === "downloading" && Dictation.downloadTotalBytes > 0
+ ? Dictation.downloadFraction
+ : -1
+ },
+ {
+ number: 3,
+ title: "First transcription",
detail: Dictation.serverReady
- ? "Running"
- : (Dictation.imageBuilt ? "Ready — starts on the first dictation" : "Not set up yet")
- value: Dictation.imageBuilt ? "Ready" : "Missing"
+ ? "The speech server is answering"
+ : "The server starts itself the first time you hold the key",
+ done: Dictation.serverReady,
+ active: false,
+ progress: -1
+ }
+ ]
+
+ // ── The on-page test ────────────────────────────────────────────────────
+ // panama-dictate types what it heard into whatever has keyboard focus, so
+ // a test needs no new plumbing: the field below takes focus, the helper
+ // runs exactly as the hotkey runs it, and the words arrive here. The helper
+ // is addressed through the path the Dictation service already publishes
+ // rather than one spelled again in this file.
+ property bool listening: false
+
+ Process {
+ id: dictateRun
+
+ onExited: (exitCode, exitStatus) => Dictation.refresh()
+ }
+
+ function runDictate(action: string): void {
+ if (dictateRun.running)
+ return;
+ dictateRun.command = [Dictation.helper, action];
+ dictateRun.running = true;
+ }
+
+ title: "Dictation"
+ lede: "Local speech to text — the audio, the model and the server never leave this machine."
+
+ // ── Ready ───────────────────────────────────────────────────────────────
+ SettingsCard {
+ visible: Dictation.ready
+
+ Item {
+ width: parent.width
+ height: 72
+
+ Rectangle {
+ id: readyTile
+
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ width: 44
+ height: 44
+ radius: 14
+ color: Theme.alpha(Theme.ok, 0.10)
+ border.width: 1
+ border.color: Theme.alpha(Theme.ok, 0.35)
+
+ Text {
+ anchors.centerIn: parent
+ text: "✓"
+ color: Theme.ok
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSizeLarge + 4
+ }
+ }
+
+ Column {
+ anchors.left: readyTile.right
+ anchors.leftMargin: 16
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 2
+
+ Text {
+ width: parent.width
+ text: "Dictation is ready"
+ color: Theme.fg
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.DemiBold
+ }
+
+ Text {
+ width: parent.width
+ text: "Speech model · " + root.modelMegabytes + " MB · "
+ + (Dictation.serverReady
+ ? "the speech server is running"
+ : "the speech server starts on your first dictation")
+ color: Theme.fgDim
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSizeSmall
+ wrapMode: Text.WordWrap
+ }
+ }
}
- 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"
+ SettingRow {
+ label: "Hold to dictate"
+ detail: "Release to type what you said, wherever the cursor is"
+ controlWidth: 220
+
+ KeycapChord {
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ chord: root.holdChord
+ }
+ }
+
+ SettingRow {
+ label: "Cancel a dictation"
+ detail: "Throws the recording away without transcribing it"
+ controlWidth: 220
+
+ KeycapChord {
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ chord: root.cancelChord
+ }
+ }
+
+ SettingRow {
+ label: "Try it"
+ detail: "Speak a sentence and it is typed into the field here, rather than into whatever you were working on"
+ controlWidth: 340
+
+ Row {
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 8
+
+ Rectangle {
+ anchors.verticalCenter: parent.verticalCenter
+ width: 200
+ height: 30
+ radius: 8
+ color: Theme.alpha(Theme.fg, 0.05)
+ border.width: 1
+ border.color: heard.activeFocus
+ ? Theme.alpha(Theme.accent, 0.55)
+ : Theme.alpha(Theme.fg, 0.12)
+
+ TextInput {
+ id: heard
+
+ anchors.fill: parent
+ anchors.leftMargin: 10
+ anchors.rightMargin: 10
+ verticalAlignment: TextInput.AlignVCenter
+ clip: true
+ color: Theme.fg
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSizeSmall
+ selectByMouse: true
+ selectionColor: Theme.alpha(Theme.accent, 0.35)
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: heard.text === ""
+ text: root.listening ? "Listening…" : "Dictated text lands here"
+ color: Theme.fgMuted
+ font: heard.font
+ }
+ }
+ }
+
+ SettingsButton {
+ anchors.verticalCenter: parent.verticalCenter
+ text: root.listening ? "Stop and type it" : "Test dictation"
+ tone: root.listening ? "accent" : "normal"
+ onClicked: {
+ // Focus first, and keep it: the helper types with wtype
+ // into whatever holds keyboard focus when it finishes.
+ heard.forceActiveFocus();
+ if (root.listening) {
+ root.listening = false;
+ root.runDictate("stop");
+ return;
+ }
+ heard.text = "";
+ root.listening = true;
+ root.runDictate("start");
+ }
+ }
+ }
+ }
+
+ SettingRow {
+ visible: !Dictation.typingAvailable
+ label: "Typing"
+ detail: "wtype is missing, so dictated text goes to the clipboard instead of being typed"
+ value: "Missing"
+ divider: false
+ }
+ }
+
+ // ── Setting up ──────────────────────────────────────────────────────────
+ SettingsCard {
+ visible: !Dictation.ready
+
+ Item {
+ width: parent.width
+ height: 72
+
+ Rectangle {
+ id: setupTile
+
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ width: 44
+ height: 44
+ radius: 14
+ color: Dictation.downloading
+ ? Theme.alpha(Theme.accent, 0.10)
+ : Theme.alpha(Theme.fg, 0.07)
+ border.width: 1
+ border.color: Dictation.downloading
+ ? Theme.alpha(Theme.accent, 0.35)
+ : Theme.alpha(Theme.fg, 0.16)
+
+ Text {
+ anchors.centerIn: parent
+ text: Dictation.downloading ? "…" : "✗"
+ color: Dictation.downloading ? Theme.accent : Theme.fgMuted
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSizeLarge + 4
+ }
+ }
+
+ Column {
+ anchors.left: setupTile.right
+ anchors.leftMargin: 16
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 2
+
+ Text {
+ width: parent.width
+ text: Dictation.downloading ? "Setting up dictation" : "Dictation is not set up yet"
+ color: Theme.fg
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.DemiBold
+ }
+
+ Text {
+ width: parent.width
+ text: "Everything stays local: a speech server in a container, and a Whisper model. Neither ships with Panama — both are large and want the network."
+ color: Theme.fgDim
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSizeSmall
+ wrapMode: Text.WordWrap
+ }
+ }
+ }
+
+ Repeater {
+ model: root.setupSteps
+
+ Item {
+ id: step
+
+ required property var modelData
+
+ width: parent ? parent.width : 620
+ height: 48
+
+ Rectangle {
+ id: number
+
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ width: 24
+ height: 24
+ radius: 12
+ color: {
+ if (step.modelData.done)
+ return Theme.alpha(Theme.ok, 0.15);
+ if (step.modelData.active)
+ return Theme.alpha(Theme.accent, 0.20);
+ return Theme.alpha(Theme.fg, 0.08);
+ }
+
+ Text {
+ anchors.centerIn: parent
+ text: step.modelData.done ? "✓" : String(step.modelData.number)
+ color: {
+ if (step.modelData.done)
+ return Theme.ok;
+ if (step.modelData.active)
+ return Theme.accent;
+ return Theme.fgDim;
+ }
+ font.family: Theme.fontFamily
+ font.features: Theme.tabularFigures
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.DemiBold
+ }
+ }
+
+ Column {
+ anchors.left: number.right
+ anchors.leftMargin: 12
+ anchors.right: progress.left
+ anchors.rightMargin: 12
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 2
+
+ Text {
+ width: parent.width
+ text: String(step.modelData.title)
+ color: step.modelData.done || step.modelData.active ? Theme.fg : Theme.fgDim
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSize
+ font.weight: Font.Medium
+ elide: Text.ElideRight
+ }
+
+ Text {
+ width: parent.width
+ text: String(step.modelData.detail)
+ color: Theme.fgDim
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSizeSmall
+ elide: Text.ElideRight
+ }
+ }
+
+ // A real bar rather than a spinner: this is the one part of
+ // setup whose length is actually known.
+ Rectangle {
+ id: progress
+
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ width: step.modelData.progress >= 0 ? 140 : 0
+ height: 5
+ radius: 3
+ visible: step.modelData.progress >= 0
+ color: Theme.alpha(Theme.fg, 0.09)
+
+ Rectangle {
+ anchors.left: parent.left
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ width: parent.width * Math.max(0, Math.min(1, step.modelData.progress))
+ radius: parent.radius
+ gradient: Gradient {
+ orientation: Gradient.Horizontal
+ GradientStop { position: 0.0; color: Theme.accent }
+ GradientStop { position: 1.0; color: Theme.accentSecondary }
+ }
+ }
+ }
+
+ Rectangle {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ height: 1
+ color: Theme.alpha(Theme.fg, 0.05)
+ }
+ }
}
// 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.
+ // 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…";
- }
+ detail: Dictation.downloading
+ ? "Working — this can take several minutes on a slow connection"
+ : "Fetches the speech server and the model. Runs once, keeps both."
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 !== ""
}
diff --git a/config/dot/quickshell/modules/settings/InputTestArea.qml b/config/dot/quickshell/modules/settings/InputTestArea.qml
new file mode 100644
index 0000000..60a4bb7
--- /dev/null
+++ b/config/dot/quickshell/modules/settings/InputTestArea.qml
@@ -0,0 +1,180 @@
+// Somewhere to try a pointer change before deciding to keep it.
+//
+// Pointer speed, acceleration, scroll direction and scroll speed are all
+// settings you cannot read: the number means nothing, and the only honest test
+// is moving the pointer. Every one of them applies live, so this is simply a
+// place to move it that is not somebody's document.
+//
+// Entirely local. Nothing here writes a preference or touches the compositor,
+// and the scribble is not saved anywhere -- it exists for the length of a
+// question ("is that too fast?") and is thrown away.
+//
+// The canvas repaints only when the pointer has actually moved across it,
+// driven from the motion handler rather than a timer. A settings page that
+// repainted continuously would be a persistent GPU load on a high-refresh
+// display, in return for a picture that had not changed.
+
+import QtQuick
+import qs.config
+
+Column {
+ id: root
+
+ width: parent ? parent.width : 620
+ spacing: 10
+
+ Rectangle {
+ id: pad
+
+ width: parent.width
+ height: 150
+ radius: Theme.cardRadius
+ color: Theme.alpha(Theme.bgDark, 0.55)
+ border.width: 1
+ border.color: Theme.alpha(Theme.fg, 0.08)
+ clip: true
+
+ // Segments drawn since the last paint. The canvas keeps what it has
+ // already been given, so each paint adds the new piece of the stroke
+ // instead of redrawing the whole scribble.
+ property var pending: []
+ property bool wiping: false
+ property bool drawn: false
+
+ Canvas {
+ id: scribble
+
+ anchors.fill: parent
+ renderStrategy: Canvas.Immediate
+
+ onPaint: {
+ const context = scribble.getContext("2d");
+ if (pad.wiping) {
+ context.reset();
+ pad.wiping = false;
+ pad.pending = [];
+ return;
+ }
+ context.strokeStyle = Theme.accent;
+ context.lineWidth = 2.4;
+ context.lineCap = "round";
+ context.lineJoin = "round";
+ context.beginPath();
+ for (const segment of pad.pending) {
+ context.moveTo(segment.fromX, segment.fromY);
+ context.lineTo(segment.toX, segment.toY);
+ }
+ context.stroke();
+ pad.pending = [];
+ }
+ }
+
+ Text {
+ anchors.centerIn: parent
+ visible: !pad.drawn
+ text: "Draw here to feel pointer speed"
+ color: Theme.fgMuted
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSize
+ }
+
+ MouseArea {
+ id: pointer
+
+ property real lastX: 0
+ property real lastY: 0
+
+ anchors.fill: parent
+ cursorShape: Qt.CrossCursor
+ // The page is a Flickable, which would otherwise take the drag off
+ // this and scroll instead of drawing.
+ preventStealing: true
+
+ onPressed: mouse => {
+ pointer.lastX = mouse.x;
+ pointer.lastY = mouse.y;
+ pad.drawn = true;
+ }
+
+ onPositionChanged: mouse => {
+ const segments = pad.pending;
+ segments.push({
+ fromX: pointer.lastX,
+ fromY: pointer.lastY,
+ toX: mouse.x,
+ toY: mouse.y
+ });
+ pad.pending = segments;
+ pointer.lastX = mouse.x;
+ pointer.lastY = mouse.y;
+ scribble.requestPaint();
+ }
+ }
+
+ SettingsButton {
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.margins: 8
+ visible: pad.drawn
+ text: "Clear"
+ onClicked: {
+ pad.wiping = true;
+ pad.drawn = false;
+ scribble.requestPaint();
+ }
+ }
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 110
+ radius: Theme.cardRadius
+ color: Theme.alpha(Theme.bgDark, 0.55)
+ border.width: 1
+ border.color: Theme.alpha(Theme.fg, 0.08)
+ clip: true
+
+ Flickable {
+ id: strip
+
+ anchors.fill: parent
+ anchors.margins: 12
+ contentWidth: width
+ contentHeight: lines.implicitHeight
+ boundsBehavior: Flickable.StopAtBounds
+
+ Column {
+ id: lines
+
+ width: parent.width
+ spacing: 6
+
+ Repeater {
+ model: [
+ "Scroll here to feel scroll speed and direction.",
+ "Natural scrolling moves the content with your fingers.",
+ "The quick brown fox jumps over the lazy dog.",
+ "Line four.",
+ "Line five.",
+ "Line six.",
+ "Line seven — still scrolling.",
+ "Line eight.",
+ "Line nine.",
+ "Line ten, the bottom."
+ ]
+
+ Text {
+ required property var modelData
+
+ width: lines.width
+ text: String(modelData)
+ color: Theme.fgDim
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSizeSmall
+ elide: Text.ElideRight
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/config/dot/quickshell/modules/settings/KeyRepeatRow.qml b/config/dot/quickshell/modules/settings/KeyRepeatRow.qml
new file mode 100644
index 0000000..99c503e
--- /dev/null
+++ b/config/dot/quickshell/modules/settings/KeyRepeatRow.qml
@@ -0,0 +1,86 @@
+// Key repeat: when a held key starts repeating, and how fast it goes.
+//
+// Two settings, one question. As separate rows they read as unrelated numbers
+// with units nobody converts between; together, under one explanation, the
+// pair is the single thing somebody came to change.
+//
+// The sliders are ordinary SliderRows, so the debounced commit, the refusal
+// fallback and the schema's own labels and units all come along unchanged.
+// This only puts them side by side, and stacks them when the window is too
+// narrow to hold both.
+
+import QtQuick
+import qs.config
+
+Column {
+ id: root
+
+ property bool divider: true
+
+ readonly property bool side: root.width >= 560
+ readonly property int columnWidth: root.side ? (root.width - 24) / 2 : root.width
+
+ width: parent ? parent.width : 620
+ spacing: 0
+
+ Item {
+ width: parent.width
+ height: copy.implicitHeight + 20
+
+ Column {
+ id: copy
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ spacing: 3
+
+ Text {
+ width: parent.width
+ text: "Key repeat"
+ color: Theme.fg
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSize
+ font.weight: Font.Medium
+ }
+
+ Text {
+ width: parent.width
+ text: "Hold a key: when repeating starts, and how fast it goes"
+ color: Theme.fgDim
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSizeSmall
+ wrapMode: Text.WordWrap
+ }
+ }
+ }
+
+ Flow {
+ width: parent.width
+ spacing: 24
+
+ SliderRow {
+ setting: "keyRepeatDelay"
+ width: root.columnWidth
+ divider: false
+ }
+
+ SliderRow {
+ setting: "keyRepeatRate"
+ width: root.columnWidth
+ divider: false
+ }
+ }
+
+ Item {
+ width: parent.width
+ height: 10
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 1
+ visible: root.divider
+ color: Theme.alpha(Theme.fg, 0.065)
+ }
+}
diff --git a/config/dot/quickshell/modules/settings/KeycapChord.qml b/config/dot/quickshell/modules/settings/KeycapChord.qml
new file mode 100644
index 0000000..34d23d0
--- /dev/null
+++ b/config/dot/quickshell/modules/settings/KeycapChord.qml
@@ -0,0 +1,109 @@
+// A key chord, drawn as keys.
+//
+// KeycapChord { chord: "Super + Shift + Q" }
+//
+// The chord arrives as the display string Keybinds already produces and is
+// split on "+" alone, so nothing here has to know what a keysym is. Modifiers
+// are tinted and spelled in title case, which is what makes a wall of chords
+// scannable: the eye finds the modifier pattern before it reads the key.
+//
+// Presentation only. The prettified names are never handed back -- a rebind is
+// keyed by the Lua chord the service holds, and this must not become a second,
+// lossy spelling of a binding.
+
+import QtQuick
+import qs.config
+
+Row {
+ id: root
+
+ property string chord: ""
+ property int capHeight: 24
+
+ readonly property var keys: String(root.chord)
+ .split("+")
+ .map(part => part.trim())
+ .filter(part => part !== "")
+
+ // Case-insensitive: Keybinds renders "Super", an override is stored as
+ // "SUPER", and both have to tint.
+ readonly property var modifierNames: ({
+ "super": "Super",
+ "meta": "Super",
+ "win": "Super",
+ "shift": "Shift",
+ "ctrl": "Ctrl",
+ "control": "Ctrl",
+ "alt": "Alt"
+ })
+
+ function isModifier(key: string): bool {
+ return root.modifierNames[String(key).toLowerCase()] !== undefined;
+ }
+
+ function pretty(key: string): string {
+ return root.modifierNames[String(key).toLowerCase()] ?? key;
+ }
+
+ spacing: 4
+
+ Repeater {
+ model: root.keys
+
+ Row {
+ id: segment
+
+ required property var modelData
+ required property int index
+
+ readonly property bool modifier: root.isModifier(String(segment.modelData))
+
+ spacing: 4
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: segment.index > 0
+ text: "+"
+ color: Theme.fgMuted
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSizeSmall
+ }
+
+ Rectangle {
+ anchors.verticalCenter: parent.verticalCenter
+ width: Math.max(26, cap.implicitWidth + 14)
+ height: root.capHeight
+ radius: 6
+ color: Theme.alpha(Theme.fg, 0.09)
+ border.width: 1
+ border.color: Theme.alpha(Theme.fg, 0.16)
+
+ // A keycap reads as a key because it has a lip. Rectangle
+ // borders are uniform, so the thicker bottom edge is its own
+ // sliver rather than a border width.
+ Rectangle {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ anchors.margins: 1
+ height: 1
+ color: Theme.alpha(Theme.fg, 0.16)
+ }
+
+ Text {
+ id: cap
+
+ anchors.centerIn: parent
+ text: root.pretty(String(segment.modelData))
+ color: segment.modifier ? Theme.accent : Theme.fg
+ font.family: Theme.fontMono
+ // Digits in a chord ("Super + 1") sit in a column of other
+ // chords; tabular figures keep that column from wobbling.
+ font.features: Theme.tabularFigures
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.DemiBold
+ }
+ }
+ }
+ }
+}
diff --git a/config/dot/quickshell/modules/settings/MousePage.qml b/config/dot/quickshell/modules/settings/MousePage.qml
index 7f3d1b1..94c1010 100644
--- a/config/dot/quickshell/modules/settings/MousePage.qml
+++ b/config/dot/quickshell/modules/settings/MousePage.qml
@@ -10,6 +10,11 @@
// preference is stored and the compositor accepts an option for a device class
// with no members, so the user would be toggling settings that can never affect
// anything with nothing to say so.
+//
+// The gestures that were a card of their own now sit at the bottom of the
+// touchpad card. They are touchpad settings -- a three-finger swipe has nowhere
+// else to happen -- and a card holding two sliders was a heading standing in
+// for a section.
import QtQuick
import qs.config
@@ -23,18 +28,100 @@ SettingsPage {
? "Pointer behavior for your mouse and touchpad."
: "Pointer behavior. Touchpad settings appear when a touchpad is attached."
+ // The name-filtered device lists InputDevices publishes. Defaulted here so
+ // the card stays a short honest list even before the service has read the
+ // compositor for the first time.
+ readonly property var realKeyboards: InputDevices.realKeyboards ?? []
+ readonly property var realMice: InputDevices.realMice ?? []
+
+ // Keyboards, then mice, then the touchpad if there is one. Built as one
+ // list so the card can draw dividers between rows without each Repeater
+ // having to know what follows it.
+ readonly property var devices: {
+ const out = [];
+ for (const keyboard of root.realKeyboards)
+ out.push({
+ glyph: "\u{F030C}", // md-keyboard
+ label: String(keyboard.pretty ?? keyboard.name ?? ""),
+ detail: InputDevices.mainKeyboardLayout === ""
+ ? "Keyboard"
+ : "Keyboard · " + InputDevices.mainKeyboardLayout
+ });
+ for (const mouse of root.realMice)
+ out.push({
+ glyph: "\u{F037D}", // md-mouse
+ label: String(mouse.pretty ?? mouse.name ?? ""),
+ detail: "Mouse"
+ });
+ if (InputDevices.hasTouchpad)
+ out.push({
+ glyph: "\u{F0621}", // md-gesture-tap
+ label: "Touchpad",
+ detail: "Its own card above, because libinput keeps it separate"
+ });
+ return out;
+ }
+
+ readonly property var scrollMethodSpec: PreferenceSchema.spec("scrollMethod")
+
+ // Scroll button only means something while the method is the held-button
+ // one, and only if the schema still offers that method at all -- the value
+ // comes from what Hyprland's own description of input:scroll_method
+ // accepts, so this asks the schema rather than assuming.
+ readonly property bool heldButtonScrolling: {
+ const options = root.scrollMethodSpec && root.scrollMethodSpec.options
+ ? root.scrollMethodSpec.options
+ : [];
+ if (!options.some(option => option.value === "on_button_down"))
+ return false;
+ return DesktopPreferences.get("scrollMethod") === "on_button_down";
+ }
+
SettingsCard {
title: "Mouse"
subtitle: "Applied to every pointing device that is not a touchpad."
SliderRow { setting: "pointerSensitivity" }
- ChoiceRow { setting: "accelProfile" }
- ToggleRow { setting: "naturalScroll" }
+ OptionPickerRow {
+ id: accelRow
+
+ readonly property var spec: PreferenceSchema.spec("accelProfile")
+
+ label: accelRow.spec ? accelRow.spec.label : "Acceleration"
+ detail: accelRow.spec ? accelRow.spec.detail : ""
+ options: accelRow.spec && accelRow.spec.options ? accelRow.spec.options : []
+ current: DesktopPreferences.get("accelProfile")
+ onPicked: value => SystemSettings.commitPreference("accelProfile", value)
+ }
SliderRow { setting: "scrollFactor" }
+ ToggleRow { setting: "naturalScroll" }
ToggleRow { setting: "leftHanded" }
ToggleRow {
setting: "middleClickPaste"
detail: "Paste the primary selection in GTK and native Wayland applications; individual apps may choose not to support it"
+ }
+
+ OptionPickerRow {
+ id: scrollMethodRow
+
+ label: root.scrollMethodSpec ? root.scrollMethodSpec.label : "Scroll method"
+ detail: root.scrollMethodSpec ? root.scrollMethodSpec.detail : ""
+ options: root.scrollMethodSpec && root.scrollMethodSpec.options
+ ? root.scrollMethodSpec.options
+ : []
+ current: DesktopPreferences.get("scrollMethod")
+ divider: scrollButtonRow.visible
+ onPicked: value => SystemSettings.commitPreference("scrollMethod", value)
+ }
+
+ // Hidden on every other method: the number stays valid and stored, but
+ // a control that governs nothing is exactly what this page exists to
+ // stop shipping.
+ SliderRow {
+ id: scrollButtonRow
+
+ setting: "scrollButton"
+ visible: root.heldButtonScrolling
divider: false
}
}
@@ -45,27 +132,87 @@ SettingsPage {
subtitle: "Separate from the mouse on purpose: libinput keeps them apart, and a touchpad and a mouse usually want to scroll in opposite directions."
ToggleRow { setting: "touchpadTapToClick" }
- ToggleRow { setting: "touchpadNaturalScroll" }
- ToggleRow { setting: "touchpadDisableWhileTyping" }
- SliderRow { setting: "touchpadScrollFactor" }
+ ToggleRow { setting: "touchpadClickfinger" }
+ ToggleRow { setting: "touchpadTapAndDrag" }
ChoiceRow { setting: "touchpadDragLock" }
- ToggleRow { setting: "touchpadMiddleButtonEmulation"; divider: false }
- }
-
- SettingsCard {
- visible: InputDevices.hasTouchpad
- title: "Gestures"
- subtitle: "Three fingers sideways moves between workspaces, up opens the overview, and down closes it — the same gestures GNOME used. Which gestures exist is fixed by the compositor at startup; what they feel like is here."
+ ToggleRow { setting: "touchpadNaturalScroll" }
+ SliderRow { setting: "touchpadScrollFactor" }
+ ToggleRow { setting: "touchpadDisableWhileTyping" }
+ ToggleRow { setting: "touchpadMiddleButtonEmulation" }
+ // Which gestures exist is fixed by the compositor at startup -- three
+ // fingers sideways moves between workspaces, up opens the overview.
+ // What they feel like is here.
SliderRow { setting: "swipeDistance" }
ToggleRow { setting: "swipeInvert"; divider: false }
}
SettingsCard {
- title: "Pointer"
+ title: "Pointer behavior"
- ChoiceRow { setting: "followMouse" }
+ OptionPickerRow {
+ id: followMouseRow
+
+ readonly property var spec: PreferenceSchema.spec("followMouse")
+
+ label: followMouseRow.spec ? followMouseRow.spec.label : "Pointer focus"
+ detail: followMouseRow.spec ? followMouseRow.spec.detail : ""
+ options: followMouseRow.spec && followMouseRow.spec.options ? followMouseRow.spec.options : []
+ current: DesktopPreferences.get("followMouse")
+ onPicked: value => SystemSettings.commitPreference("followMouse", value)
+ }
+
+ OptionPickerRow {
+ id: focusOnCloseRow
+
+ readonly property var spec: PreferenceSchema.spec("focusOnClose")
+
+ label: focusOnCloseRow.spec ? focusOnCloseRow.spec.label : "When a window closes"
+ detail: focusOnCloseRow.spec ? focusOnCloseRow.spec.detail : ""
+ options: focusOnCloseRow.spec && focusOnCloseRow.spec.options ? focusOnCloseRow.spec.options : []
+ current: DesktopPreferences.get("focusOnClose")
+ onPicked: value => SystemSettings.commitPreference("focusOnClose", value)
+ }
+
+ ToggleRow { setting: "cursorHideWhileTyping" }
SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never" }
+ ToggleRow { setting: "cursorWarpOnWorkspaceChange" }
SliderRow { setting: "cursorSize"; divider: false }
}
+
+ SettingsCard {
+ title: "Try it"
+ subtitle: "Every setting above applies live — draw and scroll to feel them. Nothing here is saved."
+
+ InputTestArea {}
+ }
+
+ SettingsCard {
+ title: "Connected devices"
+ subtitle: "What the compositor sees, minus the phantoms: transceiver siblings, virtual keyboards and consumer-control endpoints that are not devices anybody types on."
+
+ Repeater {
+ model: root.devices
+
+ TextRow {
+ id: deviceRow
+
+ required property var modelData
+ required property int index
+
+ width: parent ? parent.width : 620
+ icon: String(deviceRow.modelData.glyph)
+ label: String(deviceRow.modelData.label)
+ detail: String(deviceRow.modelData.detail)
+ divider: deviceRow.index < root.devices.length - 1
+ }
+ }
+
+ TextRow {
+ visible: root.devices.length === 0
+ label: "No input devices reported"
+ detail: "The compositor answered with nothing this page could name"
+ divider: false
+ }
+ }
}
diff --git a/config/dot/quickshell/modules/settings/ShortcutRow.qml b/config/dot/quickshell/modules/settings/ShortcutRow.qml
new file mode 100644
index 0000000..1475d7a
--- /dev/null
+++ b/config/dot/quickshell/modules/settings/ShortcutRow.qml
@@ -0,0 +1,164 @@
+// One shortcut in the shortcuts browser.
+//
+// There are around a hundred and thirty of these on the page at once, so the
+// row is deliberately plain: no Loader for the row itself, no animations, and
+// the Change/Reset buttons are ordinary children that hide when the pointer is
+// elsewhere. Only the capture field is loaded on demand -- it is a FocusScope
+// nobody needs until they ask to rebind, and instantiating one per row is a
+// hundred and thirty focus scopes for a single edit.
+//
+// The row reports what was pressed and never decides anything: conflicts,
+// refusals and the write itself belong to the page, which is where the one
+// capture at a time is tracked.
+
+import QtQuick
+import qs.config
+import qs.services
+
+Item {
+ id: root
+
+ required property var bind
+ property bool capturing: false
+ property bool divider: true
+ // Shown inside the capture field -- the page puts a refused chord here.
+ property string message: ""
+
+ signal changeRequested
+ signal resetRequested
+ signal captured(string chord)
+ signal canceled
+
+ readonly property bool overridden: Keybinds.isOverridden(root.bind.luaChord)
+
+ width: parent ? parent.width : 620
+ implicitHeight: 42
+
+ Rectangle {
+ anchors.fill: parent
+ anchors.bottomMargin: 2
+ radius: 9
+ visible: hover.containsMouse || root.capturing
+ color: root.capturing
+ ? Theme.alpha(Theme.accent, 0.10)
+ : Theme.alpha(Theme.fg, 0.05)
+ }
+
+ MouseArea {
+ id: hover
+
+ anchors.fill: parent
+ hoverEnabled: true
+ // Hover only. Accepting buttons here would put a click target over the
+ // whole row and swallow presses meant for Change and Reset.
+ acceptedButtons: Qt.NoButton
+ }
+
+ Text {
+ anchors.left: parent.left
+ anchors.leftMargin: 6
+ anchors.right: trailing.left
+ anchors.rightMargin: 12
+ anchors.verticalCenter: parent.verticalCenter
+ text: String(root.bind.description ?? "")
+ color: Theme.fg
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSize
+ elide: Text.ElideRight
+ }
+
+ Row {
+ id: trailing
+
+ anchors.right: parent.right
+ anchors.rightMargin: 6
+ anchors.verticalCenter: parent.verticalCenter
+ visible: !root.capturing
+ spacing: 8
+
+ // Where it used to be. Only while the row is under the pointer: at rest
+ // the badge says a shortcut moved, and this says where from, which is
+ // the question the badge raises and nothing else answers.
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: root.overridden && hover.containsMouse
+ text: "was " + Keybinds.shippedChordFor(root.bind.luaChord)
+ color: Theme.fgMuted
+ font.family: Theme.fontMono
+ font.pixelSize: Theme.fontSizeSmall
+ }
+
+ Rectangle {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: root.overridden
+ width: badge.implicitWidth + 14
+ height: 18
+ radius: Theme.pillRadius
+ color: Theme.alpha(Theme.warn, 0.10)
+ border.width: 1
+ border.color: Theme.alpha(Theme.warn, 0.32)
+
+ Text {
+ id: badge
+
+ anchors.centerIn: parent
+ text: "CHANGED"
+ color: Theme.warn
+ font.family: Theme.fontFamily
+ font.pixelSize: Math.max(8, Theme.fontSizeSmall - 2)
+ font.weight: Font.DemiBold
+ }
+ }
+
+ SettingsButton {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: hover.containsMouse
+ text: "Change"
+ // A scroll or click bind has no key to capture.
+ enabled: !Keybinds.reloading && !root.bind.mouse
+ onClicked: root.changeRequested()
+ }
+
+ SettingsButton {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: hover.containsMouse && root.overridden
+ text: "Reset"
+ enabled: !Keybinds.reloading
+ onClicked: root.resetRequested()
+ }
+
+ KeycapChord {
+ anchors.verticalCenter: parent.verticalCenter
+ chord: String(root.bind.chord ?? "")
+ }
+ }
+
+ Loader {
+ anchors.right: parent.right
+ anchors.rightMargin: 6
+ anchors.verticalCenter: parent.verticalCenter
+ width: 240
+ height: 30
+ active: root.capturing
+ // Focus has to travel through the Loader for the capture inside it to
+ // ever see a key press.
+ focus: root.capturing
+ sourceComponent: ShortcutCapture {
+ focus: true
+ message: root.message
+ onCaptured: chord => root.captured(chord)
+ onCanceled: root.canceled()
+ }
+ }
+
+ Rectangle {
+ anchors.left: parent.left
+ anchors.leftMargin: 6
+ anchors.right: parent.right
+ anchors.rightMargin: 6
+ anchors.bottom: parent.bottom
+ height: 1
+ visible: root.divider
+ color: Theme.alpha(Theme.fg, 0.05)
+ }
+}
diff --git a/config/dot/quickshell/modules/settings/ShortcutsPage.qml b/config/dot/quickshell/modules/settings/ShortcutsPage.qml
index 0990d97..d14c357 100644
--- a/config/dot/quickshell/modules/settings/ShortcutsPage.qml
+++ b/config/dot/quickshell/modules/settings/ShortcutsPage.qml
@@ -6,6 +6,13 @@
// and it went stale the moment a bind changed. Every bind now carries its own
// description in hypr/keybinds.lua, and this page just groups and renders them.
//
+// A hundred and thirty rows is a wall, though, and one card per group made the
+// page a mile long with the interesting part -- what a shortcut is bound to --
+// rendered as grey text. So the list is one card with a filter over it, the
+// chords are drawn as keys, and Change/Reset appear on the row under the
+// pointer instead of on all hundred and thirty at once. None of the rebinding
+// machinery moved: Keybinds still owns overrides, conflicts and the reload.
+//
// The hardware settings above the list are real controls. Keyboard layout,
// repeat behavior, and pointer response are Hyprland's, so Panama owns them;
// device-specific configuration stays with GNOME.
@@ -13,6 +20,7 @@
import QtQuick
import qs.config
import qs.services
+import qs.modules.clipboard
SettingsPage {
id: root
@@ -26,7 +34,14 @@ SettingsPage {
// itself. Held while the capture stays open so the message can name both.
property string conflict: ""
property string conflictChord: ""
+
+ // The raw XKB strings are a section rather than two rows in the middle of
+ // the card: they are how the presets above are actually stored, so they
+ // stay one click away rather than being replaced by them.
+ property bool advancedOpen: false
+
readonly property string storedXkbOptions: String(DesktopPreferences.get("keyboardOptions") ?? "")
+ readonly property string storedLayout: String(DesktopPreferences.get("keyboardLayout") ?? "")
function xkbOptions(): var {
return root.storedXkbOptions
@@ -46,53 +61,130 @@ SettingsPage {
SystemSettings.commitPreference("keyboardOptions", options.join(","));
}
+ // The layouts people actually pick, not the several hundred xkeyboard-config
+ // ships. Anything outside this list still shows -- as its own code, added
+ // below -- and Custom… opens the field that can set one.
+ readonly property var commonLayouts: [
+ { value: "us", label: "English (US)" },
+ { value: "gb", label: "English (UK)" },
+ { value: "de", label: "German" },
+ { value: "fr", label: "French" },
+ { value: "es", label: "Spanish" },
+ { value: "it", label: "Italian" },
+ { value: "pt", label: "Portuguese" },
+ { value: "br", label: "Portuguese (Brazil)" },
+ { value: "se", label: "Swedish" },
+ { value: "no", label: "Norwegian" },
+ { value: "dk", label: "Danish" },
+ { value: "fi", label: "Finnish" },
+ { value: "nl", label: "Dutch" },
+ { value: "pl", label: "Polish" },
+ { value: "cz", label: "Czech" },
+ { value: "ru", label: "Russian" },
+ { value: "jp", label: "Japanese" }
+ ]
+
+ readonly property var layoutOptions: {
+ const options = root.commonLayouts.slice();
+ // A layout this list does not carry -- "us,de", "dvorak" -- is shown as
+ // the code it is rather than silently reading as English (US).
+ if (root.storedLayout !== "" && !options.some(option => option.value === root.storedLayout))
+ options.push({
+ value: root.storedLayout,
+ label: root.storedLayout,
+ detail: "The layout this machine is set to"
+ });
+ options.push({
+ value: "__custom",
+ label: "Custom…",
+ detail: "Opens Advanced, where a layout list can be typed in full"
+ });
+ return options;
+ }
+
+ readonly property string filter: filterField.text
+ readonly property bool filtering: root.filter.trim() !== ""
+ readonly property int overrideCount: Object.keys(Keybinds.overrides).length
+
+ // Every group the compositor reports, in Keybinds' own order, narrowed by
+ // the filter. An empty filter narrows nothing: the page's job is to show
+ // the whole keymap, and searching is an extra rather than a gate.
+ readonly property var groups: {
+ const needle = root.filter.trim().toLowerCase();
+ const out = [];
+ for (const group of Keybinds.grouped()) {
+ const hits = needle === ""
+ ? group.binds
+ : group.binds.filter(bind =>
+ String(bind.description).toLowerCase().indexOf(needle) >= 0
+ || group.name.toLowerCase().indexOf(needle) >= 0);
+ if (hits.length > 0)
+ out.push({ name: group.name, binds: hits, total: group.binds.length });
+ }
+ return out;
+ }
+
+ function captureMessage(): string {
+ return root.conflict === ""
+ ? ""
+ : root.conflictChord + " is already " + root.conflict;
+ }
+
title: "Keyboard"
- lede: "The Forge mental model, carried forward into native tiling."
+ lede: "Layout, typing feel, and every shortcut the compositor has bound."
SettingsCard {
- title: "Layout & typing"
+ title: "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
// hl.config re-keymaps attached keyboards immediately -- verified by
// watching active_keymap on a real keyboard change and change back. So
// they are real controls.
- TextEntryRow { setting: "keyboardLayout"; placeholder: "us" }
- TextEntryRow { setting: "keyboardVariant"; placeholder: "none" }
+ OptionPickerRow {
+ label: "Layout"
+ detail: "What the keys produce, before any of the options below"
+ options: root.layoutOptions
+ current: root.storedLayout
+ onPicked: value => {
+ if (value === "__custom") {
+ root.advancedOpen = true;
+ return;
+ }
+ SystemSettings.commitPreference("keyboardLayout", value);
+ }
+ }
- ChoiceGrid {
- width: parent.width
+ OptionPickerRow {
label: "Caps Lock"
- detail: "Keep it conventional, or turn a prime keyboard position into Escape or Control"
- current: root.currentXkbOption("caps:")
+ detail: "What the Caps Lock key does"
options: [
- { value: "", label: "Standard" },
- { value: "caps:escape_shifted_capslock", label: "Esc · Shift for Caps" },
- { value: "caps:escape", label: "Escape" },
- { value: "caps:ctrl_modifier", label: "Control" }
+ { value: "", label: "Standard", detail: "Caps Lock, as printed on the key" },
+ { value: "caps:escape_shifted_capslock", label: "Esc · Shift for Caps",
+ detail: "Escape on its own; Shift and Caps Lock together still lock" },
+ { value: "caps:escape", label: "Escape", detail: "Caps Lock becomes Escape entirely" },
+ { value: "caps:ctrl_modifier", label: "Control", detail: "A second Control in a prime position" }
]
+ current: root.currentXkbOption("caps:")
onPicked: value => root.setXkbOption("caps:", value)
}
- ChoiceGrid {
- width: parent.width
+ OptionPickerRow {
label: "Compose key"
detail: "Type accented characters and symbols with memorable key sequences"
- current: root.currentXkbOption("compose:")
options: [
{ value: "", label: "Off" },
{ value: "compose:ralt", label: "Right Alt" },
{ value: "compose:rwin", label: "Right Super" },
{ value: "compose:menu", label: "Menu" }
]
+ current: root.currentXkbOption("compose:")
onPicked: value => root.setXkbOption("compose:", value)
}
- ChoiceGrid {
- width: parent.width
+ OptionPickerRow {
label: "Layout switching"
- detail: "Used when Keyboard layout contains more than one comma-separated layout"
- current: root.currentXkbOption("grp:")
+ detail: "Used when the layout above contains more than one comma-separated layout"
options: [
{ value: "", label: "Off" },
{ value: "grp:win_space_toggle", label: "Super + Space" },
@@ -100,15 +192,67 @@ SettingsPage {
{ value: "grp:ctrl_shift_toggle", label: "Ctrl + Shift" },
{ value: "grp:caps_toggle", label: "Caps Lock" }
]
+ current: root.currentXkbOption("grp:")
onPicked: value => root.setXkbOption("grp:", value)
}
- // Presets preserve every option outside their own category. The raw
- // value remains visible for less common xkeyboard-config features.
- TextEntryRow { setting: "keyboardOptions"; placeholder: "compose:ralt" }
- SliderRow { setting: "keyRepeatDelay" }
- SliderRow { setting: "keyRepeatRate" }
+ KeyRepeatRow {}
+
ToggleRow { setting: "numlockByDefault"; divider: false }
+
+ // Presets preserve every option outside their own category, and the raw
+ // value stays here rather than being replaced by them -- xkeyboard-config
+ // has hundreds of options and this card offers four.
+ Item {
+ width: parent.width
+ height: 40
+
+ Rectangle {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ height: 1
+ color: Theme.alpha(Theme.fg, 0.065)
+ }
+
+ Row {
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 8
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: "Advanced"
+ color: Theme.fgDim
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSize
+ font.weight: Font.Medium
+ }
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: root.advancedOpen ? "raw XKB options ▴" : "raw XKB options ▾"
+ color: Theme.fgMuted
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSizeSmall
+ }
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: root.advancedOpen = !root.advancedOpen
+ }
+ }
+
+ Column {
+ width: parent.width
+ visible: root.advancedOpen
+
+ TextEntryRow { setting: "keyboardLayout"; placeholder: "us" }
+ TextEntryRow { setting: "keyboardOptions"; placeholder: "compose:ralt" }
+ TextEntryRow { setting: "keyboardVariant"; placeholder: "none"; divider: false }
+ }
}
// Deliberately no handoff to GNOME's keyboard panel here. That panel
@@ -118,129 +262,162 @@ SettingsPage {
// work sat further up this same page. A handoff to an inert panel is a
// dead end wearing a button.
- // One card per group, built from what the compositor actually has bound.
- //
- // Both Repeaters address their model through an explicit id. Inside a
- // SettingsCard the surrounding `parent` is the card's internal Column, not
- // the card, so `parent.modelData` is undefined there and the rows silently
- // never appear -- the cards render with their heading and nothing under it.
- Repeater {
- model: Keybinds.grouped()
+ SettingsCard {
+ title: "Shortcuts"
+ subtitle: "Click Change and press the new keys. A shortcut another action holds is refused, never stolen."
- SettingsCard {
- id: groupCard
+ Item {
+ width: parent.width
+ height: 44
- required property var modelData
+ SearchField {
+ id: filterField
- title: groupCard.modelData.name
- subtitle: groupCard.modelData.binds.length === 1
- ? "1 shortcut"
- : `${groupCard.modelData.binds.length} shortcuts`
+ anchors.left: parent.left
+ anchors.right: counts.left
+ anchors.rightMargin: 14
+ anchors.verticalCenter: parent.verticalCenter
+ placeholder: "Filter shortcuts — try “window” or “volume”"
+ }
- Repeater {
- id: bindRows
+ Text {
+ id: counts
- model: groupCard.modelData.binds
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ text: Keybinds.binds.length + " bound · " + root.overrideCount + " changed"
+ color: Theme.fgMuted
+ font.family: Theme.fontFamily
+ font.features: Theme.tabularFigures
+ font.pixelSize: Theme.fontSizeSmall
+ }
+ }
- SettingRow {
- id: bindRow
+ // One Column of Repeaters rather than a Loader per row: at a hundred and
+ // thirty rows the delegates are the page, and the cheapest row is the
+ // one that is simply an Item.
+ Repeater {
+ model: root.groups
- required property var modelData
- required property int index
+ Column {
+ id: groupColumn
- readonly property bool capturing: root.capturingChord === bindRow.modelData.luaChord
- readonly property bool overridden: Keybinds.isOverridden(bindRow.modelData.luaChord)
+ required property var modelData
- label: bindRow.modelData.description
- detail: bindRow.overridden
- ? "Moved from " + Keybinds.shippedChordFor(bindRow.modelData.luaChord)
- : ""
- controlWidth: 300
- divider: bindRow.index < bindRows.count - 1
+ width: parent ? parent.width : 620
+ spacing: 0
- Item {
- anchors.right: parent.right
- anchors.verticalCenter: parent.verticalCenter
- width: 300
- height: 30
+ Item {
+ width: parent.width
+ height: 30
- ShortcutCapture {
- anchors.right: parent.right
- width: 230
- height: 30
- visible: bindRow.capturing
- focus: bindRow.capturing
- message: root.conflict === ""
- ? ""
- : root.conflictChord + " is already " + root.conflict
- // A chord already in use is reported rather than
- // taken. Two actions on one chord means whichever
- // Hyprland happens to read last wins, which is not
- // a thing to discover later by pressing it.
- onCaptured: chord => {
- const taken = Keybinds.boundTo(chord, bindRow.modelData.luaChord);
- if (taken !== "") {
- root.conflict = taken;
- root.conflictChord = chord;
- return;
- }
- root.conflict = "";
- Keybinds.rebind(bindRow.modelData.luaChord, chord);
- root.capturingChord = "";
- }
- onCanceled: {
- root.conflict = "";
- root.capturingChord = "";
- }
+ Row {
+ anchors.left: parent.left
+ anchors.leftMargin: 6
+ anchors.bottom: parent.bottom
+ anchors.bottomMargin: 4
+ spacing: 8
+
+ Text {
+ anchors.baseline: groupCount.baseline
+ text: groupColumn.modelData.name
+ color: Theme.fgMuted
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.DemiBold
+ font.capitalization: Font.AllUppercase
+ font.letterSpacing: 0.8
}
- Row {
- anchors.right: parent.right
- anchors.verticalCenter: parent.verticalCenter
- visible: !bindRow.capturing
- spacing: 8
+ Text {
+ id: groupCount
- Text {
- anchors.verticalCenter: parent.verticalCenter
- text: bindRow.modelData.chord
- color: bindRow.overridden ? Theme.accent : Theme.fgDim
- font.family: Theme.fontFamily
- font.pixelSize: Theme.fontSize
- }
+ text: root.filtering
+ ? "· showing " + groupColumn.modelData.binds.length
+ + " of " + groupColumn.modelData.total
+ : "· " + groupColumn.modelData.total
+ color: Theme.fgMuted
+ font.family: Theme.fontFamily
+ font.features: Theme.tabularFigures
+ font.pixelSize: Theme.fontSizeSmall
+ }
+ }
+ }
- SettingsButton {
- anchors.verticalCenter: parent.verticalCenter
- text: "Change"
- enabled: !Keybinds.reloading && !bindRow.modelData.mouse
- onClicked: root.capturingChord = bindRow.modelData.luaChord
- }
+ Repeater {
+ id: bindRows
- SettingsButton {
- anchors.verticalCenter: parent.verticalCenter
- visible: bindRow.overridden
- text: "Reset"
- enabled: !Keybinds.reloading
- onClicked: Keybinds.resetBind(bindRow.modelData.luaChord)
+ model: groupColumn.modelData.binds
+
+ ShortcutRow {
+ id: shortcutRow
+
+ required property var modelData
+ required property int index
+
+ bind: shortcutRow.modelData
+ capturing: root.capturingChord === shortcutRow.modelData.luaChord
+ message: shortcutRow.capturing ? root.captureMessage() : ""
+ divider: shortcutRow.index < bindRows.count - 1
+
+ onChangeRequested: {
+ root.conflict = "";
+ root.conflictChord = "";
+ root.capturingChord = shortcutRow.modelData.luaChord;
+ }
+
+ onResetRequested: Keybinds.resetBind(shortcutRow.modelData.luaChord)
+
+ // A chord already in use is reported rather than taken.
+ // Two actions on one chord means whichever Hyprland
+ // happens to read last wins, which is not a thing to
+ // discover later by pressing it.
+ onCaptured: chord => {
+ const taken = Keybinds.boundTo(chord, shortcutRow.modelData.luaChord);
+ if (taken !== "") {
+ root.conflict = taken;
+ root.conflictChord = chord;
+ return;
}
+ root.conflict = "";
+ Keybinds.rebind(shortcutRow.modelData.luaChord, chord);
+ root.capturingChord = "";
+ }
+
+ onCanceled: {
+ root.conflict = "";
+ root.capturingChord = "";
}
}
}
}
}
- }
- SettingsCard {
- visible: Object.keys(Keybinds.overrides).length > 0
- title: "Changed shortcuts"
- subtitle: "Rebinding stores only the new chord; what a shortcut does always comes from the desktop's configuration."
+ Text {
+ width: parent.width
+ visible: root.groups.length === 0 && Keybinds.loaded
+ text: root.filtering
+ ? "Nothing matches — the filter searches shortcut names and group names."
+ : "The compositor reported no shortcuts."
+ color: Theme.fgMuted
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSize
+ topPadding: 16
+ bottomPadding: 16
+ }
+ // Rebinding stores only the new chord; what a shortcut does always
+ // comes from the desktop's configuration.
ActionRow {
label: "Restore every shipped shortcut"
- detail: Object.keys(Keybinds.overrides).length
- + (Object.keys(Keybinds.overrides).length === 1 ? " shortcut moved" : " shortcuts moved")
+ detail: root.overrideCount === 0
+ ? "Every shortcut is where it shipped"
+ : root.overrideCount + (root.overrideCount === 1
+ ? " shortcut differs from the shipped keymap"
+ : " shortcuts differ from the shipped keymap")
action: "Restore all"
divider: false
- enabled: !Keybinds.reloading
+ enabled: root.overrideCount > 0 && !Keybinds.reloading
onTriggered: Keybinds.resetAll()
}
}
diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir
index bbdf93c..ce625bd 100644
--- a/config/dot/quickshell/modules/settings/qmldir
+++ b/config/dot/quickshell/modules/settings/qmldir
@@ -66,6 +66,10 @@ AutostartAppPicker 1.0 AutostartAppPicker.qml
DockPinsStrip 1.0 DockPinsStrip.qml
DockAppPicker 1.0 DockAppPicker.qml
ShortcutCapture 1.0 ShortcutCapture.qml
+KeycapChord 1.0 KeycapChord.qml
+ShortcutRow 1.0 ShortcutRow.qml
+KeyRepeatRow 1.0 KeyRepeatRow.qml
+InputTestArea 1.0 InputTestArea.qml
ChoiceGrid 1.0 ChoiceGrid.qml
DisplayModePicker 1.0 DisplayModePicker.qml
DisplayArrangement 1.0 DisplayArrangement.qml
diff --git a/config/dot/quickshell/services/InputDevices.qml b/config/dot/quickshell/services/InputDevices.qml
index b71c39e..cf8a538 100644
--- a/config/dot/quickshell/services/InputDevices.qml
+++ b/config/dot/quickshell/services/InputDevices.qml
@@ -16,6 +16,24 @@ pragma Singleton
// Read on demand rather than polled. Input devices do come and go -- a mouse is
// unplugged, a receiver is moved -- so this also refreshes when Hyprland says
// the device list changed, which is the only moment the answer can differ.
+//
+// Two views of the same data, deliberately:
+//
+// mice / keyboards every name libinput reports, lowercased. What the
+// hardware-presence checks are built on, and what
+// they must keep being built on -- a filter that
+// dropped the wrong entry would hide a real control.
+// realMice / realKeyboards the subset worth SHOWING a person, because the
+// raw lists are mostly not devices. This machine
+// reports seven "mice" and eighteen "keyboards" for
+// two actual peripherals: a wireless receiver
+// registers a mouse, a keyboard, three consumer
+// controls and a system control, the webcam and the
+// USB audio dongle each claim a keyboard, and the
+// power button, the lid switch and every virtual
+// typing tool are in there too. Listing all of that
+// under "Connected devices" would be honest about
+// libinput and useless about the desk.
import Quickshell
import Quickshell.Io
@@ -28,12 +46,69 @@ Singleton {
property var mice: []
property var keyboards: []
+ // The main keyboard's live layout, already in human form -- Hyprland
+ // resolves the XKB name for us and answers "English (US)", not "us". Empty
+ // until the first read finishes, so a page must treat it as optional.
+ property string mainKeyboardLayout: ""
+
// True when anything that looks like a touchpad is attached.
readonly property bool hasTouchpad: root.mice.some(name =>
name.includes("touchpad") || name.includes("trackpad"))
readonly property bool hasMouse: root.mice.length > 0
+ // Substrings that mark a name as an endpoint rather than a device. Matched
+ // anywhere in the name, because the interesting part of these is never at a
+ // fixed position: "generic-usb-audio-consumer-control-1" is caught twice
+ // over, and "onn-usb-2.0-webcam:-onn-usb-2.0" only by its middle.
+ //
+ // "fake" is here for `mouce-library-fake-mouse`, the phantom pointer the
+ // mouce input library registers. It is not a class of endpoint the way the
+ // others are, but it is precisely the kind of entry this list exists to keep
+ // off a card headed "Connected devices".
+ readonly property var phantomMarkers: [
+ "consumer-control", "virtual", "video-bus", "power-button",
+ "webcam", "audio", "uinput", "fake"
+ ]
+
+ readonly property var realMice: root.realDevices(root.mice)
+ readonly property var realKeyboards: root.realDevices(root.keyboards)
+
+ // Names worth showing, as { name, pretty }.
+ //
+ // Two passes. The first drops endpoints by name. The second collapses
+ // siblings: one physical transceiver announces itself as
+ // "microsoft-...-v9.0" AND "microsoft-...-v9.0-system-control", and a
+ // keyboard as both its own name and "...-keyboard". Where one surviving
+ // name is a prefix of another, the shorter is the device and the longer is
+ // one of its endpoints, so the shortest of each family is the one kept.
+ function realDevices(names: var): var {
+ const kept = [];
+ const named = (names ?? [])
+ .filter(name => name && !root.phantomMarkers.some(marker => name.includes(marker)))
+ .sort((left, right) => left.length - right.length);
+
+ for (const name of named) {
+ if (kept.some(base => name.startsWith(base + "-")))
+ continue;
+ kept.push(name);
+ }
+
+ return kept.map(name => ({ name: name, pretty: root.prettyName(name) }));
+ }
+
+ // "keychron-keychron-q10" -> "Keychron Keychron Q10". Hyprland's names are
+ // lowercased and dash-joined; this is the smallest transform that makes one
+ // readable. A word starting with a digit ("2.4ghz") is left as it is rather
+ // than mangled.
+ function prettyName(name: string): string {
+ return String(name)
+ .split("-")
+ .map(word => word.length > 0 ? word[0].toUpperCase() + word.slice(1) : word)
+ .join(" ")
+ .trim();
+ }
+
function refresh(): void {
if (!query.running)
query.running = true;
@@ -49,6 +124,13 @@ Singleton {
const parsed = JSON.parse(this.text);
root.mice = (parsed.mice ?? []).map(device => String(device.name ?? "").toLowerCase());
root.keyboards = (parsed.keyboards ?? []).map(device => String(device.name ?? "").toLowerCase());
+
+ // Names are lowercased above because every consumer matches
+ // substrings against them. The keymap is not: it is already
+ // a display string, and "english (us)" would be a downgrade.
+ const boards = parsed.keyboards ?? [];
+ const main = boards.find(device => device.main === true) ?? boards[0];
+ root.mainKeyboardLayout = main ? String(main.active_keymap ?? "") : "";
} catch (error) {
console.warn("InputDevices: could not parse hyprctl devices:", error);
}
diff --git a/config/dot/quickshell/services/SettingsSearch.qml b/config/dot/quickshell/services/SettingsSearch.qml
index c82e37d..c8726fb 100644
--- a/config/dot/quickshell/services/SettingsSearch.qml
+++ b/config/dot/quickshell/services/SettingsSearch.qml
@@ -169,6 +169,12 @@ Singleton {
{ label: "Banners or history", detail: "Send one application straight to history without a popup", page: "notifications" },
{ label: "Focus session duration", detail: "How long a focus session runs before it ends itself", page: "focus" },
{ label: "Keyboard shortcuts", detail: "Every shortcut the compositor has bound", page: "shortcuts" },
+ // The rebinding flow has no schema entry of its own — it is a button on
+ // a row — so searching for the thing people actually want to do would
+ // otherwise find only the shortcut it is being done to.
+ { label: "Rebind a shortcut", detail: "Change the keys an action answers to, or put them back", page: "shortcuts" },
+ { label: "Pointer test area", detail: "Scribble and scroll to feel a pointer change before keeping it", page: "mouse" },
+ { label: "Connected input devices", detail: "The keyboards, mice, and touchpad this machine can see", page: "mouse" },
{ 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" },
diff --git a/config/local/share/vicinae/scripts/settings-focus b/config/local/share/vicinae/scripts/settings-focus
new file mode 100755
index 0000000..053a4ba
--- /dev/null
+++ b/config/local/share/vicinae/scripts/settings-focus
@@ -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: Focus
+# @vicinae.mode silent
+# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
+# @vicinae.description Open Focus in Settings.
+# @vicinae.keywords ["settings", "focus modes", "focus session length", "focus session duration"]
+
+exec "$HOME/.config/quickshell/scripts/panama-action" settings-page focus
diff --git a/config/local/share/vicinae/scripts/settings-mouse b/config/local/share/vicinae/scripts/settings-mouse
index 331f23b..f6f4ca5 100755
--- a/config/local/share/vicinae/scripts/settings-mouse
+++ b/config/local/share/vicinae/scripts/settings-mouse
@@ -5,6 +5,6 @@
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open Mouse & Touchpad in Settings.
-# @vicinae.keywords ["settings", "pointer focus", "pointer speed", "hide pointer after", "natural scrolling", "acceleration", "scroll speed", "left-handed", "middle-click paste", "tap to click", "disable while typing", "drag lock", "middle-click by pressing both buttons"]
+# @vicinae.keywords ["settings", "pointer focus", "pointer speed", "hide pointer after", "natural scrolling", "acceleration", "scroll speed", "left-handed", "middle-click paste", "focus after closing", "scroll method", "scroll button", "hide pointer while typing"]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page mouse
diff --git a/config/local/share/vicinae/scripts/settings-shortcuts b/config/local/share/vicinae/scripts/settings-shortcuts
index dae4bc1..2f13532 100755
--- a/config/local/share/vicinae/scripts/settings-shortcuts
+++ b/config/local/share/vicinae/scripts/settings-shortcuts
@@ -5,6 +5,6 @@
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open Keyboard in Settings.
-# @vicinae.keywords ["settings", "keyboard layout", "layout variant", "keyboard options", "num lock on login", "repeat delay", "repeat rate", "keyboard shortcuts"]
+# @vicinae.keywords ["settings", "keyboard layout", "layout variant", "keyboard options", "num lock on login", "repeat delay", "repeat rate", "keyboard shortcuts", "rebind a shortcut"]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page shortcuts
diff --git a/docs/settings.md b/docs/settings.md
index 5da4ddb..3147b1e 100644
--- a/docs/settings.md
+++ b/docs/settings.md
@@ -4,7 +4,7 @@
Do not edit this file. Run `quickshell/scripts/panama-settings-docs`
after changing the schema; a contract fails when this copy is stale.
-165 settings across 35 groups. 70 of them are applied to the compositor and confirmed by reading the value back.
+172 settings across 35 groups. 77 of them are applied to the compositor and confirmed by reading the value back.
## accessibility
@@ -283,6 +283,11 @@ Found on **Input › Mouse & Touchpad**.
| **Scroll speed**
`scrollFactor` `input:scroll_factor` | 1.0 | Multiplies how far one notch of the wheel scrolls. Range 0.1–4.0. |
| **Left-handed**
`leftHanded` `input:left_handed` | false | Swap the primary and secondary buttons |
| **Middle-click paste**
`middleClickPaste` `misc:middle_click_paste` | true | Paste the primary selection in GTK and native Wayland applications |
+| **Focus after closing**
`focusOnClose` `input:focus_on_close` | 0 | Which window takes keyboard focus when the focused one goes away Choices: Next in the stack, Under the pointer, Most recently used. |
+| **Scroll method**
`scrollMethod` `input:scroll_method` | — | How a pointing device turns movement into scrolling Choices: Two fingers, Along the edge of the touchpad, While a button is held, Never scroll. |
+| **Scroll button**
`scrollButton` `input:scroll_button` | 0 | Which button is held to scroll, as an evdev code; 0 lets the device choose. Range 0–300. |
+| **Hide pointer while typing**
`cursorHideWhileTyping` `cursor:hide_on_key_press` | false | The pointer vanishes on the next keystroke and returns when you move it |
+| **Jump pointer to the focused display**
`cursorWarpOnWorkspaceChange` `cursor:warp_on_change_workspace` | false | Moves the pointer to the last focused window after switching workspace |
## search
@@ -331,6 +336,8 @@ Found on **Input › Mouse & Touchpad**.
| **Scroll speed**
`touchpadScrollFactor` `input:touchpad:scroll_factor` | 1.0 | Multiplies how far a two-finger scroll travels. Range 0.1–4.0. |
| **Drag lock**
`touchpadDragLock` `input:touchpad:drag_lock` | 0 | Keeps a tap-and-drag active when you lift a finger mid-drag Choices: Off, On, On, until you tap again. |
| **Middle-click by pressing both buttons**
`touchpadMiddleButtonEmulation` `input:touchpad:middle_button_emulation` | false | Pressing left and right together acts as a middle click |
+| **Two-finger right-click**
`touchpadClickfinger` `input:touchpad:clickfinger_behavior` | false | One, two, or three fingers pressing down give left, right, and middle click, instead of clicking by which part of the pad you press |
+| **Tap and drag**
`touchpadTapAndDrag` `input:touchpad:tap-and-drag` | true | A tap followed straight away by a tap-and-hold starts a drag, with nothing pressed down |
| **Swipe distance**
`swipeDistance` `gestures:workspace_swipe_distance` | 300 px | How far a three-finger swipe must travel to change workspace. Range 100–800. |
| **Natural swipe direction**
`swipeInvert` `gestures:workspace_swipe_invert` | true | Swiping left moves to the workspace on the right, as content follows your fingers |
diff --git a/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md b/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md
index a710b51..5dda4a9 100644
--- a/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md
+++ b/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md
@@ -459,3 +459,140 @@ needs nothing.
already true of the existing `exercise` fixture; the new fixtures add four
more chances for it. Keep the harness free of anything that turns the volume
up.
+
+## Phase 8 (Input) — append below
+
+Spec: `2026-08-24-input-redesign.md`. Keyboard, Mouse & Touchpad and Dictation
+were rebuilt around keycaps, dropdowns and a searchable shortcuts browser, and
+seven new compositor-backed preference keys landed with them.
+
+**Nothing here was run against a live harness.** Three agents edited the tree
+concurrently. What *was* verified is listed per contract below: `bash -n` on
+every changed contract, the two source-only contracts run end to end, and the
+three compositor-shape contracts replayed offline — `hyprctl descriptions` and
+`hyprctl getoption` are read-only queries, so their answers were captured once
+and the contract logic replayed against that snapshot with a stub on `PATH`,
+never against the running compositor mid-edit.
+
+### New contracts (0)
+
+None. The redesign added rows, components and schema keys to surfaces that
+already had contracts, so the README count line stays at **169** and
+`setup/readme-contract` needs nothing. (`find` counts 169; the README says 169.)
+
+### The seven new schema keys, per key
+
+Each had to satisfy three contracts at once. Replayed against the landed
+`PreferenceSchema.qml`, `hypr/input.lua` and a captured `hyprctl` snapshot:
+
+| Key | Hyprland option | enum-hypr-map | schema-hypr-shape | hypr-prefs |
+|---|---|---|---|---|
+| `focusOnClose` | `input:focus_on_close` | **PASS** — enum, all three published values offered | **PASS** — `readAs: "int"`, answers `int` | **PASS** — `prefs.getInt("focusOnClose", 0)`, def 0 |
+| `scrollMethod` | `input:scroll_method` | **PASS** — string enum, every offered word in the published list | **PASS** — `readAs: "str"`, answers `str` | **PASS** — `prefs.get("scrollMethod", "")`, def `""` |
+| `scrollButton` | `input:scroll_button` | n/a — `type: "int"`, not an enum | **PASS** — `readAs: "int"`, answers `int` | **PASS** — `prefs.get("scrollButton", 0)`, def 0 |
+| `cursorHideWhileTyping` | `cursor:hide_on_key_press` | n/a — bool | **PASS** — `readAs: "bool"`, answers `bool` | **PASS** — def false |
+| `cursorWarpOnWorkspaceChange` | `cursor:warp_on_change_workspace` | n/a — bool over an int option, so the enum rule does not reach it | **PASS** — `readAs: "int"`, answers `int` | **PASS** — `prefs.getInt(..., 0)` vs schema `false`; the contract's own true/false→1/0 normalization is what makes those agree |
+| `touchpadClickfinger` | `input:touchpad:clickfinger_behavior` | n/a — bool | **PASS** — `readAs: "bool"` | **PASS** — def false |
+| `touchpadTapAndDrag` | `input:touchpad:tap-and-drag` | n/a — bool | **PASS** — `readAs: "bool"` | **PASS** — def true |
+
+Two of those are decisions, not just passes, and both are recorded in the
+schema itself:
+
+- `focusOnClose` was specced as a two-way choice. The compositor publishes
+ three (`{"mru":2},{"cursor":1},{"next":0}`) and **0 is what this desktop runs
+ today**, so a two-option dropdown would have hidden the shipped default from
+ its own control. enum-hypr-map-contract fails an enum that drops a published
+ value, and would have caught it — verified by deleting value 0 from a copy of
+ the schema and watching it fail with exactly that message.
+- `cursorWarpOnWorkspaceChange` is a switch over an option with three states.
+ `force` (2) is deliberately unreachable from Settings. enum-hypr-map governs
+ enums only, so nothing fails — which is the point of writing it down here.
+
+### Updated contracts (6)
+
+| Contract | What it now pins | Verified |
+|---|---|---|
+| `quickshell/enum-hypr-map-contract` | **String-valued enums are now checked at all.** The parser only ever collected numeric `value:`s, so `accelProfile`, `masterOrientation`, `masterNewStatus` and `windowLayout` were silently skipped and `scrollMethod` would have been too. String options carry no `map`; Hyprland states their accepted words inside the description (`[2fg/edge/on_button_down/no_scroll]`), so those are parsed and checked **one way only**: an offered value the compositor does not name fails; a named value Settings does not offer does not, because that is a product decision (`accel_profile`'s `custom` needs a `scroll_points` curve and is a stated non-goal). Empty string is always allowed — it is how a schema entry says "leave the compositor's default", which is what `[[EMPTY]]` reads back as. Options with no bracket list print a line saying so and are skipped rather than failing. The numeric rules are untouched. | **Replayed offline** against a captured `hyprctl descriptions` (353 options) and the landed schema: PASS, 11 mapped enums (was 9). Both new failure directions exercised on a scratch copy — dropping `focus_on_close`'s value 0 fails, offering `"two_finger"` for `scroll_method` fails. |
+| `quickshell/xkb-presets-contract` | Rewritten for the Advanced disclosure. The raw `keyboardOptions`, `keyboardVariant` **and `keyboardLayout`** fields must still be editable on the Keyboard page — matched as blocks, not one-liners, so nesting them inside an expander is fine — and **collapsed-but-present passes while absent fails**: the section must be named ("Advanced") and some `onClicked`/`onTriggered`/`onToggled` handler must actually open it, and no raw field may be pinned `visible: false`. XKB *values* stay pinned exactly (`caps:escape_shifted_capslock`, `caps:ctrl_modifier`, `compose:ralt`, `grp:win_space_toggle`) because moving one changes somebody's keyboard; row *labels* are now matched loosely and case-insensitively, because "Compose key" → "Compose" is a wording decision. The category-preservation rule, both helper signatures, and the three `input.lua`/schema default needles are unchanged. | **Run end to end** against the landed tree — it is source-only and touches neither compositor nor shell. PASS. Nested-block matching and the "no handler ⇒ fail" direction both exercised. |
+| `quickshell/keybinds-contract` | A static presentation half ahead of the existing live count check, which a wall of 130 rows and a searchable browser pass identically. Page and `ShortcutRow.qml` are read as one source, so moving a control between them is not a failure: `Keybinds.grouped()` is what supplies the group order, `KeycapChord` is what draws chords, the filter exists and is case-insensitive and matches on `description`, a filtered list says how many of how many it is `showing`, the header count comes from `Keybinds.binds.length`, and the note explaining why there is no GNOME keyboard handoff survives. Count-match, description-completeness and chord-rendering rules are untouched. | **Static half replayed** against the landed `ShortcutsPage.qml` + `ShortcutRow.qml`: all needles hit. The compositor half is **deferred** — it boots a Quickshell harness. |
+| `quickshell/keybind-rebind-contract` | UI needles added to the static half (the one that already runs under `PANAMA_KEYBINDS_STATIC_ONLY=1`). Page + row read as one source: `ShortcutCapture` is still what reads key presses (a page that grew its own handler would capture SUPER as a bind of its own), `boundTo` is called **before** `rebind` on the source line order, Change/Reset/`resetBind`/`resetAll`/`isOverridden` all still exist, binds are identified by `luaChord`, and no `rebind`/`resetBind` call is keyed by `description` — the regression that once moved every bind sharing one and cost the XF86Calculator key. The restore-all row and its live differs-count are pinned on the page. Engine needles and the whole live half are untouched. | **`PANAMA_KEYBINDS_STATIC_ONLY=1` run against the landed tree: PASS (static).** All 20 needles individually replayed. Live half **deferred**. |
+| `quickshell/settings-pages-contract` | `Shortcuts` and `Mouse` added to the root-type/no-copied-Flickable sweep (neither page was ever in it), `mouse` added to the runtime routing sweep, and a hand-written check that all seven new keys render on `MousePage.qml` — by `setting: "key"` *or* by `commitPreference("key"`. That second spelling is why it is hand-written: dropdowns now render through `OptionPickerRow`, which takes label and options from `PreferenceSchema.spec()` and commits by name, and **has no `setting:` property at all**. Existing Home/Bar/Notifications/ScreenIntelligence pins unchanged. | **Static half run: PASS.** Routing sweep **deferred** — it starts an isolated Quickshell beside the live one. |
+| `setup/dictation-contract` | **Every "where text lands" pin is unchanged and none of them conflicts with the on-page test.** They live on `panama-dictate` and `keybinds.lua` — `is_speech` rejecting `[BLANK_AUDIO]`, the guard actually being called before typing, the newline collapse, `wtype` tried before `wl-copy`, one press bind and one release bind — and nothing in them constrains which window has focus. The Try-it field sends the same `start`/`stop` the hotkey sends and merely holds keyboard focus while `wtype` types. What is new is two needles for the risk the test flow *did* introduce: the page must not spell out `scripts/panama-dictate` (the service publishes that path once, and a second copy would go stale silently, since the page's status readout comes from the service and would still be right), and a page that runs a `Process` must go through `Dictation.helper`. | **Run end to end** — it is greps plus a `python3` import of the helper, no compositor and no shell. PASS, including both new needles against the landed `DictationPage.qml`. |
+
+### Verified against the new tree, no edit needed
+
+- `quickshell/schema-hypr-shape-contract` — derives everything from
+ `option: "...", readAs: "..."` pairs in the schema, so the seven new keys
+ entered it the moment they landed. All seven extract cleanly and all seven
+ `readAs` values match what `hyprctl -j getoption` answers with. Two were easy
+ to get wrong and are worth naming: `cursor:warp_on_change_workspace` answers
+ `int` despite being a switch in the UI, and `input:scroll_method` answers
+ `str` despite the neighbouring `scroll_button` answering `int`.
+- `tests/hypr/hypr-prefs-contract` — pure static, and **run**: ok, 77
+ compositor-owned keys read at config time, up from 70. All seven new keys
+ have a `prefs.get()`/`prefs.getInt()` in `config/dot/hypr/input.lua` with a
+ fallback equal to the schema default.
+- `quickshell/gnome-handoff-contract` — needle-free by construction (it derives
+ both sides). **Run**: ok, 14 handoffs checked against 39 pages. The Keyboard
+ page still has no GNOME handoff and still explains why.
+- `quickshell/schema-hypr-shape-contract`, `tests/hypr/hypr-prefs-contract` and
+ `quickshell/gnome-handoff-contract` are the three above. `setup/readme-contract`
+ is a fourth: no contract file was added or removed, `find` still counts 169,
+ and the README still claims 169.
+
+### Docs updated in the same wave
+
+- `services/SettingsSearch.qml` — three hand-written entries: **Rebind a
+ shortcut** (→ `shortcuts`), **Pointer test area** and **Connected input
+ devices** (→ `mouse`). "Key repeat" and "Scroll method" arrive automatically
+ from the schema, as the spec expected. Checked against
+ `settings-search-contract`'s fixed query list: of its 27 pinned queries only
+ `pointer` matches any new entry, and "Pointer test area" sorts *after*
+ "Pointer focus", "Pointer size" and "Pointer speed" in the same prefix rank,
+ so no pinned top result moves. Both new pages are leaves in
+ `SettingsRoutes`, so the "routes to a page anyone can land on" sweep holds.
+- No settings docs or launcher commands were regenerated here — that is the
+ orchestrator's step after the schema settled.
+
+### Still open before the run
+
+- **`settings-ownership-contract` and `search-routing-contract` are now blind
+ to dropdown rows.** Both scan for `setting: "…"` inside a fixed list of row
+ types; `OptionPickerRow` is in neither list and carries no `setting:`
+ property. `accelProfile`, `followMouse`, `focusOnClose` and `scrollMethod`
+ are all invisible to them on the rebuilt `MousePage.qml`. Nothing fails
+ today — none of those keys is a duplicate — but a duplicate introduced
+ through a dropdown would not be caught. `settings-pages-contract` now pins
+ the seven new keys directly as a stopgap; the real fix is teaching both
+ scans the `PreferenceSchema.spec()` / `commitPreference()` spelling. Owner:
+ whoever holds those two contracts next.
+- **`xkb-presets-contract` now requires a raw `keyboardLayout` field**, on the
+ reading that the layout dropdown's "Custom…" has to reveal somewhere the
+ code can actually be typed. It passes against the landed page. If the layout
+ editor is ever folded into the dropdown itself, that needle is the one to
+ revisit — the intent is "the raw code stays typeable", not "it is a
+ TextEntryRow".
+- **`keybinds-contract` pins the literal word `showing`** in the filtered-count
+ line, because the spec names that wording ("showing N of M"). It is the one
+ prose needle in the new static half; everything else keys on structure.
+- **Dictation's Try-it field and the clipboard fallback** — handled, but worth
+ knowing. `panama-dictate` falls back to `wl-copy` when `wtype` is missing, by
+ design, and on the Try-it row that means the words land on the clipboard
+ rather than in the field the page just focused. The page says so: a "Typing —
+ Missing" row appears when `Dictation.typingAvailable` is false. Nothing to
+ fix; worth a look during the run if a machine without `wtype` is around, since
+ that branch has never been seen.
+- Run order for this phase: the two source-only contracts first
+ (`xkb-presets-contract`, `gnome-handoff-contract`), then the static halves
+ (`hypr-prefs-contract`, `PANAMA_KEYBINDS_STATIC_ONLY=1 keybind-rebind-contract`,
+ `PANAMA_SETTINGS_STATIC_ONLY=1 settings-pages-contract`), then the two
+ compositor-query contracts (`enum-hypr-map-contract`,
+ `schema-hypr-shape-contract` — read-only, but they want the real compositor),
+ then the harness contracts (`keybinds-contract`, `keybind-rebind-contract`
+ in full, `settings-search-contract`), and `settings-pages-contract` last, as
+ before: it starts an isolated Quickshell beside the live one and its own
+ cleanup is what protects the running session.
+- `keybind-rebind-contract`'s live half rebinds Terminal to `SUPER + SHIFT +
+ F9` against the **real compositor** with an isolated `XDG_CONFIG_HOME`. That
+ was true before this phase and is unchanged, but it is the one contract in
+ this wave that writes to the running keymap, so it wants a quiet moment.
diff --git a/docs/superpowers/specs/2026-08-24-input-redesign.md b/docs/superpowers/specs/2026-08-24-input-redesign.md
new file mode 100644
index 0000000..b37fcf9
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-24-input-redesign.md
@@ -0,0 +1,122 @@
+# Input redesign — keycaps, search, and honest state
+
+Approved mock: `home-mocks/input.html` (scratchpad, :8642). Spec wins over mock on conflict.
+
+## Goals
+
+1. **Shortcuts browser**: the 130-row wall becomes one searchable card — keycap chips, group
+ headers with counts, hover-revealed Change/Reset, inline capture. The rebinding engine
+ (`Keybinds.rebind`, conflicts refused, `keybindOverrides`) already exists and is untouched.
+2. **Typing card modernized**: dropdowns instead of ChoiceGrid tile walls; raw XKB string and
+ variant into a collapsed Advanced section (still discoverable — a contract requires the raw
+ string not be hidden *away*, collapsed-but-present satisfies it; C verifies the needle).
+3. **Mouse & Touchpad**: dropdowns over wide segmented rows, four new option groups (below),
+ a live try-it area, and a phantom-filtered device list.
+4. **Dictation status-first**: ready hero, hotkeys as keycaps (looked up live from Keybinds so a
+ rebind shows truthfully; literal fallback), guided setup steps with a real progress bar, and
+ an on-page test field that uses the existing typing pipeline.
+
+Non-goals: per-device settings (phantom-heavy device list, zero plumbing — deliberately
+skipped), editing keybinds.lua actions (chords only, as today), touch/tablet options
+(no hardware), accel `custom` curves.
+
+## New schema keys (group / hypr option — agent A verifies each option's exact name, type, and
+value map against `hyprctl descriptions` before writing the entry; enum-hypr-map-contract and
+schema-hypr-shape-contract must hold; every key also gets its `prefs.get()` read-back in
+`config/dot/hypr/input.lua` with matching defaults — hypr-prefs-contract):
+
+| Key | Group | Hyprland option | UI |
+|---|---|---|---|
+| `focusOnClose` | pointer | `input:focus_on_close` | dropdown: "Most recently used" / "Under the pointer" |
+| `cursorHideWhileTyping` | pointer | `cursor:hide_on_key_press` | toggle "Hide pointer while typing" |
+| `cursorWarpOnWorkspaceChange` | pointer | `cursor:warp_on_change_workspace` | toggle "Jump pointer to the focused display" |
+| `scrollMethod` | pointer | `input:scroll_method` | dropdown (offer only map-published values; include "On a held button" only if the map allows) |
+| `scrollButton` | pointer | `input:scroll_button` | int row, visible only when scrollMethod is the button one |
+| `touchpadClickfinger` | touchpad | `input:touchpad:clickfinger_behavior` | toggle "Two-finger right-click" |
+| `touchpadTapAndDrag` | touchpad | `input:touchpad:tap-and-drag` | toggle "Tap and drag" |
+
+Defaults = today's effective Hyprland/input.lua values so shipping changes nothing. If an
+option's published map/type makes a row above impossible as specced (e.g. scroll_method values),
+implement what the map allows and flag the difference loudly.
+
+## Service work (A)
+
+- `InputDevices.qml`: add `realMice` / `realKeyboards` — name-filtered lists (exclude
+ substrings: `consumer-control`, `virtual`, `video-bus`, `power-button`, `webcam`,
+ `audio`, `uinput`, plus dedupe transceiver siblings by prefix), each entry `{ name, pretty }`
+ (pretty = title-cased, dashes to spaces). Keep the existing flat lists and `hasTouchpad`
+ untouched (contract-pinned behavior).
+- Expose `mainKeyboardLayout` (from the main keyboard's live layout string) for the devices card.
+
+## UI (B)
+
+**ShortcutsPage.qml** (title stays "Keyboard"):
+1. *Typing* card: Layout dropdown (curated common layouts: English (US), English (UK), German,
+ French, Spanish, Nordic…, mapping to `keyboardLayout` codes; a stored code outside the list
+ renders as the raw code and the dropdown offers "Custom…" which reveals Advanced), Caps Lock
+ dropdown, Compose dropdown, Layout-switching dropdown (all four presets keep writing
+ `keyboardOptions` through the existing page-local XKB helpers — xkb-presets-contract),
+ combined Key repeat row (delay + rate sliders), Num Lock toggle, then **Advanced** expander:
+ raw `keyboardOptions` mono field + `keyboardVariant`.
+2. *Shortcuts* card: header "130 bound · N changed" (live counts), subtitle "Click Change and
+ press the new keys. A shortcut another action holds is refused, never stolen.", filter field
+ (matches description + group, case-insensitive), grouped rows in `Keybinds.groupOrder` with
+ "showing N of M" when filtered; each row: description, CHANGED badge when overridden,
+ hover-revealed Change/Reset (Reset only on overridden), keycap chord. Capture swaps the
+ chord area for the existing `ShortcutCapture` inline. "Restore every shipped shortcut" row
+ stays, with the live differs-count detail. Keep the no-GNOME-handoff comment.
+3. New component **KeycapChord.qml**: parses a display chord ("SUPER + SHIFT + Q") into keycap
+ chips — mono font, tabular figures, modifier caps tinted accent, "+" separators muted.
+ Reused by DictationPage.
+
+**MousePage.qml**: Mouse card (speed slow/fast, Acceleration dropdown, Scroll speed, Natural
+scrolling, Left-handed, Middle-click paste, Scroll method dropdown + conditional Scroll button
+row) · Touchpad card (existing rows + the two new toggles; visible on `hasTouchpad`) ·
+Pointer behavior card (Focus dropdown — the 4 followMouse values, focusOnClose dropdown,
+hide-while-typing, hide-after slider with "Never" zero, warp toggle, Pointer size) ·
+**Try it** card (new `InputTestArea.qml`: a scribble Canvas — repaints only on pointer motion,
+cleared by a corner button — and a scrollable text strip; purely local, no compositor writes) ·
+**Connected devices** card from `InputDevices.realKeyboards/realMice` (+ touchpad when
+present), with the subtitle noting phantoms are filtered. Gestures rows fold into the Touchpad
+card (swipe distance + invert) — the separate Gestures card goes.
+
+**DictationPage.qml**: ready hero (state tile ✓ / … / ✗, title, model+size line) · hotkey rows
+with KeycapChord, chords looked up from `Keybinds.binds` by matching the dictate descriptions
+(fallback literals if not found) · **Try it** row: a read-only-styled TextField + "Test
+dictation" button that focuses the field and drives `panama-dictate start`/`stop` through the
+existing Dictation service — dictated text lands in the field via the normal wtype pipeline, no
+new script plumbing; detail explains it types here instead of your document · Microphone
+ActionRow → Sound (unchanged) · setup state replaces the hero with numbered steps (container
+image / speech model with progress bar from `downloadFraction` / first transcription), driven
+by the existing `phase` fields; errors keep their row.
+
+## Search & docs (C)
+
+Extra entries: "Rebind a shortcut" → shortcuts; "Key repeat" auto via schema; "Pointer test
+area" → mouse; "Scroll method" auto; "Connected input devices" → mouse. Existing entries stay.
+Docs + launcher commands regenerate after schema lands (orchestrator).
+
+## Contracts (C — write, never run)
+
+- `xkb-presets-contract`: verify/adjust needles for the Advanced placement (raw string must
+ remain present in the page source).
+- `keybinds-contract` / `keybind-rebind-contract`: UI needles (Change/Reset/ShortcutCapture
+ usage) reconciled with the rebuilt page; count-match and conflict rules unchanged.
+- `enum-hypr-map-contract` / `schema-hypr-shape-contract` / `hypr-prefs-contract`: the seven
+ new keys must satisfy all three (C statically replays where possible).
+- `settings-pages-contract`, `gnome-handoff-contract` needles re-verified.
+- `dictation-contract`: confirm the test-field flow doesn't violate the "where text lands" pins
+ (it uses the normal pipeline; the page merely owns focus). Flag, don't force, if it conflicts.
+- Backlog spec: Phase 8 section.
+
+## Agent ownership (parallel)
+
+- **A**: `config/PreferenceSchema.qml` (7 new keys), `config/dot/hypr/input.lua`,
+ `services/InputDevices.qml`.
+- **B**: `modules/settings/ShortcutsPage.qml`, `MousePage.qml`, `DictationPage.qml`, new
+ components (`KeycapChord.qml`, `InputTestArea.qml`, others as needed) + `modules/settings/qmldir`.
+- **C**: `services/SettingsSearch.qml`, the contracts above, backlog spec, README count line
+ only if the count changes.
+
+B programs against the schema keys and InputDevices API above; A must not change them without
+updating this spec.
diff --git a/docs/superpowers/specs/2026-08-24-notifications-focus-redesign.md b/docs/superpowers/specs/2026-08-24-notifications-focus-redesign.md
new file mode 100644
index 0000000..af32623
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-24-notifications-focus-redesign.md
@@ -0,0 +1,147 @@
+# Notifications & Focus redesign — two tabs, nothing unbounded
+
+Approved mock: `home-mocks/notifications.html` (scratchpad, :8642). This spec is the
+implementation contract; where mock and spec disagree, the spec wins.
+
+## Goals
+
+1. **Bound the app list.** Recent senders + customized apps render up top; everything else sits
+ behind a collapsed, searchable "All apps" expander. Rows expand in place to their controls.
+2. **Real per-app rules**: sound on/off, banners-vs-history, urgency override, forget.
+3. **A real focus-mode editor**: create/rename/delete/reorder (order IS priority), trigger-kind
+ editing for all five kinds, schedules with day pills, chip-based interrupt lists.
+4. **Two tabs**: Notifications | Focus, as category tabs like Shell.
+
+Non-goals: lock-screen privacy (hyprlock cannot render notifications; contract-banned),
+per-app counters (no data), time-based history retention, merging duplicate app identities
+(show the raw id honestly instead).
+
+## Rule shape (pinned — service and UI program against this)
+
+`normalizedAppRule` in `Notifs.qml` grows from `{ enabled }` to:
+
+| Field | Type | Default | Meaning |
+|---|---|---|---|
+| `enabled` | bool | true | Off = rejected before tracking/history/unread/toast (unchanged) |
+| `sound` | bool | true | false = `playBell` skips this app |
+| `display` | string | `"banners"` | `"history"` = file in history + unread, no popup, no bell |
+| `urgency` | string | `"auto"` | `"low"` / `"critical"` override what the app claims |
+| `lastSeenMs` | int | 0 | stamped in `rememberApplication` on every notification |
+| `name` | string | "" | display name cached at remember time (resolution stays live-first) |
+| `icon` | string | "" | icon cached at remember time (DesktopEntries lookup, else `appIcon`) |
+
+Unknown/stale fields (incl. the old lock-screen pair) keep being dropped on read. Old
+`{enabled}`-only blobs stay valid — every new field is optional with the defaults above.
+New API: `forgetApp(appId)` deletes the rule key outright.
+`effectiveUrgency(notification)` — the app's `urgency` override applied over
+`notification.urgency`; consumed by `playBell` (low = silent), the popup timeout choice
+(critical duration), the DND breakthrough gate, and `NotificationCard`'s critical edge.
+
+**Critical breakthrough**: new schema key `criticalBreaksThrough` (bool, def **false**, group
+`notifications`, label "Critical alerts break through"). Popup gate becomes: show when
+`!doNotDisturb || FocusModes.allows(appId) || (Settings.criticalBreaksThrough &&
+effectiveUrgency(n) === critical)`.
+
+## FocusModes API additions (pinned)
+
+- `createMode(name)` → new mode `{ id: unique slug, name, enabled: true, triggers: [{kind:
+ "manual"}], silence: true, keepAwake: false, allow: [] }`, appended (lowest priority).
+- `removeMode(id)`, `renameMode(id, name)` (non-empty, trimmed).
+- `moveMode(id, delta)` — reorder; order is priority and the UI says so.
+- `setTriggerKind(id, kind, fields)` — replaces the mode's `triggers` with one trigger of the
+ new kind. Kind-specific seeds: schedule → `{ start: "22:00", end: "07:00", days: [0..6] }`;
+ workspace → `{ id: 1 }`; fullscreen/game/manual → no fields. (Shipped modes each carry one
+ trigger; a hand-edited multi-trigger mode collapses to one on first kind change — the editor
+ edits `triggers[0]` and that is documented in a comment.)
+- **Manual-mode semantics**: investigate how a manual-trigger mode activates today and PRESERVE
+ it exactly; if manual modes currently have no activation path besides `enabled`, the header
+ toggle keeps meaning `enabled` and the editor's "Turns on: Manually" detail explains that a
+ manual mode quiets things whenever it is switched on. Do not invent new activation machinery.
+- Existing `setEnabled`/`update`/`reschedule`-style schedule + day editing semantics stay;
+ `withinWindow`, single-DND-ownership, and the gaming report-don't-silence rule are
+ contract-pinned and untouched.
+
+## Routing (pinned)
+
+`SettingsRoutes` category `notifications` gains tabs:
+`[{ page: "notifications", label: "Notifications" }, { page: "focus", label: "Focus" }]`.
+New leaf `focus` → new `FocusPage.qml`; `SettingsShell` case + Component. `groupPages`
+`"focus"` moves `"notifications"` → `"focus"` (the focusModes entry renders there now).
+`GamingPage`'s "Open Focus" action retargets `openSettings("focus")`.
+`NotificationCard`'s "Notification settings" jump stays `"notifications"`.
+
+## Notifications tab (NotificationsPage.qml rebuilt)
+
+Lede unchanged. Cards:
+1. **Quiet** — Do Not Disturb toggle; "Critical alerts break through" ToggleRow
+ (`criticalBreaksThrough`); "Quiet hours" ActionRow whose detail states the Sleep mode's
+ live schedule (or "not scheduled" when Sleep lacks/disabled) and whose button opens the
+ Focus tab (`openSettings("focus")`).
+2. **Banners & history** — the four existing schema sliders (names contract-pinned; critical
+ zero renders "Never"); history count + Clear folded into the history row.
+3. **Applications** — subtitle per mock. Sections:
+ - *Recent*: apps with `lastSeenMs` within 7 days, newest first.
+ - *Customized*: any app with a non-default field (and not already in Recent).
+ - *All apps (N)*: collapsed expander with an inline search field; alphabetical.
+ Rows: cached icon (fallback letter tile), name, subtitle (relative last-seen when known ·
+ state summary like "sound off"/"History only", else the raw appId), enabled toggle, chevron.
+ Expanded body: Play sound toggle · "Show as" dropdown (Banners & history / History only) ·
+ "Urgency" dropdown (App decides / Treat as low / Treat as critical) · "Forget this app"
+ ActionRow (detail: "Remove its rule; it returns on its next notification").
+4. **Active-mode banner** at top (ok-tinted) when a focus mode is active: " is quieting
+ notifications · because · ", button "Open Focus".
+
+## Focus tab (FocusPage.qml, new)
+
+Lede: "Modes quiet this machine on their own terms — first matching mode wins, and the order
+below is the priority."
+1. **Focus modes** card — accordion (one open at a time): drag grip (reorder = priority; also
+ keyboard up/down on the grip), mode glyph, name, summary line ("Turns on ·
+ silences everything except N apps" / "On now — "), enabled toggle, chevron.
+ Expanded: "Turns on" dropdown (five kinds) + kind fields (schedule start/end `TimeOfDayRow`-
+ style or validated HH:MM inputs + seven day pills; workspace id picker); "Silence
+ notifications" toggle; "May interrupt" chip row (chips with ×, "+ Add app" opens a searchable
+ picker over known apps — reuse the rules list's app universe); "Keep the screen awake"
+ toggle; Rename + Delete mode buttons. "+ New focus mode" dashed row at the bottom.
+2. **Focus sessions** card — default duration segmented chips (25/45/60/90 →
+ `focusDurationMinutes`), Caffeine toggle, session status row + Start focus/Show controls
+ (existing behaviors move over unchanged).
+
+Reuse existing row widgets and the Displays/Sound phase components (OptionPickerRow, etc.)
+before inventing new ones. No continuously repainting animations. All new components get
+qmldir lines in the same wave as first reference.
+
+## Search & docs
+
+Hand-written entries (page per target): Do Not Disturb, Quiet hours, Critical alerts break
+through, Application notification rules, Forget an app's notifications, Per-app notification
+sound → `notifications`; Focus modes detail already schema-indexed (now routes to `focus`),
+plus Focus session duration → `focus` if not covered by the workspaces group move. Docs and
+launcher commands regenerate after the schema lands (orchestrator's audit pass).
+
+## Contracts (write, do NOT run — cite in the backlog for the next sweep)
+
+- `notification-app-rules-contract`: extend the pinned rule shape to the table above (defaults,
+ optional back-compat, stale-field dropping incl. lock-screen pair), pin `forgetApp`, the
+ display="history" no-popup-no-bell path, sound=false no-bell, `effectiveUrgency` consumers,
+ and the breakthrough gate literal.
+- `focus-modes-contract`: pin the new CRUD/reorder/trigger APIs, keep every existing pin
+ (conditions-not-alarms, DND ownership, gaming reports, exception list consulted + editable —
+ the editable needle moves to FocusPage).
+- `settings-pages-contract` (page id list + component list), `settings-jump-contract`
+ (GamingPage → focus), `search-routing-contract` expectations, `settings-window-contract` if
+ it enumerates tabs.
+- Backlog spec gains a Phase 7 section listing all of it.
+
+## Agent ownership (parallel)
+
+- **A — services**: `services/Notifs.qml`, `services/FocusModes.qml`,
+ `config/PreferenceSchema.qml` (one new key; comments above braces).
+- **B — UI**: `modules/settings/NotificationsPage.qml`, new `modules/settings/FocusPage.qml` +
+ new components + `modules/settings/qmldir`, `services/SettingsRoutes.qml`,
+ `modules/settings/SettingsShell.qml`, `modules/notifications/NotificationCard.qml`
+ (effectiveUrgency), `modules/settings/GamingPage.qml` (Open Focus target).
+- **C — periphery**: `services/SettingsSearch.qml`, the contracts above + harness fixtures,
+ test-backlog spec, README count line only if the contract count changes.
+
+B programs against the pinned shapes; A must not change them without updating this spec.
diff --git a/tests/quickshell/enum-hypr-map-contract b/tests/quickshell/enum-hypr-map-contract
index ea36d5e..3515e92 100755
--- a/tests/quickshell/enum-hypr-map-contract
+++ b/tests/quickshell/enum-hypr-map-contract
@@ -17,6 +17,18 @@
#
# So this checks the schema's enum values against that map, and against the
# min/max range for mapped options that have no named map.
+#
+# String-valued options are checked too, less strictly and for a reason. They
+# carry no `map` at all -- Hyprland states their accepted values inside the
+# description, as `input:scroll_method` does with "[2fg/edge/on_button_down/
+# no_scroll]". That list is prose, so it is trustworthy in one direction only:
+# a value the compositor does not name is a value it will reject or silently
+# ignore, which this fails on; a value the compositor names but Settings does
+# not offer may simply be one Panama has decided against (accel_profile's
+# `custom` needs a scroll_points curve to mean anything, and is a stated
+# non-goal), so that direction is not an error here. The empty string is always
+# allowed: it is how a schema entry says "leave it at the compositor's default",
+# which is what `[[EMPTY]]` reads back as.
set -uo pipefail
@@ -33,7 +45,8 @@ descriptions="$(hyprctl descriptions 2>/dev/null)" || fail 'could not read hyprc
jq -e 'type == "array" and length > 0' >/dev/null <<<"$descriptions" \
|| fail 'hyprctl descriptions did not return a list'
-# Pull every enum entry that carries a hypr option, as: keyoptionvalues
+# Pull every enum entry that carries a hypr option, as:
+# keyoptionkindvalues (kind is "int" or "str")
entries="$(python3 - "$schema" <<'PY'
import re, sys
@@ -46,22 +59,45 @@ for block in re.findall(r'\{\s*\n?\s*key:\s*"([^"]+)"(.*?)\n \}', text, r
option = re.search(r'option:\s*"([^"]+)"', body)
if not option:
continue
- values = re.findall(r'value:\s*(-?\d+)', body)
- if not values:
- continue
- print(f"{name}\t{option.group(1)}\t{','.join(values)}")
+ numbers = re.findall(r'value:\s*(-?\d+)\s*[,}]', body)
+ strings = re.findall(r'value:\s*"([^"]*)"\s*[,}]', body)
+ if numbers:
+ print(f"{name}\t{option.group(1)}\tint\t{','.join(numbers)}")
+ elif strings:
+ print(f"{name}\t{option.group(1)}\tstr\t{','.join(strings)}")
PY
)"
[[ -n "$entries" ]] || fail 'found no compositor-backed enums in the schema -- this contract is not reading it correctly'
checked=0
-while IFS=$'\t' read -r key option values; do
+while IFS=$'\t' read -r key option kind values; do
[[ -n "$key" ]] || continue
entry="$(jq -c --arg name "$option" '.[] | select(.name == $name)' <<<"$descriptions")"
[[ -n "$entry" ]] || fail "$key maps to \"$option\", which the compositor does not publish"
+ # ── String-valued options: no map, an accepted-value list in the prose ────
+ if [[ "$kind" == "str" ]]; then
+ published="$(jq -r '.description // ""' <<<"$entry" \
+ | grep -oE '\[[a-z0-9_/-]+\]$' | tr -d '[]' | tr '/' '\n')"
+ if [[ -z "$published" ]]; then
+ printf 'enum hypr map contract: %s maps to "%s", which publishes no value list to check against\n' \
+ "$key" "$option" >&2
+ checked=$((checked + 1))
+ continue
+ fi
+ IFS=',' read -ra wanted <<<"$values"
+ for value in "${wanted[@]}"; do
+ # "" is the schema saying "leave the compositor's own default".
+ [[ -n "$value" ]] || continue
+ grep -qx "$value" <<<"$published" \
+ || fail "$key offers \"$value\" for $option, which the compositor does not accept (it names: $(tr '\n' ' ' <<<"$published"))"
+ done
+ checked=$((checked + 1))
+ continue
+ fi
+
map_values="$(jq -r 'if .map then (.map | map(to_entries[].value) | join(",")) else "" end' <<<"$entry")"
IFS=',' read -ra wanted <<<"$values"
diff --git a/tests/quickshell/keybind-rebind-contract b/tests/quickshell/keybind-rebind-contract
index c7eb7e0..157dcdf 100755
--- a/tests/quickshell/keybind-rebind-contract
+++ b/tests/quickshell/keybind-rebind-contract
@@ -18,6 +18,9 @@ set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
harness="$repo_dir/config/dot/quickshell/keybinds-harness.qml"
service="$repo_dir/config/dot/quickshell/services/Keybinds.qml"
+settings_dir="$repo_dir/config/dot/quickshell/modules/settings"
+page="$settings_dir/ShortcutsPage.qml"
+row="$settings_dir/ShortcutRow.qml"
fail() {
printf 'keybind rebind contract: %s\n' "$1" >&2
@@ -29,6 +32,61 @@ rg -Fq 'function overrideOccupantFor(chord: string, exceptShipped: string): stri
rg -Fq 'root.overrideOccupantFor(shipped, shipped)' "$service" \
|| fail 'resetBind does not check whether another override occupies its shipped chord'
+# ── The page that drives all of the above ────────────────────────────────────
+#
+# The engine is exercised for real below, but the engine is only reachable
+# through one screen, and the screen has been rebuilt around a per-row
+# component. These pin the parts of that screen that carry consequence: every
+# one of them is a way to lose the rebinding flow without any test noticing,
+# because the service would still be perfectly correct.
+#
+# The row and the page are read as one source, so moving a control between them
+# is not a failure -- removing it is.
+
+[[ -r "$page" ]] || fail "cannot read $page"
+[[ -r "$row" ]] || fail "cannot read $row -- the shortcut row component is gone"
+browser="$(cat "$page" "$row")"
+
+# The capture field is shared with nothing else and is the only thing in the
+# tree that reads a chord without acting on it. A page that grew its own key
+# handler instead would capture SUPER as a bind of its own.
+grep -Fq 'ShortcutCapture' <<<"$browser" \
+ || fail 'the shortcuts browser no longer uses ShortcutCapture, so something else is reading key presses'
+
+for needle in \
+ 'Keybinds.boundTo(' \
+ 'Keybinds.rebind(' \
+ 'Keybinds.resetBind(' \
+ 'Keybinds.resetAll()' \
+ 'Keybinds.isOverridden(' \
+ 'text: "Change"' \
+ 'text: "Reset"'; do
+ grep -Fq "$needle" <<<"$browser" \
+ || fail "the shortcuts browser is missing $needle"
+done
+
+# boundTo before rebind. Without the check the write still succeeds and two
+# actions end up on one chord, with whichever Hyprland reads last winning.
+conflict_line="$(grep -n 'Keybinds.boundTo(' <<<"$browser" | head -1 | cut -d: -f1)"
+write_line="$(grep -n 'Keybinds.rebind(' <<<"$browser" | head -1 | cut -d: -f1)"
+[[ -n "$conflict_line" && -n "$write_line" && "$conflict_line" -lt "$write_line" ]] \
+ || fail 'the chord in use is not checked before the rebind is written'
+
+# Keyed by the shipped Lua chord, never by the description. Keying by
+# description is what once moved every bind that shared one, silently costing
+# the XF86Calculator hardware key when SUPER+C was rebound.
+grep -Fq 'luaChord' <<<"$browser" \
+ || fail 'the browser does not identify binds by their shipped Lua chord'
+if grep -qE 'Keybinds\.(rebind|resetBind)\([^)]*description' <<<"$browser"; then
+ fail 'a rebind or reset is keyed by description, which moves every bind that shares one'
+fi
+
+# Restoring everything stays reachable, and says how much it would undo.
+rg -Fq 'Restore every shipped shortcut' "$page" \
+ || fail 'the restore-all row is gone'
+rg -Fq 'Object.keys(Keybinds.overrides).length' "$page" \
+ || fail 'the restore-all row no longer counts what it would put back'
+
if [[ "${PANAMA_KEYBINDS_STATIC_ONLY:-0}" == "1" ]]; then
printf 'keybind rebind contract: PASS (static)\n'
exit 0
diff --git a/tests/quickshell/keybinds-contract b/tests/quickshell/keybinds-contract
index 041863b..83073fd 100755
--- a/tests/quickshell/keybinds-contract
+++ b/tests/quickshell/keybinds-contract
@@ -16,12 +16,56 @@ set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
harness="$repo_dir/config/dot/quickshell/keybinds-harness.qml"
+settings_dir="$repo_dir/config/dot/quickshell/modules/settings"
+page="$settings_dir/ShortcutsPage.qml"
+row="$settings_dir/ShortcutRow.qml"
fail() {
printf 'keybinds contract: %s\n' "$1" >&2
exit 1
}
+# ── How the hundred and thirty are presented ─────────────────────────────────
+#
+# The count check below proves the page HAS every bind. These prove it is still
+# something a person can find one in. A wall of a hundred and thirty rows and a
+# searchable browser pass the count check identically.
+
+[[ -r "$page" ]] || fail "cannot read $page"
+[[ -r "$row" ]] || fail "cannot read $row -- the shortcut row component is gone"
+browser="$(cat "$page" "$row")"
+
+grep -Fq 'Keybinds.grouped()' <<<"$browser" \
+ || fail 'the page no longer renders the service grouping, so the group order is a second opinion'
+
+# Chords are drawn as keys. The chord string itself stays the service's -- the
+# component is presentation over what Keybinds already produced, never a second
+# spelling of a binding.
+grep -Fq 'KeycapChord' <<<"$browser" \
+ || fail 'chords are no longer drawn as keycaps'
+
+# A filter, matching what a shortcut does rather than what it is bound to:
+# nobody looking for the screenshot key knows it is Super+Shift+S, which is the
+# entire reason for searching.
+grep -Fq 'toLowerCase()' <<<"$browser" \
+ || fail 'the shortcut filter is gone, or is case-sensitive'
+grep -qE 'description[^\n]*toLowerCase|toLowerCase[^\n]*description' <<<"$browser" \
+ || fail 'the filter does not match a shortcut by its description'
+
+# Filtering must say what it hid. A list that silently shrinks reads as a
+# shortcut having been lost.
+grep -qi 'showing' <<<"$browser" \
+ || fail 'a filtered list never says how many of how many it is showing'
+
+# Counts come from the service, so adding a bind moves the number on the page.
+grep -Fq 'Keybinds.binds.length' <<<"$browser" \
+ || fail 'the header count is not read from the live keymap'
+
+# The reason there is no GNOME keyboard button here, kept where the next person
+# to wonder about it will look. gnome-handoff-contract cannot state a reason.
+rg -Fq 'writes org.gnome.desktop input-source' "$page" \
+ || fail 'the note explaining why there is no GNOME keyboard handoff is gone'
+
qs_for_harness() {
qs -p "$harness" "$@"
}
diff --git a/tests/quickshell/settings-pages-contract b/tests/quickshell/settings-pages-contract
index 02f9edb..29b5856 100755
--- a/tests/quickshell/settings-pages-contract
+++ b/tests/quickshell/settings-pages-contract
@@ -9,7 +9,7 @@ fail() {
exit 1
}
-pages=(Home MyHome Phone Displays Connectivity Bar Dock ControlCenter Tiling Workspaces Sync Sound Dictation Notifications Focus ScreenIntelligence Health About)
+pages=(Home MyHome Phone Displays Connectivity Bar Dock ControlCenter Tiling Workspaces Sync Sound Shortcuts Mouse Dictation Notifications Focus ScreenIntelligence Health About)
for page in "${pages[@]}"; do
page_file="$repo_dir/config/dot/quickshell/modules/settings/${page}Page.qml"
[[ -f "$page_file" ]] || fail "${page}Page.qml is missing"
@@ -79,6 +79,24 @@ block = re.search(
raise SystemExit(0 if block and re.search(r'zeroLabel\s*:\s*"Never"', block.group(0)) else 1)
PY
+# The pointer and touchpad keys added with the Input redesign, each on the page
+# its schema group routes to.
+#
+# Checked by hand rather than through require_row, because half of them render
+# through OptionPickerRow, which takes its label and options from
+# `PreferenceSchema.spec()` and commits by name -- it has no `setting:`
+# property at all. That is a deliberate pattern for dropdowns, but it means the
+# scans that look for `setting:` rows (settings-ownership-contract's
+# duplicate-key sweep, search-routing-contract's routing sweep) cannot see
+# these rows, so a key that stopped rendering would leave no other trace.
+mouse_page="$repo_dir/config/dot/quickshell/modules/settings/MousePage.qml"
+for setting in focusOnClose scrollMethod scrollButton cursorHideWhileTyping \
+ cursorWarpOnWorkspaceChange touchpadClickfinger touchpadTapAndDrag; do
+ rg -Fq "setting: \"$setting\"" "$mouse_page" \
+ || rg -Fq "commitPreference(\"$setting\"" "$mouse_page" \
+ || fail "MousePage.qml renders no control for $setting"
+done
+
intelligence_page="$repo_dir/config/dot/quickshell/modules/settings/ScreenIntelligencePage.qml"
# Free text, not a choice. Three preset folders could not include the one the
# rest of somebody's software already writes to, which is the only folder that
@@ -311,7 +329,7 @@ shell_pid="$harness_pid"
# 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 my-home phone bar dock control-center tiling workspaces sync sound dictation notifications focus screen-intelligence shortcuts services manual about)
+pages=(home appearance displays connectivity my-home phone bar dock control-center tiling workspaces sync sound dictation notifications focus screen-intelligence shortcuts mouse services manual about)
for page in "${pages[@]}"; do
qs_for_test ipc call settings page "$page" >/dev/null
for _ in $(seq 1 20); do
diff --git a/tests/quickshell/xkb-presets-contract b/tests/quickshell/xkb-presets-contract
index c15bd6f..b20b69c 100755
--- a/tests/quickshell/xkb-presets-contract
+++ b/tests/quickshell/xkb-presets-contract
@@ -3,6 +3,21 @@
# Common XKB behavior should be discoverable without hiding the raw option
# string from advanced users. This is source-only so it never remaps the live
# keyboard while the desktop is in use.
+#
+# The presets became dropdowns and the raw strings moved into an "Advanced"
+# disclosure, which is the change this contract exists to bound. Collapsed is
+# fine -- a disclosure somebody can open is still the page telling them the
+# setting is there. Gone is not. So what is checked is that the raw
+# `keyboardOptions`, `keyboardVariant` and `keyboardLayout` fields are still on
+# THIS page, still editable, and still behind something that opens; not that
+# they are visible at rest.
+#
+# The reason for the distinction: every preset here writes one category of a
+# single comma-separated string. xkeyboard-config has hundreds of options and
+# the dropdowns offer eleven. Without the raw field, choosing anything else
+# means editing a preferences file by hand -- and worse, a preset would then
+# silently discard an option somebody had put there, with no way to see that it
+# had. The preservation rule below is only honest while the string is legible.
set -euo pipefail
@@ -16,26 +31,91 @@ fail() {
exit 1
}
+[[ -r "$page" ]] || fail "cannot read $page"
+
+# ── The presets ──────────────────────────────────────────────────────────────
+#
+# The values are pinned; the labels are matched loosely, because what an
+# xkb option string says is a fact and what a row is called is a wording
+# decision. `caps:escape_shifted_capslock` moving would change somebody's
+# keyboard. "Compose key" becoming "Compose" would not.
+
for needle in \
'function currentXkbOption(prefix: string): string' \
'function setXkbOption(prefix: string, option: string): void' \
- 'label: "Caps Lock"' \
'value: "caps:escape_shifted_capslock"' \
'value: "caps:ctrl_modifier"' \
- 'label: "Compose key"' \
'value: "compose:ralt"' \
- 'label: "Layout switching"' \
'value: "grp:win_space_toggle"' \
- 'SystemSettings.commitPreference("keyboardOptions"' \
- 'TextEntryRow { setting: "keyboardOptions"'; do
+ 'SystemSettings.commitPreference("keyboardOptions"'; do
rg -Fq "$needle" "$page" || fail "Shortcuts is missing $needle"
done
+for label in 'Caps Lock' 'Compose' 'Layout switching'; do
+ rg -qi "label: \"$label" "$page" || fail "Shortcuts has no \"$label\" row"
+done
+
# Picking one category must replace only that category, preserving advanced
# options from every other group.
rg -Fq 'option.indexOf(prefix) !== 0' "$page" \
|| fail 'preset updates do not preserve unrelated XKB options'
+# ── The raw strings, wherever they now sit on the page ───────────────────────
+#
+# Matched as a block rather than a line, because these rows are nested inside a
+# disclosure now and a one-line `TextEntryRow { setting: "..." }` is no longer
+# how they are written.
+
+python3 - "$page" <<'PY' || fail 'the raw XKB fields are no longer editable on the Keyboard page -- a preset that silently drops an unrecognised option is the failure this prevents'
+import re
+import sys
+
+text = open(sys.argv[1], encoding="utf-8").read()
+missing = []
+for setting in ("keyboardOptions", "keyboardVariant", "keyboardLayout"):
+ pattern = rf"TextEntryRow\s*\{{(?:(?!\n\s*[A-Z][A-Za-z0-9]*\s*\{{).)*?setting\s*:\s*\"{setting}\""
+ if not re.search(pattern, text, re.S):
+ missing.append(setting)
+if missing:
+ print("missing raw editors for: " + ", ".join(missing), file=sys.stderr)
+raise SystemExit(1 if missing else 0)
+PY
+
+# Collapsed is allowed; sealed shut is not. Something on the page has to open
+# the disclosure, and it has to be named so somebody knows what is behind it.
+rg -qi 'advanced' "$page" \
+ || fail 'the raw XKB fields are on the page but nothing names the section holding them'
+
+python3 - "$page" <<'PY' || fail 'the Advanced section has no control that opens it, so the raw fields are present but unreachable'
+import re
+import sys
+
+text = open(sys.argv[1], encoding="utf-8").read()
+# Whatever the state is called, a disclosure needs a handler that changes it.
+# `onClicked: root.showAdvanced = !root.showAdvanced`, `onTriggered:
+# root.advancedOpen = true`, an `expanded` id -- any of them satisfy this; a
+# hardcoded `visible: false` does not.
+handlers = re.findall(r"on(?:Clicked|Triggered|Toggled)\s*:.*", text)
+raise SystemExit(0 if any(re.search(r"advanced", line, re.I) for line in handlers) else 1)
+PY
+
+# A field that exists but can never be shown is the same as no field.
+python3 - "$page" <<'PY' || fail 'a raw XKB field is pinned invisible, which is indistinguishable from removing it'
+import re
+import sys
+
+text = open(sys.argv[1], encoding="utf-8").read()
+for setting in ("keyboardOptions", "keyboardVariant", "keyboardLayout"):
+ pattern = rf"TextEntryRow\s*\{{((?:(?!\n\s*[A-Z][A-Za-z0-9]*\s*\{{).)*?setting\s*:\s*\"{setting}\".*?)\n"
+ block = re.search(pattern, text, re.S)
+ if block and re.search(r"visible\s*:\s*false", block.group(1)):
+ print(f"{setting} is declared visible: false", file=sys.stderr)
+ raise SystemExit(1)
+raise SystemExit(0)
+PY
+
+# ── The values that survive a reload ─────────────────────────────────────────
+
rg -Fq 'kb_variant = prefs.get("keyboardVariant", "")' "$input" \
|| fail 'Hyprland does not replay the stored keyboard variant'
rg -Fq 'key: "keyboardOptions", type: "string", def: "caps:escape_shifted_capslock"' "$schema" \
diff --git a/tests/setup/dictation-contract b/tests/setup/dictation-contract
index 7d5b724..4f5cd57 100755
--- a/tests/setup/dictation-contract
+++ b/tests/setup/dictation-contract
@@ -130,6 +130,25 @@ grep -q 'dictate("cancel")' "$keybinds" \
grep -q '"status"' "$service" \
|| note 'the settings service never asks the helper what is installed'
+# The page grew an on-page test that runs the helper directly, so that the
+# dictated words arrive in a field somebody is looking at instead of in their
+# document. That is fine -- it is the same start/stop the hotkey sends, and
+# nothing about where text lands changes, because wtype types into whatever
+# holds keyboard focus and the page holds it.
+#
+# What is not fine is the page spelling out where the helper lives. The service
+# publishes that path once; a second copy in the page would go stale the moment
+# the helper moves, and silently, because the page's own status readout comes
+# from the service and would still be correct.
+page="$repo_dir/config/dot/quickshell/modules/settings/DictationPage.qml"
+if [[ -f "$page" ]]; then
+ grep -q 'scripts/panama-dictate' "$page" \
+ && note 'the Dictation page spells out the helper path instead of using the one the service publishes'
+ if grep -q 'Process' "$page" && ! grep -q 'Dictation.helper' "$page"; then
+ note 'the Dictation page runs a process without going through Dictation.helper'
+ fi
+fi
+
if (( ${#findings[@]} > 0 )); then
mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u)
printf 'dictation contract: %d finding(s)\n' "${#findings[@]}" >&2