diff --git a/shell/Ui/PanelHero.qml b/shell/Ui/PanelHero.qml index 8ef8c617..7d663f37 100644 --- a/shell/Ui/PanelHero.qml +++ b/shell/Ui/PanelHero.qml @@ -14,10 +14,16 @@ Item { property real iconOpacity: 1.0 property alias metaOpacity: metaText.opacity + // Optional control pinned to the trailing edge of the hero — a ToggleSwitch, + // a small button. The hero centers it against the labels and reserves the + // space itself, so callers never do the geometry. + property Component trailingControl: null + readonly property color dim: Qt.darker(foreground, 1.4) + readonly property real trailingInset: trailingLoader.item && trailingLoader.item.visible ? trailingLoader.width + Style.space(12) : 0 width: parent ? parent.width : implicitWidth - implicitHeight: Math.max(iconLoader.implicitHeight, heroLabels.implicitHeight) + implicitHeight: Math.max(iconLoader.implicitHeight, heroLabels.implicitHeight, trailingLoader.implicitHeight) Loader { id: iconLoader @@ -32,6 +38,7 @@ Item { anchors.left: iconLoader.right anchors.leftMargin: Style.space(14) anchors.right: parent.right + anchors.rightMargin: root.trailingInset anchors.verticalCenter: parent.verticalCenter spacing: Style.space(2) @@ -91,4 +98,11 @@ Item { elide: Text.ElideRight } } + + Loader { + id: trailingLoader + sourceComponent: root.trailingControl + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + } } diff --git a/shell/Ui/Toggle.qml b/shell/Ui/Toggle.qml index 0933508f..26a5cf96 100644 --- a/shell/Ui/Toggle.qml +++ b/shell/Ui/Toggle.qml @@ -1,17 +1,17 @@ import QtQuick import qs.Commons -// Labeled toggle row: title + optional description on the left, a switch -// on the right. Clicking anywhere on the row emits `clicked()`; consumers -// flip `checked` in response (the component is stateless about the actual -// value so it composes cleanly with model-driven UI). +// Labeled toggle row: title + optional description on the left, a +// `ToggleSwitch` on the right. Clicking anywhere on the row emits `clicked()`; +// consumers flip `checked` in response (the component is stateless about the +// actual value so it composes cleanly with model-driven UI). // // Cursor and focus styling match the rest of the kit: hasCursor / mouse // hover and activeFocus share the hover-cursor defaults. // -// `rounded` auto-detects from Style.cornerRadius so the switch follows -// the theme: pill shape when Hyprland corners are rounded, square on sharp. -// Callers can override per-instance. +// `rounded` is forwarded to the switch, which auto-detects from +// Style.cornerRadius: pill shape when Hyprland corners are rounded, square on +// sharp. Callers can override per-instance. BorderSurface { id: root @@ -42,11 +42,6 @@ BorderSurface { Keys.onEnterPressed: root.clicked() Keys.onSpacePressed: root.clicked() - readonly property int trackHeight: Math.max(22, Math.round(Style.spacing.controlHeight * 0.55)) - readonly property int trackWidth: Math.max(42, Math.round(trackHeight * 1.9)) - readonly property int knobSize: Math.max(16, Math.round(trackHeight * 0.72)) - readonly property int knobInset: Math.max(2, Math.round((trackHeight - knobSize) / 2)) - implicitHeight: Math.max(54, content.implicitHeight + Style.spacing.huge) implicitWidth: Style.space(240) radius: Style.cornerRadius @@ -94,32 +89,15 @@ BorderSurface { } } - BorderSurface { + // The row owns the click, so the switch is presentation only here. + ToggleSwitch { id: track - width: root.trackWidth - height: root.trackHeight - radius: root.rounded ? height / 2 : 0 - color: root.checked - ? Style.selectedFillFor(root.foreground, root.accent) - : Style.normalFillFor(root.foreground, root.accent) - borderSpec: root.checked - ? Border.controlSpec("selected", root.foreground, root.accent) - : Border.controlSpec("normal", root.foreground, root.accent) + checked: root.checked + rounded: root.rounded + foreground: root.foreground + accent: root.accent + interactive: false anchors.verticalCenter: parent.verticalCenter - - Behavior on color { ColorAnimation { duration: 120 } } - - Rectangle { - width: root.knobSize - height: root.knobSize - radius: root.rounded ? height / 2 : 0 - x: root.checked ? track.width - width - root.knobInset : root.knobInset - y: root.knobInset - color: root.checked ? Style.selectedStateColor(root.foreground, root.accent) : Qt.darker(root.foreground, 1.25) - - Behavior on x { NumberAnimation { duration: 120; easing.type: Easing.OutCubic } } - Behavior on color { ColorAnimation { duration: 120 } } - } } } diff --git a/shell/Ui/ToggleSwitch.qml b/shell/Ui/ToggleSwitch.qml new file mode 100644 index 00000000..4da6c445 --- /dev/null +++ b/shell/Ui/ToggleSwitch.qml @@ -0,0 +1,104 @@ +import QtQuick +import qs.Commons + +// Bare on/off switch: a track with a sliding knob and no label. This is the +// switch `Toggle` parks at the end of its labeled row, factored out so panel +// headers and other compact controls render the identical thing. +// +// The caller owns the value: bind `checked` to real state and flip it in +// response to `toggled()`. Services that already track a desired state +// optimistically (see the Tailscale service's `_desired`) get an instant knob +// throw for free, because `checked` is already the optimistic value. +// +// `busy` marks an operation in flight and swallows further clicks, but leaves +// hover, cursor, and tooltips alone so the control does not flicker every time +// a background refresh runs. +// +// The cursor is a ring drawn outside the track rather than a state on the +// track itself: themes give normal chrome a stronger border than hover-cursor +// (0.4 vs 0.25 by default), which is right for controls that are borderless at +// rest but would make a bordered track go *fainter* under the cursor. On the +// panel background the ring reads immediately. `cursorRing` follows +// `interactive` — a switch whose surrounding row owns the click owns the +// cursor too. +// +// `rounded` auto-detects from Style.cornerRadius so the switch follows the +// theme: pill shape when Hyprland corners are rounded, square on sharp. +Item { + id: root + + property bool checked: false + property bool busy: false + + // Off when the surrounding row owns the click, as in `Toggle`. + property bool interactive: true + + // Panel-cursor flag. Same role as Button.hasCursor: panels with their own + // keyboard cursor bind this to drive the highlight separately from hover. + property bool hasCursor: false + + property bool cursorRing: interactive + property int cursorPad: Style.space(6) + property bool rounded: Style.cornerRadius > 0 + property color foreground: Color.foreground + property color accent: Color.accent + + signal toggled() + signal hovered(bool isHovered) + + readonly property alias containsMouse: mouse.containsMouse + readonly property bool hot: hasCursor || mouse.containsMouse + readonly property int trackHeight: Math.max(22, Math.round(Style.spacing.controlHeight * 0.55)) + readonly property int trackWidth: Math.max(42, Math.round(trackHeight * 1.9)) + readonly property int knobSize: Math.max(16, Math.round(trackHeight * 0.72)) + readonly property int knobInset: Math.max(2, Math.round((trackHeight - knobSize) / 2)) + + readonly property int _pad: cursorRing ? cursorPad : 0 + + implicitWidth: trackWidth + _pad * 2 + implicitHeight: trackHeight + _pad * 2 + + BorderSurface { + anchors.fill: parent + visible: root.cursorRing && root.hot + color: "transparent" + radius: Style.cornerRadius + borderSpec: Border.controlSpec("hover-cursor", root.foreground, root.accent) + } + + BorderSurface { + id: track + width: root.trackWidth + height: root.trackHeight + anchors.centerIn: parent + radius: root.rounded ? height / 2 : 0 + color: root.checked + ? Style.selectedFillFor(root.foreground, root.accent) + : Style.normalFillFor(root.foreground, root.accent) + borderSpec: Border.controlSpec(root.checked ? "selected" : "normal", root.foreground, root.accent) + + Behavior on color { ColorAnimation { duration: 120 } } + + Rectangle { + width: root.knobSize + height: root.knobSize + radius: root.rounded ? height / 2 : 0 + x: root.checked ? track.width - width - root.knobInset : root.knobInset + anchors.verticalCenter: parent.verticalCenter + color: root.checked ? Style.selectedStateColor(root.foreground, root.accent) : Qt.darker(root.foreground, 1.25) + + Behavior on x { NumberAnimation { duration: 120; easing.type: Easing.OutCubic } } + Behavior on color { ColorAnimation { duration: 120 } } + } + } + + MouseArea { + id: mouse + anchors.fill: parent + enabled: root.interactive + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onContainsMouseChanged: root.hovered(containsMouse) + onClicked: if (!root.busy) root.toggled() + } +} diff --git a/shell/Ui/qmldir b/shell/Ui/qmldir index 5953f312..92d39848 100644 --- a/shell/Ui/qmldir +++ b/shell/Ui/qmldir @@ -28,4 +28,5 @@ PopupCard 1.0 PopupCard.qml SearchableDropdown 1.0 SearchableDropdown.qml TextField 1.0 TextField.qml Toggle 1.0 Toggle.qml +ToggleSwitch 1.0 ToggleSwitch.qml WidgetButton 1.0 WidgetButton.qml diff --git a/shell/plugins/dev-gallery/GalleryPanel.qml b/shell/plugins/dev-gallery/GalleryPanel.qml index aec74b36..945b6252 100644 --- a/shell/plugins/dev-gallery/GalleryPanel.qml +++ b/shell/plugins/dev-gallery/GalleryPanel.qml @@ -105,6 +105,8 @@ Item { property string choiceDemoValue: "top" property bool toggleDemoOn: true property bool toggleSquareOn: false + property bool switchDemoOn: true + property bool switchBusyOn: false property string dropdownDemoValue: "Clock" property string searchableDemoValue: "" property int numberDemoValue: 15 @@ -112,7 +114,7 @@ Item { readonly property var visibleSections: [ "cursor-surface", "button", "button-group", "panel-action-button", "panel-tool-tip", "slider", "text-field", "number-field", - "toggle", "dropdown", "searchable-dropdown", "composed" + "toggle", "toggle-switch", "dropdown", "searchable-dropdown", "composed" ] function sectionCount(section) { @@ -126,6 +128,7 @@ Item { case "text-field": return 2 case "number-field": return 1 case "toggle": return 2 + case "toggle-switch": return 2 case "dropdown": return 1 case "searchable-dropdown": return 1 case "composed": return 2 @@ -139,6 +142,7 @@ Item { return section === "button" || section === "button-group" || section === "panel-action-button" + || section === "toggle-switch" } // True for sections where h/l should adjust a value rather than walk. @@ -214,6 +218,11 @@ Item { else root.toggleSquareOn = !root.toggleSquareOn return } + if (focusSection === "toggle-switch") { + // The busy switch swallows activation the same way it swallows clicks. + if (selectedIndex === 0) root.switchDemoOn = !root.switchDemoOn + return + } if (focusSection === "dropdown") { demoDropdown.toggle() return @@ -1470,6 +1479,69 @@ Item { } } + // ---- ToggleSwitch -------------------------------------------------- + Column { + width: parent.width + spacing: Style.space(8) + + Text { + text: "ToggleSwitch" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.subtitle + font.bold: true + } + Text { + text: "The bare switch Toggle puts at the end of its row, for places with no room for a labeled row — a panel hero's trailingControl, for instance. Caller owns `checked` and flips it on `toggled()`. Set `busy` while an operation is in flight to swallow further clicks without disturbing hover or tooltips; services that track a desired state optimistically get an instant knob throw because `checked` is already the optimistic value." + color: Qt.darker(root.foreground, 1.5) + font.family: root.fontFamily + font.pixelSize: Style.font.caption + width: parent.width + wrapMode: Text.WordWrap + } + + Row { + spacing: Style.space(24) + + ToggleSwitch { + checked: root.switchDemoOn + foreground: root.foreground + accent: root.accent + hasCursor: root.focusSection === "toggle-switch" && root.selectedIndex === 0 + onHovered: function(h) { + if (h) { root.focusSection = "toggle-switch"; root.selectedIndex = 0 } + } + onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(this) + onToggled: { + root.focusSection = "toggle-switch"; root.selectedIndex = 0 + root.switchDemoOn = !root.switchDemoOn + } + } + + ToggleSwitch { + checked: root.switchBusyOn + busy: true + foreground: root.foreground + accent: root.accent + hasCursor: root.focusSection === "toggle-switch" && root.selectedIndex === 1 + onHovered: function(h) { + if (h) { root.focusSection = "toggle-switch"; root.selectedIndex = 1 } + } + onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(this) + onToggled: root.switchBusyOn = !root.switchBusyOn + } + } + + Text { + text: "The second switch is `busy: true` — hover still responds, clicks do not." + color: Qt.darker(root.foreground, 1.5) + font.family: root.fontFamily + font.pixelSize: Style.font.caption + width: parent.width + wrapMode: Text.WordWrap + } + } + // ---- Dropdown ----------------------------------------------------- Column { width: parent.width diff --git a/shell/plugins/panels/audio/Panel.qml b/shell/plugins/panels/audio/Panel.qml index a47fdf61..f93ff0b6 100644 --- a/shell/plugins/panels/audio/Panel.qml +++ b/shell/plugins/panels/audio/Panel.qml @@ -165,7 +165,7 @@ Panel { // "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 string toggleHint: root.outputMuted ? "Unmute output" : "Mute output" readonly property color hoverFill: bar ? Style.hoverFillFor(bar.foreground, Color.accent) @@ -373,6 +373,10 @@ Panel { function clampCursor() { var sections = visibleSections if (!sections || !sections.length) return + // "header" is virtual and never appears in visibleSections, so it has to + // be let through: muting republishes the PipeWire snapshot, and clamping + // would knock the cursor off the hero switch on every toggle. + if (focusSection === "header") return if (sections.indexOf(focusSection) < 0) { focusSection = visibleSections[0] selectedIndex = sectionHasSlider(focusSection) ? -1 : 0 @@ -670,19 +674,9 @@ Panel { Item { id: heroItem width: parent.width - 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) - } + implicitHeight: Math.max(heroIcon.implicitHeight, heroLabels.implicitHeight, powerSwitch.implicitHeight) + // Status only — the switch owns muting, mouse and keyboard alike. Text { id: heroIcon text: root.outputIcon() @@ -691,15 +685,26 @@ 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() + // Compact on/off switch on the trailing edge of the hero, and the + // header's only cursor target. Checked means audible, so muting + // reads as switching the output off. + ToggleSwitch { + id: powerSwitch + checked: !root.outputMuted + hasCursor: root.headerHasCursor + foreground: root.bar.foreground + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + onHovered: function(on) { if (on) root.setHeaderCursor() } + onToggled: root.toggleOutputMute() + + PanelToolTip { + visible: powerSwitch.containsMouse + text: root.toggleHint + fontFamily: root.bar.fontFamily } } @@ -708,6 +713,7 @@ Panel { anchors.left: heroIcon.right anchors.leftMargin: Style.space(14) anchors.right: parent.right + anchors.rightMargin: powerSwitch.width + Style.space(12) anchors.verticalCenter: parent.verticalCenter spacing: Style.space(2) diff --git a/shell/plugins/panels/bluetooth/Panel.qml b/shell/plugins/panels/bluetooth/Panel.qml index c769b519..8e036595 100644 --- a/shell/plugins/panels/bluetooth/Panel.qml +++ b/shell/plugins/panels/bluetooth/Panel.qml @@ -91,7 +91,7 @@ Panel { // 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 string toggleHint: root.adapter && root.adapter.enabled ? "Turn Bluetooth off" : "Turn Bluetooth on" readonly property color hoverFill: bar ? Style.hoverFillFor(bar.foreground, Color.accent) @@ -406,6 +406,11 @@ Panel { function clampCursor() { var sections = visibleSections + // "header" is virtual and never appears in visibleSections, so it has to + // be let through: toggling the adapter empties and refills the device + // lists, and clamping would knock the cursor off the hero switch every + // time it is used. + if (focusSection === "header") return if (!sections || !sections.length) { selectedIndex = 0 return @@ -540,43 +545,36 @@ Panel { // ---------- Hero: Bluetooth icon · status ---------- Item { width: parent.width - 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) - } + implicitHeight: Math.max(heroIcon.implicitHeight, heroLabels.implicitHeight, powerSwitch.implicitHeight) + // Status only — the switch owns toggling, mouse and keyboard alike. Text { id: heroIcon anchors.left: parent.left - anchors.leftMargin: root.heroRingPad anchors.verticalCenter: parent.verticalCenter text: root.icon color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.display opacity: root.adapter && root.adapter.enabled ? 1.0 : 0.5 + } - MouseArea { - id: heroIconMouse - anchors.fill: parent - hoverEnabled: true - cursorShape: root.adapter ? Qt.PointingHandCursor : Qt.ArrowCursor - enabled: !!root.adapter - onContainsMouseChanged: if (containsMouse) root.setHeaderCursor() - onClicked: root.toggleBluetooth() - } + // Compact on/off switch on the trailing edge of the hero, and the + // header's only cursor target. + ToggleSwitch { + id: powerSwitch + visible: !!root.adapter + checked: !!root.adapter && root.adapter.enabled + hasCursor: root.headerHasCursor + foreground: root.bar.foreground + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + onHovered: function(on) { if (on) root.setHeaderCursor() } + onToggled: root.toggleBluetooth() PanelToolTip { - visible: heroIconMouse.containsMouse - text: root.adapter && root.adapter.enabled ? "Turn Bluetooth off" : "Turn Bluetooth on" + visible: powerSwitch.containsMouse + text: root.toggleHint fontFamily: root.bar.fontFamily } } @@ -586,6 +584,7 @@ Panel { anchors.left: heroIcon.right anchors.leftMargin: Style.space(14) anchors.right: parent.right + anchors.rightMargin: powerSwitch.visible ? powerSwitch.width + Style.space(12) : 0 anchors.verticalCenter: parent.verticalCenter spacing: Style.space(2) diff --git a/shell/plugins/panels/dropbox/Panel.qml b/shell/plugins/panels/dropbox/Panel.qml index c153c401..f1dc2301 100644 --- a/shell/plugins/panels/dropbox/Panel.qml +++ b/shell/plugins/panels/dropbox/Panel.qml @@ -37,9 +37,11 @@ Panel { readonly property color dim: Qt.darker(foreground, 1.55) readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family readonly property color iconColor: dropbox.authenticated && dropbox.active ? foreground : dim + readonly property string toggleHint: dropbox.active ? "Pause syncing" : "Resume syncing" 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) + // Only claim the header cursor when the switch is actually on screen — + // "header" stays navigable, but an absent CLI leaves nothing to highlight. + readonly property bool headerHasCursor: cursorActive && focusSection === "header" && dropbox.installed function ensureCursor() { if (!dropbox.authenticated) { @@ -232,61 +234,46 @@ Panel { id: header visible: dropbox.authenticated width: parent.width - implicitHeight: hero.implicitHeight + root.heroRingPad - // Exposed for the hero's iconComponent, whose `root` resolves to + implicitHeight: hero.implicitHeight + // Exposed for the hero's trailingControl, 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 - x: root.heroRingPad - y: root.heroRingPad - width: parent.width - root.heroRingPad + 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 + // Status only — the switch owns toggling, mouse and keyboard alike. iconComponent: Component { - Item { - implicitWidth: heroIcon.implicitWidth - implicitHeight: heroIcon.implicitHeight + DropboxIcon { + iconSize: Style.font.display + color: root.iconColor + } + } - // 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) - } - - 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() - } + // Compact on/off switch on the trailing edge of the hero, and the + // header's only cursor target. The service already flips `active` + // optimistically, so the knob throws the instant you click it. + trailingControl: Component { + ToggleSwitch { + id: powerSwitch + visible: dropbox.installed + checked: dropbox.active + busy: dropbox.busy + hasCursor: header.ringVisible + foreground: hero.foreground + onHovered: function(on) { if (on) header.focusHero() } + onToggled: root.toggleRunning() PanelToolTip { - visible: heroMouse.containsMouse - text: dropbox.active ? "Pause syncing" : "Resume syncing" - fontFamily: root.fontFamily + visible: powerSwitch.containsMouse + text: root.toggleHint + fontFamily: hero.fontFamily } } } diff --git a/shell/plugins/panels/network/Panel.qml b/shell/plugins/panels/network/Panel.qml index aa55f1dd..ffe54434 100644 --- a/shell/plugins/panels/network/Panel.qml +++ b/shell/plugins/panels/network/Panel.qml @@ -109,9 +109,16 @@ Panel { property int headerIndex: 0 readonly property bool canDisconnect: !!connectedWifiNetwork readonly property bool headerHasDisconnect: false - readonly property int headerActionCount: networkManagerAvailable ? 1 : 0 - readonly property bool headerHasCursor: cursorActive && focusSection === "header" - readonly property int heroRingPad: Style.space(6) + // The hero switch is the Wi-Fi radio and nothing else, so it only exists + // when there is a radio to switch. A click carried no state, but a switch + // asserts one: on a wired box it would otherwise sit there reading "off" + // beside a perfectly live Ethernet connection. + readonly property bool canToggleWifi: networkManagerAvailable && wifiStationAvailable + readonly property int headerActionCount: canToggleWifi ? 1 : 0 + // Only claim the header cursor when the switch is actually on screen — + // "header" stays navigable, but a machine with no radio has nothing to highlight. + readonly property bool headerHasCursor: cursorActive && focusSection === "header" && canToggleWifi + readonly property string toggleHint: Networking.wifiEnabled ? "Turn Wi-Fi off" : "Turn Wi-Fi on" readonly property var dnsProviders: ["DHCP", "Cloudflare", "Google", "Custom"] property int dnsIndex: 0 @@ -855,19 +862,9 @@ Panel { // ---------- Hero: network icon · SSID + state · actions ---------- Item { width: parent.width - 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) - } + implicitHeight: Math.max(heroIcon.implicitHeight, heroLabels.implicitHeight, powerSwitch.implicitHeight) + // Status only — the switch owns toggling, mouse and keyboard alike. Text { id: heroIcon text: root.icon @@ -876,25 +873,25 @@ 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 { - id: heroIconMouse - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - enabled: root.networkManagerAvailable - onContainsMouseChanged: if (containsMouse) root.setHeaderCursor() - onClicked: { - Networking.wifiEnabled = !Networking.wifiEnabled - Qt.callLater(function() { root.refresh(true) }) - } - } + // Compact on/off switch on the trailing edge of the hero, and the + // header's only cursor target. + ToggleSwitch { + id: powerSwitch + visible: root.canToggleWifi + checked: Networking.wifiEnabled + hasCursor: root.headerHasCursor + foreground: root.bar.foreground + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + onHovered: function(on) { if (on) root.setHeaderCursor() } + onToggled: root.toggleNetwork() PanelToolTip { - visible: heroIconMouse.containsMouse - text: root.info.type === "ethernet" ? "Toggle network" : "Toggle Wi-Fi" + visible: powerSwitch.containsMouse + text: root.toggleHint fontFamily: root.bar.fontFamily } } @@ -904,10 +901,13 @@ Panel { anchors.left: heroIcon.right anchors.leftMargin: Style.space(14) anchors.right: parent.right + anchors.rightMargin: powerSwitch.visible ? powerSwitch.width + Style.space(12) : 0 anchors.verticalCenter: parent.verticalCenter spacing: Style.space(2) - Row { + // Link detail rides inline after the name — "Ethernet (2.5gbit)" — + // rather than in a pill, which crowded the on/off switch. + Text { id: heroSsid width: parent.width @@ -918,38 +918,12 @@ Panel { } readonly property string detail: root.headerDetail() - Text { - text: heroSsid.title - width: Math.min(implicitWidth, Math.max(0, parent.width - (heroDetailPill.visible ? heroDetailPill.implicitWidth + Style.space(8) : 0))) - color: root.bar.foreground - font.family: root.bar.fontFamily - font.pixelSize: Style.font.title - font.bold: true - elide: Text.ElideRight - } - - Item { width: Math.max(0, parent.width - parent.children[0].width - heroDetailPill.implicitWidth); height: 1 } - - BorderSurface { - id: heroDetailPill - visible: heroSsid.detail !== "" - implicitWidth: heroDetail.implicitWidth + Style.space(10) - implicitHeight: heroDetail.implicitHeight + Style.space(4) - anchors.verticalCenter: parent.verticalCenter - color: "transparent" - borderSpec: Border.controlSpec("normal", root.bar.foreground, Color.accent) - radius: Style.cornerRadius - - Text { - id: heroDetail - anchors.centerIn: parent - text: heroSsid.detail - color: Qt.darker(root.bar.foreground, 1.4) - font.family: root.bar.fontFamily - font.pixelSize: Style.font.body - font.bold: true - } - } + text: heroSsid.detail !== "" ? heroSsid.title + " (" + heroSsid.detail + ")" : heroSsid.title + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: Style.font.title + font.bold: true + elide: Text.ElideRight } Text { diff --git a/shell/plugins/panels/tailscale/Panel.qml b/shell/plugins/panels/tailscale/Panel.qml index 3202a686..5c495706 100644 --- a/shell/plugins/panels/tailscale/Panel.qml +++ b/shell/plugins/panels/tailscale/Panel.qml @@ -48,9 +48,11 @@ 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) + // Only claim the header cursor when the switch is actually on screen — + // "header" stays navigable, but an absent CLI leaves nothing to highlight. + readonly property bool headerHasCursor: cursorActive && focusSection === "header" && tailscale.installed readonly property color iconColor: tailscale.active ? foreground : dim + readonly property string toggleHint: tailscale.active ? "Turn Tailscale off" : (tailscale.needsLogin ? "Authorize this device" : "Turn Tailscale on") readonly property color barIconColor: tailscale.active ? barForeground : Qt.darker(barForeground, 1.55) readonly property color hoverFill: bar ? Style.hoverFillFor(bar.foreground, Color.accent) : "transparent" readonly property color selectedFill: bar ? Style.selectedFillFor(bar.foreground, Color.accent) : "transparent" @@ -445,64 +447,49 @@ Panel { Item { id: header width: parent.width - implicitHeight: hero.implicitHeight + root.heroRingPad - // Exposed for the hero's iconComponent, whose `root` resolves to + implicitHeight: hero.implicitHeight + // Exposed for the hero's trailingControl, 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 - x: root.heroRingPad - y: root.heroRingPad - width: parent.width - root.heroRingPad + width: parent.width title: tailscale.installed ? (tailscale.selfName || "Tailscale") : "Tailscale" meta: tailscale.active ? root.heroPhraseText : "Tailscale is disconnected" foreground: root.foreground fontFamily: root.fontFamily iconOpacity: tailscale.active ? 1.0 : 0.5 + // Status only — the switch owns toggling, mouse and keyboard alike. iconComponent: Component { - Item { - implicitWidth: icon.implicitWidth - implicitHeight: icon.implicitHeight + TailscaleIcon { + iconSize: Style.font.display + color: root.iconColor + badgeColor: root.urgent + crossed: !tailscale.active && !tailscale.needsLogin + warning: tailscale.needsLogin + } + } - // 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 - color: root.iconColor - badgeColor: root.urgent - crossed: !tailscale.active && !tailscale.needsLogin - warning: tailscale.needsLogin - anchors.centerIn: parent - } - - MouseArea { - id: heroIconMouse - anchors.fill: parent - hoverEnabled: true - enabled: tailscale.installed && !tailscale.busy - cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor - onContainsMouseChanged: if (containsMouse) header.focusHero() - onClicked: tailscale.toggleTailscale() - } + // Compact on/off switch on the trailing edge of the hero, and the + // header's only cursor target. The service already flips `active` + // optimistically, so the knob throws the instant you click it. + trailingControl: Component { + ToggleSwitch { + id: powerSwitch + visible: tailscale.installed + checked: tailscale.active + busy: tailscale.busy + hasCursor: header.ringVisible + foreground: hero.foreground + onHovered: function(on) { if (on) header.focusHero() } + onToggled: tailscale.toggleTailscale() PanelToolTip { - visible: heroIconMouse.containsMouse - text: tailscale.active ? "Turn Tailscale off" : (tailscale.needsLogin ? "Authorize this device" : "Turn Tailscale on") - fontFamily: root.fontFamily + visible: powerSwitch.containsMouse + text: root.toggleHint + fontFamily: hero.fontFamily } } }