diff --git a/default/quickshell/omarchy-shell/Ui/PanelKeyCatcher.qml b/default/quickshell/omarchy-shell/Ui/PanelKeyCatcher.qml new file mode 100644 index 00000000..786d12f7 --- /dev/null +++ b/default/quickshell/omarchy-shell/Ui/PanelKeyCatcher.qml @@ -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) + } + } +} diff --git a/default/quickshell/omarchy-shell/Ui/qmldir b/default/quickshell/omarchy-shell/Ui/qmldir index a221b5cc..cd21c397 100644 --- a/default/quickshell/omarchy-shell/Ui/qmldir +++ b/default/quickshell/omarchy-shell/Ui/qmldir @@ -4,6 +4,7 @@ ChoiceButton 1.0 ChoiceButton.qml CursorSurface 1.0 CursorSurface.qml KeyboardPanel 1.0 KeyboardPanel.qml PanelActionButton 1.0 PanelActionButton.qml +PanelKeyCatcher 1.0 PanelKeyCatcher.qml PanelSectionHeader 1.0 PanelSectionHeader.qml PanelSeparator 1.0 PanelSeparator.qml PanelSlider 1.0 PanelSlider.qml diff --git a/default/quickshell/omarchy-shell/plugins/bar/widgets/audioPanel.qml b/default/quickshell/omarchy-shell/plugins/bar/widgets/audioPanel.qml index c09a31d0..3b9c974e 100644 --- a/default/quickshell/omarchy-shell/plugins/bar/widgets/audioPanel.qml +++ b/default/quickshell/omarchy-shell/plugins/bar/widgets/audioPanel.qml @@ -375,37 +375,19 @@ Item { contentWidth: 370 contentHeight: Math.min(560, panelColumn.implicitHeight + 28) - Item { + PanelKeyCatcher { id: keyCatcher anchors.fill: parent - focus: true - Keys.priority: Keys.AfterItem - Keys.onPressed: function(event) { - 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.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") { + onMoveRequested: function(dx, dy) { + if (dy !== 0) root.moveCursor(dy) + else if (dx !== 0) root.adjustVolume(dx * 0.05) + } + onActivateRequested: root.activateCursor() + onCloseRequested: root.closePopout() + onTextKey: function(t) { + // 'm' mutes whatever the cursor is on: focused section's slider + // for output/input, the focused stream for streams. + if (t === "m" || t === "M") { if (root.focusSection === "streams" && root.selectedIndex >= 0 && root.selectedIndex < root.audioStreams.length) { var s = root.audioStreams[root.selectedIndex] @@ -415,7 +397,6 @@ Item { } else { root.toggleOutputMute() } - event.accepted = true } } diff --git a/default/quickshell/omarchy-shell/plugins/bar/widgets/bluetoothPanel.qml b/default/quickshell/omarchy-shell/plugins/bar/widgets/bluetoothPanel.qml index 75c2564c..0ff251ae 100644 --- a/default/quickshell/omarchy-shell/plugins/bar/widgets/bluetoothPanel.qml +++ b/default/quickshell/omarchy-shell/plugins/bar/widgets/bluetoothPanel.qml @@ -331,34 +331,16 @@ Item { contentWidth: 320 contentHeight: column.implicitHeight + 28 - Item { + PanelKeyCatcher { id: keyCatcher anchors.fill: parent - focus: true - Keys.priority: Keys.AfterItem - Keys.onPressed: function(event) { - 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 - } + onMoveRequested: function(dx, dy) { + if (dy !== 0) root.moveCursor(dy) + else if (dx !== 0) root.moveCursorH(dx) } + onActivateRequested: root.activateCursor() + onCloseRequested: root.closePopout() + onDeleteRequested: root.deleteSelected() Column { id: column diff --git a/default/quickshell/omarchy-shell/plugins/bar/widgets/networkPanel.qml b/default/quickshell/omarchy-shell/plugins/bar/widgets/networkPanel.qml index f90acf5f..a1f4976e 100644 --- a/default/quickshell/omarchy-shell/plugins/bar/widgets/networkPanel.qml +++ b/default/quickshell/omarchy-shell/plugins/bar/widgets/networkPanel.qml @@ -552,62 +552,42 @@ iwctl station "$station" get-networks rssi-dbms 2>/dev/null \\ // Catches all unhandled keys for keyboard navigation. AfterItem priority // lets the passphrase TextField (a child via focus chain) get its keys // first; only events the focused subtree ignores bubble back here. - Item { + PanelKeyCatcher { id: keyCatcher anchors.fill: parent - focus: true - Keys.priority: Keys.AfterItem - Keys.onPressed: function(event) { - if (root.passwordSsid !== "") return - if (event.key === Qt.Key_Escape) { - root.closePopout() - event.accepted = true - return - } - if (event.text === "r" || event.text === "R") { - root.refresh() - event.accepted = true - return - } + // Freeze the cursor model while the inline password prompt is open; + // the TextField inside owns input until Esc/Enter/Cancel. + blocked: root.passwordSsid !== "" + + onMoveRequested: function(dx, dy) { + if (dy !== 0) { if (root.focusSection === "dns") { - if (event.key === Qt.Key_Left || event.text === "h") { - root.selectDnsByDelta(-1) - event.accepted = true - } else if (event.key === Qt.Key_Right || event.text === "l") { - root.selectDnsByDelta(1) - 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 + // j from DNS drops into the wifi list if there's anywhere to + // land; otherwise hold position so j isn't a no-op surprise. + if (dy > 0 && root.wifiNetworks.length > 0) { + root.focusSection = "wifi" + if (root.selectedIndex < 0) root.selectedIndex = 0 } - return - } - // 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") { + } else { // wifi // k from the top row escapes back up into the DNS row rather // than wrapping around to the bottom of the list. - if (root.selectedIndex <= 0) root.focusSection = "dns" - else root.selectByDelta(-1) - 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 (dy < 0 && root.selectedIndex <= 0) root.focusSection = "dns" + else root.selectByDelta(dy) } } + 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 { id: column