From 228b5ac3bad3a719d8c40490ba529ca37031800c Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 20 Jul 2026 15:50:05 -0700 Subject: [PATCH] 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) --- shell/plugins/panels/audio/Panel.qml | 34 ++++++- shell/plugins/panels/bluetooth/Panel.qml | 48 +++++++++- shell/plugins/panels/dropbox/Panel.qml | 115 +++++++++++++++++------ shell/plugins/panels/network/Panel.qml | 36 ++++++- shell/plugins/panels/tailscale/Panel.qml | 36 +++++-- 5 files changed, 224 insertions(+), 45 deletions(-) diff --git a/shell/plugins/panels/audio/Panel.qml b/shell/plugins/panels/audio/Panel.qml index bdce12b3..abe73657 100644 --- a/shell/plugins/panels/audio/Panel.qml +++ b/shell/plugins/panels/audio/Panel.qml @@ -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() } } diff --git a/shell/plugins/panels/bluetooth/Panel.qml b/shell/plugins/panels/bluetooth/Panel.qml index a82de67b..d6b4cf5d 100644 --- a/shell/plugins/panels/bluetooth/Panel.qml +++ b/shell/plugins/panels/bluetooth/Panel.qml @@ -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() } diff --git a/shell/plugins/panels/dropbox/Panel.qml b/shell/plugins/panels/dropbox/Panel.qml index e3b549ec..c153c401 100644 --- a/shell/plugins/panels/dropbox/Panel.qml +++ b/shell/plugins/panels/dropbox/Panel.qml @@ -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 + } } } } diff --git a/shell/plugins/panels/network/Panel.qml b/shell/plugins/panels/network/Panel.qml index 1277c1f2..a30963d9 100644 --- a/shell/plugins/panels/network/Panel.qml +++ b/shell/plugins/panels/network/Panel.qml @@ -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) }) diff --git a/shell/plugins/panels/tailscale/Panel.qml b/shell/plugins/panels/tailscale/Panel.qml index 7c51babb..f188ca76 100644 --- a/shell/plugins/panels/tailscale/Panel.qml +++ b/shell/plugins/panels/tailscale/Panel.qml @@ -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() }