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