Give every hero toggle a keyboard focus ring

Each panel's hero on/off toggle (Tailscale, Dropbox Wi-Fi, Bluetooth,
network, audio mute) now has a reachable "header" cursor section, a focus
ring around the hero icon, Enter/Space activation, a letter shortcut, and
hover parity. The hero is inset by heroRingPad so the ring stays inside
the Flickable/ScrollView clip box instead of being cut off.

Bluetooth and audio get a virtual "header" section above their device
sections, so the adapter/mute can be toggled by keyboard even when no
device rows exist. Network repurposes its previously dead header section.

For the PanelHero-based panels (Tailscale, Dropbox), route panel state
through the wrapper's `header` id: inside a PanelHero iconComponent `root`
resolves to PanelHero, not the Panel, so `root.headerHasCursor` and
`root.setHeaderCursor()` silently referenced the wrong object.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Heinemeier Hansson
2026-07-20 15:50:05 -07:00
co-authored by Claude Opus 4.8
parent 835181f112
commit 228b5ac3ba
5 changed files with 224 additions and 45 deletions
+33 -1
View File
@@ -127,6 +127,11 @@ Panel {
property int selectedIndex: -1
property bool cursorActive: false
// "header" is a virtual section for the hero output mute toggle; it sits
// above the output section so the speaker can be muted from the keyboard.
readonly property bool headerHasCursor: cursorActive && focusSection === "header"
readonly property int heroRingPad: Style.space(6)
readonly property color hoverFill: bar
? Style.hoverFillFor(bar.foreground, Color.accent)
: "transparent"
@@ -167,6 +172,10 @@ Panel {
function moveCursor(delta) {
var sections = visibleSections
if (sections.length === 0) return
if (focusSection === "header") {
if (delta > 0) { focusSection = sections[0]; selectedIndex = sectionHasSlider(sections[0]) ? -1 : 0 }
return
}
var sIdx = sections.indexOf(focusSection)
if (sIdx < 0) { focusSection = sections[0]; selectedIndex = sectionHasSlider(focusSection) ? -1 : 0; return }
@@ -189,10 +198,18 @@ Panel {
focusSection = sections[sIdx - 1]
var prevMax = sectionCount(focusSection) - 1
selectedIndex = prevMax >= 0 ? prevMax : (sectionHasSlider(focusSection) ? -1 : 0)
} else {
focusSection = "header"
}
}
}
function setHeaderCursor() {
cursorActive = true
focusSection = "header"
selectedIndex = -1
}
function moveSection(delta) {
var sections = visibleSections
if (sections.length === 0) return
@@ -227,6 +244,7 @@ Panel {
// Enter/Space: activate whatever the cursor is on.
function activateCursor() {
if (focusSection === "header") { toggleOutputMute(); return }
if (focusSection === "output") {
if (selectedIndex === -1) { toggleOutputMute(); return }
var sink = displayAudioSinks[selectedIndex]
@@ -597,7 +615,18 @@ Panel {
Item {
id: heroItem
width: parent.width
implicitHeight: Math.max(heroIcon.implicitHeight, heroLabels.implicitHeight)
implicitHeight: Math.max(heroIcon.implicitHeight, heroLabels.implicitHeight) + root.heroRingPad * 2
// Keyboard focus ring around the hero output-mute toggle. heroIcon
// is inset by heroRingPad so this ring stays inside the clip box.
BorderSurface {
anchors.fill: heroIcon
anchors.margins: -root.heroRingPad
color: "transparent"
radius: Style.cornerRadius
visible: root.headerHasCursor
borderSpec: Border.controlSpec("hover-cursor", root.bar.foreground, Color.accent)
}
Text {
id: heroIcon
@@ -607,11 +636,14 @@ Panel {
font.pixelSize: Style.font.display
opacity: root.outputMuted ? 0.5 : 1.0
anchors.left: parent.left
anchors.leftMargin: root.heroRingPad
anchors.verticalCenter: parent.verticalCenter
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onContainsMouseChanged: if (containsMouse) root.setHeaderCursor()
onClicked: root.toggleOutputMute()
}
}
+45 -3
View File
@@ -87,6 +87,12 @@ Panel {
// address across section changes instead of preserving a stale row index.
property string focusedDeviceAddress: ""
// "header" is a virtual section for the hero Bluetooth on/off toggle; it
// sits above the device sections so the adapter can be toggled by keyboard
// even when it is off and no device rows exist.
readonly property bool headerHasCursor: cursorActive && focusSection === "header"
readonly property int heroRingPad: Style.space(6)
readonly property color hoverFill: bar
? Style.hoverFillFor(bar.foreground, Color.accent)
: "transparent"
@@ -251,10 +257,17 @@ Panel {
if (changed) pendingActions = next
}
// j/k navigates between device sections row-by-row.
// j/k navigates the hero toggle ("header") and the device sections
// row-by-row.
function moveCursor(delta) {
var sections = visibleSections
if (!sections || sections.length === 0) return
if (focusSection === "header") {
if (delta > 0 && sections && sections.length > 0) {
focusSection = sections[0]; selectedIndex = 0; actionFocused = false
}
return
}
if (!sections || sections.length === 0) { focusSection = "header"; actionFocused = false; return }
var sIdx = sections.indexOf(focusSection)
if (sIdx < 0) { focusSection = sections[0]; selectedIndex = 0; actionFocused = false; return }
@@ -274,10 +287,18 @@ Panel {
focusSection = sections[sIdx - 1]
selectedIndex = sectionCount(focusSection) - 1
actionFocused = false
} else {
focusSection = "header"; actionFocused = false
}
}
}
function setHeaderCursor() {
cursorActive = true
focusSection = "header"
actionFocused = false
}
function moveCursorH(delta) {
if (!cursorActive) { cursorActive = true; return }
if (focusSection !== "known" && focusSection !== "connected") return
@@ -288,6 +309,10 @@ Panel {
}
function activateCursor() {
if (focusSection === "header") {
toggleBluetooth()
return
}
if (actionFocused) {
deleteSelected()
return
@@ -321,6 +346,7 @@ Panel {
if (connectedDevices.length > 0) { focusSection = "connected"; selectedIndex = 0 }
else if (knownDevices.length > 0) { focusSection = "known"; selectedIndex = 0 }
else if (discoveredDevices.length > 0) { focusSection = "discovered"; selectedIndex = 0 }
else { focusSection = "header" }
actionFocused = false
cursorActive = false
}
@@ -503,6 +529,9 @@ Panel {
onCloseRequested: root.close()
onTabRequested: function(direction) { root.switchPanel(direction) }
onDeleteRequested: if (root.cursorActive) root.deleteSelected()
onTextKey: function(t) {
if (t === "b" || t === "B") root.toggleBluetooth()
}
Column {
id: column
@@ -512,11 +541,23 @@ Panel {
// ---------- Hero: Bluetooth icon · status ----------
Item {
width: parent.width
implicitHeight: Math.max(heroIcon.implicitHeight, heroLabels.implicitHeight)
implicitHeight: Math.max(heroIcon.implicitHeight, heroLabels.implicitHeight) + root.heroRingPad * 2
// Keyboard focus ring around the hero Bluetooth toggle. heroIcon is
// inset by heroRingPad so this ring stays inside the panel's clip box.
BorderSurface {
anchors.fill: heroIcon
anchors.margins: -root.heroRingPad
color: "transparent"
radius: Style.cornerRadius
visible: root.headerHasCursor
borderSpec: Border.controlSpec("hover-cursor", root.bar.foreground, Color.accent)
}
Text {
id: heroIcon
anchors.left: parent.left
anchors.leftMargin: root.heroRingPad
anchors.verticalCenter: parent.verticalCenter
text: root.icon
color: root.bar.foreground
@@ -530,6 +571,7 @@ Panel {
hoverEnabled: true
cursorShape: root.adapter ? Qt.PointingHandCursor : Qt.ArrowCursor
enabled: !!root.adapter
onContainsMouseChanged: if (containsMouse) root.setHeaderCursor()
onClicked: root.toggleBluetooth()
}
+84 -31
View File
@@ -38,6 +38,8 @@ Panel {
readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family
readonly property color iconColor: dropbox.authenticated && dropbox.active ? foreground : dim
readonly property color barIconColor: dropbox.authenticated && dropbox.active ? barForeground : Qt.darker(barForeground, 1.55)
readonly property bool headerHasCursor: cursorActive && focusSection === "header"
readonly property int heroRingPad: Style.space(6)
function ensureCursor() {
if (!dropbox.authenticated) {
@@ -50,7 +52,7 @@ Panel {
fileIndex = 0
return
}
if (focusSection !== "files") focusSection = "files"
if (focusSection !== "files" && focusSection !== "header") focusSection = "files"
if (fileIndex >= dropbox.files.length) fileIndex = Math.max(0, dropbox.files.length - 1)
if (fileIndex < 0) fileIndex = 0
}
@@ -58,15 +60,39 @@ Panel {
function moveCursor(dx, dy) {
cursorActive = true
ensureCursor()
if (focusSection === "files" && dy !== 0) {
if (dy === 0) return
if (focusSection === "header") {
if (dy > 0 && dropbox.files.length > 0) {
focusSection = "files"
fileIndex = 0
scrollCursorIntoView()
}
return
}
if (focusSection === "files") {
if (dy < 0 && fileIndex === 0) {
setHeaderCursor()
return
}
fileIndex = Math.max(0, Math.min(dropbox.files.length - 1, fileIndex + dy))
scrollCursorIntoView()
}
}
function setHeaderCursor() {
cursorActive = true
focusSection = "header"
if (panelFlick) panelFlick.contentY = 0
}
function toggleRunning() {
if (dropbox.installed && !dropbox.busy) dropbox.toggleRunning()
}
function activateCursor() {
ensureCursor()
if (focusSection === "login") dropbox.login()
else if (focusSection === "header") toggleRunning()
else if (focusSection === "files") dropbox.openFile(selectedFile())
}
@@ -183,6 +209,7 @@ Panel {
onTextKey: function(t) {
if (t === "r" || t === "R") dropbox.refresh()
else if (t === "l" || t === "L") dropbox.login()
else if (t === "p" || t === "P") root.toggleRunning()
}
Flickable {
@@ -201,40 +228,66 @@ Panel {
width: panelFlick.width
spacing: Style.space(12)
PanelHero {
id: hero
Item {
id: header
visible: dropbox.authenticated
width: parent.width
title: "Dropbox"
meta: dropbox.active ? root.heroPhraseText : "Syncing paused"
foreground: root.foreground
fontFamily: root.fontFamily
iconOpacity: dropbox.active ? 1.0 : 0.5
iconComponent: Component {
Item {
implicitWidth: heroIcon.implicitWidth
implicitHeight: heroIcon.implicitHeight
implicitHeight: hero.implicitHeight + root.heroRingPad
// Exposed for the hero's iconComponent, whose `root` resolves to
// PanelHero (not this Panel) — reach panel state via `header`.
readonly property bool ringVisible: root.headerHasCursor
readonly property int ringPad: root.heroRingPad
function focusHero() { root.setHeaderCursor() }
DropboxIcon {
id: heroIcon
iconSize: Style.font.display
color: root.iconColor
anchors.centerIn: parent
}
PanelHero {
id: hero
x: root.heroRingPad
y: root.heroRingPad
width: parent.width - root.heroRingPad
title: "Dropbox"
meta: dropbox.active ? root.heroPhraseText : "Syncing paused"
foreground: root.foreground
fontFamily: root.fontFamily
iconOpacity: dropbox.active ? 1.0 : 0.5
iconComponent: Component {
Item {
implicitWidth: heroIcon.implicitWidth
implicitHeight: heroIcon.implicitHeight
MouseArea {
id: heroMouse
anchors.fill: parent
hoverEnabled: true
enabled: dropbox.installed && !dropbox.busy
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: dropbox.toggleRunning()
}
// Keyboard focus ring around the hero toggle. The hero is
// inset by heroRingPad so this ring stays inside the
// Flickable's clip box instead of being cut off.
BorderSurface {
anchors.fill: heroIcon
anchors.margins: -header.ringPad
color: "transparent"
radius: Style.cornerRadius
visible: header.ringVisible
borderSpec: Border.controlSpec("hover-cursor", hero.foreground, Color.accent)
}
PanelToolTip {
visible: heroMouse.containsMouse
text: dropbox.active ? "Pause syncing" : "Resume syncing"
fontFamily: root.fontFamily
DropboxIcon {
id: heroIcon
iconSize: Style.font.display
color: root.iconColor
anchors.centerIn: parent
}
MouseArea {
id: heroMouse
anchors.fill: parent
hoverEnabled: true
enabled: dropbox.installed && !dropbox.busy
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onContainsMouseChanged: if (containsMouse) header.focusHero()
onClicked: dropbox.toggleRunning()
}
PanelToolTip {
visible: heroMouse.containsMouse
text: dropbox.active ? "Pause syncing" : "Resume syncing"
fontFamily: root.fontFamily
}
}
}
}
+32 -4
View File
@@ -102,7 +102,9 @@ Panel {
property int headerIndex: 0
readonly property bool canDisconnect: !!connectedWifiNetwork
readonly property bool headerHasDisconnect: false
readonly property int headerActionCount: 0
readonly property int headerActionCount: networkManagerAvailable ? 1 : 0
readonly property bool headerHasCursor: cursorActive && focusSection === "header"
readonly property int heroRingPad: Style.space(6)
readonly property var dnsProviders: ["DHCP", "Cloudflare", "Google", "Custom"]
property int dnsIndex: 0
@@ -118,8 +120,20 @@ Panel {
headerIndex = Math.max(0, Math.min(headerActionCount - 1, headerIndex + delta))
}
function toggleNetwork() {
if (!networkManagerAvailable) return
Networking.wifiEnabled = !Networking.wifiEnabled
Qt.callLater(function() { root.refresh(true) })
}
function activateHeader() {
if (headerHasDisconnect && headerIndex === 0 && !busy) disconnect(connectedWifiNetwork)
toggleNetwork()
}
function setHeaderCursor() {
cursorActive = true
focusSection = "header"
headerIndex = 0
}
function selectDnsByDelta(delta) {
@@ -756,7 +770,7 @@ Panel {
// one; otherwise stays put. j drops into the wifi list if there's
// anywhere to land.
if (dy < 0) {
if (root.headerHasDisconnect) {
if (root.headerActionCount > 0) {
root.focusSection = "header"
root.headerIndex = 0
}
@@ -791,6 +805,7 @@ Panel {
onTabRequested: function(direction) { root.switchPanel(direction) }
onTextKey: function(t) {
if (t === "r" || t === "R") root.refresh()
else if (t === "w" || t === "W") root.toggleNetwork()
}
Column {
@@ -803,7 +818,18 @@ Panel {
// ---------- Hero: network icon · SSID + state · actions ----------
Item {
width: parent.width
implicitHeight: Math.max(heroIcon.implicitHeight, heroLabels.implicitHeight)
implicitHeight: Math.max(heroIcon.implicitHeight, heroLabels.implicitHeight) + root.heroRingPad * 2
// Keyboard focus ring around the hero Wi-Fi toggle. heroIcon is inset
// by heroRingPad so this ring stays inside the panel's clip box.
BorderSurface {
anchors.fill: heroIcon
anchors.margins: -root.heroRingPad
color: "transparent"
radius: Style.cornerRadius
visible: root.headerHasCursor
borderSpec: Border.controlSpec("hover-cursor", root.bar.foreground, Color.accent)
}
Text {
id: heroIcon
@@ -813,6 +839,7 @@ Panel {
font.pixelSize: Style.font.display
opacity: root.networkManagerAvailable ? 1.0 : 0.5
anchors.left: parent.left
anchors.leftMargin: root.heroRingPad
anchors.verticalCenter: parent.verticalCenter
MouseArea {
@@ -821,6 +848,7 @@ Panel {
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
enabled: root.networkManagerAvailable
onContainsMouseChanged: if (containsMouse) root.setHeaderCursor()
onClicked: {
Networking.wifiEnabled = !Networking.wifiEnabled
Qt.callLater(function() { root.refresh(true) })
+30 -6
View File
@@ -48,6 +48,8 @@ Panel {
readonly property var exitNodes: displayExitNodes()
readonly property bool showExitNodes: tailscale.active && (exitNodes.length > 0 || tailscale.mullvadRegions.length > 0)
readonly property var filteredMullvadRegions: filteredMullvadRegionNodes()
readonly property bool headerHasCursor: cursorActive && focusSection === "header"
readonly property int heroRingPad: Style.space(6)
readonly property color iconColor: tailscale.active ? foreground : dim
readonly property color barIconColor: tailscale.active ? barForeground : Qt.darker(barForeground, 1.55)
readonly property color hoverFill: bar ? Style.hoverFillFor(bar.foreground, Color.accent) : "transparent"
@@ -317,6 +319,12 @@ Panel {
focusSection = "auth"
}
function setHeaderCursor() {
cursorActive = true
focusSection = "header"
headerIndex = 0
}
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
@@ -429,11 +437,18 @@ Panel {
Item {
id: header
width: parent.width
implicitHeight: hero.implicitHeight
implicitHeight: hero.implicitHeight + root.heroRingPad
// Exposed for the hero's iconComponent, whose `root` resolves to
// PanelHero (not this Panel) — reach panel state via `header`.
readonly property bool ringVisible: root.headerHasCursor
readonly property int ringPad: root.heroRingPad
function focusHero() { root.setHeaderCursor() }
PanelHero {
id: hero
width: parent.width
x: root.heroRingPad
y: root.heroRingPad
width: parent.width - root.heroRingPad
title: tailscale.installed ? (tailscale.selfName || "Tailscale") : "Tailscale"
meta: tailscale.active ? root.heroPhraseText : "Tailscale is disconnected"
foreground: root.foreground
@@ -444,6 +459,18 @@ Panel {
implicitWidth: icon.implicitWidth
implicitHeight: icon.implicitHeight
// Keyboard focus ring around the hero toggle. The hero is
// inset by heroRingPad so this ring stays inside the
// Flickable's clip box instead of being cut off.
BorderSurface {
anchors.fill: icon
anchors.margins: -header.ringPad
color: "transparent"
radius: Style.cornerRadius
visible: header.ringVisible
borderSpec: Border.controlSpec("hover-cursor", hero.foreground, Color.accent)
}
TailscaleIcon {
id: icon
iconSize: Style.font.display
@@ -460,10 +487,7 @@ Panel {
hoverEnabled: true
enabled: tailscale.installed && !tailscale.busy
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onContainsMouseChanged: if (containsMouse) {
root.focusSection = "header"
root.headerIndex = 0
}
onContainsMouseChanged: if (containsMouse) header.focusHero()
onClicked: tailscale.toggleTailscale()
}