Give Input keycaps, a shortcut search, and the missing pointer basics
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -35,6 +35,13 @@ hl.config({
|
|||||||
-- Still focus-follows-pointer, just less twitchy.
|
-- Still focus-follows-pointer, just less twitchy.
|
||||||
mouse_refocus = false,
|
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
|
-- Flat pointer response by default, no acceleration. Matters for
|
||||||
-- gaming; Settings offers adaptive for people who want it back.
|
-- gaming; Settings offers adaptive for people who want it back.
|
||||||
sensitivity = prefs.get("pointerSensitivity", 0),
|
sensitivity = prefs.get("pointerSensitivity", 0),
|
||||||
@@ -44,6 +51,21 @@ hl.config({
|
|||||||
scroll_factor = prefs.get("scrollFactor", 1.0),
|
scroll_factor = prefs.get("scrollFactor", 1.0),
|
||||||
left_handed = prefs.get("leftHanded", false),
|
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.
|
-- Clicking a floating window raises and focuses it.
|
||||||
float_switch_override_focus = 2,
|
float_switch_override_focus = 2,
|
||||||
|
|
||||||
@@ -61,9 +83,33 @@ hl.config({
|
|||||||
scroll_factor = prefs.get("touchpadScrollFactor", 1.0),
|
scroll_factor = prefs.get("touchpadScrollFactor", 1.0),
|
||||||
drag_lock = prefs.getInt("touchpadDragLock", 0),
|
drag_lock = prefs.getInt("touchpadDragLock", 0),
|
||||||
middle_button_emulation = prefs.get("touchpadMiddleButtonEmulation", false),
|
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.
|
-- Tuning for the three-finger gestures registered below.
|
||||||
gestures = {
|
gestures = {
|
||||||
workspace_swipe_distance = prefs.getInt("swipeDistance", 300),
|
workspace_swipe_distance = prefs.getInt("swipeDistance", 300),
|
||||||
|
|||||||
@@ -757,6 +757,88 @@ Singleton {
|
|||||||
detail: "Paste the primary selection in GTK and native Wayland applications",
|
detail: "Paste the primary selection in GTK and native Wayland applications",
|
||||||
hypr: { path: ["misc", "middle_click_paste"], option: "misc:middle_click_paste", readAs: "bool" }
|
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 ────────────────────────────────────────────────────────
|
// ── Touchpad ────────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
@@ -814,6 +896,28 @@ Singleton {
|
|||||||
detail: "Pressing left and right together acts as a middle click",
|
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" }
|
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.
|
// Tuning for the three-finger gestures registered in hypr/input.lua.
|
||||||
// The gestures themselves are not settings: Hyprland reads a gesture
|
// The gestures themselves are not settings: Hyprland reads a gesture
|
||||||
|
|||||||
@@ -1,68 +1,440 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
|
import Quickshell.Io
|
||||||
import qs.config
|
import qs.config
|
||||||
import qs.services
|
import qs.services
|
||||||
|
|
||||||
// Dictation is an input method, so it lives under Input beside the keyboard —
|
// 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
|
// but it listens through whichever device the Sound page selects, so the
|
||||||
// microphone card below hands off there rather than duplicating the picker.
|
// 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 {
|
SettingsPage {
|
||||||
title: "Dictation"
|
id: root
|
||||||
lede: Dictation.ready
|
|
||||||
? "Hold Super+D, speak, and release. The words are typed where the cursor is."
|
|
||||||
: "Speech to text, on the GPU, once the one-time setup below has run."
|
|
||||||
|
|
||||||
SettingsCard {
|
// Chords come from the compositor by matching the descriptions
|
||||||
title: "Dictation"
|
// hypr/keybinds.lua gives the dictation binds. The literals are a fallback
|
||||||
subtitle: Dictation.ready
|
// for the moment before the keymap has loaded, not a second source of truth.
|
||||||
? "Hold Super+D, speak, and release. The words are typed where the cursor is."
|
function chordFor(needle: string, fallback: string): string {
|
||||||
: "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."
|
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.
|
readonly property string holdChord: root.chordFor("dictate (hold to talk)", "Super + D")
|
||||||
TextRow {
|
readonly property string cancelChord: root.chordFor("cancel dictation", "Super + Shift + D")
|
||||||
label: "Speech server"
|
|
||||||
|
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
|
detail: Dictation.serverReady
|
||||||
? "Running"
|
? "The speech server is answering"
|
||||||
: (Dictation.imageBuilt ? "Ready — starts on the first dictation" : "Not set up yet")
|
: "The server starts itself the first time you hold the key",
|
||||||
value: Dictation.imageBuilt ? "Ready" : "Missing"
|
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 {
|
SettingRow {
|
||||||
label: "Speech model"
|
label: "Hold to dictate"
|
||||||
detail: Dictation.modelInstalled
|
detail: "Release to type what you said, wherever the cursor is"
|
||||||
? "Kept across rebuilds of the server"
|
controlWidth: 220
|
||||||
: "About 490 MB, downloaded once"
|
|
||||||
value: Dictation.modelInstalled ? Math.round(Dictation.modelBytes / 1048576) + " MB" : "Missing"
|
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
|
// ONE action that actually works: panama-dictate setup pulls the
|
||||||
// server image and downloads the model together. This card used to
|
// server image and downloads the model together. This card used to
|
||||||
// offer a Download button wired to a command the helper does not have,
|
// 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
|
// so nothing here did anything. It does now.
|
||||||
// exist -- so nothing here did anything. It does now.
|
|
||||||
ActionRow {
|
ActionRow {
|
||||||
visible: !Dictation.ready || Dictation.downloading
|
|
||||||
label: "Set up dictation"
|
label: "Set up dictation"
|
||||||
detail: {
|
detail: Dictation.downloading
|
||||||
if (!Dictation.downloading)
|
? "Working — this can take several minutes on a slow connection"
|
||||||
return "Fetches the speech server and the model. Runs once, keeps both.";
|
: "Fetches the speech server and the model. Runs once, keeps both."
|
||||||
if (Dictation.phase === "pulling")
|
|
||||||
return "Fetching the speech server…";
|
|
||||||
if (Dictation.downloadTotalBytes > 0)
|
|
||||||
return "Downloading model — " + Math.round(Dictation.downloadFraction * 100)
|
|
||||||
+ "% of " + Math.round(Dictation.downloadTotalBytes / 1048576) + " MB";
|
|
||||||
return "Setting up…";
|
|
||||||
}
|
|
||||||
action: Dictation.downloading ? "Working…" : "Set up"
|
action: Dictation.downloading ? "Working…" : "Set up"
|
||||||
enabled: !Dictation.downloading
|
enabled: !Dictation.downloading
|
||||||
onTriggered: Dictation.setup()
|
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 !== ""
|
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
|
// 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
|
// with no members, so the user would be toggling settings that can never affect
|
||||||
// anything with nothing to say so.
|
// 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 QtQuick
|
||||||
import qs.config
|
import qs.config
|
||||||
@@ -23,18 +28,100 @@ SettingsPage {
|
|||||||
? "Pointer behavior for your mouse and touchpad."
|
? "Pointer behavior for your mouse and touchpad."
|
||||||
: "Pointer behavior. Touchpad settings appear when a touchpad is attached."
|
: "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 {
|
SettingsCard {
|
||||||
title: "Mouse"
|
title: "Mouse"
|
||||||
subtitle: "Applied to every pointing device that is not a touchpad."
|
subtitle: "Applied to every pointing device that is not a touchpad."
|
||||||
|
|
||||||
SliderRow { setting: "pointerSensitivity" }
|
SliderRow { setting: "pointerSensitivity" }
|
||||||
ChoiceRow { setting: "accelProfile" }
|
OptionPickerRow {
|
||||||
ToggleRow { setting: "naturalScroll" }
|
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" }
|
SliderRow { setting: "scrollFactor" }
|
||||||
|
ToggleRow { setting: "naturalScroll" }
|
||||||
ToggleRow { setting: "leftHanded" }
|
ToggleRow { setting: "leftHanded" }
|
||||||
ToggleRow {
|
ToggleRow {
|
||||||
setting: "middleClickPaste"
|
setting: "middleClickPaste"
|
||||||
detail: "Paste the primary selection in GTK and native Wayland applications; individual apps may choose not to support it"
|
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
|
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."
|
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: "touchpadTapToClick" }
|
||||||
ToggleRow { setting: "touchpadNaturalScroll" }
|
ToggleRow { setting: "touchpadClickfinger" }
|
||||||
ToggleRow { setting: "touchpadDisableWhileTyping" }
|
ToggleRow { setting: "touchpadTapAndDrag" }
|
||||||
SliderRow { setting: "touchpadScrollFactor" }
|
|
||||||
ChoiceRow { setting: "touchpadDragLock" }
|
ChoiceRow { setting: "touchpadDragLock" }
|
||||||
ToggleRow { setting: "touchpadMiddleButtonEmulation"; divider: false }
|
ToggleRow { setting: "touchpadNaturalScroll" }
|
||||||
}
|
SliderRow { setting: "touchpadScrollFactor" }
|
||||||
|
ToggleRow { setting: "touchpadDisableWhileTyping" }
|
||||||
SettingsCard {
|
ToggleRow { setting: "touchpadMiddleButtonEmulation" }
|
||||||
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."
|
|
||||||
|
|
||||||
|
// 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" }
|
SliderRow { setting: "swipeDistance" }
|
||||||
ToggleRow { setting: "swipeInvert"; divider: false }
|
ToggleRow { setting: "swipeInvert"; divider: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
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" }
|
SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never" }
|
||||||
|
ToggleRow { setting: "cursorWarpOnWorkspaceChange" }
|
||||||
SliderRow { setting: "cursorSize"; divider: false }
|
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
|
// 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.
|
// 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,
|
// The hardware settings above the list are real controls. Keyboard layout,
|
||||||
// repeat behavior, and pointer response are Hyprland's, so Panama owns them;
|
// repeat behavior, and pointer response are Hyprland's, so Panama owns them;
|
||||||
// device-specific configuration stays with GNOME.
|
// device-specific configuration stays with GNOME.
|
||||||
@@ -13,6 +20,7 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import qs.config
|
import qs.config
|
||||||
import qs.services
|
import qs.services
|
||||||
|
import qs.modules.clipboard
|
||||||
|
|
||||||
SettingsPage {
|
SettingsPage {
|
||||||
id: root
|
id: root
|
||||||
@@ -26,7 +34,14 @@ SettingsPage {
|
|||||||
// itself. Held while the capture stays open so the message can name both.
|
// itself. Held while the capture stays open so the message can name both.
|
||||||
property string conflict: ""
|
property string conflict: ""
|
||||||
property string conflictChord: ""
|
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 storedXkbOptions: String(DesktopPreferences.get("keyboardOptions") ?? "")
|
||||||
|
readonly property string storedLayout: String(DesktopPreferences.get("keyboardLayout") ?? "")
|
||||||
|
|
||||||
function xkbOptions(): var {
|
function xkbOptions(): var {
|
||||||
return root.storedXkbOptions
|
return root.storedXkbOptions
|
||||||
@@ -46,53 +61,130 @@ SettingsPage {
|
|||||||
SystemSettings.commitPreference("keyboardOptions", options.join(","));
|
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"
|
title: "Keyboard"
|
||||||
lede: "The Forge mental model, carried forward into native tiling."
|
lede: "Layout, typing feel, and every shortcut the compositor has bound."
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Layout & typing"
|
title: "Typing"
|
||||||
|
|
||||||
// These were read-only text, on the grounds that a layout change needed
|
// These were read-only text, on the grounds that a layout change needed
|
||||||
// a compositor reload. It does not: setting input:kb_variant through
|
// a compositor reload. It does not: setting input:kb_variant through
|
||||||
// hl.config re-keymaps attached keyboards immediately -- verified by
|
// hl.config re-keymaps attached keyboards immediately -- verified by
|
||||||
// watching active_keymap on a real keyboard change and change back. So
|
// watching active_keymap on a real keyboard change and change back. So
|
||||||
// they are real controls.
|
// they are real controls.
|
||||||
TextEntryRow { setting: "keyboardLayout"; placeholder: "us" }
|
OptionPickerRow {
|
||||||
TextEntryRow { setting: "keyboardVariant"; placeholder: "none" }
|
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 {
|
OptionPickerRow {
|
||||||
width: parent.width
|
|
||||||
label: "Caps Lock"
|
label: "Caps Lock"
|
||||||
detail: "Keep it conventional, or turn a prime keyboard position into Escape or Control"
|
detail: "What the Caps Lock key does"
|
||||||
current: root.currentXkbOption("caps:")
|
|
||||||
options: [
|
options: [
|
||||||
{ value: "", label: "Standard" },
|
{ value: "", label: "Standard", detail: "Caps Lock, as printed on the key" },
|
||||||
{ value: "caps:escape_shifted_capslock", label: "Esc · Shift for Caps" },
|
{ value: "caps:escape_shifted_capslock", label: "Esc · Shift for Caps",
|
||||||
{ value: "caps:escape", label: "Escape" },
|
detail: "Escape on its own; Shift and Caps Lock together still lock" },
|
||||||
{ value: "caps:ctrl_modifier", label: "Control" }
|
{ 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)
|
onPicked: value => root.setXkbOption("caps:", value)
|
||||||
}
|
}
|
||||||
|
|
||||||
ChoiceGrid {
|
OptionPickerRow {
|
||||||
width: parent.width
|
|
||||||
label: "Compose key"
|
label: "Compose key"
|
||||||
detail: "Type accented characters and symbols with memorable key sequences"
|
detail: "Type accented characters and symbols with memorable key sequences"
|
||||||
current: root.currentXkbOption("compose:")
|
|
||||||
options: [
|
options: [
|
||||||
{ value: "", label: "Off" },
|
{ value: "", label: "Off" },
|
||||||
{ value: "compose:ralt", label: "Right Alt" },
|
{ value: "compose:ralt", label: "Right Alt" },
|
||||||
{ value: "compose:rwin", label: "Right Super" },
|
{ value: "compose:rwin", label: "Right Super" },
|
||||||
{ value: "compose:menu", label: "Menu" }
|
{ value: "compose:menu", label: "Menu" }
|
||||||
]
|
]
|
||||||
|
current: root.currentXkbOption("compose:")
|
||||||
onPicked: value => root.setXkbOption("compose:", value)
|
onPicked: value => root.setXkbOption("compose:", value)
|
||||||
}
|
}
|
||||||
|
|
||||||
ChoiceGrid {
|
OptionPickerRow {
|
||||||
width: parent.width
|
|
||||||
label: "Layout switching"
|
label: "Layout switching"
|
||||||
detail: "Used when Keyboard layout contains more than one comma-separated layout"
|
detail: "Used when the layout above contains more than one comma-separated layout"
|
||||||
current: root.currentXkbOption("grp:")
|
|
||||||
options: [
|
options: [
|
||||||
{ value: "", label: "Off" },
|
{ value: "", label: "Off" },
|
||||||
{ value: "grp:win_space_toggle", label: "Super + Space" },
|
{ value: "grp:win_space_toggle", label: "Super + Space" },
|
||||||
@@ -100,15 +192,67 @@ SettingsPage {
|
|||||||
{ value: "grp:ctrl_shift_toggle", label: "Ctrl + Shift" },
|
{ value: "grp:ctrl_shift_toggle", label: "Ctrl + Shift" },
|
||||||
{ value: "grp:caps_toggle", label: "Caps Lock" }
|
{ value: "grp:caps_toggle", label: "Caps Lock" }
|
||||||
]
|
]
|
||||||
|
current: root.currentXkbOption("grp:")
|
||||||
onPicked: value => root.setXkbOption("grp:", value)
|
onPicked: value => root.setXkbOption("grp:", value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Presets preserve every option outside their own category. The raw
|
KeyRepeatRow {}
|
||||||
// value remains visible for less common xkeyboard-config features.
|
|
||||||
TextEntryRow { setting: "keyboardOptions"; placeholder: "compose:ralt" }
|
|
||||||
SliderRow { setting: "keyRepeatDelay" }
|
|
||||||
SliderRow { setting: "keyRepeatRate" }
|
|
||||||
ToggleRow { setting: "numlockByDefault"; divider: false }
|
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
|
// 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
|
// work sat further up this same page. A handoff to an inert panel is a
|
||||||
// dead end wearing a button.
|
// dead end wearing a button.
|
||||||
|
|
||||||
// One card per group, built from what the compositor actually has bound.
|
SettingsCard {
|
||||||
//
|
title: "Shortcuts"
|
||||||
// Both Repeaters address their model through an explicit id. Inside a
|
subtitle: "Click Change and press the new keys. A shortcut another action holds is refused, never stolen."
|
||||||
// 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 {
|
Item {
|
||||||
id: groupCard
|
width: parent.width
|
||||||
|
height: 44
|
||||||
|
|
||||||
required property var modelData
|
SearchField {
|
||||||
|
id: filterField
|
||||||
|
|
||||||
title: groupCard.modelData.name
|
anchors.left: parent.left
|
||||||
subtitle: groupCard.modelData.binds.length === 1
|
anchors.right: counts.left
|
||||||
? "1 shortcut"
|
anchors.rightMargin: 14
|
||||||
: `${groupCard.modelData.binds.length} shortcuts`
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
placeholder: "Filter shortcuts — try “window” or “volume”"
|
||||||
|
}
|
||||||
|
|
||||||
Repeater {
|
Text {
|
||||||
id: bindRows
|
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 {
|
// One Column of Repeaters rather than a Loader per row: at a hundred and
|
||||||
id: bindRow
|
// 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
|
Column {
|
||||||
required property int index
|
id: groupColumn
|
||||||
|
|
||||||
readonly property bool capturing: root.capturingChord === bindRow.modelData.luaChord
|
required property var modelData
|
||||||
readonly property bool overridden: Keybinds.isOverridden(bindRow.modelData.luaChord)
|
|
||||||
|
|
||||||
label: bindRow.modelData.description
|
width: parent ? parent.width : 620
|
||||||
detail: bindRow.overridden
|
spacing: 0
|
||||||
? "Moved from " + Keybinds.shippedChordFor(bindRow.modelData.luaChord)
|
|
||||||
: ""
|
|
||||||
controlWidth: 300
|
|
||||||
divider: bindRow.index < bindRows.count - 1
|
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
anchors.right: parent.right
|
width: parent.width
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
height: 30
|
||||||
width: 300
|
|
||||||
height: 30
|
|
||||||
|
|
||||||
ShortcutCapture {
|
Row {
|
||||||
anchors.right: parent.right
|
anchors.left: parent.left
|
||||||
width: 230
|
anchors.leftMargin: 6
|
||||||
height: 30
|
anchors.bottom: parent.bottom
|
||||||
visible: bindRow.capturing
|
anchors.bottomMargin: 4
|
||||||
focus: bindRow.capturing
|
spacing: 8
|
||||||
message: root.conflict === ""
|
|
||||||
? ""
|
Text {
|
||||||
: root.conflictChord + " is already " + root.conflict
|
anchors.baseline: groupCount.baseline
|
||||||
// A chord already in use is reported rather than
|
text: groupColumn.modelData.name
|
||||||
// taken. Two actions on one chord means whichever
|
color: Theme.fgMuted
|
||||||
// Hyprland happens to read last wins, which is not
|
font.family: Theme.fontFamily
|
||||||
// a thing to discover later by pressing it.
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
onCaptured: chord => {
|
font.weight: Font.DemiBold
|
||||||
const taken = Keybinds.boundTo(chord, bindRow.modelData.luaChord);
|
font.capitalization: Font.AllUppercase
|
||||||
if (taken !== "") {
|
font.letterSpacing: 0.8
|
||||||
root.conflict = taken;
|
|
||||||
root.conflictChord = chord;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
root.conflict = "";
|
|
||||||
Keybinds.rebind(bindRow.modelData.luaChord, chord);
|
|
||||||
root.capturingChord = "";
|
|
||||||
}
|
|
||||||
onCanceled: {
|
|
||||||
root.conflict = "";
|
|
||||||
root.capturingChord = "";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Row {
|
Text {
|
||||||
anchors.right: parent.right
|
id: groupCount
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
visible: !bindRow.capturing
|
|
||||||
spacing: 8
|
|
||||||
|
|
||||||
Text {
|
text: root.filtering
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
? "· showing " + groupColumn.modelData.binds.length
|
||||||
text: bindRow.modelData.chord
|
+ " of " + groupColumn.modelData.total
|
||||||
color: bindRow.overridden ? Theme.accent : Theme.fgDim
|
: "· " + groupColumn.modelData.total
|
||||||
font.family: Theme.fontFamily
|
color: Theme.fgMuted
|
||||||
font.pixelSize: Theme.fontSize
|
font.family: Theme.fontFamily
|
||||||
}
|
font.features: Theme.tabularFigures
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SettingsButton {
|
Repeater {
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
id: bindRows
|
||||||
text: "Change"
|
|
||||||
enabled: !Keybinds.reloading && !bindRow.modelData.mouse
|
|
||||||
onClicked: root.capturingChord = bindRow.modelData.luaChord
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingsButton {
|
model: groupColumn.modelData.binds
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
visible: bindRow.overridden
|
ShortcutRow {
|
||||||
text: "Reset"
|
id: shortcutRow
|
||||||
enabled: !Keybinds.reloading
|
|
||||||
onClicked: Keybinds.resetBind(bindRow.modelData.luaChord)
|
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 {
|
Text {
|
||||||
visible: Object.keys(Keybinds.overrides).length > 0
|
width: parent.width
|
||||||
title: "Changed shortcuts"
|
visible: root.groups.length === 0 && Keybinds.loaded
|
||||||
subtitle: "Rebinding stores only the new chord; what a shortcut does always comes from the desktop's configuration."
|
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 {
|
ActionRow {
|
||||||
label: "Restore every shipped shortcut"
|
label: "Restore every shipped shortcut"
|
||||||
detail: Object.keys(Keybinds.overrides).length
|
detail: root.overrideCount === 0
|
||||||
+ (Object.keys(Keybinds.overrides).length === 1 ? " shortcut moved" : " shortcuts moved")
|
? "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"
|
action: "Restore all"
|
||||||
divider: false
|
divider: false
|
||||||
enabled: !Keybinds.reloading
|
enabled: root.overrideCount > 0 && !Keybinds.reloading
|
||||||
onTriggered: Keybinds.resetAll()
|
onTriggered: Keybinds.resetAll()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ AutostartAppPicker 1.0 AutostartAppPicker.qml
|
|||||||
DockPinsStrip 1.0 DockPinsStrip.qml
|
DockPinsStrip 1.0 DockPinsStrip.qml
|
||||||
DockAppPicker 1.0 DockAppPicker.qml
|
DockAppPicker 1.0 DockAppPicker.qml
|
||||||
ShortcutCapture 1.0 ShortcutCapture.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
|
ChoiceGrid 1.0 ChoiceGrid.qml
|
||||||
DisplayModePicker 1.0 DisplayModePicker.qml
|
DisplayModePicker 1.0 DisplayModePicker.qml
|
||||||
DisplayArrangement 1.0 DisplayArrangement.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
|
// 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
|
// 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.
|
// 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
|
||||||
import Quickshell.Io
|
import Quickshell.Io
|
||||||
@@ -28,12 +46,69 @@ Singleton {
|
|||||||
property var mice: []
|
property var mice: []
|
||||||
property var keyboards: []
|
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.
|
// True when anything that looks like a touchpad is attached.
|
||||||
readonly property bool hasTouchpad: root.mice.some(name =>
|
readonly property bool hasTouchpad: root.mice.some(name =>
|
||||||
name.includes("touchpad") || name.includes("trackpad"))
|
name.includes("touchpad") || name.includes("trackpad"))
|
||||||
|
|
||||||
readonly property bool hasMouse: root.mice.length > 0
|
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 {
|
function refresh(): void {
|
||||||
if (!query.running)
|
if (!query.running)
|
||||||
query.running = true;
|
query.running = true;
|
||||||
@@ -49,6 +124,13 @@ Singleton {
|
|||||||
const parsed = JSON.parse(this.text);
|
const parsed = JSON.parse(this.text);
|
||||||
root.mice = (parsed.mice ?? []).map(device => String(device.name ?? "").toLowerCase());
|
root.mice = (parsed.mice ?? []).map(device => String(device.name ?? "").toLowerCase());
|
||||||
root.keyboards = (parsed.keyboards ?? []).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) {
|
} catch (error) {
|
||||||
console.warn("InputDevices: could not parse hyprctl devices:", 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: "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: "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" },
|
{ 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: "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: "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" },
|
{ label: "System Health", detail: "Check Panama services, integrations, tools, and recovery actions", page: "services" },
|
||||||
|
|||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Generated by scripts/panama-settings-commands -- do not edit by hand.
|
||||||
|
# @vicinae.schemaVersion 1
|
||||||
|
# @vicinae.title Settings: Focus
|
||||||
|
# @vicinae.mode silent
|
||||||
|
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
||||||
|
# @vicinae.description Open Focus in Settings.
|
||||||
|
# @vicinae.keywords ["settings", "focus modes", "focus session length", "focus session duration"]
|
||||||
|
|
||||||
|
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page focus
|
||||||
@@ -5,6 +5,6 @@
|
|||||||
# @vicinae.mode silent
|
# @vicinae.mode silent
|
||||||
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
||||||
# @vicinae.description Open Mouse & Touchpad in Settings.
|
# @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
|
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page mouse
|
||||||
|
|||||||
@@ -5,6 +5,6 @@
|
|||||||
# @vicinae.mode silent
|
# @vicinae.mode silent
|
||||||
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
||||||
# @vicinae.description Open Keyboard in Settings.
|
# @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
|
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page shortcuts
|
||||||
|
|||||||
+8
-1
@@ -4,7 +4,7 @@
|
|||||||
Do not edit this file. Run `quickshell/scripts/panama-settings-docs`
|
Do not edit this file. Run `quickshell/scripts/panama-settings-docs`
|
||||||
after changing the schema; a contract fails when this copy is stale.
|
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
|
## accessibility
|
||||||
|
|
||||||
@@ -283,6 +283,11 @@ Found on **Input › Mouse & Touchpad**.
|
|||||||
| **Scroll speed**<br>`scrollFactor` `input:scroll_factor` | 1.0 | Multiplies how far one notch of the wheel scrolls. Range 0.1–4.0. |
|
| **Scroll speed**<br>`scrollFactor` `input:scroll_factor` | 1.0 | Multiplies how far one notch of the wheel scrolls. Range 0.1–4.0. |
|
||||||
| **Left-handed**<br>`leftHanded` `input:left_handed` | false | Swap the primary and secondary buttons |
|
| **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 |
|
| **Middle-click paste**<br>`middleClickPaste` `misc:middle_click_paste` | true | Paste the primary selection in GTK and native Wayland applications |
|
||||||
|
| **Focus after closing**<br>`focusOnClose` `input:focus_on_close` | 0 | Which window takes keyboard focus when the focused one goes away Choices: Next in the stack, Under the pointer, Most recently used. |
|
||||||
|
| **Scroll method**<br>`scrollMethod` `input:scroll_method` | — | How a pointing device turns movement into scrolling Choices: Two fingers, Along the edge of the touchpad, While a button is held, Never scroll. |
|
||||||
|
| **Scroll button**<br>`scrollButton` `input:scroll_button` | 0 | Which button is held to scroll, as an evdev code; 0 lets the device choose. Range 0–300. |
|
||||||
|
| **Hide pointer while typing**<br>`cursorHideWhileTyping` `cursor:hide_on_key_press` | false | The pointer vanishes on the next keystroke and returns when you move it |
|
||||||
|
| **Jump pointer to the focused display**<br>`cursorWarpOnWorkspaceChange` `cursor:warp_on_change_workspace` | false | Moves the pointer to the last focused window after switching workspace |
|
||||||
|
|
||||||
## search
|
## search
|
||||||
|
|
||||||
@@ -331,6 +336,8 @@ Found on **Input › Mouse & Touchpad**.
|
|||||||
| **Scroll speed**<br>`touchpadScrollFactor` `input:touchpad:scroll_factor` | 1.0 | Multiplies how far a two-finger scroll travels. Range 0.1–4.0. |
|
| **Scroll speed**<br>`touchpadScrollFactor` `input:touchpad:scroll_factor` | 1.0 | Multiplies how far a two-finger scroll travels. Range 0.1–4.0. |
|
||||||
| **Drag lock**<br>`touchpadDragLock` `input:touchpad:drag_lock` | 0 | Keeps a tap-and-drag active when you lift a finger mid-drag Choices: Off, On, On, until you tap again. |
|
| **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 |
|
| **Middle-click by pressing both buttons**<br>`touchpadMiddleButtonEmulation` `input:touchpad:middle_button_emulation` | false | Pressing left and right together acts as a middle click |
|
||||||
|
| **Two-finger right-click**<br>`touchpadClickfinger` `input:touchpad:clickfinger_behavior` | false | One, two, or three fingers pressing down give left, right, and middle click, instead of clicking by which part of the pad you press |
|
||||||
|
| **Tap and drag**<br>`touchpadTapAndDrag` `input:touchpad:tap-and-drag` | true | A tap followed straight away by a tap-and-hold starts a drag, with nothing pressed down |
|
||||||
| **Swipe distance**<br>`swipeDistance` `gestures:workspace_swipe_distance` | 300 px | How far a three-finger swipe must travel to change workspace. Range 100–800. |
|
| **Swipe distance**<br>`swipeDistance` `gestures:workspace_swipe_distance` | 300 px | How far a three-finger swipe must travel to change workspace. Range 100–800. |
|
||||||
| **Natural swipe direction**<br>`swipeInvert` `gestures:workspace_swipe_invert` | true | Swiping left moves to the workspace on the right, as content follows your fingers |
|
| **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
|
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
|
more chances for it. Keep the harness free of anything that turns the volume
|
||||||
up.
|
up.
|
||||||
|
|
||||||
|
## Phase 8 (Input) — append below
|
||||||
|
|
||||||
|
Spec: `2026-08-24-input-redesign.md`. Keyboard, Mouse & Touchpad and Dictation
|
||||||
|
were rebuilt around keycaps, dropdowns and a searchable shortcuts browser, and
|
||||||
|
seven new compositor-backed preference keys landed with them.
|
||||||
|
|
||||||
|
**Nothing here was run against a live harness.** Three agents edited the tree
|
||||||
|
concurrently. What *was* verified is listed per contract below: `bash -n` on
|
||||||
|
every changed contract, the two source-only contracts run end to end, and the
|
||||||
|
three compositor-shape contracts replayed offline — `hyprctl descriptions` and
|
||||||
|
`hyprctl getoption` are read-only queries, so their answers were captured once
|
||||||
|
and the contract logic replayed against that snapshot with a stub on `PATH`,
|
||||||
|
never against the running compositor mid-edit.
|
||||||
|
|
||||||
|
### New contracts (0)
|
||||||
|
|
||||||
|
None. The redesign added rows, components and schema keys to surfaces that
|
||||||
|
already had contracts, so the README count line stays at **169** and
|
||||||
|
`setup/readme-contract` needs nothing. (`find` counts 169; the README says 169.)
|
||||||
|
|
||||||
|
### The seven new schema keys, per key
|
||||||
|
|
||||||
|
Each had to satisfy three contracts at once. Replayed against the landed
|
||||||
|
`PreferenceSchema.qml`, `hypr/input.lua` and a captured `hyprctl` snapshot:
|
||||||
|
|
||||||
|
| Key | Hyprland option | enum-hypr-map | schema-hypr-shape | hypr-prefs |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `focusOnClose` | `input:focus_on_close` | **PASS** — enum, all three published values offered | **PASS** — `readAs: "int"`, answers `int` | **PASS** — `prefs.getInt("focusOnClose", 0)`, def 0 |
|
||||||
|
| `scrollMethod` | `input:scroll_method` | **PASS** — string enum, every offered word in the published list | **PASS** — `readAs: "str"`, answers `str` | **PASS** — `prefs.get("scrollMethod", "")`, def `""` |
|
||||||
|
| `scrollButton` | `input:scroll_button` | n/a — `type: "int"`, not an enum | **PASS** — `readAs: "int"`, answers `int` | **PASS** — `prefs.get("scrollButton", 0)`, def 0 |
|
||||||
|
| `cursorHideWhileTyping` | `cursor:hide_on_key_press` | n/a — bool | **PASS** — `readAs: "bool"`, answers `bool` | **PASS** — def false |
|
||||||
|
| `cursorWarpOnWorkspaceChange` | `cursor:warp_on_change_workspace` | n/a — bool over an int option, so the enum rule does not reach it | **PASS** — `readAs: "int"`, answers `int` | **PASS** — `prefs.getInt(..., 0)` vs schema `false`; the contract's own true/false→1/0 normalization is what makes those agree |
|
||||||
|
| `touchpadClickfinger` | `input:touchpad:clickfinger_behavior` | n/a — bool | **PASS** — `readAs: "bool"` | **PASS** — def false |
|
||||||
|
| `touchpadTapAndDrag` | `input:touchpad:tap-and-drag` | n/a — bool | **PASS** — `readAs: "bool"` | **PASS** — def true |
|
||||||
|
|
||||||
|
Two of those are decisions, not just passes, and both are recorded in the
|
||||||
|
schema itself:
|
||||||
|
|
||||||
|
- `focusOnClose` was specced as a two-way choice. The compositor publishes
|
||||||
|
three (`{"mru":2},{"cursor":1},{"next":0}`) and **0 is what this desktop runs
|
||||||
|
today**, so a two-option dropdown would have hidden the shipped default from
|
||||||
|
its own control. enum-hypr-map-contract fails an enum that drops a published
|
||||||
|
value, and would have caught it — verified by deleting value 0 from a copy of
|
||||||
|
the schema and watching it fail with exactly that message.
|
||||||
|
- `cursorWarpOnWorkspaceChange` is a switch over an option with three states.
|
||||||
|
`force` (2) is deliberately unreachable from Settings. enum-hypr-map governs
|
||||||
|
enums only, so nothing fails — which is the point of writing it down here.
|
||||||
|
|
||||||
|
### Updated contracts (6)
|
||||||
|
|
||||||
|
| Contract | What it now pins | Verified |
|
||||||
|
|---|---|---|
|
||||||
|
| `quickshell/enum-hypr-map-contract` | **String-valued enums are now checked at all.** The parser only ever collected numeric `value:`s, so `accelProfile`, `masterOrientation`, `masterNewStatus` and `windowLayout` were silently skipped and `scrollMethod` would have been too. String options carry no `map`; Hyprland states their accepted words inside the description (`[2fg/edge/on_button_down/no_scroll]`), so those are parsed and checked **one way only**: an offered value the compositor does not name fails; a named value Settings does not offer does not, because that is a product decision (`accel_profile`'s `custom` needs a `scroll_points` curve and is a stated non-goal). Empty string is always allowed — it is how a schema entry says "leave the compositor's default", which is what `[[EMPTY]]` reads back as. Options with no bracket list print a line saying so and are skipped rather than failing. The numeric rules are untouched. | **Replayed offline** against a captured `hyprctl descriptions` (353 options) and the landed schema: PASS, 11 mapped enums (was 9). Both new failure directions exercised on a scratch copy — dropping `focus_on_close`'s value 0 fails, offering `"two_finger"` for `scroll_method` fails. |
|
||||||
|
| `quickshell/xkb-presets-contract` | Rewritten for the Advanced disclosure. The raw `keyboardOptions`, `keyboardVariant` **and `keyboardLayout`** fields must still be editable on the Keyboard page — matched as blocks, not one-liners, so nesting them inside an expander is fine — and **collapsed-but-present passes while absent fails**: the section must be named ("Advanced") and some `onClicked`/`onTriggered`/`onToggled` handler must actually open it, and no raw field may be pinned `visible: false`. XKB *values* stay pinned exactly (`caps:escape_shifted_capslock`, `caps:ctrl_modifier`, `compose:ralt`, `grp:win_space_toggle`) because moving one changes somebody's keyboard; row *labels* are now matched loosely and case-insensitively, because "Compose key" → "Compose" is a wording decision. The category-preservation rule, both helper signatures, and the three `input.lua`/schema default needles are unchanged. | **Run end to end** against the landed tree — it is source-only and touches neither compositor nor shell. PASS. Nested-block matching and the "no handler ⇒ fail" direction both exercised. |
|
||||||
|
| `quickshell/keybinds-contract` | A static presentation half ahead of the existing live count check, which a wall of 130 rows and a searchable browser pass identically. Page and `ShortcutRow.qml` are read as one source, so moving a control between them is not a failure: `Keybinds.grouped()` is what supplies the group order, `KeycapChord` is what draws chords, the filter exists and is case-insensitive and matches on `description`, a filtered list says how many of how many it is `showing`, the header count comes from `Keybinds.binds.length`, and the note explaining why there is no GNOME keyboard handoff survives. Count-match, description-completeness and chord-rendering rules are untouched. | **Static half replayed** against the landed `ShortcutsPage.qml` + `ShortcutRow.qml`: all needles hit. The compositor half is **deferred** — it boots a Quickshell harness. |
|
||||||
|
| `quickshell/keybind-rebind-contract` | UI needles added to the static half (the one that already runs under `PANAMA_KEYBINDS_STATIC_ONLY=1`). Page + row read as one source: `ShortcutCapture` is still what reads key presses (a page that grew its own handler would capture SUPER as a bind of its own), `boundTo` is called **before** `rebind` on the source line order, Change/Reset/`resetBind`/`resetAll`/`isOverridden` all still exist, binds are identified by `luaChord`, and no `rebind`/`resetBind` call is keyed by `description` — the regression that once moved every bind sharing one and cost the XF86Calculator key. The restore-all row and its live differs-count are pinned on the page. Engine needles and the whole live half are untouched. | **`PANAMA_KEYBINDS_STATIC_ONLY=1` run against the landed tree: PASS (static).** All 20 needles individually replayed. Live half **deferred**. |
|
||||||
|
| `quickshell/settings-pages-contract` | `Shortcuts` and `Mouse` added to the root-type/no-copied-Flickable sweep (neither page was ever in it), `mouse` added to the runtime routing sweep, and a hand-written check that all seven new keys render on `MousePage.qml` — by `setting: "key"` *or* by `commitPreference("key"`. That second spelling is why it is hand-written: dropdowns now render through `OptionPickerRow`, which takes label and options from `PreferenceSchema.spec()` and commits by name, and **has no `setting:` property at all**. Existing Home/Bar/Notifications/ScreenIntelligence pins unchanged. | **Static half run: PASS.** Routing sweep **deferred** — it starts an isolated Quickshell beside the live one. |
|
||||||
|
| `setup/dictation-contract` | **Every "where text lands" pin is unchanged and none of them conflicts with the on-page test.** They live on `panama-dictate` and `keybinds.lua` — `is_speech` rejecting `[BLANK_AUDIO]`, the guard actually being called before typing, the newline collapse, `wtype` tried before `wl-copy`, one press bind and one release bind — and nothing in them constrains which window has focus. The Try-it field sends the same `start`/`stop` the hotkey sends and merely holds keyboard focus while `wtype` types. What is new is two needles for the risk the test flow *did* introduce: the page must not spell out `scripts/panama-dictate` (the service publishes that path once, and a second copy would go stale silently, since the page's status readout comes from the service and would still be right), and a page that runs a `Process` must go through `Dictation.helper`. | **Run end to end** — it is greps plus a `python3` import of the helper, no compositor and no shell. PASS, including both new needles against the landed `DictationPage.qml`. |
|
||||||
|
|
||||||
|
### Verified against the new tree, no edit needed
|
||||||
|
|
||||||
|
- `quickshell/schema-hypr-shape-contract` — derives everything from
|
||||||
|
`option: "...", readAs: "..."` pairs in the schema, so the seven new keys
|
||||||
|
entered it the moment they landed. All seven extract cleanly and all seven
|
||||||
|
`readAs` values match what `hyprctl -j getoption` answers with. Two were easy
|
||||||
|
to get wrong and are worth naming: `cursor:warp_on_change_workspace` answers
|
||||||
|
`int` despite being a switch in the UI, and `input:scroll_method` answers
|
||||||
|
`str` despite the neighbouring `scroll_button` answering `int`.
|
||||||
|
- `tests/hypr/hypr-prefs-contract` — pure static, and **run**: ok, 77
|
||||||
|
compositor-owned keys read at config time, up from 70. All seven new keys
|
||||||
|
have a `prefs.get()`/`prefs.getInt()` in `config/dot/hypr/input.lua` with a
|
||||||
|
fallback equal to the schema default.
|
||||||
|
- `quickshell/gnome-handoff-contract` — needle-free by construction (it derives
|
||||||
|
both sides). **Run**: ok, 14 handoffs checked against 39 pages. The Keyboard
|
||||||
|
page still has no GNOME handoff and still explains why.
|
||||||
|
- `quickshell/schema-hypr-shape-contract`, `tests/hypr/hypr-prefs-contract` and
|
||||||
|
`quickshell/gnome-handoff-contract` are the three above. `setup/readme-contract`
|
||||||
|
is a fourth: no contract file was added or removed, `find` still counts 169,
|
||||||
|
and the README still claims 169.
|
||||||
|
|
||||||
|
### Docs updated in the same wave
|
||||||
|
|
||||||
|
- `services/SettingsSearch.qml` — three hand-written entries: **Rebind a
|
||||||
|
shortcut** (→ `shortcuts`), **Pointer test area** and **Connected input
|
||||||
|
devices** (→ `mouse`). "Key repeat" and "Scroll method" arrive automatically
|
||||||
|
from the schema, as the spec expected. Checked against
|
||||||
|
`settings-search-contract`'s fixed query list: of its 27 pinned queries only
|
||||||
|
`pointer` matches any new entry, and "Pointer test area" sorts *after*
|
||||||
|
"Pointer focus", "Pointer size" and "Pointer speed" in the same prefix rank,
|
||||||
|
so no pinned top result moves. Both new pages are leaves in
|
||||||
|
`SettingsRoutes`, so the "routes to a page anyone can land on" sweep holds.
|
||||||
|
- No settings docs or launcher commands were regenerated here — that is the
|
||||||
|
orchestrator's step after the schema settled.
|
||||||
|
|
||||||
|
### Still open before the run
|
||||||
|
|
||||||
|
- **`settings-ownership-contract` and `search-routing-contract` are now blind
|
||||||
|
to dropdown rows.** Both scan for `setting: "…"` inside a fixed list of row
|
||||||
|
types; `OptionPickerRow` is in neither list and carries no `setting:`
|
||||||
|
property. `accelProfile`, `followMouse`, `focusOnClose` and `scrollMethod`
|
||||||
|
are all invisible to them on the rebuilt `MousePage.qml`. Nothing fails
|
||||||
|
today — none of those keys is a duplicate — but a duplicate introduced
|
||||||
|
through a dropdown would not be caught. `settings-pages-contract` now pins
|
||||||
|
the seven new keys directly as a stopgap; the real fix is teaching both
|
||||||
|
scans the `PreferenceSchema.spec()` / `commitPreference()` spelling. Owner:
|
||||||
|
whoever holds those two contracts next.
|
||||||
|
- **`xkb-presets-contract` now requires a raw `keyboardLayout` field**, on the
|
||||||
|
reading that the layout dropdown's "Custom…" has to reveal somewhere the
|
||||||
|
code can actually be typed. It passes against the landed page. If the layout
|
||||||
|
editor is ever folded into the dropdown itself, that needle is the one to
|
||||||
|
revisit — the intent is "the raw code stays typeable", not "it is a
|
||||||
|
TextEntryRow".
|
||||||
|
- **`keybinds-contract` pins the literal word `showing`** in the filtered-count
|
||||||
|
line, because the spec names that wording ("showing N of M"). It is the one
|
||||||
|
prose needle in the new static half; everything else keys on structure.
|
||||||
|
- **Dictation's Try-it field and the clipboard fallback** — handled, but worth
|
||||||
|
knowing. `panama-dictate` falls back to `wl-copy` when `wtype` is missing, by
|
||||||
|
design, and on the Try-it row that means the words land on the clipboard
|
||||||
|
rather than in the field the page just focused. The page says so: a "Typing —
|
||||||
|
Missing" row appears when `Dictation.typingAvailable` is false. Nothing to
|
||||||
|
fix; worth a look during the run if a machine without `wtype` is around, since
|
||||||
|
that branch has never been seen.
|
||||||
|
- Run order for this phase: the two source-only contracts first
|
||||||
|
(`xkb-presets-contract`, `gnome-handoff-contract`), then the static halves
|
||||||
|
(`hypr-prefs-contract`, `PANAMA_KEYBINDS_STATIC_ONLY=1 keybind-rebind-contract`,
|
||||||
|
`PANAMA_SETTINGS_STATIC_ONLY=1 settings-pages-contract`), then the two
|
||||||
|
compositor-query contracts (`enum-hypr-map-contract`,
|
||||||
|
`schema-hypr-shape-contract` — read-only, but they want the real compositor),
|
||||||
|
then the harness contracts (`keybinds-contract`, `keybind-rebind-contract`
|
||||||
|
in full, `settings-search-contract`), and `settings-pages-contract` last, as
|
||||||
|
before: it starts an isolated Quickshell beside the live one and its own
|
||||||
|
cleanup is what protects the running session.
|
||||||
|
- `keybind-rebind-contract`'s live half rebinds Terminal to `SUPER + SHIFT +
|
||||||
|
F9` against the **real compositor** with an isolated `XDG_CONFIG_HOME`. That
|
||||||
|
was true before this phase and is unchanged, but it is the one contract in
|
||||||
|
this wave that writes to the running keymap, so it wants a quiet moment.
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# Input redesign — keycaps, search, and honest state
|
||||||
|
|
||||||
|
Approved mock: `home-mocks/input.html` (scratchpad, :8642). Spec wins over mock on conflict.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. **Shortcuts browser**: the 130-row wall becomes one searchable card — keycap chips, group
|
||||||
|
headers with counts, hover-revealed Change/Reset, inline capture. The rebinding engine
|
||||||
|
(`Keybinds.rebind`, conflicts refused, `keybindOverrides`) already exists and is untouched.
|
||||||
|
2. **Typing card modernized**: dropdowns instead of ChoiceGrid tile walls; raw XKB string and
|
||||||
|
variant into a collapsed Advanced section (still discoverable — a contract requires the raw
|
||||||
|
string not be hidden *away*, collapsed-but-present satisfies it; C verifies the needle).
|
||||||
|
3. **Mouse & Touchpad**: dropdowns over wide segmented rows, four new option groups (below),
|
||||||
|
a live try-it area, and a phantom-filtered device list.
|
||||||
|
4. **Dictation status-first**: ready hero, hotkeys as keycaps (looked up live from Keybinds so a
|
||||||
|
rebind shows truthfully; literal fallback), guided setup steps with a real progress bar, and
|
||||||
|
an on-page test field that uses the existing typing pipeline.
|
||||||
|
|
||||||
|
Non-goals: per-device settings (phantom-heavy device list, zero plumbing — deliberately
|
||||||
|
skipped), editing keybinds.lua actions (chords only, as today), touch/tablet options
|
||||||
|
(no hardware), accel `custom` curves.
|
||||||
|
|
||||||
|
## New schema keys (group / hypr option — agent A verifies each option's exact name, type, and
|
||||||
|
value map against `hyprctl descriptions` before writing the entry; enum-hypr-map-contract and
|
||||||
|
schema-hypr-shape-contract must hold; every key also gets its `prefs.get()` read-back in
|
||||||
|
`config/dot/hypr/input.lua` with matching defaults — hypr-prefs-contract):
|
||||||
|
|
||||||
|
| Key | Group | Hyprland option | UI |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `focusOnClose` | pointer | `input:focus_on_close` | dropdown: "Most recently used" / "Under the pointer" |
|
||||||
|
| `cursorHideWhileTyping` | pointer | `cursor:hide_on_key_press` | toggle "Hide pointer while typing" |
|
||||||
|
| `cursorWarpOnWorkspaceChange` | pointer | `cursor:warp_on_change_workspace` | toggle "Jump pointer to the focused display" |
|
||||||
|
| `scrollMethod` | pointer | `input:scroll_method` | dropdown (offer only map-published values; include "On a held button" only if the map allows) |
|
||||||
|
| `scrollButton` | pointer | `input:scroll_button` | int row, visible only when scrollMethod is the button one |
|
||||||
|
| `touchpadClickfinger` | touchpad | `input:touchpad:clickfinger_behavior` | toggle "Two-finger right-click" |
|
||||||
|
| `touchpadTapAndDrag` | touchpad | `input:touchpad:tap-and-drag` | toggle "Tap and drag" |
|
||||||
|
|
||||||
|
Defaults = today's effective Hyprland/input.lua values so shipping changes nothing. If an
|
||||||
|
option's published map/type makes a row above impossible as specced (e.g. scroll_method values),
|
||||||
|
implement what the map allows and flag the difference loudly.
|
||||||
|
|
||||||
|
## Service work (A)
|
||||||
|
|
||||||
|
- `InputDevices.qml`: add `realMice` / `realKeyboards` — name-filtered lists (exclude
|
||||||
|
substrings: `consumer-control`, `virtual`, `video-bus`, `power-button`, `webcam`,
|
||||||
|
`audio`, `uinput`, plus dedupe transceiver siblings by prefix), each entry `{ name, pretty }`
|
||||||
|
(pretty = title-cased, dashes to spaces). Keep the existing flat lists and `hasTouchpad`
|
||||||
|
untouched (contract-pinned behavior).
|
||||||
|
- Expose `mainKeyboardLayout` (from the main keyboard's live layout string) for the devices card.
|
||||||
|
|
||||||
|
## UI (B)
|
||||||
|
|
||||||
|
**ShortcutsPage.qml** (title stays "Keyboard"):
|
||||||
|
1. *Typing* card: Layout dropdown (curated common layouts: English (US), English (UK), German,
|
||||||
|
French, Spanish, Nordic…, mapping to `keyboardLayout` codes; a stored code outside the list
|
||||||
|
renders as the raw code and the dropdown offers "Custom…" which reveals Advanced), Caps Lock
|
||||||
|
dropdown, Compose dropdown, Layout-switching dropdown (all four presets keep writing
|
||||||
|
`keyboardOptions` through the existing page-local XKB helpers — xkb-presets-contract),
|
||||||
|
combined Key repeat row (delay + rate sliders), Num Lock toggle, then **Advanced** expander:
|
||||||
|
raw `keyboardOptions` mono field + `keyboardVariant`.
|
||||||
|
2. *Shortcuts* card: header "130 bound · N changed" (live counts), subtitle "Click Change and
|
||||||
|
press the new keys. A shortcut another action holds is refused, never stolen.", filter field
|
||||||
|
(matches description + group, case-insensitive), grouped rows in `Keybinds.groupOrder` with
|
||||||
|
"showing N of M" when filtered; each row: description, CHANGED badge when overridden,
|
||||||
|
hover-revealed Change/Reset (Reset only on overridden), keycap chord. Capture swaps the
|
||||||
|
chord area for the existing `ShortcutCapture` inline. "Restore every shipped shortcut" row
|
||||||
|
stays, with the live differs-count detail. Keep the no-GNOME-handoff comment.
|
||||||
|
3. New component **KeycapChord.qml**: parses a display chord ("SUPER + SHIFT + Q") into keycap
|
||||||
|
chips — mono font, tabular figures, modifier caps tinted accent, "+" separators muted.
|
||||||
|
Reused by DictationPage.
|
||||||
|
|
||||||
|
**MousePage.qml**: Mouse card (speed slow/fast, Acceleration dropdown, Scroll speed, Natural
|
||||||
|
scrolling, Left-handed, Middle-click paste, Scroll method dropdown + conditional Scroll button
|
||||||
|
row) · Touchpad card (existing rows + the two new toggles; visible on `hasTouchpad`) ·
|
||||||
|
Pointer behavior card (Focus dropdown — the 4 followMouse values, focusOnClose dropdown,
|
||||||
|
hide-while-typing, hide-after slider with "Never" zero, warp toggle, Pointer size) ·
|
||||||
|
**Try it** card (new `InputTestArea.qml`: a scribble Canvas — repaints only on pointer motion,
|
||||||
|
cleared by a corner button — and a scrollable text strip; purely local, no compositor writes) ·
|
||||||
|
**Connected devices** card from `InputDevices.realKeyboards/realMice` (+ touchpad when
|
||||||
|
present), with the subtitle noting phantoms are filtered. Gestures rows fold into the Touchpad
|
||||||
|
card (swipe distance + invert) — the separate Gestures card goes.
|
||||||
|
|
||||||
|
**DictationPage.qml**: ready hero (state tile ✓ / … / ✗, title, model+size line) · hotkey rows
|
||||||
|
with KeycapChord, chords looked up from `Keybinds.binds` by matching the dictate descriptions
|
||||||
|
(fallback literals if not found) · **Try it** row: a read-only-styled TextField + "Test
|
||||||
|
dictation" button that focuses the field and drives `panama-dictate start`/`stop` through the
|
||||||
|
existing Dictation service — dictated text lands in the field via the normal wtype pipeline, no
|
||||||
|
new script plumbing; detail explains it types here instead of your document · Microphone
|
||||||
|
ActionRow → Sound (unchanged) · setup state replaces the hero with numbered steps (container
|
||||||
|
image / speech model with progress bar from `downloadFraction` / first transcription), driven
|
||||||
|
by the existing `phase` fields; errors keep their row.
|
||||||
|
|
||||||
|
## Search & docs (C)
|
||||||
|
|
||||||
|
Extra entries: "Rebind a shortcut" → shortcuts; "Key repeat" auto via schema; "Pointer test
|
||||||
|
area" → mouse; "Scroll method" auto; "Connected input devices" → mouse. Existing entries stay.
|
||||||
|
Docs + launcher commands regenerate after schema lands (orchestrator).
|
||||||
|
|
||||||
|
## Contracts (C — write, never run)
|
||||||
|
|
||||||
|
- `xkb-presets-contract`: verify/adjust needles for the Advanced placement (raw string must
|
||||||
|
remain present in the page source).
|
||||||
|
- `keybinds-contract` / `keybind-rebind-contract`: UI needles (Change/Reset/ShortcutCapture
|
||||||
|
usage) reconciled with the rebuilt page; count-match and conflict rules unchanged.
|
||||||
|
- `enum-hypr-map-contract` / `schema-hypr-shape-contract` / `hypr-prefs-contract`: the seven
|
||||||
|
new keys must satisfy all three (C statically replays where possible).
|
||||||
|
- `settings-pages-contract`, `gnome-handoff-contract` needles re-verified.
|
||||||
|
- `dictation-contract`: confirm the test-field flow doesn't violate the "where text lands" pins
|
||||||
|
(it uses the normal pipeline; the page merely owns focus). Flag, don't force, if it conflicts.
|
||||||
|
- Backlog spec: Phase 8 section.
|
||||||
|
|
||||||
|
## Agent ownership (parallel)
|
||||||
|
|
||||||
|
- **A**: `config/PreferenceSchema.qml` (7 new keys), `config/dot/hypr/input.lua`,
|
||||||
|
`services/InputDevices.qml`.
|
||||||
|
- **B**: `modules/settings/ShortcutsPage.qml`, `MousePage.qml`, `DictationPage.qml`, new
|
||||||
|
components (`KeycapChord.qml`, `InputTestArea.qml`, others as needed) + `modules/settings/qmldir`.
|
||||||
|
- **C**: `services/SettingsSearch.qml`, the contracts above, backlog spec, README count line
|
||||||
|
only if the count changes.
|
||||||
|
|
||||||
|
B programs against the schema keys and InputDevices API above; A must not change them without
|
||||||
|
updating this spec.
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# Notifications & Focus redesign — two tabs, nothing unbounded
|
||||||
|
|
||||||
|
Approved mock: `home-mocks/notifications.html` (scratchpad, :8642). This spec is the
|
||||||
|
implementation contract; where mock and spec disagree, the spec wins.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. **Bound the app list.** Recent senders + customized apps render up top; everything else sits
|
||||||
|
behind a collapsed, searchable "All apps" expander. Rows expand in place to their controls.
|
||||||
|
2. **Real per-app rules**: sound on/off, banners-vs-history, urgency override, forget.
|
||||||
|
3. **A real focus-mode editor**: create/rename/delete/reorder (order IS priority), trigger-kind
|
||||||
|
editing for all five kinds, schedules with day pills, chip-based interrupt lists.
|
||||||
|
4. **Two tabs**: Notifications | Focus, as category tabs like Shell.
|
||||||
|
|
||||||
|
Non-goals: lock-screen privacy (hyprlock cannot render notifications; contract-banned),
|
||||||
|
per-app counters (no data), time-based history retention, merging duplicate app identities
|
||||||
|
(show the raw id honestly instead).
|
||||||
|
|
||||||
|
## Rule shape (pinned — service and UI program against this)
|
||||||
|
|
||||||
|
`normalizedAppRule` in `Notifs.qml` grows from `{ enabled }` to:
|
||||||
|
|
||||||
|
| Field | Type | Default | Meaning |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `enabled` | bool | true | Off = rejected before tracking/history/unread/toast (unchanged) |
|
||||||
|
| `sound` | bool | true | false = `playBell` skips this app |
|
||||||
|
| `display` | string | `"banners"` | `"history"` = file in history + unread, no popup, no bell |
|
||||||
|
| `urgency` | string | `"auto"` | `"low"` / `"critical"` override what the app claims |
|
||||||
|
| `lastSeenMs` | int | 0 | stamped in `rememberApplication` on every notification |
|
||||||
|
| `name` | string | "" | display name cached at remember time (resolution stays live-first) |
|
||||||
|
| `icon` | string | "" | icon cached at remember time (DesktopEntries lookup, else `appIcon`) |
|
||||||
|
|
||||||
|
Unknown/stale fields (incl. the old lock-screen pair) keep being dropped on read. Old
|
||||||
|
`{enabled}`-only blobs stay valid — every new field is optional with the defaults above.
|
||||||
|
New API: `forgetApp(appId)` deletes the rule key outright.
|
||||||
|
`effectiveUrgency(notification)` — the app's `urgency` override applied over
|
||||||
|
`notification.urgency`; consumed by `playBell` (low = silent), the popup timeout choice
|
||||||
|
(critical duration), the DND breakthrough gate, and `NotificationCard`'s critical edge.
|
||||||
|
|
||||||
|
**Critical breakthrough**: new schema key `criticalBreaksThrough` (bool, def **false**, group
|
||||||
|
`notifications`, label "Critical alerts break through"). Popup gate becomes: show when
|
||||||
|
`!doNotDisturb || FocusModes.allows(appId) || (Settings.criticalBreaksThrough &&
|
||||||
|
effectiveUrgency(n) === critical)`.
|
||||||
|
|
||||||
|
## FocusModes API additions (pinned)
|
||||||
|
|
||||||
|
- `createMode(name)` → new mode `{ id: unique slug, name, enabled: true, triggers: [{kind:
|
||||||
|
"manual"}], silence: true, keepAwake: false, allow: [] }`, appended (lowest priority).
|
||||||
|
- `removeMode(id)`, `renameMode(id, name)` (non-empty, trimmed).
|
||||||
|
- `moveMode(id, delta)` — reorder; order is priority and the UI says so.
|
||||||
|
- `setTriggerKind(id, kind, fields)` — replaces the mode's `triggers` with one trigger of the
|
||||||
|
new kind. Kind-specific seeds: schedule → `{ start: "22:00", end: "07:00", days: [0..6] }`;
|
||||||
|
workspace → `{ id: 1 }`; fullscreen/game/manual → no fields. (Shipped modes each carry one
|
||||||
|
trigger; a hand-edited multi-trigger mode collapses to one on first kind change — the editor
|
||||||
|
edits `triggers[0]` and that is documented in a comment.)
|
||||||
|
- **Manual-mode semantics**: investigate how a manual-trigger mode activates today and PRESERVE
|
||||||
|
it exactly; if manual modes currently have no activation path besides `enabled`, the header
|
||||||
|
toggle keeps meaning `enabled` and the editor's "Turns on: Manually" detail explains that a
|
||||||
|
manual mode quiets things whenever it is switched on. Do not invent new activation machinery.
|
||||||
|
- Existing `setEnabled`/`update`/`reschedule`-style schedule + day editing semantics stay;
|
||||||
|
`withinWindow`, single-DND-ownership, and the gaming report-don't-silence rule are
|
||||||
|
contract-pinned and untouched.
|
||||||
|
|
||||||
|
## Routing (pinned)
|
||||||
|
|
||||||
|
`SettingsRoutes` category `notifications` gains tabs:
|
||||||
|
`[{ page: "notifications", label: "Notifications" }, { page: "focus", label: "Focus" }]`.
|
||||||
|
New leaf `focus` → new `FocusPage.qml`; `SettingsShell` case + Component. `groupPages`
|
||||||
|
`"focus"` moves `"notifications"` → `"focus"` (the focusModes entry renders there now).
|
||||||
|
`GamingPage`'s "Open Focus" action retargets `openSettings("focus")`.
|
||||||
|
`NotificationCard`'s "Notification settings" jump stays `"notifications"`.
|
||||||
|
|
||||||
|
## Notifications tab (NotificationsPage.qml rebuilt)
|
||||||
|
|
||||||
|
Lede unchanged. Cards:
|
||||||
|
1. **Quiet** — Do Not Disturb toggle; "Critical alerts break through" ToggleRow
|
||||||
|
(`criticalBreaksThrough`); "Quiet hours" ActionRow whose detail states the Sleep mode's
|
||||||
|
live schedule (or "not scheduled" when Sleep lacks/disabled) and whose button opens the
|
||||||
|
Focus tab (`openSettings("focus")`).
|
||||||
|
2. **Banners & history** — the four existing schema sliders (names contract-pinned; critical
|
||||||
|
zero renders "Never"); history count + Clear folded into the history row.
|
||||||
|
3. **Applications** — subtitle per mock. Sections:
|
||||||
|
- *Recent*: apps with `lastSeenMs` within 7 days, newest first.
|
||||||
|
- *Customized*: any app with a non-default field (and not already in Recent).
|
||||||
|
- *All apps (N)*: collapsed expander with an inline search field; alphabetical.
|
||||||
|
Rows: cached icon (fallback letter tile), name, subtitle (relative last-seen when known ·
|
||||||
|
state summary like "sound off"/"History only", else the raw appId), enabled toggle, chevron.
|
||||||
|
Expanded body: Play sound toggle · "Show as" dropdown (Banners & history / History only) ·
|
||||||
|
"Urgency" dropdown (App decides / Treat as low / Treat as critical) · "Forget this app"
|
||||||
|
ActionRow (detail: "Remove its rule; it returns on its next notification").
|
||||||
|
4. **Active-mode banner** at top (ok-tinted) when a focus mode is active: "<name> is quieting
|
||||||
|
notifications · because <reason> · <allow summary>", button "Open Focus".
|
||||||
|
|
||||||
|
## Focus tab (FocusPage.qml, new)
|
||||||
|
|
||||||
|
Lede: "Modes quiet this machine on their own terms — first matching mode wins, and the order
|
||||||
|
below is the priority."
|
||||||
|
1. **Focus modes** card — accordion (one open at a time): drag grip (reorder = priority; also
|
||||||
|
keyboard up/down on the grip), mode glyph, name, summary line ("Turns on <trigger summary> ·
|
||||||
|
silences everything except N apps" / "On now — <reason>"), enabled toggle, chevron.
|
||||||
|
Expanded: "Turns on" dropdown (five kinds) + kind fields (schedule start/end `TimeOfDayRow`-
|
||||||
|
style or validated HH:MM inputs + seven day pills; workspace id picker); "Silence
|
||||||
|
notifications" toggle; "May interrupt" chip row (chips with ×, "+ Add app" opens a searchable
|
||||||
|
picker over known apps — reuse the rules list's app universe); "Keep the screen awake"
|
||||||
|
toggle; Rename + Delete mode buttons. "+ New focus mode" dashed row at the bottom.
|
||||||
|
2. **Focus sessions** card — default duration segmented chips (25/45/60/90 →
|
||||||
|
`focusDurationMinutes`), Caffeine toggle, session status row + Start focus/Show controls
|
||||||
|
(existing behaviors move over unchanged).
|
||||||
|
|
||||||
|
Reuse existing row widgets and the Displays/Sound phase components (OptionPickerRow, etc.)
|
||||||
|
before inventing new ones. No continuously repainting animations. All new components get
|
||||||
|
qmldir lines in the same wave as first reference.
|
||||||
|
|
||||||
|
## Search & docs
|
||||||
|
|
||||||
|
Hand-written entries (page per target): Do Not Disturb, Quiet hours, Critical alerts break
|
||||||
|
through, Application notification rules, Forget an app's notifications, Per-app notification
|
||||||
|
sound → `notifications`; Focus modes detail already schema-indexed (now routes to `focus`),
|
||||||
|
plus Focus session duration → `focus` if not covered by the workspaces group move. Docs and
|
||||||
|
launcher commands regenerate after the schema lands (orchestrator's audit pass).
|
||||||
|
|
||||||
|
## Contracts (write, do NOT run — cite in the backlog for the next sweep)
|
||||||
|
|
||||||
|
- `notification-app-rules-contract`: extend the pinned rule shape to the table above (defaults,
|
||||||
|
optional back-compat, stale-field dropping incl. lock-screen pair), pin `forgetApp`, the
|
||||||
|
display="history" no-popup-no-bell path, sound=false no-bell, `effectiveUrgency` consumers,
|
||||||
|
and the breakthrough gate literal.
|
||||||
|
- `focus-modes-contract`: pin the new CRUD/reorder/trigger APIs, keep every existing pin
|
||||||
|
(conditions-not-alarms, DND ownership, gaming reports, exception list consulted + editable —
|
||||||
|
the editable needle moves to FocusPage).
|
||||||
|
- `settings-pages-contract` (page id list + component list), `settings-jump-contract`
|
||||||
|
(GamingPage → focus), `search-routing-contract` expectations, `settings-window-contract` if
|
||||||
|
it enumerates tabs.
|
||||||
|
- Backlog spec gains a Phase 7 section listing all of it.
|
||||||
|
|
||||||
|
## Agent ownership (parallel)
|
||||||
|
|
||||||
|
- **A — services**: `services/Notifs.qml`, `services/FocusModes.qml`,
|
||||||
|
`config/PreferenceSchema.qml` (one new key; comments above braces).
|
||||||
|
- **B — UI**: `modules/settings/NotificationsPage.qml`, new `modules/settings/FocusPage.qml` +
|
||||||
|
new components + `modules/settings/qmldir`, `services/SettingsRoutes.qml`,
|
||||||
|
`modules/settings/SettingsShell.qml`, `modules/notifications/NotificationCard.qml`
|
||||||
|
(effectiveUrgency), `modules/settings/GamingPage.qml` (Open Focus target).
|
||||||
|
- **C — periphery**: `services/SettingsSearch.qml`, the contracts above + harness fixtures,
|
||||||
|
test-backlog spec, README count line only if the contract count changes.
|
||||||
|
|
||||||
|
B programs against the pinned shapes; A must not change them without updating this spec.
|
||||||
@@ -17,6 +17,18 @@
|
|||||||
#
|
#
|
||||||
# So this checks the schema's enum values against that map, and against the
|
# 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.
|
# 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
|
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" \
|
jq -e 'type == "array" and length > 0' >/dev/null <<<"$descriptions" \
|
||||||
|| fail 'hyprctl descriptions did not return a list'
|
|| 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'
|
entries="$(python3 - "$schema" <<'PY'
|
||||||
import re, sys
|
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)
|
option = re.search(r'option:\s*"([^"]+)"', body)
|
||||||
if not option:
|
if not option:
|
||||||
continue
|
continue
|
||||||
values = re.findall(r'value:\s*(-?\d+)', body)
|
numbers = re.findall(r'value:\s*(-?\d+)\s*[,}]', body)
|
||||||
if not values:
|
strings = re.findall(r'value:\s*"([^"]*)"\s*[,}]', body)
|
||||||
continue
|
if numbers:
|
||||||
print(f"{name}\t{option.group(1)}\t{','.join(values)}")
|
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
|
PY
|
||||||
)"
|
)"
|
||||||
|
|
||||||
[[ -n "$entries" ]] || fail 'found no compositor-backed enums in the schema -- this contract is not reading it correctly'
|
[[ -n "$entries" ]] || fail 'found no compositor-backed enums in the schema -- this contract is not reading it correctly'
|
||||||
|
|
||||||
checked=0
|
checked=0
|
||||||
while IFS=$'\t' read -r key option values; do
|
while IFS=$'\t' read -r key option kind values; do
|
||||||
[[ -n "$key" ]] || continue
|
[[ -n "$key" ]] || continue
|
||||||
|
|
||||||
entry="$(jq -c --arg name "$option" '.[] | select(.name == $name)' <<<"$descriptions")"
|
entry="$(jq -c --arg name "$option" '.[] | select(.name == $name)' <<<"$descriptions")"
|
||||||
[[ -n "$entry" ]] || fail "$key maps to \"$option\", which the compositor does not publish"
|
[[ -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")"
|
map_values="$(jq -r 'if .map then (.map | map(to_entries[].value) | join(",")) else "" end' <<<"$entry")"
|
||||||
|
|
||||||
IFS=',' read -ra wanted <<<"$values"
|
IFS=',' read -ra wanted <<<"$values"
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ set -euo pipefail
|
|||||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
harness="$repo_dir/config/dot/quickshell/keybinds-harness.qml"
|
harness="$repo_dir/config/dot/quickshell/keybinds-harness.qml"
|
||||||
service="$repo_dir/config/dot/quickshell/services/Keybinds.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() {
|
fail() {
|
||||||
printf 'keybind rebind contract: %s\n' "$1" >&2
|
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" \
|
rg -Fq 'root.overrideOccupantFor(shipped, shipped)' "$service" \
|
||||||
|| fail 'resetBind does not check whether another override occupies its shipped chord'
|
|| 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
|
if [[ "${PANAMA_KEYBINDS_STATIC_ONLY:-0}" == "1" ]]; then
|
||||||
printf 'keybind rebind contract: PASS (static)\n'
|
printf 'keybind rebind contract: PASS (static)\n'
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -16,12 +16,56 @@ set -euo pipefail
|
|||||||
|
|
||||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
harness="$repo_dir/config/dot/quickshell/keybinds-harness.qml"
|
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() {
|
fail() {
|
||||||
printf 'keybinds contract: %s\n' "$1" >&2
|
printf 'keybinds contract: %s\n' "$1" >&2
|
||||||
exit 1
|
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_for_harness() {
|
||||||
qs -p "$harness" "$@"
|
qs -p "$harness" "$@"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ fail() {
|
|||||||
exit 1
|
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
|
for page in "${pages[@]}"; do
|
||||||
page_file="$repo_dir/config/dot/quickshell/modules/settings/${page}Page.qml"
|
page_file="$repo_dir/config/dot/quickshell/modules/settings/${page}Page.qml"
|
||||||
[[ -f "$page_file" ]] || fail "${page}Page.qml is missing"
|
[[ -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)
|
raise SystemExit(0 if block and re.search(r'zeroLabel\s*:\s*"Never"', block.group(0)) else 1)
|
||||||
PY
|
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"
|
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
|
# 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
|
# 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.
|
# 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
|
# 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.
|
# 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
|
for page in "${pages[@]}"; do
|
||||||
qs_for_test ipc call settings page "$page" >/dev/null
|
qs_for_test ipc call settings page "$page" >/dev/null
|
||||||
for _ in $(seq 1 20); do
|
for _ in $(seq 1 20); do
|
||||||
|
|||||||
@@ -3,6 +3,21 @@
|
|||||||
# Common XKB behavior should be discoverable without hiding the raw option
|
# 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
|
# string from advanced users. This is source-only so it never remaps the live
|
||||||
# keyboard while the desktop is in use.
|
# 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
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -16,26 +31,91 @@ fail() {
|
|||||||
exit 1
|
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 \
|
for needle in \
|
||||||
'function currentXkbOption(prefix: string): string' \
|
'function currentXkbOption(prefix: string): string' \
|
||||||
'function setXkbOption(prefix: string, option: string): void' \
|
'function setXkbOption(prefix: string, option: string): void' \
|
||||||
'label: "Caps Lock"' \
|
|
||||||
'value: "caps:escape_shifted_capslock"' \
|
'value: "caps:escape_shifted_capslock"' \
|
||||||
'value: "caps:ctrl_modifier"' \
|
'value: "caps:ctrl_modifier"' \
|
||||||
'label: "Compose key"' \
|
|
||||||
'value: "compose:ralt"' \
|
'value: "compose:ralt"' \
|
||||||
'label: "Layout switching"' \
|
|
||||||
'value: "grp:win_space_toggle"' \
|
'value: "grp:win_space_toggle"' \
|
||||||
'SystemSettings.commitPreference("keyboardOptions"' \
|
'SystemSettings.commitPreference("keyboardOptions"'; do
|
||||||
'TextEntryRow { setting: "keyboardOptions"'; do
|
|
||||||
rg -Fq "$needle" "$page" || fail "Shortcuts is missing $needle"
|
rg -Fq "$needle" "$page" || fail "Shortcuts is missing $needle"
|
||||||
done
|
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
|
# Picking one category must replace only that category, preserving advanced
|
||||||
# options from every other group.
|
# options from every other group.
|
||||||
rg -Fq 'option.indexOf(prefix) !== 0' "$page" \
|
rg -Fq 'option.indexOf(prefix) !== 0' "$page" \
|
||||||
|| fail 'preset updates do not preserve unrelated XKB options'
|
|| 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" \
|
rg -Fq 'kb_variant = prefs.get("keyboardVariant", "")' "$input" \
|
||||||
|| fail 'Hyprland does not replay the stored keyboard variant'
|
|| fail 'Hyprland does not replay the stored keyboard variant'
|
||||||
rg -Fq 'key: "keyboardOptions", type: "string", def: "caps:escape_shifted_capslock"' "$schema" \
|
rg -Fq 'key: "keyboardOptions", type: "string", def: "caps:escape_shifted_capslock"' "$schema" \
|
||||||
|
|||||||
@@ -130,6 +130,25 @@ grep -q 'dictate("cancel")' "$keybinds" \
|
|||||||
grep -q '"status"' "$service" \
|
grep -q '"status"' "$service" \
|
||||||
|| note 'the settings service never asks the helper what is installed'
|
|| 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
|
if (( ${#findings[@]} > 0 )); then
|
||||||
mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u)
|
mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u)
|
||||||
printf 'dictation contract: %d finding(s)\n' "${#findings[@]}" >&2
|
printf 'dictation contract: %d finding(s)\n' "${#findings[@]}" >&2
|
||||||
|
|||||||
Reference in New Issue
Block a user