Give Input keycaps, a shortcut search, and the missing pointer basics

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 15:21:01 -04:00
parent b8f88a91f3
commit aba2d16ffa
25 changed files with 2346 additions and 191 deletions
+46
View File
@@ -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
View File
@@ -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
View File
@@ -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.14.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 0300. |
| **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.14.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 100800. |
| **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.
+42 -6
View File
@@ -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"
+58
View File
@@ -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
+44
View File
@@ -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" "$@"
}
+20 -2
View File
@@ -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
+85 -5
View File
@@ -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" \
+19
View File
@@ -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