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