Extract PanelKeyCatcher, share key dispatch across wifi/audio/bluetooth

This commit is contained in:
Ryan Hughes
2026-05-17 23:14:12 -04:00
parent daed74733b
commit b751109c6b
5 changed files with 120 additions and 103 deletions
@@ -0,0 +1,73 @@
import QtQuick
// Drop-in key dispatcher for keyboard-driven panels. Wraps panel content
// and emits semantic signals so each panel keeps its own state machine
// (focusSection, selectedIndex, activation rules) while the boilerplate
// key handling lives here.
//
// Usage:
// Common.KeyboardPanel {
// ...
// PanelKeyCatcher {
// anchors.fill: parent
// onMoveRequested: function(dx, dy) { root.moveCursor(dx, dy) }
// onActivateRequested: root.activateCursor()
// onCloseRequested: root.closePopout()
// onDeleteRequested: root.deleteSelected()
// onTextKey: function(t) { if (t === "r") root.refresh() }
//
// Column { ... panel content ... }
// }
// }
//
// Keys.priority: Keys.AfterItem means a focused descendant (e.g. a
// TextField inside an inline password prompt) gets the event first. Only
// events the focused subtree ignores reach this handler — that's what
// lets j/k/Esc keep working in the panel while an input field consumes
// typing.
//
// blocked: when true, ALL keys are forwarded to descendants without
// triggering signals. Useful when an inline editor is open and the
// caller wants the cursor model frozen.
Item {
id: root
property bool blocked: false
signal moveRequested(int dx, int dy)
signal activateRequested()
signal closeRequested()
signal deleteRequested()
signal textKey(string text)
focus: true
Keys.priority: Keys.AfterItem
Keys.onPressed: function(event) {
if (blocked) return
if (event.key === Qt.Key_Escape) {
closeRequested(); event.accepted = true; return
}
if (event.key === Qt.Key_Down || event.text === "j") {
moveRequested(0, 1); event.accepted = true; return
}
if (event.key === Qt.Key_Up || event.text === "k") {
moveRequested(0, -1); event.accepted = true; return
}
if (event.key === Qt.Key_Right || event.text === "l") {
moveRequested(1, 0); event.accepted = true; return
}
if (event.key === Qt.Key_Left || event.text === "h") {
moveRequested(-1, 0); event.accepted = true; return
}
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_Space) {
activateRequested(); event.accepted = true; return
}
if (event.text === "x" || event.text === "X") {
deleteRequested(); event.accepted = true; return
}
if (event.text && event.text.length === 1) {
textKey(event.text)
}
}
}
@@ -4,6 +4,7 @@ ChoiceButton 1.0 ChoiceButton.qml
CursorSurface 1.0 CursorSurface.qml CursorSurface 1.0 CursorSurface.qml
KeyboardPanel 1.0 KeyboardPanel.qml KeyboardPanel 1.0 KeyboardPanel.qml
PanelActionButton 1.0 PanelActionButton.qml PanelActionButton 1.0 PanelActionButton.qml
PanelKeyCatcher 1.0 PanelKeyCatcher.qml
PanelSectionHeader 1.0 PanelSectionHeader.qml PanelSectionHeader 1.0 PanelSectionHeader.qml
PanelSeparator 1.0 PanelSeparator.qml PanelSeparator 1.0 PanelSeparator.qml
PanelSlider 1.0 PanelSlider.qml PanelSlider 1.0 PanelSlider.qml
@@ -375,37 +375,19 @@ Item {
contentWidth: 370 contentWidth: 370
contentHeight: Math.min(560, panelColumn.implicitHeight + 28) contentHeight: Math.min(560, panelColumn.implicitHeight + 28)
Item { PanelKeyCatcher {
id: keyCatcher id: keyCatcher
anchors.fill: parent anchors.fill: parent
focus: true onMoveRequested: function(dx, dy) {
Keys.priority: Keys.AfterItem if (dy !== 0) root.moveCursor(dy)
Keys.onPressed: function(event) { else if (dx !== 0) root.adjustVolume(dx * 0.05)
if (event.key === Qt.Key_Escape) { }
root.closePopout() onActivateRequested: root.activateCursor()
event.accepted = true onCloseRequested: root.closePopout()
return onTextKey: function(t) {
} // 'm' mutes whatever the cursor is on: focused section's slider
if (event.key === Qt.Key_Down || event.text === "j") { // for output/input, the focused stream for streams.
root.moveCursor(1); event.accepted = true; return if (t === "m" || t === "M") {
}
if (event.key === Qt.Key_Up || event.text === "k") {
root.moveCursor(-1); event.accepted = true; return
}
if (event.key === Qt.Key_Right || event.text === "l") {
root.adjustVolume(0.05); event.accepted = true; return
}
if (event.key === Qt.Key_Left || event.text === "h") {
root.adjustVolume(-0.05); event.accepted = true; return
}
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_Space) {
root.activateCursor(); event.accepted = true; return
}
// 'm' mutes whatever the cursor is on: focused section's slider for
// output/input, the focused stream for streams. Without the stream
// branch, m would mute the global output while the cursor sat on a
// per-app row — surprising and inconsistent with Enter behaviour.
if (event.text === "m" || event.text === "M") {
if (root.focusSection === "streams" && root.selectedIndex >= 0 if (root.focusSection === "streams" && root.selectedIndex >= 0
&& root.selectedIndex < root.audioStreams.length) { && root.selectedIndex < root.audioStreams.length) {
var s = root.audioStreams[root.selectedIndex] var s = root.audioStreams[root.selectedIndex]
@@ -415,7 +397,6 @@ Item {
} else { } else {
root.toggleOutputMute() root.toggleOutputMute()
} }
event.accepted = true
} }
} }
@@ -331,34 +331,16 @@ Item {
contentWidth: 320 contentWidth: 320
contentHeight: column.implicitHeight + 28 contentHeight: column.implicitHeight + 28
Item { PanelKeyCatcher {
id: keyCatcher id: keyCatcher
anchors.fill: parent anchors.fill: parent
focus: true onMoveRequested: function(dx, dy) {
Keys.priority: Keys.AfterItem if (dy !== 0) root.moveCursor(dy)
Keys.onPressed: function(event) { else if (dx !== 0) root.moveCursorH(dx)
if (event.key === Qt.Key_Escape) {
root.closePopout(); event.accepted = true; return
}
if (event.key === Qt.Key_Down || event.text === "j") {
root.moveCursor(1); event.accepted = true; return
}
if (event.key === Qt.Key_Up || event.text === "k") {
root.moveCursor(-1); event.accepted = true; return
}
if (event.key === Qt.Key_Right || event.text === "l") {
root.moveCursorH(1); event.accepted = true; return
}
if (event.key === Qt.Key_Left || event.text === "h") {
root.moveCursorH(-1); event.accepted = true; return
}
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_Space) {
root.activateCursor(); event.accepted = true; return
}
if (event.text === "x" || event.text === "X") {
root.deleteSelected(); event.accepted = true
}
} }
onActivateRequested: root.activateCursor()
onCloseRequested: root.closePopout()
onDeleteRequested: root.deleteSelected()
Column { Column {
id: column id: column
@@ -552,62 +552,42 @@ iwctl station "$station" get-networks rssi-dbms 2>/dev/null \\
// Catches all unhandled keys for keyboard navigation. AfterItem priority // Catches all unhandled keys for keyboard navigation. AfterItem priority
// lets the passphrase TextField (a child via focus chain) get its keys // lets the passphrase TextField (a child via focus chain) get its keys
// first; only events the focused subtree ignores bubble back here. // first; only events the focused subtree ignores bubble back here.
Item { PanelKeyCatcher {
id: keyCatcher id: keyCatcher
anchors.fill: parent anchors.fill: parent
focus: true // Freeze the cursor model while the inline password prompt is open;
Keys.priority: Keys.AfterItem // the TextField inside owns input until Esc/Enter/Cancel.
Keys.onPressed: function(event) { blocked: root.passwordSsid !== ""
if (root.passwordSsid !== "") return
if (event.key === Qt.Key_Escape) { onMoveRequested: function(dx, dy) {
root.closePopout() if (dy !== 0) {
event.accepted = true
return
}
if (event.text === "r" || event.text === "R") {
root.refresh()
event.accepted = true
return
}
if (root.focusSection === "dns") { if (root.focusSection === "dns") {
if (event.key === Qt.Key_Left || event.text === "h") { // j from DNS drops into the wifi list if there's anywhere to
root.selectDnsByDelta(-1) // land; otherwise hold position so j isn't a no-op surprise.
event.accepted = true if (dy > 0 && root.wifiNetworks.length > 0) {
} else if (event.key === Qt.Key_Right || event.text === "l") { root.focusSection = "wifi"
root.selectDnsByDelta(1) if (root.selectedIndex < 0) root.selectedIndex = 0
event.accepted = true
} else if (event.key === Qt.Key_Down || event.text === "j") {
// Drop into the wifi list if there's anything to land on;
// otherwise hold position so j isn't a no-op surprise.
if (root.wifiNetworks.length > 0) {
root.focusSection = "wifi"
if (root.selectedIndex < 0) root.selectedIndex = 0
}
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_Space) {
root.activateDns()
event.accepted = true
} }
return } else { // wifi
}
// focusSection === "wifi"
if (event.key === Qt.Key_Down || event.text === "j") {
root.selectByDelta(1)
event.accepted = true
} else if (event.key === Qt.Key_Up || event.text === "k") {
// k from the top row escapes back up into the DNS row rather // k from the top row escapes back up into the DNS row rather
// than wrapping around to the bottom of the list. // than wrapping around to the bottom of the list.
if (root.selectedIndex <= 0) root.focusSection = "dns" if (dy < 0 && root.selectedIndex <= 0) root.focusSection = "dns"
else root.selectByDelta(-1) else root.selectByDelta(dy)
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_Space) {
root.activateSelected()
event.accepted = true
} else if (event.text === "x" || event.text === "X") {
root.forgetSelected()
event.accepted = true
} }
} }
if (dx !== 0 && root.focusSection === "dns") root.selectDnsByDelta(dx)
}
onActivateRequested: {
if (root.focusSection === "dns") root.activateDns()
else root.activateSelected()
}
onCloseRequested: root.closePopout()
onDeleteRequested: {
if (root.focusSection === "wifi") root.forgetSelected()
}
onTextKey: function(t) {
if (t === "r" || t === "R") root.refresh()
}
Column { Column {
id: column id: column