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

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 15:21:01 -04:00
parent b8f88a91f3
commit aba2d16ffa
25 changed files with 2346 additions and 191 deletions
+46
View File
@@ -35,6 +35,13 @@ hl.config({
-- Still focus-follows-pointer, just less twitchy.
mouse_refocus = false,
-- 0 = NEXT, the compositor's own default: focus goes to the next window
-- in the layout order. `hyprctl descriptions` publishes
-- map: [{"mru":2},{"cursor":1},{"next":0}], and Settings offers all
-- three; 0 is repeated here so nothing changes on a machine with no
-- settings file.
focus_on_close = prefs.getInt("focusOnClose", 0),
-- Flat pointer response by default, no acceleration. Matters for
-- gaming; Settings offers adaptive for people who want it back.
sensitivity = prefs.get("pointerSensitivity", 0),
@@ -44,6 +51,21 @@ hl.config({
scroll_factor = prefs.get("scrollFactor", 1.0),
left_handed = prefs.get("leftHanded", false),
-- Empty is a value, not an omission: it is what `hyprctl getoption`
-- reports as "[[EMPTY]]" before anything writes the option, and it means
-- "let libinput pick per device" -- two fingers on a touchpad, the wheel
-- on a mouse. Writing it back explicitly is the same branch a stock
-- Hyprland takes, and it keeps the setting reversible: without an empty
-- choice in the schema there would be no way back from a scroll method
-- once one was picked. Settings offers 2fg / edge / on_button_down /
-- no_scroll alongside it, the four words the option's own description
-- names (it publishes no map).
scroll_method = prefs.get("scrollMethod", ""),
-- Only consulted while scroll_method is on_button_down. 0 means the
-- device's own middle button.
scroll_button = prefs.get("scrollButton", 0),
-- Clicking a floating window raises and focuses it.
float_switch_override_focus = 2,
@@ -61,9 +83,33 @@ hl.config({
scroll_factor = prefs.get("touchpadScrollFactor", 1.0),
drag_lock = prefs.getInt("touchpadDragLock", 0),
middle_button_emulation = prefs.get("touchpadMiddleButtonEmulation", false),
clickfinger_behavior = prefs.get("touchpadClickfinger", false),
-- Underscores here, hyphens in the option name getoption answers to
-- (input:touchpad:tap-and-drag) -- the same split tap_to_click has
-- at the top of this table. Hyprland's own default is true; it is
-- repeated rather than omitted so the schema, the Lua, and the
-- compositor all state the same value.
tap_and_drag = prefs.get("touchpadTapAndDrag", true),
},
},
-- Pointer BEHAVIOUR, as opposed to pointer appearance: how the cursor reacts
-- to typing and to workspace switches. The cursor's looks -- theme, size,
-- hardware cursors, the inactivity fade -- are a separate cursor table in
-- looks.lua. hl.config calls are additive per option, so the two tables
-- coexist; they are split by what a person would go looking for, and these
-- two appear on Settings' Mouse & Touchpad page rather than in Appearance.
cursor = {
hide_on_key_press = prefs.get("cursorHideWhileTyping", false),
-- An integer with three states, written from a switch: disable = 0,
-- enable = 1, force = 2. Settings offers the first two, so getInt is
-- what bridges a stored boolean to the number Hyprland wants -- the same
-- pairing render.cm_auto_hdr uses in looks.lua.
warp_on_change_workspace = prefs.getInt("cursorWarpOnWorkspaceChange", 0),
},
-- Tuning for the three-finger gestures registered below.
gestures = {
workspace_swipe_distance = prefs.getInt("swipeDistance", 300),
@@ -757,6 +757,88 @@ Singleton {
detail: "Paste the primary selection in GTK and native Wayland applications",
hypr: { path: ["misc", "middle_click_paste"], option: "misc:middle_click_paste", readAs: "bool" }
},
{
key: "focusOnClose", type: "enum", def: 0, group: "pointer",
label: "Focus after closing",
detail: "Which window takes keyboard focus when the focused one goes away",
// Designed as a two-way choice; the compositor publishes three.
// map: [{"mru":2},{"cursor":1},{"next":0}]
// and 0 -- the value this desktop runs on today -- is "next in the
// stack", which is neither of the two the design named. Hiding it
// would make the shipped default unreachable from its own dropdown,
// and enum-hypr-map-contract refuses an enum that drops a published
// value for exactly that reason.
options: [
{ value: 0, label: "Next in the stack",
detail: "Whichever window Hyprland has next in the layout order" },
{ value: 1, label: "Under the pointer",
detail: "Whatever window the pointer happens to be over" },
{ value: 2, label: "Most recently used",
detail: "The window you were on before this one" }
],
hypr: { path: ["input", "focus_on_close"], option: "input:focus_on_close", readAs: "int" }
},
{
key: "scrollMethod", type: "enum", def: "", group: "pointer",
label: "Scroll method",
detail: "How a pointing device turns movement into scrolling",
// No `map` is published for this one -- it is a plain string option,
// and the words it accepts live in its description instead:
// [2fg/edge/on_button_down/no_scroll].
//
// Unset is a real state rather than an absence, and it is the state
// Panama ships: getoption answers "[[EMPTY]]" until something writes
// the option, and an empty value means "whatever libinput picks for
// this device", which is the branch every stock Hyprland takes. So
// empty is offered as a choice of its own -- without it the setting
// would be a one-way door, and its default would be unreachable.
// Writing "" reads back as "" with set:true, the same round trip
// input:kb_variant has made for as long as it has been empty.
options: [
{ value: "", label: "Whatever suits the device",
detail: "Two fingers on a touchpad, the wheel on a mouse" },
{ value: "2fg", label: "Two fingers" },
{ value: "edge", label: "Along the edge of the touchpad" },
{ value: "on_button_down", label: "While a button is held" },
{ value: "no_scroll", label: "Never scroll" }
],
hypr: { path: ["input", "scroll_method"], option: "input:scroll_method", readAs: "str" }
},
{
key: "scrollButton", type: "int", def: 0, min: 0, max: 300, step: 1,
group: "pointer",
label: "Scroll button",
detail: "Which button is held to scroll, as an evdev code; 0 lets the device choose",
// The range is the compositor's own rather than a guess: descriptions
// gives min 0, max 300. Only meaningful while Scroll method is
// "While a button is held", which is a UI condition, not a schema one
// -- the value stays valid and stored either way.
hypr: { path: ["input", "scroll_button"], option: "input:scroll_button", readAs: "int" }
},
{
key: "cursorHideWhileTyping", type: "bool", def: false, group: "pointer",
label: "Hide pointer while typing",
detail: "The pointer vanishes on the next keystroke and returns when you move it",
// A `cursor:` option rather than an `input:` one, so its read-back in
// the Lua sits in a cursor table of its own; see hypr/input.lua.
hypr: { path: ["cursor", "hide_on_key_press"], option: "cursor:hide_on_key_press", readAs: "bool" }
},
{
key: "cursorWarpOnWorkspaceChange", type: "bool", def: false, group: "pointer",
label: "Jump pointer to the focused display",
detail: "Moves the pointer to the last focused window after switching workspace",
// A switch here, an integer in the compositor -- the same shape
// autoHdr has, and `readAs: "int"` is what keeps the two sides in
// agreement. The published map is
// map: [{"force":2},{"enable":1},{"disable":0}]
// and "force" -- warp even when the pointer is already on that
// display -- is deliberately not offered: a third state would turn a
// switch into a dropdown for a distinction almost nobody wants.
// enum-hypr-map-contract governs enums only, so this is a decision
// rather than a violation, but it IS a decision: value 2 is not
// reachable from Settings.
hypr: { path: ["cursor", "warp_on_change_workspace"], option: "cursor:warp_on_change_workspace", readAs: "int" }
},
// ── Touchpad ────────────────────────────────────────────────────────
//
@@ -814,6 +896,28 @@ Singleton {
detail: "Pressing left and right together acts as a middle click",
hypr: { path: ["input", "touchpad", "middle_button_emulation"], option: "input:touchpad:middle_button_emulation", readAs: "bool" }
},
{
key: "touchpadClickfinger", type: "bool", def: false, group: "touchpad",
label: "Two-finger right-click",
detail: "One, two, or three fingers pressing down give left, right, and middle click, instead of clicking by which part of the pad you press",
hypr: { path: ["input", "touchpad", "clickfinger_behavior"], option: "input:touchpad:clickfinger_behavior", readAs: "bool" }
},
{
key: "touchpadTapAndDrag", type: "bool", def: true, group: "touchpad",
label: "Tap and drag",
detail: "A tap followed straight away by a tap-and-hold starts a drag, with nothing pressed down",
// Hyphens in the option name, underscores in the Lua path -- the same
// split tap-to-click documents above, and the only other option in
// the touchpad section spelled that way.
//
// `hyprctl descriptions` contradicts itself here: it reports current
// false while `hyprctl getoption` answers bool true with set:false,
// meaning nothing has ever written it and it is sitting on
// Hyprland's own default of true. getoption is the authority, since
// it is what the write path verifies against, so true is what ships
// and nothing changes on a machine that has a touchpad.
hypr: { path: ["input", "touchpad", "tap_and_drag"], option: "input:touchpad:tap-and-drag", readAs: "bool" }
},
// Tuning for the three-finger gestures registered in hypr/input.lua.
// The gestures themselves are not settings: Hyprland reads a gesture
@@ -1,68 +1,440 @@
import QtQuick
import Quickshell.Io
import qs.config
import qs.services
// Dictation is an input method, so it lives under Input beside the keyboard —
// but it listens through whichever device the Sound page selects, so the
// microphone card below hands off there rather than duplicating the picker.
//
// Status first. The two questions this page exists to answer are "is it ready"
// and "which keys" -- both were previously spelled as a pair of rows reading
// "Ready / Missing" beside a hardcoded sentence about Super+D. The hotkeys are
// now looked up from the live keymap, so rebinding the dictation key changes
// what this page says instead of quietly making it wrong.
SettingsPage {
title: "Dictation"
lede: Dictation.ready
? "Hold Super+D, speak, and release. The words are typed where the cursor is."
: "Speech to text, on the GPU, once the one-time setup below has run."
id: root
SettingsCard {
title: "Dictation"
subtitle: Dictation.ready
? "Hold Super+D, speak, and release. The words are typed where the cursor is."
: "The one-time setup fetches a speech server and a ~490 MB model — neither ships with Panama, because both are large and want the network."
// Chords come from the compositor by matching the descriptions
// hypr/keybinds.lua gives the dictation binds. The literals are a fallback
// for the moment before the keymap has loaded, not a second source of truth.
function chordFor(needle: string, fallback: string): string {
for (const bind of Keybinds.binds) {
if (String(bind.description).toLowerCase().indexOf(needle) >= 0)
return String(bind.chord);
}
return fallback;
}
// Status of the two pieces, read from the machine rather than guessed.
TextRow {
label: "Speech server"
readonly property string holdChord: root.chordFor("dictate (hold to talk)", "Super + D")
readonly property string cancelChord: root.chordFor("cancel dictation", "Super + Shift + D")
readonly property int modelMegabytes: Math.round(Dictation.modelBytes / 1048576)
// What the setup is doing, as steps rather than a percentage with no
// subject. Driven entirely by the service's existing phase and progress
// fields -- nothing new is asked of panama-dictate.
readonly property var setupSteps: [
{
number: 1,
title: "Container image",
detail: Dictation.imageBuilt
? "The speech server is on this machine"
: (Dictation.phase === "pulling" ? "Fetching the speech server…" : "About 1 GB, pulled once"),
done: Dictation.imageBuilt,
active: Dictation.phase === "pulling",
progress: -1
},
{
number: 2,
title: "Speech model",
detail: Dictation.modelInstalled
? root.modelMegabytes + " MB, kept across rebuilds of the server"
: (Dictation.phase === "downloading" && Dictation.downloadTotalBytes > 0
? "Downloading — " + Math.round(Dictation.downloadedBytes / 1048576)
+ " of " + Math.round(Dictation.downloadTotalBytes / 1048576) + " MB"
: "About 490 MB, downloaded once"),
done: Dictation.modelInstalled,
active: Dictation.phase === "downloading",
progress: Dictation.phase === "downloading" && Dictation.downloadTotalBytes > 0
? Dictation.downloadFraction
: -1
},
{
number: 3,
title: "First transcription",
detail: Dictation.serverReady
? "Running"
: (Dictation.imageBuilt ? "Ready — starts on the first dictation" : "Not set up yet")
value: Dictation.imageBuilt ? "Ready" : "Missing"
? "The speech server is answering"
: "The server starts itself the first time you hold the key",
done: Dictation.serverReady,
active: false,
progress: -1
}
]
// ── The on-page test ────────────────────────────────────────────────────
// panama-dictate types what it heard into whatever has keyboard focus, so
// a test needs no new plumbing: the field below takes focus, the helper
// runs exactly as the hotkey runs it, and the words arrive here. The helper
// is addressed through the path the Dictation service already publishes
// rather than one spelled again in this file.
property bool listening: false
Process {
id: dictateRun
onExited: (exitCode, exitStatus) => Dictation.refresh()
}
function runDictate(action: string): void {
if (dictateRun.running)
return;
dictateRun.command = [Dictation.helper, action];
dictateRun.running = true;
}
title: "Dictation"
lede: "Local speech to text — the audio, the model and the server never leave this machine."
// ── Ready ───────────────────────────────────────────────────────────────
SettingsCard {
visible: Dictation.ready
Item {
width: parent.width
height: 72
Rectangle {
id: readyTile
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: 44
height: 44
radius: 14
color: Theme.alpha(Theme.ok, 0.10)
border.width: 1
border.color: Theme.alpha(Theme.ok, 0.35)
Text {
anchors.centerIn: parent
text: "✓"
color: Theme.ok
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge + 4
}
}
Column {
anchors.left: readyTile.right
anchors.leftMargin: 16
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
width: parent.width
text: "Dictation is ready"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
}
Text {
width: parent.width
text: "Speech model · " + root.modelMegabytes + " MB · "
+ (Dictation.serverReady
? "the speech server is running"
: "the speech server starts on your first dictation")
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
}
}
TextRow {
label: "Speech model"
detail: Dictation.modelInstalled
? "Kept across rebuilds of the server"
: "About 490 MB, downloaded once"
value: Dictation.modelInstalled ? Math.round(Dictation.modelBytes / 1048576) + " MB" : "Missing"
SettingRow {
label: "Hold to dictate"
detail: "Release to type what you said, wherever the cursor is"
controlWidth: 220
KeycapChord {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
chord: root.holdChord
}
}
SettingRow {
label: "Cancel a dictation"
detail: "Throws the recording away without transcribing it"
controlWidth: 220
KeycapChord {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
chord: root.cancelChord
}
}
SettingRow {
label: "Try it"
detail: "Speak a sentence and it is typed into the field here, rather than into whatever you were working on"
controlWidth: 340
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: 200
height: 30
radius: 8
color: Theme.alpha(Theme.fg, 0.05)
border.width: 1
border.color: heard.activeFocus
? Theme.alpha(Theme.accent, 0.55)
: Theme.alpha(Theme.fg, 0.12)
TextInput {
id: heard
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 10
verticalAlignment: TextInput.AlignVCenter
clip: true
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
selectByMouse: true
selectionColor: Theme.alpha(Theme.accent, 0.35)
Text {
anchors.verticalCenter: parent.verticalCenter
visible: heard.text === ""
text: root.listening ? "Listening…" : "Dictated text lands here"
color: Theme.fgMuted
font: heard.font
}
}
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
text: root.listening ? "Stop and type it" : "Test dictation"
tone: root.listening ? "accent" : "normal"
onClicked: {
// Focus first, and keep it: the helper types with wtype
// into whatever holds keyboard focus when it finishes.
heard.forceActiveFocus();
if (root.listening) {
root.listening = false;
root.runDictate("stop");
return;
}
heard.text = "";
root.listening = true;
root.runDictate("start");
}
}
}
}
SettingRow {
visible: !Dictation.typingAvailable
label: "Typing"
detail: "wtype is missing, so dictated text goes to the clipboard instead of being typed"
value: "Missing"
divider: false
}
}
// ── Setting up ──────────────────────────────────────────────────────────
SettingsCard {
visible: !Dictation.ready
Item {
width: parent.width
height: 72
Rectangle {
id: setupTile
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: 44
height: 44
radius: 14
color: Dictation.downloading
? Theme.alpha(Theme.accent, 0.10)
: Theme.alpha(Theme.fg, 0.07)
border.width: 1
border.color: Dictation.downloading
? Theme.alpha(Theme.accent, 0.35)
: Theme.alpha(Theme.fg, 0.16)
Text {
anchors.centerIn: parent
text: Dictation.downloading ? "…" : "✗"
color: Dictation.downloading ? Theme.accent : Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge + 4
}
}
Column {
anchors.left: setupTile.right
anchors.leftMargin: 16
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
width: parent.width
text: Dictation.downloading ? "Setting up dictation" : "Dictation is not set up yet"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
}
Text {
width: parent.width
text: "Everything stays local: a speech server in a container, and a Whisper model. Neither ships with Panama — both are large and want the network."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
}
}
Repeater {
model: root.setupSteps
Item {
id: step
required property var modelData
width: parent ? parent.width : 620
height: 48
Rectangle {
id: number
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: 24
height: 24
radius: 12
color: {
if (step.modelData.done)
return Theme.alpha(Theme.ok, 0.15);
if (step.modelData.active)
return Theme.alpha(Theme.accent, 0.20);
return Theme.alpha(Theme.fg, 0.08);
}
Text {
anchors.centerIn: parent
text: step.modelData.done ? "✓" : String(step.modelData.number)
color: {
if (step.modelData.done)
return Theme.ok;
if (step.modelData.active)
return Theme.accent;
return Theme.fgDim;
}
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
}
Column {
anchors.left: number.right
anchors.leftMargin: 12
anchors.right: progress.left
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
width: parent.width
text: String(step.modelData.title)
color: step.modelData.done || step.modelData.active ? Theme.fg : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.Medium
elide: Text.ElideRight
}
Text {
width: parent.width
text: String(step.modelData.detail)
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
// A real bar rather than a spinner: this is the one part of
// setup whose length is actually known.
Rectangle {
id: progress
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: step.modelData.progress >= 0 ? 140 : 0
height: 5
radius: 3
visible: step.modelData.progress >= 0
color: Theme.alpha(Theme.fg, 0.09)
Rectangle {
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: parent.width * Math.max(0, Math.min(1, step.modelData.progress))
radius: parent.radius
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: Theme.accent }
GradientStop { position: 1.0; color: Theme.accentSecondary }
}
}
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 1
color: Theme.alpha(Theme.fg, 0.05)
}
}
}
// ONE action that actually works: panama-dictate setup pulls the
// server image and downloads the model together. This card used to
// offer a Download button wired to a command the helper does not have,
// and told you to build a "panama app whisper-vulkan" that does not
// exist -- so nothing here did anything. It does now.
// so nothing here did anything. It does now.
ActionRow {
visible: !Dictation.ready || Dictation.downloading
label: "Set up dictation"
detail: {
if (!Dictation.downloading)
return "Fetches the speech server and the model. Runs once, keeps both.";
if (Dictation.phase === "pulling")
return "Fetching the speech server…";
if (Dictation.downloadTotalBytes > 0)
return "Downloading model — " + Math.round(Dictation.downloadFraction * 100)
+ "% of " + Math.round(Dictation.downloadTotalBytes / 1048576) + " MB";
return "Setting up…";
}
detail: Dictation.downloading
? "Working — this can take several minutes on a slow connection"
: "Fetches the speech server and the model. Runs once, keeps both."
action: Dictation.downloading ? "Working…" : "Set up"
enabled: !Dictation.downloading
onTriggered: Dictation.setup()
divider: !Dictation.typingAvailable || Dictation.lastError !== ""
}
TextRow {
visible: !Dictation.typingAvailable
label: "Typing"
detail: "wtype is missing, so dictated text would go to the clipboard instead of being typed."
value: "Missing"
divider: Dictation.lastError !== ""
}
@@ -0,0 +1,180 @@
// Somewhere to try a pointer change before deciding to keep it.
//
// Pointer speed, acceleration, scroll direction and scroll speed are all
// settings you cannot read: the number means nothing, and the only honest test
// is moving the pointer. Every one of them applies live, so this is simply a
// place to move it that is not somebody's document.
//
// Entirely local. Nothing here writes a preference or touches the compositor,
// and the scribble is not saved anywhere -- it exists for the length of a
// question ("is that too fast?") and is thrown away.
//
// The canvas repaints only when the pointer has actually moved across it,
// driven from the motion handler rather than a timer. A settings page that
// repainted continuously would be a persistent GPU load on a high-refresh
// display, in return for a picture that had not changed.
import QtQuick
import qs.config
Column {
id: root
width: parent ? parent.width : 620
spacing: 10
Rectangle {
id: pad
width: parent.width
height: 150
radius: Theme.cardRadius
color: Theme.alpha(Theme.bgDark, 0.55)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.08)
clip: true
// Segments drawn since the last paint. The canvas keeps what it has
// already been given, so each paint adds the new piece of the stroke
// instead of redrawing the whole scribble.
property var pending: []
property bool wiping: false
property bool drawn: false
Canvas {
id: scribble
anchors.fill: parent
renderStrategy: Canvas.Immediate
onPaint: {
const context = scribble.getContext("2d");
if (pad.wiping) {
context.reset();
pad.wiping = false;
pad.pending = [];
return;
}
context.strokeStyle = Theme.accent;
context.lineWidth = 2.4;
context.lineCap = "round";
context.lineJoin = "round";
context.beginPath();
for (const segment of pad.pending) {
context.moveTo(segment.fromX, segment.fromY);
context.lineTo(segment.toX, segment.toY);
}
context.stroke();
pad.pending = [];
}
}
Text {
anchors.centerIn: parent
visible: !pad.drawn
text: "Draw here to feel pointer speed"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
MouseArea {
id: pointer
property real lastX: 0
property real lastY: 0
anchors.fill: parent
cursorShape: Qt.CrossCursor
// The page is a Flickable, which would otherwise take the drag off
// this and scroll instead of drawing.
preventStealing: true
onPressed: mouse => {
pointer.lastX = mouse.x;
pointer.lastY = mouse.y;
pad.drawn = true;
}
onPositionChanged: mouse => {
const segments = pad.pending;
segments.push({
fromX: pointer.lastX,
fromY: pointer.lastY,
toX: mouse.x,
toY: mouse.y
});
pad.pending = segments;
pointer.lastX = mouse.x;
pointer.lastY = mouse.y;
scribble.requestPaint();
}
}
SettingsButton {
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 8
visible: pad.drawn
text: "Clear"
onClicked: {
pad.wiping = true;
pad.drawn = false;
scribble.requestPaint();
}
}
}
Rectangle {
width: parent.width
height: 110
radius: Theme.cardRadius
color: Theme.alpha(Theme.bgDark, 0.55)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.08)
clip: true
Flickable {
id: strip
anchors.fill: parent
anchors.margins: 12
contentWidth: width
contentHeight: lines.implicitHeight
boundsBehavior: Flickable.StopAtBounds
Column {
id: lines
width: parent.width
spacing: 6
Repeater {
model: [
"Scroll here to feel scroll speed and direction.",
"Natural scrolling moves the content with your fingers.",
"The quick brown fox jumps over the lazy dog.",
"Line four.",
"Line five.",
"Line six.",
"Line seven — still scrolling.",
"Line eight.",
"Line nine.",
"Line ten, the bottom."
]
Text {
required property var modelData
width: lines.width
text: String(modelData)
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
}
}
}
}
@@ -0,0 +1,86 @@
// Key repeat: when a held key starts repeating, and how fast it goes.
//
// Two settings, one question. As separate rows they read as unrelated numbers
// with units nobody converts between; together, under one explanation, the
// pair is the single thing somebody came to change.
//
// The sliders are ordinary SliderRows, so the debounced commit, the refusal
// fallback and the schema's own labels and units all come along unchanged.
// This only puts them side by side, and stacks them when the window is too
// narrow to hold both.
import QtQuick
import qs.config
Column {
id: root
property bool divider: true
readonly property bool side: root.width >= 560
readonly property int columnWidth: root.side ? (root.width - 24) / 2 : root.width
width: parent ? parent.width : 620
spacing: 0
Item {
width: parent.width
height: copy.implicitHeight + 20
Column {
id: copy
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
spacing: 3
Text {
width: parent.width
text: "Key repeat"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.Medium
}
Text {
width: parent.width
text: "Hold a key: when repeating starts, and how fast it goes"
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
}
}
Flow {
width: parent.width
spacing: 24
SliderRow {
setting: "keyRepeatDelay"
width: root.columnWidth
divider: false
}
SliderRow {
setting: "keyRepeatRate"
width: root.columnWidth
divider: false
}
}
Item {
width: parent.width
height: 10
}
Rectangle {
width: parent.width
height: 1
visible: root.divider
color: Theme.alpha(Theme.fg, 0.065)
}
}
@@ -0,0 +1,109 @@
// A key chord, drawn as keys.
//
// KeycapChord { chord: "Super + Shift + Q" }
//
// The chord arrives as the display string Keybinds already produces and is
// split on "+" alone, so nothing here has to know what a keysym is. Modifiers
// are tinted and spelled in title case, which is what makes a wall of chords
// scannable: the eye finds the modifier pattern before it reads the key.
//
// Presentation only. The prettified names are never handed back -- a rebind is
// keyed by the Lua chord the service holds, and this must not become a second,
// lossy spelling of a binding.
import QtQuick
import qs.config
Row {
id: root
property string chord: ""
property int capHeight: 24
readonly property var keys: String(root.chord)
.split("+")
.map(part => part.trim())
.filter(part => part !== "")
// Case-insensitive: Keybinds renders "Super", an override is stored as
// "SUPER", and both have to tint.
readonly property var modifierNames: ({
"super": "Super",
"meta": "Super",
"win": "Super",
"shift": "Shift",
"ctrl": "Ctrl",
"control": "Ctrl",
"alt": "Alt"
})
function isModifier(key: string): bool {
return root.modifierNames[String(key).toLowerCase()] !== undefined;
}
function pretty(key: string): string {
return root.modifierNames[String(key).toLowerCase()] ?? key;
}
spacing: 4
Repeater {
model: root.keys
Row {
id: segment
required property var modelData
required property int index
readonly property bool modifier: root.isModifier(String(segment.modelData))
spacing: 4
Text {
anchors.verticalCenter: parent.verticalCenter
visible: segment.index > 0
text: "+"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: Math.max(26, cap.implicitWidth + 14)
height: root.capHeight
radius: 6
color: Theme.alpha(Theme.fg, 0.09)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.16)
// A keycap reads as a key because it has a lip. Rectangle
// borders are uniform, so the thicker bottom edge is its own
// sliver rather than a border width.
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: 1
height: 1
color: Theme.alpha(Theme.fg, 0.16)
}
Text {
id: cap
anchors.centerIn: parent
text: root.pretty(String(segment.modelData))
color: segment.modifier ? Theme.accent : Theme.fg
font.family: Theme.fontMono
// Digits in a chord ("Super + 1") sit in a column of other
// chords; tabular figures keep that column from wobbling.
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
}
}
}
}
@@ -10,6 +10,11 @@
// preference is stored and the compositor accepts an option for a device class
// with no members, so the user would be toggling settings that can never affect
// anything with nothing to say so.
//
// The gestures that were a card of their own now sit at the bottom of the
// touchpad card. They are touchpad settings -- a three-finger swipe has nowhere
// else to happen -- and a card holding two sliders was a heading standing in
// for a section.
import QtQuick
import qs.config
@@ -23,18 +28,100 @@ SettingsPage {
? "Pointer behavior for your mouse and touchpad."
: "Pointer behavior. Touchpad settings appear when a touchpad is attached."
// The name-filtered device lists InputDevices publishes. Defaulted here so
// the card stays a short honest list even before the service has read the
// compositor for the first time.
readonly property var realKeyboards: InputDevices.realKeyboards ?? []
readonly property var realMice: InputDevices.realMice ?? []
// Keyboards, then mice, then the touchpad if there is one. Built as one
// list so the card can draw dividers between rows without each Repeater
// having to know what follows it.
readonly property var devices: {
const out = [];
for (const keyboard of root.realKeyboards)
out.push({
glyph: "\u{F030C}", // md-keyboard
label: String(keyboard.pretty ?? keyboard.name ?? ""),
detail: InputDevices.mainKeyboardLayout === ""
? "Keyboard"
: "Keyboard · " + InputDevices.mainKeyboardLayout
});
for (const mouse of root.realMice)
out.push({
glyph: "\u{F037D}", // md-mouse
label: String(mouse.pretty ?? mouse.name ?? ""),
detail: "Mouse"
});
if (InputDevices.hasTouchpad)
out.push({
glyph: "\u{F0621}", // md-gesture-tap
label: "Touchpad",
detail: "Its own card above, because libinput keeps it separate"
});
return out;
}
readonly property var scrollMethodSpec: PreferenceSchema.spec("scrollMethod")
// Scroll button only means something while the method is the held-button
// one, and only if the schema still offers that method at all -- the value
// comes from what Hyprland's own description of input:scroll_method
// accepts, so this asks the schema rather than assuming.
readonly property bool heldButtonScrolling: {
const options = root.scrollMethodSpec && root.scrollMethodSpec.options
? root.scrollMethodSpec.options
: [];
if (!options.some(option => option.value === "on_button_down"))
return false;
return DesktopPreferences.get("scrollMethod") === "on_button_down";
}
SettingsCard {
title: "Mouse"
subtitle: "Applied to every pointing device that is not a touchpad."
SliderRow { setting: "pointerSensitivity" }
ChoiceRow { setting: "accelProfile" }
ToggleRow { setting: "naturalScroll" }
OptionPickerRow {
id: accelRow
readonly property var spec: PreferenceSchema.spec("accelProfile")
label: accelRow.spec ? accelRow.spec.label : "Acceleration"
detail: accelRow.spec ? accelRow.spec.detail : ""
options: accelRow.spec && accelRow.spec.options ? accelRow.spec.options : []
current: DesktopPreferences.get("accelProfile")
onPicked: value => SystemSettings.commitPreference("accelProfile", value)
}
SliderRow { setting: "scrollFactor" }
ToggleRow { setting: "naturalScroll" }
ToggleRow { setting: "leftHanded" }
ToggleRow {
setting: "middleClickPaste"
detail: "Paste the primary selection in GTK and native Wayland applications; individual apps may choose not to support it"
}
OptionPickerRow {
id: scrollMethodRow
label: root.scrollMethodSpec ? root.scrollMethodSpec.label : "Scroll method"
detail: root.scrollMethodSpec ? root.scrollMethodSpec.detail : ""
options: root.scrollMethodSpec && root.scrollMethodSpec.options
? root.scrollMethodSpec.options
: []
current: DesktopPreferences.get("scrollMethod")
divider: scrollButtonRow.visible
onPicked: value => SystemSettings.commitPreference("scrollMethod", value)
}
// Hidden on every other method: the number stays valid and stored, but
// a control that governs nothing is exactly what this page exists to
// stop shipping.
SliderRow {
id: scrollButtonRow
setting: "scrollButton"
visible: root.heldButtonScrolling
divider: false
}
}
@@ -45,27 +132,87 @@ SettingsPage {
subtitle: "Separate from the mouse on purpose: libinput keeps them apart, and a touchpad and a mouse usually want to scroll in opposite directions."
ToggleRow { setting: "touchpadTapToClick" }
ToggleRow { setting: "touchpadNaturalScroll" }
ToggleRow { setting: "touchpadDisableWhileTyping" }
SliderRow { setting: "touchpadScrollFactor" }
ToggleRow { setting: "touchpadClickfinger" }
ToggleRow { setting: "touchpadTapAndDrag" }
ChoiceRow { setting: "touchpadDragLock" }
ToggleRow { setting: "touchpadMiddleButtonEmulation"; divider: false }
}
SettingsCard {
visible: InputDevices.hasTouchpad
title: "Gestures"
subtitle: "Three fingers sideways moves between workspaces, up opens the overview, and down closes it — the same gestures GNOME used. Which gestures exist is fixed by the compositor at startup; what they feel like is here."
ToggleRow { setting: "touchpadNaturalScroll" }
SliderRow { setting: "touchpadScrollFactor" }
ToggleRow { setting: "touchpadDisableWhileTyping" }
ToggleRow { setting: "touchpadMiddleButtonEmulation" }
// Which gestures exist is fixed by the compositor at startup -- three
// fingers sideways moves between workspaces, up opens the overview.
// What they feel like is here.
SliderRow { setting: "swipeDistance" }
ToggleRow { setting: "swipeInvert"; divider: false }
}
SettingsCard {
title: "Pointer"
title: "Pointer behavior"
ChoiceRow { setting: "followMouse" }
OptionPickerRow {
id: followMouseRow
readonly property var spec: PreferenceSchema.spec("followMouse")
label: followMouseRow.spec ? followMouseRow.spec.label : "Pointer focus"
detail: followMouseRow.spec ? followMouseRow.spec.detail : ""
options: followMouseRow.spec && followMouseRow.spec.options ? followMouseRow.spec.options : []
current: DesktopPreferences.get("followMouse")
onPicked: value => SystemSettings.commitPreference("followMouse", value)
}
OptionPickerRow {
id: focusOnCloseRow
readonly property var spec: PreferenceSchema.spec("focusOnClose")
label: focusOnCloseRow.spec ? focusOnCloseRow.spec.label : "When a window closes"
detail: focusOnCloseRow.spec ? focusOnCloseRow.spec.detail : ""
options: focusOnCloseRow.spec && focusOnCloseRow.spec.options ? focusOnCloseRow.spec.options : []
current: DesktopPreferences.get("focusOnClose")
onPicked: value => SystemSettings.commitPreference("focusOnClose", value)
}
ToggleRow { setting: "cursorHideWhileTyping" }
SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never" }
ToggleRow { setting: "cursorWarpOnWorkspaceChange" }
SliderRow { setting: "cursorSize"; divider: false }
}
SettingsCard {
title: "Try it"
subtitle: "Every setting above applies live — draw and scroll to feel them. Nothing here is saved."
InputTestArea {}
}
SettingsCard {
title: "Connected devices"
subtitle: "What the compositor sees, minus the phantoms: transceiver siblings, virtual keyboards and consumer-control endpoints that are not devices anybody types on."
Repeater {
model: root.devices
TextRow {
id: deviceRow
required property var modelData
required property int index
width: parent ? parent.width : 620
icon: String(deviceRow.modelData.glyph)
label: String(deviceRow.modelData.label)
detail: String(deviceRow.modelData.detail)
divider: deviceRow.index < root.devices.length - 1
}
}
TextRow {
visible: root.devices.length === 0
label: "No input devices reported"
detail: "The compositor answered with nothing this page could name"
divider: false
}
}
}
@@ -0,0 +1,164 @@
// One shortcut in the shortcuts browser.
//
// There are around a hundred and thirty of these on the page at once, so the
// row is deliberately plain: no Loader for the row itself, no animations, and
// the Change/Reset buttons are ordinary children that hide when the pointer is
// elsewhere. Only the capture field is loaded on demand -- it is a FocusScope
// nobody needs until they ask to rebind, and instantiating one per row is a
// hundred and thirty focus scopes for a single edit.
//
// The row reports what was pressed and never decides anything: conflicts,
// refusals and the write itself belong to the page, which is where the one
// capture at a time is tracked.
import QtQuick
import qs.config
import qs.services
Item {
id: root
required property var bind
property bool capturing: false
property bool divider: true
// Shown inside the capture field -- the page puts a refused chord here.
property string message: ""
signal changeRequested
signal resetRequested
signal captured(string chord)
signal canceled
readonly property bool overridden: Keybinds.isOverridden(root.bind.luaChord)
width: parent ? parent.width : 620
implicitHeight: 42
Rectangle {
anchors.fill: parent
anchors.bottomMargin: 2
radius: 9
visible: hover.containsMouse || root.capturing
color: root.capturing
? Theme.alpha(Theme.accent, 0.10)
: Theme.alpha(Theme.fg, 0.05)
}
MouseArea {
id: hover
anchors.fill: parent
hoverEnabled: true
// Hover only. Accepting buttons here would put a click target over the
// whole row and swallow presses meant for Change and Reset.
acceptedButtons: Qt.NoButton
}
Text {
anchors.left: parent.left
anchors.leftMargin: 6
anchors.right: trailing.left
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: String(root.bind.description ?? "")
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
elide: Text.ElideRight
}
Row {
id: trailing
anchors.right: parent.right
anchors.rightMargin: 6
anchors.verticalCenter: parent.verticalCenter
visible: !root.capturing
spacing: 8
// Where it used to be. Only while the row is under the pointer: at rest
// the badge says a shortcut moved, and this says where from, which is
// the question the badge raises and nothing else answers.
Text {
anchors.verticalCenter: parent.verticalCenter
visible: root.overridden && hover.containsMouse
text: "was " + Keybinds.shippedChordFor(root.bind.luaChord)
color: Theme.fgMuted
font.family: Theme.fontMono
font.pixelSize: Theme.fontSizeSmall
}
Rectangle {
anchors.verticalCenter: parent.verticalCenter
visible: root.overridden
width: badge.implicitWidth + 14
height: 18
radius: Theme.pillRadius
color: Theme.alpha(Theme.warn, 0.10)
border.width: 1
border.color: Theme.alpha(Theme.warn, 0.32)
Text {
id: badge
anchors.centerIn: parent
text: "CHANGED"
color: Theme.warn
font.family: Theme.fontFamily
font.pixelSize: Math.max(8, Theme.fontSizeSmall - 2)
font.weight: Font.DemiBold
}
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
visible: hover.containsMouse
text: "Change"
// A scroll or click bind has no key to capture.
enabled: !Keybinds.reloading && !root.bind.mouse
onClicked: root.changeRequested()
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
visible: hover.containsMouse && root.overridden
text: "Reset"
enabled: !Keybinds.reloading
onClicked: root.resetRequested()
}
KeycapChord {
anchors.verticalCenter: parent.verticalCenter
chord: String(root.bind.chord ?? "")
}
}
Loader {
anchors.right: parent.right
anchors.rightMargin: 6
anchors.verticalCenter: parent.verticalCenter
width: 240
height: 30
active: root.capturing
// Focus has to travel through the Loader for the capture inside it to
// ever see a key press.
focus: root.capturing
sourceComponent: ShortcutCapture {
focus: true
message: root.message
onCaptured: chord => root.captured(chord)
onCanceled: root.canceled()
}
}
Rectangle {
anchors.left: parent.left
anchors.leftMargin: 6
anchors.right: parent.right
anchors.rightMargin: 6
anchors.bottom: parent.bottom
height: 1
visible: root.divider
color: Theme.alpha(Theme.fg, 0.05)
}
}
@@ -6,6 +6,13 @@
// and it went stale the moment a bind changed. Every bind now carries its own
// description in hypr/keybinds.lua, and this page just groups and renders them.
//
// A hundred and thirty rows is a wall, though, and one card per group made the
// page a mile long with the interesting part -- what a shortcut is bound to --
// rendered as grey text. So the list is one card with a filter over it, the
// chords are drawn as keys, and Change/Reset appear on the row under the
// pointer instead of on all hundred and thirty at once. None of the rebinding
// machinery moved: Keybinds still owns overrides, conflicts and the reload.
//
// The hardware settings above the list are real controls. Keyboard layout,
// repeat behavior, and pointer response are Hyprland's, so Panama owns them;
// device-specific configuration stays with GNOME.
@@ -13,6 +20,7 @@
import QtQuick
import qs.config
import qs.services
import qs.modules.clipboard
SettingsPage {
id: root
@@ -26,7 +34,14 @@ SettingsPage {
// itself. Held while the capture stays open so the message can name both.
property string conflict: ""
property string conflictChord: ""
// The raw XKB strings are a section rather than two rows in the middle of
// the card: they are how the presets above are actually stored, so they
// stay one click away rather than being replaced by them.
property bool advancedOpen: false
readonly property string storedXkbOptions: String(DesktopPreferences.get("keyboardOptions") ?? "")
readonly property string storedLayout: String(DesktopPreferences.get("keyboardLayout") ?? "")
function xkbOptions(): var {
return root.storedXkbOptions
@@ -46,53 +61,130 @@ SettingsPage {
SystemSettings.commitPreference("keyboardOptions", options.join(","));
}
// The layouts people actually pick, not the several hundred xkeyboard-config
// ships. Anything outside this list still shows -- as its own code, added
// below -- and Custom… opens the field that can set one.
readonly property var commonLayouts: [
{ value: "us", label: "English (US)" },
{ value: "gb", label: "English (UK)" },
{ value: "de", label: "German" },
{ value: "fr", label: "French" },
{ value: "es", label: "Spanish" },
{ value: "it", label: "Italian" },
{ value: "pt", label: "Portuguese" },
{ value: "br", label: "Portuguese (Brazil)" },
{ value: "se", label: "Swedish" },
{ value: "no", label: "Norwegian" },
{ value: "dk", label: "Danish" },
{ value: "fi", label: "Finnish" },
{ value: "nl", label: "Dutch" },
{ value: "pl", label: "Polish" },
{ value: "cz", label: "Czech" },
{ value: "ru", label: "Russian" },
{ value: "jp", label: "Japanese" }
]
readonly property var layoutOptions: {
const options = root.commonLayouts.slice();
// A layout this list does not carry -- "us,de", "dvorak" -- is shown as
// the code it is rather than silently reading as English (US).
if (root.storedLayout !== "" && !options.some(option => option.value === root.storedLayout))
options.push({
value: root.storedLayout,
label: root.storedLayout,
detail: "The layout this machine is set to"
});
options.push({
value: "__custom",
label: "Custom…",
detail: "Opens Advanced, where a layout list can be typed in full"
});
return options;
}
readonly property string filter: filterField.text
readonly property bool filtering: root.filter.trim() !== ""
readonly property int overrideCount: Object.keys(Keybinds.overrides).length
// Every group the compositor reports, in Keybinds' own order, narrowed by
// the filter. An empty filter narrows nothing: the page's job is to show
// the whole keymap, and searching is an extra rather than a gate.
readonly property var groups: {
const needle = root.filter.trim().toLowerCase();
const out = [];
for (const group of Keybinds.grouped()) {
const hits = needle === ""
? group.binds
: group.binds.filter(bind =>
String(bind.description).toLowerCase().indexOf(needle) >= 0
|| group.name.toLowerCase().indexOf(needle) >= 0);
if (hits.length > 0)
out.push({ name: group.name, binds: hits, total: group.binds.length });
}
return out;
}
function captureMessage(): string {
return root.conflict === ""
? ""
: root.conflictChord + " is already " + root.conflict;
}
title: "Keyboard"
lede: "The Forge mental model, carried forward into native tiling."
lede: "Layout, typing feel, and every shortcut the compositor has bound."
SettingsCard {
title: "Layout & typing"
title: "Typing"
// These were read-only text, on the grounds that a layout change needed
// a compositor reload. It does not: setting input:kb_variant through
// hl.config re-keymaps attached keyboards immediately -- verified by
// watching active_keymap on a real keyboard change and change back. So
// they are real controls.
TextEntryRow { setting: "keyboardLayout"; placeholder: "us" }
TextEntryRow { setting: "keyboardVariant"; placeholder: "none" }
OptionPickerRow {
label: "Layout"
detail: "What the keys produce, before any of the options below"
options: root.layoutOptions
current: root.storedLayout
onPicked: value => {
if (value === "__custom") {
root.advancedOpen = true;
return;
}
SystemSettings.commitPreference("keyboardLayout", value);
}
}
ChoiceGrid {
width: parent.width
OptionPickerRow {
label: "Caps Lock"
detail: "Keep it conventional, or turn a prime keyboard position into Escape or Control"
current: root.currentXkbOption("caps:")
detail: "What the Caps Lock key does"
options: [
{ value: "", label: "Standard" },
{ value: "caps:escape_shifted_capslock", label: "Esc · Shift for Caps" },
{ value: "caps:escape", label: "Escape" },
{ value: "caps:ctrl_modifier", label: "Control" }
{ value: "", label: "Standard", detail: "Caps Lock, as printed on the key" },
{ value: "caps:escape_shifted_capslock", label: "Esc · Shift for Caps",
detail: "Escape on its own; Shift and Caps Lock together still lock" },
{ value: "caps:escape", label: "Escape", detail: "Caps Lock becomes Escape entirely" },
{ value: "caps:ctrl_modifier", label: "Control", detail: "A second Control in a prime position" }
]
current: root.currentXkbOption("caps:")
onPicked: value => root.setXkbOption("caps:", value)
}
ChoiceGrid {
width: parent.width
OptionPickerRow {
label: "Compose key"
detail: "Type accented characters and symbols with memorable key sequences"
current: root.currentXkbOption("compose:")
options: [
{ value: "", label: "Off" },
{ value: "compose:ralt", label: "Right Alt" },
{ value: "compose:rwin", label: "Right Super" },
{ value: "compose:menu", label: "Menu" }
]
current: root.currentXkbOption("compose:")
onPicked: value => root.setXkbOption("compose:", value)
}
ChoiceGrid {
width: parent.width
OptionPickerRow {
label: "Layout switching"
detail: "Used when Keyboard layout contains more than one comma-separated layout"
current: root.currentXkbOption("grp:")
detail: "Used when the layout above contains more than one comma-separated layout"
options: [
{ value: "", label: "Off" },
{ value: "grp:win_space_toggle", label: "Super + Space" },
@@ -100,15 +192,67 @@ SettingsPage {
{ value: "grp:ctrl_shift_toggle", label: "Ctrl + Shift" },
{ value: "grp:caps_toggle", label: "Caps Lock" }
]
current: root.currentXkbOption("grp:")
onPicked: value => root.setXkbOption("grp:", value)
}
// Presets preserve every option outside their own category. The raw
// value remains visible for less common xkeyboard-config features.
TextEntryRow { setting: "keyboardOptions"; placeholder: "compose:ralt" }
SliderRow { setting: "keyRepeatDelay" }
SliderRow { setting: "keyRepeatRate" }
KeyRepeatRow {}
ToggleRow { setting: "numlockByDefault"; divider: false }
// Presets preserve every option outside their own category, and the raw
// value stays here rather than being replaced by them -- xkeyboard-config
// has hundreds of options and this card offers four.
Item {
width: parent.width
height: 40
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
height: 1
color: Theme.alpha(Theme.fg, 0.065)
}
Row {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: 8
Text {
anchors.verticalCenter: parent.verticalCenter
text: "Advanced"
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.Medium
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.advancedOpen ? "raw XKB options ▴" : "raw XKB options ▾"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.advancedOpen = !root.advancedOpen
}
}
Column {
width: parent.width
visible: root.advancedOpen
TextEntryRow { setting: "keyboardLayout"; placeholder: "us" }
TextEntryRow { setting: "keyboardOptions"; placeholder: "compose:ralt" }
TextEntryRow { setting: "keyboardVariant"; placeholder: "none"; divider: false }
}
}
// Deliberately no handoff to GNOME's keyboard panel here. That panel
@@ -118,129 +262,162 @@ SettingsPage {
// work sat further up this same page. A handoff to an inert panel is a
// dead end wearing a button.
// One card per group, built from what the compositor actually has bound.
//
// Both Repeaters address their model through an explicit id. Inside a
// SettingsCard the surrounding `parent` is the card's internal Column, not
// the card, so `parent.modelData` is undefined there and the rows silently
// never appear -- the cards render with their heading and nothing under it.
Repeater {
model: Keybinds.grouped()
SettingsCard {
title: "Shortcuts"
subtitle: "Click Change and press the new keys. A shortcut another action holds is refused, never stolen."
SettingsCard {
id: groupCard
Item {
width: parent.width
height: 44
required property var modelData
SearchField {
id: filterField
title: groupCard.modelData.name
subtitle: groupCard.modelData.binds.length === 1
? "1 shortcut"
: `${groupCard.modelData.binds.length} shortcuts`
anchors.left: parent.left
anchors.right: counts.left
anchors.rightMargin: 14
anchors.verticalCenter: parent.verticalCenter
placeholder: "Filter shortcuts — try “window” or “volume”"
}
Repeater {
id: bindRows
Text {
id: counts
model: groupCard.modelData.binds
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: Keybinds.binds.length + " bound · " + root.overrideCount + " changed"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
}
}
SettingRow {
id: bindRow
// One Column of Repeaters rather than a Loader per row: at a hundred and
// thirty rows the delegates are the page, and the cheapest row is the
// one that is simply an Item.
Repeater {
model: root.groups
required property var modelData
required property int index
Column {
id: groupColumn
readonly property bool capturing: root.capturingChord === bindRow.modelData.luaChord
readonly property bool overridden: Keybinds.isOverridden(bindRow.modelData.luaChord)
required property var modelData
label: bindRow.modelData.description
detail: bindRow.overridden
? "Moved from " + Keybinds.shippedChordFor(bindRow.modelData.luaChord)
: ""
controlWidth: 300
divider: bindRow.index < bindRows.count - 1
width: parent ? parent.width : 620
spacing: 0
Item {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 300
height: 30
Item {
width: parent.width
height: 30
ShortcutCapture {
anchors.right: parent.right
width: 230
height: 30
visible: bindRow.capturing
focus: bindRow.capturing
message: root.conflict === ""
? ""
: root.conflictChord + " is already " + root.conflict
// A chord already in use is reported rather than
// taken. Two actions on one chord means whichever
// Hyprland happens to read last wins, which is not
// a thing to discover later by pressing it.
onCaptured: chord => {
const taken = Keybinds.boundTo(chord, bindRow.modelData.luaChord);
if (taken !== "") {
root.conflict = taken;
root.conflictChord = chord;
return;
}
root.conflict = "";
Keybinds.rebind(bindRow.modelData.luaChord, chord);
root.capturingChord = "";
}
onCanceled: {
root.conflict = "";
root.capturingChord = "";
}
Row {
anchors.left: parent.left
anchors.leftMargin: 6
anchors.bottom: parent.bottom
anchors.bottomMargin: 4
spacing: 8
Text {
anchors.baseline: groupCount.baseline
text: groupColumn.modelData.name
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
font.capitalization: Font.AllUppercase
font.letterSpacing: 0.8
}
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
visible: !bindRow.capturing
spacing: 8
Text {
id: groupCount
Text {
anchors.verticalCenter: parent.verticalCenter
text: bindRow.modelData.chord
color: bindRow.overridden ? Theme.accent : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
text: root.filtering
? "· showing " + groupColumn.modelData.binds.length
+ " of " + groupColumn.modelData.total
: "· " + groupColumn.modelData.total
color: Theme.fgMuted
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
}
}
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
text: "Change"
enabled: !Keybinds.reloading && !bindRow.modelData.mouse
onClicked: root.capturingChord = bindRow.modelData.luaChord
}
Repeater {
id: bindRows
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
visible: bindRow.overridden
text: "Reset"
enabled: !Keybinds.reloading
onClicked: Keybinds.resetBind(bindRow.modelData.luaChord)
model: groupColumn.modelData.binds
ShortcutRow {
id: shortcutRow
required property var modelData
required property int index
bind: shortcutRow.modelData
capturing: root.capturingChord === shortcutRow.modelData.luaChord
message: shortcutRow.capturing ? root.captureMessage() : ""
divider: shortcutRow.index < bindRows.count - 1
onChangeRequested: {
root.conflict = "";
root.conflictChord = "";
root.capturingChord = shortcutRow.modelData.luaChord;
}
onResetRequested: Keybinds.resetBind(shortcutRow.modelData.luaChord)
// A chord already in use is reported rather than taken.
// Two actions on one chord means whichever Hyprland
// happens to read last wins, which is not a thing to
// discover later by pressing it.
onCaptured: chord => {
const taken = Keybinds.boundTo(chord, shortcutRow.modelData.luaChord);
if (taken !== "") {
root.conflict = taken;
root.conflictChord = chord;
return;
}
root.conflict = "";
Keybinds.rebind(shortcutRow.modelData.luaChord, chord);
root.capturingChord = "";
}
onCanceled: {
root.conflict = "";
root.capturingChord = "";
}
}
}
}
}
}
SettingsCard {
visible: Object.keys(Keybinds.overrides).length > 0
title: "Changed shortcuts"
subtitle: "Rebinding stores only the new chord; what a shortcut does always comes from the desktop's configuration."
Text {
width: parent.width
visible: root.groups.length === 0 && Keybinds.loaded
text: root.filtering
? "Nothing matches — the filter searches shortcut names and group names."
: "The compositor reported no shortcuts."
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
topPadding: 16
bottomPadding: 16
}
// Rebinding stores only the new chord; what a shortcut does always
// comes from the desktop's configuration.
ActionRow {
label: "Restore every shipped shortcut"
detail: Object.keys(Keybinds.overrides).length
+ (Object.keys(Keybinds.overrides).length === 1 ? " shortcut moved" : " shortcuts moved")
detail: root.overrideCount === 0
? "Every shortcut is where it shipped"
: root.overrideCount + (root.overrideCount === 1
? " shortcut differs from the shipped keymap"
: " shortcuts differ from the shipped keymap")
action: "Restore all"
divider: false
enabled: !Keybinds.reloading
enabled: root.overrideCount > 0 && !Keybinds.reloading
onTriggered: Keybinds.resetAll()
}
}
@@ -66,6 +66,10 @@ AutostartAppPicker 1.0 AutostartAppPicker.qml
DockPinsStrip 1.0 DockPinsStrip.qml
DockAppPicker 1.0 DockAppPicker.qml
ShortcutCapture 1.0 ShortcutCapture.qml
KeycapChord 1.0 KeycapChord.qml
ShortcutRow 1.0 ShortcutRow.qml
KeyRepeatRow 1.0 KeyRepeatRow.qml
InputTestArea 1.0 InputTestArea.qml
ChoiceGrid 1.0 ChoiceGrid.qml
DisplayModePicker 1.0 DisplayModePicker.qml
DisplayArrangement 1.0 DisplayArrangement.qml
@@ -16,6 +16,24 @@ pragma Singleton
// Read on demand rather than polled. Input devices do come and go -- a mouse is
// unplugged, a receiver is moved -- so this also refreshes when Hyprland says
// the device list changed, which is the only moment the answer can differ.
//
// Two views of the same data, deliberately:
//
// mice / keyboards every name libinput reports, lowercased. What the
// hardware-presence checks are built on, and what
// they must keep being built on -- a filter that
// dropped the wrong entry would hide a real control.
// realMice / realKeyboards the subset worth SHOWING a person, because the
// raw lists are mostly not devices. This machine
// reports seven "mice" and eighteen "keyboards" for
// two actual peripherals: a wireless receiver
// registers a mouse, a keyboard, three consumer
// controls and a system control, the webcam and the
// USB audio dongle each claim a keyboard, and the
// power button, the lid switch and every virtual
// typing tool are in there too. Listing all of that
// under "Connected devices" would be honest about
// libinput and useless about the desk.
import Quickshell
import Quickshell.Io
@@ -28,12 +46,69 @@ Singleton {
property var mice: []
property var keyboards: []
// The main keyboard's live layout, already in human form -- Hyprland
// resolves the XKB name for us and answers "English (US)", not "us". Empty
// until the first read finishes, so a page must treat it as optional.
property string mainKeyboardLayout: ""
// True when anything that looks like a touchpad is attached.
readonly property bool hasTouchpad: root.mice.some(name =>
name.includes("touchpad") || name.includes("trackpad"))
readonly property bool hasMouse: root.mice.length > 0
// Substrings that mark a name as an endpoint rather than a device. Matched
// anywhere in the name, because the interesting part of these is never at a
// fixed position: "generic-usb-audio-consumer-control-1" is caught twice
// over, and "onn-usb-2.0-webcam:-onn-usb-2.0" only by its middle.
//
// "fake" is here for `mouce-library-fake-mouse`, the phantom pointer the
// mouce input library registers. It is not a class of endpoint the way the
// others are, but it is precisely the kind of entry this list exists to keep
// off a card headed "Connected devices".
readonly property var phantomMarkers: [
"consumer-control", "virtual", "video-bus", "power-button",
"webcam", "audio", "uinput", "fake"
]
readonly property var realMice: root.realDevices(root.mice)
readonly property var realKeyboards: root.realDevices(root.keyboards)
// Names worth showing, as { name, pretty }.
//
// Two passes. The first drops endpoints by name. The second collapses
// siblings: one physical transceiver announces itself as
// "microsoft-...-v9.0" AND "microsoft-...-v9.0-system-control", and a
// keyboard as both its own name and "...-keyboard". Where one surviving
// name is a prefix of another, the shorter is the device and the longer is
// one of its endpoints, so the shortest of each family is the one kept.
function realDevices(names: var): var {
const kept = [];
const named = (names ?? [])
.filter(name => name && !root.phantomMarkers.some(marker => name.includes(marker)))
.sort((left, right) => left.length - right.length);
for (const name of named) {
if (kept.some(base => name.startsWith(base + "-")))
continue;
kept.push(name);
}
return kept.map(name => ({ name: name, pretty: root.prettyName(name) }));
}
// "keychron-keychron-q10" -> "Keychron Keychron Q10". Hyprland's names are
// lowercased and dash-joined; this is the smallest transform that makes one
// readable. A word starting with a digit ("2.4ghz") is left as it is rather
// than mangled.
function prettyName(name: string): string {
return String(name)
.split("-")
.map(word => word.length > 0 ? word[0].toUpperCase() + word.slice(1) : word)
.join(" ")
.trim();
}
function refresh(): void {
if (!query.running)
query.running = true;
@@ -49,6 +124,13 @@ Singleton {
const parsed = JSON.parse(this.text);
root.mice = (parsed.mice ?? []).map(device => String(device.name ?? "").toLowerCase());
root.keyboards = (parsed.keyboards ?? []).map(device => String(device.name ?? "").toLowerCase());
// Names are lowercased above because every consumer matches
// substrings against them. The keymap is not: it is already
// a display string, and "english (us)" would be a downgrade.
const boards = parsed.keyboards ?? [];
const main = boards.find(device => device.main === true) ?? boards[0];
root.mainKeyboardLayout = main ? String(main.active_keymap ?? "") : "";
} catch (error) {
console.warn("InputDevices: could not parse hyprctl devices:", error);
}
@@ -169,6 +169,12 @@ Singleton {
{ label: "Banners or history", detail: "Send one application straight to history without a popup", page: "notifications" },
{ label: "Focus session duration", detail: "How long a focus session runs before it ends itself", page: "focus" },
{ label: "Keyboard shortcuts", detail: "Every shortcut the compositor has bound", page: "shortcuts" },
// The rebinding flow has no schema entry of its own — it is a button on
// a row — so searching for the thing people actually want to do would
// otherwise find only the shortcut it is being done to.
{ label: "Rebind a shortcut", detail: "Change the keys an action answers to, or put them back", page: "shortcuts" },
{ label: "Pointer test area", detail: "Scribble and scroll to feel a pointer change before keeping it", page: "mouse" },
{ label: "Connected input devices", detail: "The keyboards, mice, and touchpad this machine can see", page: "mouse" },
{ label: "Dictation", detail: "Speech to text with Super+D, typed where the cursor is", page: "dictation" },
{ label: "Speech to text", detail: "Set up the local speech server and model", page: "dictation" },
{ label: "System Health", detail: "Check Panama services, integrations, tools, and recovery actions", page: "services" },