From 832244c10eff59813f32c9a673e25676ff3bc921 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Tue, 19 May 2026 01:02:16 -0400 Subject: [PATCH] Refine shell cursor highlights --- default/themed/shell.toml.tpl | 2 +- shell/Commons/Style.qml | 2 +- shell/plugins/bar/widgets/audioPanel.qml | 23 +++-- shell/plugins/bar/widgets/bluetoothPanel.qml | 15 ++- shell/plugins/bar/widgets/monitorPanel.qml | 17 +++- shell/plugins/bar/widgets/networkPanel.qml | 96 +++++++++++++++---- shell/plugins/bar/widgets/powerPanel.qml | 12 ++- .../clipboard-picker/ClipboardPicker.qml | 31 ++++-- shell/plugins/dev-gallery/GalleryPanel.qml | 2 +- shell/plugins/emoji-picker/EmojiPicker.qml | 39 ++++++-- shell/plugins/menu/Menu.qml | 33 +++++-- 11 files changed, 206 insertions(+), 66 deletions(-) diff --git a/default/themed/shell.toml.tpl b/default/themed/shell.toml.tpl index 2ee95dce..42a202a7 100644 --- a/default/themed/shell.toml.tpl +++ b/default/themed/shell.toml.tpl @@ -26,7 +26,7 @@ normal-border-alpha = 0.4 hover-cursor-color = "foreground" hover-cursor-fill-alpha = 0.08 hover-cursor-border-width = 1 -hover-cursor-border-alpha = 1.0 +hover-cursor-border-alpha = 0.25 # Selected: persistent chosen/current state. selected-color = "foreground" diff --git a/shell/Commons/Style.qml b/shell/Commons/Style.qml index 32c7d372..90422b1f 100644 --- a/shell/Commons/Style.qml +++ b/shell/Commons/Style.qml @@ -115,7 +115,7 @@ QtObject { readonly property real selectionFillAlpha: styleAlpha("selection-fill-alpha", 0.35) readonly property real normalBorderAlpha: styleAlpha(["normal-border-alpha", "idle-border-alpha"], 0.4) - readonly property real hoverBorderAlpha: styleAlpha(["hover-cursor-border-alpha", "hover-border-alpha"], 1.0) + readonly property real hoverBorderAlpha: styleAlpha(["hover-cursor-border-alpha", "hover-border-alpha"], 0.25) readonly property real selectedBorderAlpha: styleAlpha("selected-border-alpha", 1.0) readonly property real focusBorderAlpha: styleAlpha("focus-border-alpha", hoverBorderAlpha) diff --git a/shell/plugins/bar/widgets/audioPanel.qml b/shell/plugins/bar/widgets/audioPanel.qml index 1ea03c0c..89b49d9c 100644 --- a/shell/plugins/bar/widgets/audioPanel.qml +++ b/shell/plugins/bar/widgets/audioPanel.qml @@ -105,6 +105,7 @@ Item { // keyboard + mouse like wifi does. property string focusSection: "output" property int selectedIndex: -1 + property bool cursorActive: false readonly property color hoverFill: bar ? Style.hoverFillFor(bar.foreground, Color.accent) @@ -216,7 +217,8 @@ Item { onPopupOpenChanged: { if (popupOpen) { focusSection = "output" - selectedIndex = -1 // start on the output slider + selectedIndex = -1 // first keyboard cursor reveal starts on the output slider + cursorActive = false Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() }) } } @@ -488,15 +490,17 @@ for block in re.split(r"(?m)^Sink #", sys.stdin.read())[1:]: id: keyCatcher anchors.fill: parent onMoveRequested: function(dx, dy) { + if (!root.cursorActive) { root.cursorActive = true; return } if (dy !== 0) root.moveCursor(dy) else if (dx !== 0) root.adjustVolume(dx * 0.05) } - onActivateRequested: root.activateCursor() + onActivateRequested: if (root.cursorActive) 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.cursorActive) return if (root.focusSection === "streams" && root.selectedIndex >= 0 && root.selectedIndex < root.audioStreams.length) { var s = root.audioStreams[root.selectedIndex] @@ -556,7 +560,7 @@ for block in re.split(r"(?m)^Sink #", sys.stdin.read())[1:]: id: outputSliderRow width: parent.width height: outputSliderInner.implicitHeight + Style.spacing.controlGap - hasCursor: root.focusSection === "output" && root.selectedIndex === -1 + hasCursor: root.cursorActive && root.focusSection === "output" && root.selectedIndex === -1 onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(outputSliderRow) foreground: root.bar.foreground outline: true @@ -620,6 +624,7 @@ for block in re.split(r"(?m)^Sink #", sys.stdin.read())[1:]: acceptedButtons: Qt.NoButton propagateComposedEvents: true onContainsMouseChanged: if (containsMouse) { + root.cursorActive = true root.focusSection = "output" root.selectedIndex = -1 } @@ -673,7 +678,7 @@ for block in re.split(r"(?m)^Sink #", sys.stdin.read())[1:]: visible: !!root.source width: parent.width height: inputSliderInner.implicitHeight + Style.spacing.controlGap - hasCursor: root.focusSection === "input" && root.selectedIndex === -1 + hasCursor: root.cursorActive && root.focusSection === "input" && root.selectedIndex === -1 onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(inputSliderRow) foreground: root.bar.foreground outline: true @@ -737,6 +742,7 @@ for block in re.split(r"(?m)^Sink #", sys.stdin.read())[1:]: acceptedButtons: Qt.NoButton propagateComposedEvents: true onContainsMouseChanged: if (containsMouse) { + root.cursorActive = true root.focusSection = "input" root.selectedIndex = -1 } @@ -797,7 +803,7 @@ for block in re.split(r"(?m)^Sink #", sys.stdin.read())[1:]: required property int rowIndex readonly property bool isActive: root.sink && node && root.sink.id === node.id - hasCursor: root.focusSection === "output" && root.selectedIndex === rowIndex + hasCursor: root.cursorActive && root.focusSection === "output" && root.selectedIndex === rowIndex onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(sinkRow) current: isActive foreground: root.bar.foreground @@ -850,6 +856,7 @@ for block in re.split(r"(?m)^Sink #", sys.stdin.read())[1:]: hoverEnabled: true cursorShape: Qt.PointingHandCursor onContainsMouseChanged: if (containsMouse) { + root.cursorActive = true root.focusSection = "output" root.selectedIndex = sinkRow.rowIndex } @@ -864,7 +871,7 @@ for block in re.split(r"(?m)^Sink #", sys.stdin.read())[1:]: required property int rowIndex readonly property bool isActive: root.source && node && root.source.id === node.id - hasCursor: root.focusSection === "input" && root.selectedIndex === rowIndex + hasCursor: root.cursorActive && root.focusSection === "input" && root.selectedIndex === rowIndex onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(sourceRow) current: isActive foreground: root.bar.foreground @@ -917,6 +924,7 @@ for block in re.split(r"(?m)^Sink #", sys.stdin.read())[1:]: hoverEnabled: true cursorShape: Qt.PointingHandCursor onContainsMouseChanged: if (containsMouse) { + root.cursorActive = true root.focusSection = "input" root.selectedIndex = sourceRow.rowIndex } @@ -936,7 +944,7 @@ for block in re.split(r"(?m)^Sink #", sys.stdin.read())[1:]: readonly property real streamVolume: node && node.audio ? node.audio.volume : 0 readonly property bool streamMuted: node && node.audio ? node.audio.muted : false - hasCursor: root.focusSection === "streams" && root.selectedIndex === rowIndex + hasCursor: root.cursorActive && root.focusSection === "streams" && root.selectedIndex === rowIndex onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(streamRow) foreground: root.bar.foreground fill: root.hoverFill @@ -1020,6 +1028,7 @@ for block in re.split(r"(?m)^Sink #", sys.stdin.read())[1:]: acceptedButtons: Qt.NoButton propagateComposedEvents: true onContainsMouseChanged: if (containsMouse) { + root.cursorActive = true root.focusSection = "streams" root.selectedIndex = streamRow.rowIndex } diff --git a/shell/plugins/bar/widgets/bluetoothPanel.qml b/shell/plugins/bar/widgets/bluetoothPanel.qml index fcedf3a9..5baf51ae 100644 --- a/shell/plugins/bar/widgets/bluetoothPanel.qml +++ b/shell/plugins/bar/widgets/bluetoothPanel.qml @@ -100,7 +100,8 @@ Item { // never from containsMouse. Mouse hover updates root cursor state too, // guaranteeing one highlight on screen. property string focusSection: "header" - property int selectedIndex: 1 // default = toggle pill + property int selectedIndex: 1 // default = toggle pill once the cursor is revealed + property bool cursorActive: false readonly property int headerPillCount: 2 // Stable identity for the focused known device. The known list is sorted @@ -224,6 +225,7 @@ Item { if (adapter && adapter.enabled && !adapter.discovering) adapter.discovering = true if (knownDevices.length > 0) { focusSection = "known"; selectedIndex = 0 } else { focusSection = "header"; selectedIndex = 1 } + cursorActive = false Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() }) } } @@ -369,12 +371,13 @@ Item { id: keyCatcher anchors.fill: parent onMoveRequested: function(dx, dy) { + if (!root.cursorActive) { root.cursorActive = true; return } if (dy !== 0) root.moveCursor(dy) else if (dx !== 0) root.moveCursorH(dx) } - onActivateRequested: root.activateCursor() + onActivateRequested: if (root.cursorActive) root.activateCursor() onCloseRequested: root.closePopout() - onDeleteRequested: root.deleteSelected() + onDeleteRequested: if (root.cursorActive) root.deleteSelected() Column { id: column @@ -523,11 +526,12 @@ Item { enabled: pillEnabled opacity: pillEnabled ? 1 : 0.4 - hasCursor: root.focusSection === "header" && root.selectedIndex === pillIndex + hasCursor: root.cursorActive && root.focusSection === "header" && root.selectedIndex === pillIndex onClicked: pill.activated() onHovered: function(isHovered) { if (!isHovered) return + root.cursorActive = true root.focusSection = "header" root.selectedIndex = pill.pillIndex } @@ -548,7 +552,7 @@ Item { readonly property int devState: dev && dev.state !== undefined ? dev.state : -1 readonly property string sectionName: isDiscovered ? "discovered" : "known" - hasCursor: root.focusSection === sectionName && root.selectedIndex === rowIndex + hasCursor: root.cursorActive && root.focusSection === sectionName && root.selectedIndex === rowIndex onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(row) current: isConnected foreground: root.bar.foreground @@ -623,6 +627,7 @@ Item { cursorShape: row.dev ? Qt.PointingHandCursor : Qt.ArrowCursor onContainsMouseChanged: if (containsMouse) { + root.cursorActive = true root.focusSection = row.sectionName root.selectedIndex = row.rowIndex } diff --git a/shell/plugins/bar/widgets/monitorPanel.qml b/shell/plugins/bar/widgets/monitorPanel.qml index 82506226..1dc2bce8 100644 --- a/shell/plugins/bar/widgets/monitorPanel.qml +++ b/shell/plugins/bar/widgets/monitorPanel.qml @@ -40,6 +40,7 @@ Item { readonly property var scaleValues: ["1", "1.25", "1.6", "2", "3", "4"] property string focusSection: "scale" property int selectedIndex: 0 + property bool cursorActive: false readonly property var visibleSections: { var list = [] @@ -271,7 +272,8 @@ Item { Component.onCompleted: refresh() // KeyboardPanel takes Exclusive focus at map-time, so SUPER-bound IPC - // summons land with j/k ready to navigate. Seed the cursor on each open. + // summons land with j/k ready to navigate. Keep a default landing point, + // but don't paint the cursor until hover or the first navigation key. onPopupOpenChanged: { if (popupOpen) { refresh() @@ -282,6 +284,7 @@ Item { focusSection = "scale" selectedIndex = 0 } + cursorActive = false Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() }) } } @@ -374,13 +377,14 @@ Item { id: keyCatcher anchors.fill: parent onMoveRequested: function(dx, dy) { + if (!root.cursorActive) { root.cursorActive = true; return } if (dy !== 0) root.moveCursor(dy) else if (dx !== 0) { if (root.focusSection === "brightness") root.adjustBrightness(dx * 5) else if (root.focusSection === "scale") root.moveCursorH(dx) } } - onActivateRequested: root.activateCursor() + onActivateRequested: if (root.cursorActive) root.activateCursor() onCloseRequested: root.closePopout() ScrollView { @@ -412,7 +416,7 @@ Item { visible: root.brightnessAvailable width: parent.width height: brightnessInner.implicitHeight + Style.spacing.controlGap - hasCursor: root.focusSection === "brightness" && root.selectedIndex === -1 + hasCursor: root.cursorActive && root.focusSection === "brightness" && root.selectedIndex === -1 onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(brightnessRow) foreground: root.bar.foreground outline: true @@ -465,6 +469,7 @@ Item { HoverHandler { onHoveredChanged: if (hovered) { + root.cursorActive = true root.focusSection = "brightness" root.selectedIndex = -1 } @@ -514,10 +519,11 @@ Item { horizontalPadding: 0 verticalPadding: Style.spacing.controlPaddingY active: root.normalizeScale(root.monitorScale) === root.normalizeScale(modelData) - hasCursor: root.focusSection === "scale" && root.selectedIndex === index + hasCursor: root.cursorActive && root.focusSection === "scale" && root.selectedIndex === index onClicked: root.setScale(modelData) onHovered: function(h) { if (h) { + root.cursorActive = true root.focusSection = "scale" root.selectedIndex = index } @@ -555,10 +561,11 @@ Item { foreground: root.bar.foreground accent: root.bar.foreground fontFamily: root.bar.fontFamily - hasCursor: root.focusSection === "monitors" && root.selectedIndex === index + hasCursor: root.cursorActive && root.focusSection === "monitors" && root.selectedIndex === index onClicked: root.toggleDisplay(modelData.name, modelData.enabled) onHovered: function(h) { if (h) { + root.cursorActive = true root.focusSection = "monitors" root.selectedIndex = index } diff --git a/shell/plugins/bar/widgets/networkPanel.qml b/shell/plugins/bar/widgets/networkPanel.qml index bcb74a16..67e96907 100644 --- a/shell/plugins/bar/widgets/networkPanel.qml +++ b/shell/plugins/bar/widgets/networkPanel.qml @@ -47,14 +47,38 @@ Item { // Index into `wifiNetworks` for keyboard navigation. -1 = no selection. property int selectedIndex: -1 + property bool cursorActive: false - // Keyboard focus zone for the panel. j/k crosses the boundary: from the - // top of the wifi list a k goes back up to the DNS row, j from DNS drops - // into the wifi list. h/l only mean something while focused on DNS. - property string focusSection: "dns" // "dns" | "wifi" + // Keyboard focus zone for the panel. j/k crosses row boundaries: + // header actions ⇄ DNS row ⇄ Wi-Fi networks. h/l move within header + // actions or DNS providers. + property string focusSection: "dns" // "header" | "dns" | "wifi" + property int headerIndex: 0 + readonly property bool canDisconnect: info.type === "wifi" && !!info.ssid + readonly property int headerActionCount: canDisconnect ? 2 : 1 readonly property var dnsProviders: ["DHCP", "Cloudflare", "Google", "Custom"] property int dnsIndex: 0 + onHeaderActionCountChanged: clampHeaderIndex() + + function clampHeaderIndex() { + var max = Math.max(0, headerActionCount - 1) + if (headerIndex > max) headerIndex = max + if (headerIndex < 0) headerIndex = 0 + } + + function selectHeaderByDelta(delta) { + headerIndex = Math.max(0, Math.min(headerActionCount - 1, headerIndex + delta)) + } + + function activateHeader() { + if (canDisconnect && headerIndex === 0) { + if (!busy) disconnect(info.ssid) + return + } + refresh(true) + } + function selectDnsByDelta(delta) { dnsIndex = Math.max(0, Math.min(dnsProviders.length - 1, dnsIndex + delta)) } @@ -65,10 +89,10 @@ Item { } // Single cursor model: exactly one highlighted spot across the whole - // panel, located via `focusSection` + (`selectedIndex` | `dnsIndex`). - // Mouse hover and keyboard nav both mutate this state at the root; items - // never read containsMouse for visuals. See CursorSurface for the - // shared chrome (fill / border) shared by NetworkRow and DnsProviderPill. + // panel, located via `focusSection` + (`headerIndex` | `dnsIndex` | + // `selectedIndex`). Mouse hover and keyboard nav both mutate this state + // at the root; items never read containsMouse for visuals. See + // CursorSurface for the shared chrome shared by rows and pills. readonly property color hoverFill: bar ? Style.hoverFillFor(bar.foreground, Color.accent) : "transparent" readonly property color selectedFill: bar ? Style.selectedFillFor(bar.foreground, Color.accent) : "transparent" @@ -83,6 +107,7 @@ Item { focusSection = wifiNetworks.length > 0 ? "wifi" : "dns" var idx = dnsProviders.indexOf(dnsProvider) dnsIndex = idx >= 0 ? idx : 0 + cursorActive = false Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() }) } } @@ -638,11 +663,20 @@ fi blocked: root.passwordSsid !== "" onMoveRequested: function(dx, dy) { + if (!root.cursorActive) { + root.cursorActive = true + if (dy >= 0) return + } if (dy !== 0) { - if (root.focusSection === "dns") { - // 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) { + if (root.focusSection === "header") { + if (dy > 0) root.focusSection = "dns" + } else if (root.focusSection === "dns") { + // k from DNS moves up into header actions; j drops into the + // wifi list if there's anywhere to land. + if (dy < 0) { + root.focusSection = "header" + root.headerIndex = root.headerActionCount - 1 // refresh by default + } else if (root.wifiNetworks.length > 0) { root.focusSection = "wifi" if (root.selectedIndex < 0) root.selectedIndex = 0 } @@ -653,15 +687,21 @@ fi else root.selectByDelta(dy) } } - if (dx !== 0 && root.focusSection === "dns") root.selectDnsByDelta(dx) + if (dx !== 0) { + if (root.focusSection === "header") root.selectHeaderByDelta(dx) + else if (root.focusSection === "dns") root.selectDnsByDelta(dx) + } } onActivateRequested: { - if (root.focusSection === "dns") root.activateDns() - else root.activateSelected() + if (root.cursorActive) { + if (root.focusSection === "header") root.activateHeader() + else if (root.focusSection === "dns") root.activateDns() + else root.activateSelected() + } } onCloseRequested: root.closePopout() onDeleteRequested: { - if (root.focusSection === "wifi") root.forgetSelected() + if (root.cursorActive && root.focusSection === "wifi") root.forgetSelected() } onTextKey: function(t) { if (t === "r" || t === "R") root.refresh() @@ -747,8 +787,9 @@ fi PanelActionButton { id: disconnectBtn - visible: root.info.type === "wifi" && !!root.info.ssid + visible: root.canDisconnect enabled: !root.busy + hasCursor: root.cursorActive && root.focusSection === "header" && root.headerIndex === 0 iconText: "󰅙" tooltipText: "Disconnect" foreground: root.bar.foreground @@ -756,6 +797,12 @@ fi panelBackground: root.bar.background fontFamily: root.bar.fontFamily anchors.verticalCenter: parent.verticalCenter + onHovered: function(h) { + if (!h) return + root.cursorActive = true + root.focusSection = "header" + root.headerIndex = 0 + } onClicked: root.disconnect(root.info.ssid) } @@ -772,6 +819,13 @@ fi verticalPadding: Style.spacing.labelGap iconSize: Style.font.icon active: root.scanning + hasCursor: root.cursorActive && root.focusSection === "header" && root.headerIndex === (root.canDisconnect ? 1 : 0) + onHovered: function(h) { + if (!h) return + root.cursorActive = true + root.focusSection = "header" + root.headerIndex = root.canDisconnect ? 1 : 0 + } onClicked: root.refresh(true) } } @@ -950,10 +1004,11 @@ fi // `current DNS` is the pill's `active` fill; the keyboard cursor lights // up `hasCursor`. active: root.dnsProvider === provider - hasCursor: root.focusSection === "dns" && root.dnsIndex === index + hasCursor: root.cursorActive && root.focusSection === "dns" && root.dnsIndex === index onHovered: function(isHovered) { if (!isHovered) return + root.cursorActive = true root.focusSection = "dns" root.dnsIndex = pill.index } @@ -972,7 +1027,7 @@ fi readonly property bool isProtected: root.isProtected(net ? net.security : "") readonly property bool isSelected: root.focusSection === "wifi" && root.selectedIndex === index - hasCursor: isSelected + hasCursor: root.cursorActive && isSelected current: isConnected foreground: root.bar.foreground fill: root.hoverFill @@ -1017,12 +1072,13 @@ fi // Move the cursor here when the mouse enters; mouse leaving doesn't // clear it (so the cursor stays where the mouse last was and // subsequent j/k pick up from this row). - onContainsMouseChanged: if (containsMouse) { root.focusSection = "wifi"; root.selectedIndex = row.index } + onContainsMouseChanged: if (containsMouse) { root.cursorActive = true; root.focusSection = "wifi"; root.selectedIndex = row.index } onClicked: { if (!row.net) return // Resync cursor in case keyboard nav moved it away while the mouse // stayed parked on this row — the click target is unambiguously here. + root.cursorActive = true root.focusSection = "wifi" root.selectedIndex = row.index if (row.isConnected) { diff --git a/shell/plugins/bar/widgets/powerPanel.qml b/shell/plugins/bar/widgets/powerPanel.qml index 9573622a..d640fb5e 100644 --- a/shell/plugins/bar/widgets/powerPanel.qml +++ b/shell/plugins/bar/widgets/powerPanel.qml @@ -18,6 +18,7 @@ Item { property var profiles: [] property string activeProfile: "" property int profileIndex: 0 + property bool cursorActive: false function closePopout() { popupOpen = false } @@ -112,6 +113,7 @@ Item { refresh() var idx = profiles.indexOf(activeProfile) profileIndex = idx >= 0 ? idx : 0 + cursorActive = false Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() }) } } @@ -187,10 +189,11 @@ printf 'time\t%s\n' "$($OMARCHY_PATH/bin/omarchy-battery-remaining-time 2>/dev/n id: keyCatcher anchors.fill: parent onMoveRequested: function(dx, dy) { + if (!root.cursorActive) { root.cursorActive = true; return } if (dx !== 0) root.selectProfileByDelta(dx) else if (dy !== 0) root.selectProfileByDelta(dy) } - onActivateRequested: root.activateSelectedProfile() + onActivateRequested: if (root.cursorActive) root.activateSelectedProfile() onCloseRequested: root.closePopout() Column { @@ -283,10 +286,13 @@ printf 'time\t%s\n' "$($OMARCHY_PATH/bin/omarchy-battery-remaining-time 2>/dev/n horizontalPadding: Style.spacing.controlPaddingX verticalPadding: Style.spacing.controlPaddingY active: root.activeProfile === modelData - hasCursor: root.profileIndex === index + hasCursor: root.cursorActive && root.profileIndex === index onClicked: root.setProfile(modelData) onHovered: function(h) { - if (h) root.profileIndex = index + if (h) { + root.cursorActive = true + root.profileIndex = index + } } } } diff --git a/shell/plugins/clipboard-picker/ClipboardPicker.qml b/shell/plugins/clipboard-picker/ClipboardPicker.qml index e2e20fb4..346c40e4 100644 --- a/shell/plugins/clipboard-picker/ClipboardPicker.qml +++ b/shell/plugins/clipboard-picker/ClipboardPicker.qml @@ -15,6 +15,7 @@ Item { property bool opened: false property string filterText: "" property int selectedIndex: 0 + property bool cursorActive: false property var items: [] property color accent: Color.menu.selected @@ -34,6 +35,7 @@ Item { root.opened = true root.filterText = "" root.selectedIndex = 0 + root.cursorActive = false // Trigger fetch fetchProc.collected = "" @@ -98,13 +100,19 @@ Item { function select(delta) { if (displayModel.count === 0) return - selectedIndex = (selectedIndex + delta + displayModel.count) % displayModel.count + if (!cursorActive) { + cursorActive = true + selectedIndex = delta < 0 ? displayModel.count - 1 : 0 + } else { + selectedIndex = (selectedIndex + delta + displayModel.count) % displayModel.count + } resultList.positionViewAtIndex(selectedIndex, ListView.Contain) } function setFilter(nextFilter) { root.filterText = nextFilter root.selectedIndex = 0 + root.cursorActive = false root.rebuildDisplay() } @@ -212,7 +220,8 @@ Item { root.select(6) event.accepted = true } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { - root.activateIndex(root.selectedIndex) + if (root.cursorActive) root.activateIndex(root.selectedIndex) + else if (displayModel.count > 0) root.cursorActive = true event.accepted = true } else if (event.text && event.text.length === 1 && event.text.charCodeAt(0) >= 32 && event.text.charCodeAt(0) !== 127) { root.setFilter(root.filterText + event.text) @@ -270,12 +279,14 @@ Item { required property string previewType required property bool isPassword + readonly property bool hasCursor: root.cursorActive && index === root.selectedIndex + width: ListView.view.width height: root.rowHeight radius: root.cornerRadius - color: index === root.selectedIndex ? Style.hoverFillFor(root.foreground, root.accent) : "transparent" - border.color: index === root.selectedIndex ? Style.hoverBorderFor(root.foreground, root.accent) : "transparent" - border.width: index === root.selectedIndex ? Style.hoverBorderWidth : 0 + color: hasCursor ? Style.hoverFillFor(root.foreground, root.accent) : "transparent" + border.color: hasCursor ? Style.hoverBorderFor(root.foreground, root.accent) : "transparent" + border.width: hasCursor ? Style.hoverBorderWidth : 0 Rectangle { visible: false @@ -299,7 +310,7 @@ Item { width: parent.width height: parent.height text: parent.parent.isPassword ? "••••••••" : (parent.parent.previewType === "text" ? parent.parent.previewText : "Image") - color: index === root.selectedIndex ? Style.hoverStateColor(root.foreground, root.accent) : root.foreground + color: parent.parent.hasCursor ? Style.hoverStateColor(root.foreground, root.accent) : root.foreground font.family: root.fontFamily font.pixelSize: Style.font.title font.italic: parent.parent.previewType === "file" || parent.parent.isPassword @@ -315,8 +326,12 @@ Item { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor - onContainsMouseChanged: if (containsMouse) root.selectedIndex = index + onContainsMouseChanged: if (containsMouse) { + root.cursorActive = true + root.selectedIndex = index + } onClicked: { + root.cursorActive = true root.selectedIndex = index root.activateIndex(index) } @@ -333,7 +348,7 @@ Item { border.width: Style.normalBorderWidth clip: true - property var activeRow: displayModel.count > 0 && root.selectedIndex >= 0 && root.selectedIndex < displayModel.count ? displayModel.get(root.selectedIndex) : null + property var activeRow: root.cursorActive && displayModel.count > 0 && root.selectedIndex >= 0 && root.selectedIndex < displayModel.count ? displayModel.get(root.selectedIndex) : null Text { visible: parent.activeRow && parent.activeRow.previewType === "text" diff --git a/shell/plugins/dev-gallery/GalleryPanel.qml b/shell/plugins/dev-gallery/GalleryPanel.qml index 53df51ce..2305a323 100644 --- a/shell/plugins/dev-gallery/GalleryPanel.qml +++ b/shell/plugins/dev-gallery/GalleryPanel.qml @@ -410,7 +410,7 @@ Item { color: Qt.darker(root.foreground, 1.4) font.family: root.fontFamily font.pixelSize: Style.font.bodySmall - text: "Single cursor. Most reusable panel primitives expose hasCursor: bool and emit hovered(bool); composed rows (including sliders) wrap their content in CursorSurface. The panel root owns focusSection + selectedIndex; each element binds hasCursor: root.focusSection === 'X' && root.selectedIndex === N, and onHovered updates the same state. One highlight on screen, keyboard and mouse always agree. See plugins/bar/widgets/audioPanel.qml for the canonical recipe." + text: "Single cursor. Most reusable panel primitives expose hasCursor: bool and emit hovered(bool); composed rows (including sliders) wrap their content in CursorSurface. The panel root owns cursorActive + focusSection + selectedIndex; each element binds hasCursor: root.cursorActive && root.focusSection === 'X' && root.selectedIndex === N, and onHovered flips cursorActive on while updating the same state. No initial highlight, then one highlight on screen once the keyboard or mouse enters. See plugins/bar/widgets/audioPanel.qml for the canonical recipe." } Text { width: parent.width diff --git a/shell/plugins/emoji-picker/EmojiPicker.qml b/shell/plugins/emoji-picker/EmojiPicker.qml index 4cd7b697..d4297622 100644 --- a/shell/plugins/emoji-picker/EmojiPicker.qml +++ b/shell/plugins/emoji-picker/EmojiPicker.qml @@ -15,6 +15,7 @@ Item { property bool opened: false property string filterText: "" property int selectedIndex: 0 + property bool cursorActive: false property var emojis: [] property var filteredEmojis: [] @@ -38,6 +39,7 @@ Item { root.opened = true root.filterText = "" root.selectedIndex = 0 + root.cursorActive = false root.rebuildDisplay() Qt.callLater(function() { keyCatcher.forceActiveFocus() }) } @@ -100,12 +102,23 @@ Item { function select(delta) { if (displayModel.count === 0) return - selectedIndex = (selectedIndex + delta + displayModel.count) % displayModel.count + if (!cursorActive) { + cursorActive = true + selectedIndex = delta < 0 ? displayModel.count - 1 : 0 + } else { + selectedIndex = (selectedIndex + delta + displayModel.count) % displayModel.count + } resultGrid.positionViewAtIndex(selectedIndex, GridView.Contain) } function selectRow(delta) { if (displayModel.count === 0) return + if (!cursorActive) { + cursorActive = true + selectedIndex = delta < 0 ? displayModel.count - 1 : 0 + resultGrid.positionViewAtIndex(selectedIndex, GridView.Contain) + return + } var newIndex = selectedIndex + delta * columns if (newIndex < 0) newIndex = 0 if (newIndex >= displayModel.count) newIndex = displayModel.count - 1 @@ -115,6 +128,12 @@ Item { function selectPage(delta) { if (displayModel.count === 0) return + if (!cursorActive) { + cursorActive = true + selectedIndex = delta < 0 ? displayModel.count - 1 : 0 + resultGrid.positionViewAtIndex(selectedIndex, GridView.Contain) + return + } var visibleRows = Math.max(1, Math.floor(resultGrid.height / cellHeight)) var newIndex = selectedIndex + delta * columns * visibleRows if (newIndex < 0) newIndex = 0 @@ -126,6 +145,7 @@ Item { function setFilter(nextFilter) { root.filterText = nextFilter root.selectedIndex = 0 + root.cursorActive = false root.rebuildDisplay() } @@ -220,7 +240,8 @@ Item { root.selectPage(1) event.accepted = true } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { - root.activateIndex(root.selectedIndex) + if (root.cursorActive) root.activateIndex(root.selectedIndex) + else if (displayModel.count > 0) root.cursorActive = true event.accepted = true } else if (event.text && event.text.length === 1 && event.text.charCodeAt(0) >= 32 && event.text.charCodeAt(0) !== 127) { root.setFilter(root.filterText + event.text) @@ -271,12 +292,14 @@ Item { required property int index required property string emoji + readonly property bool hasCursor: root.cursorActive && index === root.selectedIndex + width: root.cellWidth height: root.cellHeight radius: root.cornerRadius - color: index === root.selectedIndex ? Style.hoverFillFor(root.foreground, root.accent) : "transparent" - border.color: index === root.selectedIndex ? Style.hoverBorderFor(root.foreground, root.accent) : "transparent" - border.width: index === root.selectedIndex ? Style.hoverBorderWidth : 0 + color: hasCursor ? Style.hoverFillFor(root.foreground, root.accent) : "transparent" + border.color: hasCursor ? Style.hoverBorderFor(root.foreground, root.accent) : "transparent" + border.width: hasCursor ? Style.hoverBorderWidth : 0 Text { text: parent.emoji @@ -292,8 +315,12 @@ Item { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor - onContainsMouseChanged: if (containsMouse) root.selectedIndex = index + onContainsMouseChanged: if (containsMouse) { + root.cursorActive = true + root.selectedIndex = index + } onClicked: { + root.cursorActive = true root.selectedIndex = index root.activateIndex(index) } diff --git a/shell/plugins/menu/Menu.qml b/shell/plugins/menu/Menu.qml index d89445fc..bdf06288 100644 --- a/shell/plugins/menu/Menu.qml +++ b/shell/plugins/menu/Menu.qml @@ -44,6 +44,7 @@ Item { property string activeMenu: "root" property string filterText: "" property int selectedIndex: 0 + property bool cursorActive: false property int requestSerial: 0 property int applySerial: 0 property var items: ({}) @@ -537,13 +538,19 @@ Item { function select(delta) { if (displayModel.count === 0) return - selectedIndex = (selectedIndex + delta + displayModel.count) % displayModel.count + if (!cursorActive) { + cursorActive = true + selectedIndex = delta < 0 ? displayModel.count - 1 : 0 + } else { + selectedIndex = (selectedIndex + delta + displayModel.count) % displayModel.count + } resultList.positionViewAtIndex(selectedIndex, ListView.Contain) } function setFilter(nextFilter) { root.filterText = nextFilter root.selectedIndex = 0 + root.cursorActive = false if (root.filterText.trim()) root.loadProvidersForSearch() root.rebuildDisplay() } @@ -554,6 +561,7 @@ Item { root.activeMenu = id root.filterText = "" root.selectedIndex = 0 + root.cursorActive = false root.rebuildDisplay() root.loadProviderForMenu(id) } @@ -605,6 +613,7 @@ Item { navStack = [] filterText = "" selectedIndex = 0 + cursorActive = false root.evaluateGuards() opened = true rebuildDisplay() @@ -842,7 +851,8 @@ Item { if (!root.filterText) root.goBack() event.accepted = true } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_Right) { - root.activateIndex(root.selectedIndex) + if (root.cursorActive) root.activateIndex(root.selectedIndex) + else if (displayModel.count > 0) root.cursorActive = true event.accepted = true } else if (event.text && event.text.length === 1 && event.text.charCodeAt(0) >= 32 && event.text.charCodeAt(0) !== 127 && (event.modifiers === Qt.NoModifier || event.modifiers === Qt.ShiftModifier)) { root.setFilter(root.filterText + event.text) @@ -922,12 +932,14 @@ Item { required property string action required property int childCount + readonly property bool hasCursor: root.cursorActive && row.index === root.selectedIndex + width: ListView.view.width height: root.rowHeightForDetail(row.detail) radius: root.cornerRadius - color: index === root.selectedIndex ? Style.hoverFillFor(root.foreground, root.accent) : "transparent" - border.color: index === root.selectedIndex ? Style.hoverBorderFor(root.foreground, root.accent) : "transparent" - border.width: index === root.selectedIndex ? Style.hoverBorderWidth : 0 + color: row.hasCursor ? Style.hoverFillFor(root.foreground, root.accent) : "transparent" + border.color: row.hasCursor ? Style.hoverBorderFor(root.foreground, root.accent) : "transparent" + border.width: row.hasCursor ? Style.hoverBorderWidth : 0 Rectangle { visible: false @@ -943,7 +955,7 @@ Item { Text { id: iconText text: row.icon - color: index === root.selectedIndex ? Style.hoverStateColor(root.foreground, root.accent) : root.foreground + color: row.hasCursor ? Style.hoverStateColor(root.foreground, root.accent) : root.foreground opacity: row.kind === "back" ? 0.7 : 1 font.family: root.fontFamily font.pixelSize: Style.font.iconLarge @@ -968,7 +980,7 @@ Item { id: labelText width: parent.width text: row.label - color: index === root.selectedIndex ? Style.hoverStateColor(root.foreground, root.accent) : root.foreground + color: row.hasCursor ? Style.hoverStateColor(root.foreground, root.accent) : root.foreground font.family: root.fontFamily font.pixelSize: Style.font.heading font.weight: Font.Medium @@ -1007,7 +1019,7 @@ Item { Text { text: row.kind === "menu" || row.kind === "link" ? "›" : "" - color: index === root.selectedIndex ? Style.hoverStateColor(root.foreground, root.accent) : root.foreground + color: row.hasCursor ? Style.hoverStateColor(root.foreground, root.accent) : root.foreground opacity: row.kind === "menu" || row.kind === "link" ? 0.36 : 0 font.family: root.fontFamily font.pixelSize: Style.font.heading @@ -1021,7 +1033,10 @@ Item { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor - onContainsMouseChanged: if (containsMouse) root.selectedIndex = row.index + onContainsMouseChanged: if (containsMouse) { + root.cursorActive = true + root.selectedIndex = row.index + } onClicked: root.activateIndex(row.index) } }