Give Input keycaps, a shortcut search, and the missing pointer basics
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 !== ""
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
# Generated by scripts/panama-settings-commands -- do not edit by hand.
|
||||
# @vicinae.schemaVersion 1
|
||||
# @vicinae.title Settings: 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+8
-1
@@ -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**<br>`scrollFactor` `input:scroll_factor` | 1.0 | Multiplies how far one notch of the wheel scrolls. Range 0.1–4.0. |
|
||||
| **Left-handed**<br>`leftHanded` `input:left_handed` | false | Swap the primary and secondary buttons |
|
||||
| **Middle-click paste**<br>`middleClickPaste` `misc:middle_click_paste` | true | Paste the primary selection in GTK and native Wayland applications |
|
||||
| **Focus after closing**<br>`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**<br>`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**<br>`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**<br>`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**<br>`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**<br>`touchpadScrollFactor` `input:touchpad:scroll_factor` | 1.0 | Multiplies how far a two-finger scroll travels. Range 0.1–4.0. |
|
||||
| **Drag lock**<br>`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**<br>`touchpadMiddleButtonEmulation` `input:touchpad:middle_button_emulation` | false | Pressing left and right together acts as a middle click |
|
||||
| **Two-finger right-click**<br>`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**<br>`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**<br>`swipeDistance` `gestures:workspace_swipe_distance` | 300 px | How far a three-finger swipe must travel to change workspace. Range 100–800. |
|
||||
| **Natural swipe direction**<br>`swipeInvert` `gestures:workspace_swipe_invert` | true | Swiping left moves to the workspace on the right, as content follows your fingers |
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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: "<name> is quieting
|
||||
notifications · because <reason> · <allow summary>", 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 <trigger summary> ·
|
||||
silences everything except N apps" / "On now — <reason>"), 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.
|
||||
@@ -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: key<TAB>option<TAB>values
|
||||
# Pull every enum entry that carries a hypr option, as:
|
||||
# key<TAB>option<TAB>kind<TAB>values (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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" "$@"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user