Gallery: replicate the panel cursor model from wifi/audio/bluetooth/monitor

This commit is contained in:
Ryan Hughes
2026-05-18 01:43:23 -04:00
parent 91a87470a2
commit b8188ce46c
3 changed files with 353 additions and 41 deletions
@@ -63,15 +63,190 @@ Item {
readonly property int barSize: 26
}
// ---- cursor demo state --------------------------------------------------
property int cursorDemoIndex: 1
property int pillDemoIndex: 1
// ---- cursor model -------------------------------------------------------
//
// The gallery itself uses the same recipe wifi / audio / bluetooth /
// monitor panels use: focusSection + selectedIndex drive a single
// highlight that crosses kit primitives uniformly, with j/k walking
// targets (jumping section boundaries automatically), h/l acting
// locally (horizontal rows / slider adjustment), Enter activating, and
// Esc closing. Mouse hover updates the same (focusSection,
// selectedIndex) so keyboard and pointer never diverge.
//
// Plugin authors: copy this section verbatim as a template. Replace
// the section IDs with whatever your panel needs. The shape
// (visibleSections, sectionCount, sectionIsHorizontal,
// sectionAdjustsValue, moveCursor, moveCursorH, activateCursor,
// ensureCursorVisible, clampCursor) is the canonical pattern.
property string focusSection: "cursor-surface"
property int selectedIndex: 0
// Demo state mutated by activation.
property string choiceDemoValue: "top"
property bool toggleDemoOn: true
property bool toggleSquareOn: false
property string dropdownDemoValue: "calendar"
property string searchableDemoValue: ""
readonly property var visibleSections: [
"cursor-surface", "pill-button", "cursor-pill", "panel-action-button",
"panel-tool-tip", "slider", "choice-button", "text-field", "toggle",
"dropdown", "searchable-dropdown", "composed"
]
function sectionCount(section) {
switch (section) {
case "cursor-surface": return 3
case "pill-button": return 5
case "cursor-pill": return 4
case "panel-action-button": return 4
case "panel-tool-tip": return 1
case "slider": return 1
case "choice-button": return 4
case "text-field": return 2
case "toggle": return 2
case "dropdown": return 1
case "searchable-dropdown": return 1
case "composed": return 2
}
return 0
}
// True for sections whose primitives lay out horizontally (a row of
// pills, choice buttons, etc.) — j/k jumps to the next/prev section,
// h/l walks within the row.
function sectionIsHorizontal(section) {
return section === "pill-button"
|| section === "cursor-pill"
|| section === "panel-action-button"
|| section === "choice-button"
}
// True for sections where h/l should adjust a value rather than walk.
function sectionAdjustsValue(section) {
return section === "slider"
}
// Where to land when entering a section from above / below.
function sectionFirstIndex(section) { return 0 }
function sectionLastIndex(section) { return Math.max(0, sectionCount(section) - 1) }
function moveCursor(delta) {
var sections = visibleSections
var sIdx = sections.indexOf(focusSection)
if (sIdx < 0) {
focusSection = sections[0]
selectedIndex = sectionFirstIndex(focusSection)
return
}
if (sectionIsHorizontal(focusSection) || sectionAdjustsValue(focusSection)
|| sectionCount(focusSection) <= 1) {
// Single-row / horizontal / value-adjust sections: j/k crosses to
// the next section.
if (delta > 0 && sIdx < sections.length - 1) {
focusSection = sections[sIdx + 1]
selectedIndex = sectionFirstIndex(focusSection)
} else if (delta < 0 && sIdx > 0) {
focusSection = sections[sIdx - 1]
selectedIndex = sectionLastIndex(focusSection)
}
return
}
// Vertical multi-row section: walk within, then cross at boundaries.
var next = selectedIndex + delta
if (next < 0) {
if (sIdx > 0) {
focusSection = sections[sIdx - 1]
selectedIndex = sectionLastIndex(focusSection)
}
} else if (next >= sectionCount(focusSection)) {
if (sIdx < sections.length - 1) {
focusSection = sections[sIdx + 1]
selectedIndex = sectionFirstIndex(focusSection)
}
} else {
selectedIndex = next
}
}
function moveCursorH(delta) {
if (sectionAdjustsValue(focusSection)) {
// h/l on the slider section nudges the demo volume by 5%.
sliderRow.demoVolume = Math.max(0, Math.min(1, sliderRow.demoVolume + delta * 0.05))
return
}
if (!sectionIsHorizontal(focusSection)) return
var next = selectedIndex + delta
var max = sectionCount(focusSection) - 1
if (next < 0) next = 0
if (next > max) next = max
selectedIndex = next
}
function activateCursor() {
if (focusSection === "choice-button") {
var opts = ["top", "right", "bottom", "left"]
if (selectedIndex >= 0 && selectedIndex < opts.length)
root.choiceDemoValue = opts[selectedIndex]
return
}
if (focusSection === "toggle") {
if (selectedIndex === 0) root.toggleDemoOn = !root.toggleDemoOn
else root.toggleSquareOn = !root.toggleSquareOn
return
}
if (focusSection === "dropdown") {
demoDropdown.toggle()
return
}
if (focusSection === "searchable-dropdown") {
demoSearchableDropdown.toggle()
return
}
if (focusSection === "text-field") {
if (selectedIndex === 0) demoTextField.forceActiveFocus()
else demoPasswordField.forceActiveFocus()
return
}
// pill / panel-action-button / cursor-surface / composed: nothing to
// mutate in a demo, but real consumers would call their clicked().
}
function clampCursor() {
var sections = visibleSections
if (sections.indexOf(focusSection) < 0) {
focusSection = sections[0]
selectedIndex = sectionFirstIndex(focusSection)
return
}
if (selectedIndex < 0) selectedIndex = 0
var max = sectionLastIndex(focusSection)
if (selectedIndex > max) selectedIndex = max
}
// Scroll the gallery so the given Item is fully visible inside
// scrollArea's viewport, with a 20px breathing margin. Wired into the
// hasCursor change handler of every cursor target below.
function ensureCursorVisible(item) {
if (!item || !scrollArea) return
var sb = scrollArea.ScrollBar.vertical
if (!sb || scrollArea.contentHeight <= scrollArea.height) return
var contentItem = scrollArea.contentItem
if (!contentItem) return
var p = item.mapToItem(contentItem, 0, 0)
var itemTop = p.y
var itemBottom = p.y + item.height
var viewTop = sb.position * scrollArea.contentHeight
var viewBottom = viewTop + scrollArea.height
var pad = 20
if (itemTop < viewTop + pad) {
sb.position = Math.max(0, (itemTop - pad) / scrollArea.contentHeight)
} else if (itemBottom > viewBottom - pad) {
var newPos = (itemBottom + pad - scrollArea.height) / scrollArea.contentHeight
sb.position = Math.max(0, Math.min(1 - sb.size, newPos))
}
}
FloatingWindow {
id: window
title: "Omarchy shell dev gallery"
@@ -90,9 +265,6 @@ Item {
anchors.fill: parent
focus: true
// Scroll the gallery's ScrollView by `dy` pixels. Bound below 0 and
// above (1 - thumbSize) so the thumb stays on the track when the
// user mashes Page_Down past the end.
function scrollBy(dy) {
var sb = scrollArea.ScrollBar.vertical
if (!sb || scrollArea.contentHeight <= scrollArea.height) return
@@ -118,22 +290,24 @@ Item {
}
}
// Panel-style key dispatch. j/k scroll the gallery; h/l walk the
// cursor / pill demos; Esc closes. Same component the wifi, audio,
// bluetooth, monitor, and settings panels all use — the gallery
// is meant to demonstrate the standard, so it should USE the
// standard rather than reimplement its own keyhandler.
// Panel-style key dispatch — the gallery demonstrates the standard,
// so it USES the standard. j/k walks cursor targets across sections,
// h/l acts locally (rows + slider adjust), Enter activates the
// current target, Esc closes. The catcher suspends itself while a
// dropdown popup or text field owns keyboard input, so typing into
// the embedded controls doesn't double-drive the panel cursor.
PanelKeyCatcher {
id: keyCatcher
anchors.fill: parent
blocked: demoDropdown.popupOpen
|| demoSearchableDropdown.popupOpen
|| demoTextField.activeFocus
|| demoPasswordField.activeFocus
onMoveRequested: function(dx, dy) {
if (dy !== 0) {
focusScope.scrollBy(dy * 60)
} else if (dx !== 0) {
root.cursorDemoIndex = Math.max(0, Math.min(2, root.cursorDemoIndex + dx))
root.pillDemoIndex = Math.max(0, Math.min(3, root.pillDemoIndex + dx))
}
if (dy !== 0) root.moveCursor(dy)
else if (dx !== 0) root.moveCursorH(dx)
}
onActivateRequested: root.activateCursor()
onCloseRequested: root.requestClose()
ScrollView {
@@ -320,10 +494,11 @@ Item {
required property int index
width: parent.width
implicitHeight: csLabel.implicitHeight + 16
hasCursor: root.cursorDemoIndex === index
hasCursor: root.focusSection === "cursor-surface" && root.selectedIndex === index
current: index === 1
foreground: root.foreground
fill: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.18)
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(this)
Text {
id: csLabel
@@ -343,7 +518,10 @@ Item {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onContainsMouseChanged: if (containsMouse) root.cursorDemoIndex = parent.index
onContainsMouseChanged: if (containsMouse) {
root.focusSection = "cursor-surface"
root.selectedIndex = parent.index
}
}
}
}
@@ -394,6 +572,9 @@ Item {
tooltipForeground: root.foreground
foreground: root.foreground
fontFamily: root.fontFamily
hasCursor: root.focusSection === "pill-button" && root.selectedIndex === 0
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(this)
HoverHandler { onHoveredChanged: if (hovered) { root.focusSection = "pill-button"; root.selectedIndex = 0 } }
}
PillButton {
@@ -404,6 +585,9 @@ Item {
foreground: root.foreground
fontFamily: root.fontFamily
active: true
hasCursor: root.focusSection === "pill-button" && root.selectedIndex === 1
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(this)
HoverHandler { onHoveredChanged: if (hovered) { root.focusSection = "pill-button"; root.selectedIndex = 1 } }
}
PillButton {
@@ -415,6 +599,9 @@ Item {
fontFamily: root.fontFamily
horizontalPadding: 8
verticalPadding: 4
hasCursor: root.focusSection === "pill-button" && root.selectedIndex === 2
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(this)
HoverHandler { onHoveredChanged: if (hovered) { root.focusSection = "pill-button"; root.selectedIndex = 2 } }
}
PillButton {
@@ -426,6 +613,9 @@ Item {
foreground: root.foreground
fontFamily: root.fontFamily
active: true
hasCursor: root.focusSection === "pill-button" && root.selectedIndex === 3
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(this)
HoverHandler { onHoveredChanged: if (hovered) { root.focusSection = "pill-button"; root.selectedIndex = 3 } }
}
PillButton {
@@ -434,6 +624,9 @@ Item {
fontFamily: root.fontFamily
focusable: true
bordered: true
hasCursor: root.focusSection === "pill-button" && root.selectedIndex === 4
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(this)
HoverHandler { onHoveredChanged: if (hovered) { root.focusSection = "pill-button"; root.selectedIndex = 4 } }
}
}
}
@@ -486,10 +679,13 @@ Item {
tooltipForeground: root.foreground
fontFamily: root.fontFamily
tooltipText: "Pick " + modelData
hasCursor: root.pillDemoIndex === index
hasCursor: root.focusSection === "cursor-pill" && root.selectedIndex === index
active: modelData === "Cloudflare"
onHovered: function(h) { if (h) root.pillDemoIndex = index }
onClicked: root.pillDemoIndex = index
onHovered: function(h) {
if (h) { root.focusSection = "cursor-pill"; root.selectedIndex = index }
}
onClicked: { root.focusSection = "cursor-pill"; root.selectedIndex = index }
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(this)
}
}
}
@@ -538,6 +734,11 @@ Item {
foreground: root.foreground
panelBackground: root.background
fontFamily: root.fontFamily
hasCursor: root.focusSection === "panel-action-button" && root.selectedIndex === 0
onHovered: function(h) {
if (h) { root.focusSection = "panel-action-button"; root.selectedIndex = 0 }
}
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(this)
}
PanelActionButton {
@@ -547,6 +748,11 @@ Item {
hoverColor: root.urgent
panelBackground: root.background
fontFamily: root.fontFamily
hasCursor: root.focusSection === "panel-action-button" && root.selectedIndex === 1
onHovered: function(h) {
if (h) { root.focusSection = "panel-action-button"; root.selectedIndex = 1 }
}
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(this)
}
PanelActionButton {
@@ -556,6 +762,11 @@ Item {
panelBackground: root.background
fontFamily: root.fontFamily
enabled: false
hasCursor: root.focusSection === "panel-action-button" && root.selectedIndex === 2
onHovered: function(h) {
if (h) { root.focusSection = "panel-action-button"; root.selectedIndex = 2 }
}
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(this)
}
PanelActionButton {
@@ -567,6 +778,11 @@ Item {
fontSize: 13
size: 26
focusable: true
hasCursor: root.focusSection === "panel-action-button" && root.selectedIndex === 3
onHovered: function(h) {
if (h) { root.focusSection = "panel-action-button"; root.selectedIndex = 3 }
}
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(this)
}
}
}
@@ -594,12 +810,19 @@ Item {
}
Rectangle {
id: tipSwatch
width: 140
height: 36
color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.10)
border.color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.35)
border.width: 1
readonly property bool focused: root.focusSection === "panel-tool-tip"
color: tipMouse.containsMouse || focused
? Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.18)
: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.10)
border.color: focused
? Style.focusBorderColor
: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.35)
border.width: focused ? Style.focusBorderWidth : 1
radius: Style.cornerRadius
onFocusedChanged: if (focused) root.ensureCursorVisible(this)
Text {
anchors.centerIn: parent
@@ -614,10 +837,14 @@ Item {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onContainsMouseChanged: if (containsMouse) {
root.focusSection = "panel-tool-tip"
root.selectedIndex = 0
}
}
PanelToolTip {
visible: tipMouse.containsMouse
visible: tipMouse.containsMouse || tipSwatch.focused
text: "Styled tooltip — drop into any panel"
panelForeground: root.foreground
panelBackground: root.background
@@ -649,12 +876,24 @@ Item {
}
Rectangle {
id: sliderWrapper
width: parent.width
implicitHeight: sliderRow.implicitHeight + 24
readonly property bool focused: root.focusSection === "slider"
color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.04)
radius: Style.cornerRadius
border.color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.10)
border.width: 1
border.color: focused
? Style.focusBorderColor
: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.10)
border.width: focused ? Style.focusBorderWidth : 1
onFocusedChanged: if (focused) root.ensureCursorVisible(this)
HoverHandler {
onHoveredChanged: if (hovered) {
root.focusSection = "slider"
root.selectedIndex = 0
}
}
Row {
id: sliderRow
@@ -738,13 +977,23 @@ Item {
model: ["top", "right", "bottom", "left"]
ChoiceButton {
required property string modelData
required property int index
text: modelData
foreground: root.foreground
background: root.background
accent: root.accent
fontFamily: root.fontFamily
selected: root.choiceDemoValue === modelData
onClicked: root.choiceDemoValue = modelData
hasCursor: root.focusSection === "choice-button" && root.selectedIndex === index
onClicked: {
root.focusSection = "choice-button"
root.selectedIndex = index
root.choiceDemoValue = modelData
}
onHovered: function(h) {
if (h) { root.focusSection = "choice-button"; root.selectedIndex = index }
}
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(this)
}
}
}
@@ -790,15 +1039,28 @@ Item {
spacing: 10
TextField {
id: demoTextField
width: parent.width
placeholderText: "Type something"
placeholderText: "Type something (Enter to start editing, Esc to leave)"
foreground: root.foreground
accent: root.accent
font.family: root.fontFamily
font.pixelSize: 12
hasCursor: !activeFocus && root.focusSection === "text-field" && root.selectedIndex === 0
onHoveredChanged: if (hovered) {
root.focusSection = "text-field"; root.selectedIndex = 0
}
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(this)
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Escape) {
focus = false
event.accepted = true
}
}
}
TextField {
id: demoPasswordField
width: parent.width
password: true
placeholderText: "Password"
@@ -806,6 +1068,17 @@ Item {
accent: root.accent
font.family: root.fontFamily
font.pixelSize: 12
hasCursor: !activeFocus && root.focusSection === "text-field" && root.selectedIndex === 1
onHoveredChanged: if (hovered) {
root.focusSection = "text-field"; root.selectedIndex = 1
}
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(this)
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Escape) {
focus = false
event.accepted = true
}
}
}
}
}
@@ -840,7 +1113,15 @@ Item {
accent: root.accent
fontFamily: root.fontFamily
checked: root.toggleDemoOn
onClicked: root.toggleDemoOn = !root.toggleDemoOn
hasCursor: root.focusSection === "toggle" && root.selectedIndex === 0
onHovered: function(h) {
if (h) { root.focusSection = "toggle"; root.selectedIndex = 0 }
}
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(this)
onClicked: {
root.focusSection = "toggle"; root.selectedIndex = 0
root.toggleDemoOn = !root.toggleDemoOn
}
}
Toggle {
@@ -852,7 +1133,15 @@ Item {
fontFamily: root.fontFamily
rounded: false
checked: root.toggleSquareOn
onClicked: root.toggleSquareOn = !root.toggleSquareOn
hasCursor: root.focusSection === "toggle" && root.selectedIndex === 1
onHovered: function(h) {
if (h) { root.focusSection = "toggle"; root.selectedIndex = 1 }
}
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(this)
onClicked: {
root.focusSection = "toggle"; root.selectedIndex = 1
root.toggleSquareOn = !root.toggleSquareOn
}
}
}
@@ -895,11 +1184,17 @@ Item {
spacing: 6
Dropdown {
id: demoDropdown
width: 260
label: "Center anchor"
fontFamily: root.fontFamily
options: ["calendar", "weather", "clock", "battery"]
value: root.dropdownDemoValue
hasCursor: root.focusSection === "dropdown" && root.selectedIndex === 0
onHovered: function(h) {
if (h) { root.focusSection = "dropdown"; root.selectedIndex = 0 }
}
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(this)
onChanged: function(v) { root.dropdownDemoValue = v }
}
}
@@ -945,10 +1240,16 @@ Item {
spacing: 6
SearchableDropdown {
id: demoSearchableDropdown
width: 280
label: "Add widget"
fontFamily: root.fontFamily
placeholderText: "Search widgets..."
hasCursor: root.focusSection === "searchable-dropdown" && root.selectedIndex === 0
onHovered: function(h) {
if (h) { root.focusSection = "searchable-dropdown"; root.selectedIndex = 0 }
}
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(this)
options: [
{ value: "clock", label: "Clock", description: "Time + date display" },
{ value: "weather", label: "Weather", description: "Local conditions and forecast" },
@@ -1023,6 +1324,14 @@ Item {
current: true
foreground: root.foreground
fill: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.18)
hasCursor: root.focusSection === "composed" && root.selectedIndex === 0
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(this)
HoverHandler {
onHoveredChanged: if (hovered) {
root.focusSection = "composed"; root.selectedIndex = 0
}
}
Item {
id: composedRow
@@ -1090,6 +1399,14 @@ Item {
implicitHeight: idleRow.implicitHeight + 12
foreground: root.foreground
fill: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.18)
hasCursor: root.focusSection === "composed" && root.selectedIndex === 1
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(this)
HoverHandler {
onHoveredChanged: if (hovered) {
root.focusSection = "composed"; root.selectedIndex = 1
}
}
Item {
id: idleRow