Add keyboard navigation to settings panel

Two zones — sidebar and body — with vim-style bindings:

  sidebar  j/Down  next category
           k/Up    previous category
           l/Right/Tab/Enter/Ctrl-L  enter body
           Esc     close panel

  body     j/Down/Tab        next focusable
           k/Up/Shift-Tab    previous focusable
           Enter/Space       activate focused control
           h/Left/Esc/Ctrl-H back to sidebar

Wraps panel content in a FocusScope with Keys.priority: Keys.BeforeItem
so j/k always reach the nav handler even when a child control has focus.
Body focusables are gathered by walking the visible subtree of the active
category for items with activeFocusOnTab — automatically excluding
hidden category branches. Sidebar shows an accent-tinted highlight when
focused; each interactive control (DefaultsRow, StyleOptionTile,
StyleChip, FontRow, StyleToggleRow, ActionPill, IconButton, PluginRow)
renders an accent focus ring while focused.
This commit is contained in:
Ryan Hughes
2026-05-14 02:25:05 -04:00
parent c7da0f74aa
commit 6f5fac3f13
@@ -27,7 +27,10 @@ Item {
root.activeCategory = payload.focusPluginId ? "plugins" : "bar"
}
root.syncSidebarIndexFromCategory()
root.focusZone = "sidebar"
window.visible = true
Qt.callLater(function() { if (navRoot) navRoot.forceActiveFocus() })
}
function close() {
@@ -71,6 +74,97 @@ Item {
readonly property var categoryIds: ["defaults", "style", "bar", "system", "plugins"]
property string activeCategory: "bar"
// Keyboard nav state. Two zones: "sidebar" (j/k cycles category) and
// "body" (j/k walks through visible focusable controls). Tab/Enter/l/Right
// dive from sidebar into body; h/Left/Esc backs out.
property string focusZone: "sidebar"
property int sidebarIndex: 2
function syncSidebarIndexFromCategory() {
var idx = categoryIds.indexOf(activeCategory)
if (idx >= 0) sidebarIndex = idx
}
function setSidebarIndex(i) {
if (categoryIds.length === 0) return
if (i < 0) i = categoryIds.length - 1
if (i >= categoryIds.length) i = 0
sidebarIndex = i
activeCategory = categoryIds[i]
}
function enterBodyZone() {
focusZone = "body"
Qt.callLater(focusFirstBodyItem)
}
function exitBodyZone() {
focusZone = "sidebar"
if (navRoot) navRoot.forceActiveFocus()
}
// Walk the visible body subtree and collect any item with
// `activeFocusOnTab: true`. Filters out invisible categories so j/k stays
// within the active tab.
function gatherBodyFocusables() {
var arr = []
function walk(item) {
if (!item || !item.visible || item.enabled === false) return
if (item.activeFocusOnTab === true) arr.push(item)
var children = item.children
if (!children) return
for (var i = 0; i < children.length; i++) walk(children[i])
}
if (typeof bodyScroll !== "undefined" && bodyScroll && bodyScroll.contentItem)
walk(bodyScroll.contentItem)
return arr
}
function focusFirstBodyItem() {
var items = gatherBodyFocusables()
if (items.length > 0) {
items[0].forceActiveFocus()
ensureBodyItemVisible(items[0])
} else if (navRoot) {
navRoot.forceActiveFocus()
}
}
function focusBodyDelta(delta) {
var items = gatherBodyFocusables()
if (items.length === 0) { exitBodyZone(); return }
var current = -1
for (var i = 0; i < items.length; i++) {
if (items[i].activeFocus) { current = i; break }
}
var next = current < 0 ? 0 : current + delta
if (next < 0) next = items.length - 1
if (next >= items.length) next = 0
items[next].forceActiveFocus()
ensureBodyItemVisible(items[next])
}
// Scroll the bodyScroll Flickable so `item` is fully on-screen, with a
// little padding above/below.
function ensureBodyItemVisible(item) {
if (!item || typeof bodyScroll === "undefined" || !bodyScroll || !bodyScroll.contentItem) return
var pos = item.mapToItem(bodyScroll.contentItem, 0, 0)
var pad = 24
var top = pos.y - pad
var bottom = pos.y + item.height + pad
if (top < bodyScroll.contentY) {
bodyScroll.contentY = Math.max(0, top)
} else if (bottom > bodyScroll.contentY + bodyScroll.height) {
var maxY = Math.max(0, bodyScroll.contentHeight - bodyScroll.height)
bodyScroll.contentY = Math.min(maxY, bottom - bodyScroll.height)
}
}
onActiveCategoryChanged: {
syncSidebarIndexFromCategory()
if (focusZone === "body") Qt.callLater(focusFirstBodyItem)
}
// ---------------- bundled defaults ---------------------------------------
readonly property var builtinShellConfig: ({
version: 1,
@@ -486,8 +580,58 @@ Item {
onVisibleChanged: {
if (!visible && !root.closingFromHost && root.shell && typeof root.shell.hide === "function")
root.shell.hide("omarchy.settings")
if (visible) Qt.callLater(function() { if (navRoot) navRoot.forceActiveFocus() })
}
FocusScope {
id: navRoot
anchors.fill: parent
focus: true
Component.onCompleted: forceActiveFocus()
Keys.priority: Keys.BeforeItem
Keys.onPressed: function(event) {
var ctrl = (event.modifiers & Qt.ControlModifier) !== 0
if (root.focusZone === "sidebar") {
switch (event.key) {
case Qt.Key_J:
case Qt.Key_Down:
root.setSidebarIndex(root.sidebarIndex + 1); event.accepted = true; return
case Qt.Key_K:
case Qt.Key_Up:
root.setSidebarIndex(root.sidebarIndex - 1); event.accepted = true; return
case Qt.Key_L:
case Qt.Key_Right:
case Qt.Key_Tab:
case Qt.Key_Return:
case Qt.Key_Enter:
root.enterBodyZone(); event.accepted = true; return
case Qt.Key_Escape:
root.close(); event.accepted = true; return
}
if (ctrl && event.key === Qt.Key_L) { root.enterBodyZone(); event.accepted = true; return }
} else {
// body
switch (event.key) {
case Qt.Key_Escape:
case Qt.Key_H:
case Qt.Key_Left:
case Qt.Key_Backtab:
root.exitBodyZone(); event.accepted = true; return
case Qt.Key_J:
case Qt.Key_Down:
case Qt.Key_Tab:
root.focusBodyDelta(+1); event.accepted = true; return
case Qt.Key_K:
case Qt.Key_Up:
root.focusBodyDelta(-1); event.accepted = true; return
}
if (ctrl && event.key === Qt.Key_H) { root.exitBodyZone(); event.accepted = true; return }
}
}
Rectangle {
anchors.fill: parent
color: root.background
@@ -598,6 +742,7 @@ Item {
}
}
}
}
}
// ===================== sidebar row =======================================
@@ -607,15 +752,20 @@ Item {
property string label: ""
property string glyph: ""
readonly property bool active: root.activeCategory === categoryId
readonly property bool sidebarFocused: root.focusZone === "sidebar"
Layout.fillWidth: true
Layout.preferredHeight: 30
radius: root.cornerRadius
color: sb.active
? Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.14)
? (sb.sidebarFocused
? Qt.rgba(root.accent.r, root.accent.g, root.accent.b, 0.20)
: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.14))
: (sbArea.containsMouse ? Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.07) : "transparent")
border.color: sb.active ? Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.25) : "transparent"
border.width: 1
border.color: sb.active
? (sb.sidebarFocused ? root.accent : Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.25))
: "transparent"
border.width: sb.active && sb.sidebarFocused ? 2 : 1
Behavior on color { ColorAnimation { duration: 100 } }
@@ -647,7 +797,11 @@ Item {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.activeCategory = sb.categoryId
onClicked: {
root.activeCategory = sb.categoryId
root.focusZone = "sidebar"
if (navRoot) navRoot.forceActiveFocus()
}
}
}
@@ -912,15 +1066,22 @@ Item {
property bool available: false
signal clicked()
activeFocusOnTab: dr.available
Keys.onReturnPressed: if (dr.available && !dr.selected) dr.clicked()
Keys.onEnterPressed: if (dr.available && !dr.selected) dr.clicked()
Keys.onSpacePressed: if (dr.available && !dr.selected) dr.clicked()
implicitHeight: 38
radius: root.cornerRadius
color: drArea.containsMouse
? Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.08)
: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.03)
border.color: dr.selected
color: dr.activeFocus
? Qt.rgba(root.accent.r, root.accent.g, root.accent.b, 0.10)
: (drArea.containsMouse
? Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.08)
: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.03))
border.color: dr.activeFocus
? root.accent
: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.12)
border.width: 1
: (dr.selected ? root.accent : Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.12))
border.width: dr.activeFocus ? 2 : 1
opacity: dr.available ? 1 : 0.45
Behavior on color { ColorAnimation { duration: 100 } }
@@ -1538,14 +1699,21 @@ Item {
property bool selected: false
signal clicked()
activeFocusOnTab: true
Keys.onReturnPressed: tile.clicked()
Keys.onEnterPressed: tile.clicked()
Keys.onSpacePressed: tile.clicked()
implicitWidth: 140
implicitHeight: 52
radius: root.cornerRadius
color: tileArea.containsMouse
? Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.10)
: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.03)
border.color: tile.selected ? root.accent : Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.18)
border.width: tile.selected ? 2 : 1
color: tile.activeFocus
? Qt.rgba(root.accent.r, root.accent.g, root.accent.b, 0.12)
: (tileArea.containsMouse
? Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.10)
: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.03))
border.color: tile.activeFocus || tile.selected ? root.accent : Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.18)
border.width: tile.activeFocus || tile.selected ? 2 : 1
Behavior on color { ColorAnimation { duration: 100 } }
@@ -1589,14 +1757,21 @@ Item {
property bool selected: false
signal clicked()
activeFocusOnTab: true
Keys.onReturnPressed: chip.clicked()
Keys.onEnterPressed: chip.clicked()
Keys.onSpacePressed: chip.clicked()
implicitWidth: chipText.implicitWidth + 22
implicitHeight: 30
radius: root.cornerRadius
color: chipArea.containsMouse
? Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.10)
: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.03)
border.color: chip.selected ? root.accent : Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.18)
border.width: chip.selected ? 2 : 1
color: chip.activeFocus
? Qt.rgba(root.accent.r, root.accent.g, root.accent.b, 0.12)
: (chipArea.containsMouse
? Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.10)
: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.03))
border.color: chip.activeFocus || chip.selected ? root.accent : Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.18)
border.width: chip.activeFocus || chip.selected ? 2 : 1
Behavior on color { ColorAnimation { duration: 100 } }
@@ -1716,13 +1891,20 @@ Item {
property bool selected: false
signal picked()
activeFocusOnTab: true
Keys.onReturnPressed: if (!fr.selected) fr.picked()
Keys.onEnterPressed: if (!fr.selected) fr.picked()
Keys.onSpacePressed: if (!fr.selected) fr.picked()
implicitHeight: 44
radius: root.cornerRadius
color: frArea.containsMouse
? Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.08)
: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.03)
border.color: fr.selected ? root.accent : Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.12)
border.width: fr.selected ? 2 : 1
color: fr.activeFocus
? Qt.rgba(root.accent.r, root.accent.g, root.accent.b, 0.10)
: (frArea.containsMouse
? Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.08)
: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.03))
border.color: fr.activeFocus || fr.selected ? root.accent : Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.12)
border.width: fr.activeFocus || fr.selected ? 2 : 1
Behavior on color { ColorAnimation { duration: 100 } }
@@ -1770,14 +1952,21 @@ Item {
property bool isOn: false
signal toggle()
activeFocusOnTab: true
Keys.onReturnPressed: tr.toggle()
Keys.onEnterPressed: tr.toggle()
Keys.onSpacePressed: tr.toggle()
Layout.fillWidth: true
implicitHeight: 46
radius: root.cornerRadius
color: trArea.containsMouse
? Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.08)
: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.03)
border.color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.12)
border.width: 1
color: tr.activeFocus
? Qt.rgba(root.accent.r, root.accent.g, root.accent.b, 0.10)
: (trArea.containsMouse
? Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.08)
: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.03))
border.color: tr.activeFocus ? root.accent : Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.12)
border.width: tr.activeFocus ? 2 : 1
Behavior on color { ColorAnimation { duration: 100 } }
@@ -1849,12 +2038,19 @@ Item {
property bool bordered: true
signal clicked()
activeFocusOnTab: true
Keys.onReturnPressed: pill.clicked()
Keys.onEnterPressed: pill.clicked()
Keys.onSpacePressed: pill.clicked()
implicitWidth: pillLabel.implicitWidth + 22
implicitHeight: 26
radius: root.cornerRadius
color: pillArea.containsMouse ? Qt.rgba(pill.foreground.r, pill.foreground.g, pill.foreground.b, 0.15) : "transparent"
border.color: pill.bordered ? pill.foreground : "transparent"
border.width: 1
color: pill.activeFocus
? Qt.rgba(pill.foreground.r, pill.foreground.g, pill.foreground.b, 0.20)
: (pillArea.containsMouse ? Qt.rgba(pill.foreground.r, pill.foreground.g, pill.foreground.b, 0.15) : "transparent")
border.color: pill.activeFocus ? root.accent : (pill.bordered ? pill.foreground : "transparent")
border.width: pill.activeFocus ? 2 : 1
Behavior on color { ColorAnimation { duration: 100 } }
@@ -1883,10 +2079,19 @@ Item {
property color foreground: root.foreground
signal clicked()
activeFocusOnTab: true
Keys.onReturnPressed: iconButton.clicked()
Keys.onEnterPressed: iconButton.clicked()
Keys.onSpacePressed: iconButton.clicked()
implicitWidth: 26
implicitHeight: 26
radius: root.cornerRadius
color: iconArea.containsMouse ? Qt.rgba(iconButton.foreground.r, iconButton.foreground.g, iconButton.foreground.b, 0.18) : "transparent"
color: iconButton.activeFocus
? Qt.rgba(iconButton.foreground.r, iconButton.foreground.g, iconButton.foreground.b, 0.25)
: (iconArea.containsMouse ? Qt.rgba(iconButton.foreground.r, iconButton.foreground.g, iconButton.foreground.b, 0.18) : "transparent")
border.color: iconButton.activeFocus ? root.accent : "transparent"
border.width: iconButton.activeFocus ? 2 : 0
Behavior on color { ColorAnimation { duration: 100 } }
@@ -2507,10 +2712,17 @@ Item {
property bool firstParty: false
property bool expanded: false
activeFocusOnTab: !row.firstParty
Keys.onReturnPressed: if (!row.firstParty) root.pluginRegistry.setEnabled(row.pluginId, !row.pluginEnabled)
Keys.onEnterPressed: if (!row.firstParty) root.pluginRegistry.setEnabled(row.pluginId, !row.pluginEnabled)
Keys.onSpacePressed: if (!row.firstParty) root.pluginRegistry.setEnabled(row.pluginId, !row.pluginEnabled)
radius: root.cornerRadius
color: rowArea.containsMouse ? Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.08) : Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.03)
border.color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.12)
border.width: 1
color: row.activeFocus
? Qt.rgba(root.accent.r, root.accent.g, root.accent.b, 0.10)
: (rowArea.containsMouse ? Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.08) : Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.03))
border.color: row.activeFocus ? root.accent : Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.12)
border.width: row.activeFocus ? 2 : 1
implicitHeight: rowContent.implicitHeight + 16
Behavior on color { ColorAnimation { duration: 100 } }