diff --git a/default/quickshell/omarchy-shell/plugins/README.md b/default/quickshell/omarchy-shell/plugins/README.md index 11755bd3..a8fe4cd5 100644 --- a/default/quickshell/omarchy-shell/plugins/README.md +++ b/default/quickshell/omarchy-shell/plugins/README.md @@ -19,6 +19,7 @@ User-installed plugins live alongside these conceptually but on disk under | Notifications | `omarchy.notifications` | `service` | persistent | `notifications/Service.qml` | | OSD | `omarchy.osd` | `panel` | persistent | `osd/Osd.qml` | | Polkit agent | `omarchy.polkit` | `service` | persistent | `polkit/PolkitAgent.qml` | +| Dev gallery | `omarchy.dev-gallery` | `panel` | on-demand | `dev-gallery/GalleryPanel.qml` | ## Bar diff --git a/default/quickshell/omarchy-shell/plugins/bar/common/PanelActionButton.qml b/default/quickshell/omarchy-shell/plugins/bar/common/PanelActionButton.qml new file mode 100644 index 00000000..5bd5e16b --- /dev/null +++ b/default/quickshell/omarchy-shell/plugins/bar/common/PanelActionButton.qml @@ -0,0 +1,64 @@ +import QtQuick + +// Small (22×22 by default) icon button used at the right edge of panel rows +// for inline actions — forget network, confirm passphrase, unpair device, +// etc. Two visual modes are supported via `hoverColor`: +// - default: hoverColor === foreground → subtle foreground-tint hover +// - urgent: hoverColor === bar.urgent → red-tint hover for destructive +// actions like forget/unpair +// +// `enabled` gates clicks and dims the icon. The component owns its own +// hover state visuals; mouse hover does NOT update any panel cursor state +// here because action buttons are not cursor targets — the row they live +// in is. +Rectangle { + id: root + + property string iconText: "" + property string tooltipText: "" + property color foreground: "#cacccc" + property color hoverColor: foreground + property color panelBackground: "#101315" + property string fontFamily: "JetBrainsMono Nerd Font" + property real fontSize: 14 + property real size: 22 + + signal clicked() + + implicitWidth: size + implicitHeight: size + radius: 4 + + color: mouse.containsMouse && root.enabled + ? Qt.rgba(hoverColor.r, hoverColor.g, hoverColor.b, 0.20) + : "transparent" + + Behavior on color { ColorAnimation { duration: 60 } } + + Text { + anchors.centerIn: parent + text: root.iconText + color: root.enabled + ? (mouse.containsMouse ? root.hoverColor : Qt.darker(root.foreground, 1.3)) + : Qt.darker(root.foreground, 2.0) + font.family: root.fontFamily + font.pixelSize: root.fontSize + } + + MouseArea { + id: mouse + anchors.fill: parent + hoverEnabled: true + cursorShape: root.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + enabled: root.enabled + onClicked: root.clicked() + } + + PanelToolTip { + visible: root.tooltipText !== "" && mouse.containsMouse + text: root.tooltipText + panelForeground: root.foreground + panelBackground: root.panelBackground + fontFamily: root.fontFamily + } +} diff --git a/default/quickshell/omarchy-shell/plugins/bar/common/PanelSectionHeader.qml b/default/quickshell/omarchy-shell/plugins/bar/common/PanelSectionHeader.qml new file mode 100644 index 00000000..2e23cd86 --- /dev/null +++ b/default/quickshell/omarchy-shell/plugins/bar/common/PanelSectionHeader.qml @@ -0,0 +1,17 @@ +import QtQuick + +// Small-caps-style label that introduces a panel section ("DNS provider", +// "Wi-Fi networks", "Output device", "Paired devices"). Sits between a +// PanelSeparator and the content rows. +Text { + id: root + + property color foreground: "#cacccc" + property string fontFamily: "JetBrainsMono Nerd Font" + property real fontSize: 10 + + color: Qt.darker(foreground, 1.4) + font.family: fontFamily + font.pixelSize: fontSize + font.bold: true +} diff --git a/default/quickshell/omarchy-shell/plugins/bar/common/PanelSeparator.qml b/default/quickshell/omarchy-shell/plugins/bar/common/PanelSeparator.qml new file mode 100644 index 00000000..8b38afd6 --- /dev/null +++ b/default/quickshell/omarchy-shell/plugins/bar/common/PanelSeparator.qml @@ -0,0 +1,17 @@ +import QtQuick + +// 1px horizontal divider for panel sections. The alpha-on-foreground tint +// keeps the rule legible against the panel background without competing +// with text or borders. +Rectangle { + id: root + + property color foreground: "#cacccc" + property real strength: 0.12 + + width: parent ? parent.width : implicitWidth + implicitWidth: 100 + implicitHeight: 1 + height: 1 + color: Qt.rgba(foreground.r, foreground.g, foreground.b, strength) +} diff --git a/default/quickshell/omarchy-shell/plugins/bar/common/PanelToolTip.qml b/default/quickshell/omarchy-shell/plugins/bar/common/PanelToolTip.qml new file mode 100644 index 00000000..5d04aac9 --- /dev/null +++ b/default/quickshell/omarchy-shell/plugins/bar/common/PanelToolTip.qml @@ -0,0 +1,45 @@ +import QtQuick +import QtQuick.Controls + +// Styled wrapper around Qt Quick Controls ToolTip. Drop-in: declare inside +// the hovered item and bind `visible` to the hover state, e.g. +// Common.PanelToolTip { +// visible: mouse.containsMouse +// text: "Forget network" +// panelForeground: bar.foreground +// panelBackground: bar.background +// fontFamily: bar.fontFamily +// } +// +// Property names are prefixed `panel*` to avoid clashing with ToolTip's +// built-in `background`/`font` properties. +ToolTip { + id: root + + property color panelForeground: "#cacccc" + property color panelBackground: "#101315" + property string fontFamily: "JetBrainsMono Nerd Font" + property real fontSize: 11 + + delay: 400 + padding: 0 + + background: Rectangle { + color: root.panelBackground + border.color: root.panelForeground + border.width: 1 + radius: 0 + opacity: 0.97 + } + + contentItem: Text { + text: root.text + color: root.panelForeground + font.family: root.fontFamily + font.pixelSize: root.fontSize + leftPadding: 10 + rightPadding: 10 + topPadding: 6 + bottomPadding: 6 + } +} diff --git a/default/quickshell/omarchy-shell/plugins/bar/common/PillButton.qml b/default/quickshell/omarchy-shell/plugins/bar/common/PillButton.qml index 8f091ab9..0df3dd74 100644 --- a/default/quickshell/omarchy-shell/plugins/bar/common/PillButton.qml +++ b/default/quickshell/omarchy-shell/plugins/bar/common/PillButton.qml @@ -22,6 +22,11 @@ Rectangle { property bool leftAlign: false property color activeBackground: Qt.rgba(foreground.r, foreground.g, foreground.b, 0.18) + // Keyboard cursor flag. When true, the pill renders the same fill + border + // as a mouse hover — so j/k navigation and pointer hover are visually + // indistinguishable. Default false; bind from a panel's cursor state. + property bool hasCursor: false + ToolTip { visible: root.tooltipText !== "" && mouseArea.containsMouse text: root.tooltipText @@ -54,7 +59,17 @@ Rectangle { implicitWidth: row.implicitWidth + horizontalPadding * 2 implicitHeight: row.implicitHeight + verticalPadding * 2 radius: 4 - color: mouseArea.pressed ? pressedBackground : (mouseArea.containsMouse ? hoverBackground : (active ? activeBackground : background)) + + // Hot = mouse hover OR keyboard cursor. Pressed wins over both; otherwise + // hot beats `active` (so a cursor on an already-active pill still reads + // as hovered, matching CursorSurface's behaviour for navigable rows). + readonly property bool hot: mouseArea.containsMouse || hasCursor + + color: mouseArea.pressed ? pressedBackground + : hot ? hoverBackground + : (active ? activeBackground : background) + border.width: hot ? 1 : 0 + border.color: foreground Behavior on color { ColorAnimation { duration: 120 } diff --git a/default/quickshell/omarchy-shell/plugins/bar/widgets/audioPanel.qml b/default/quickshell/omarchy-shell/plugins/bar/widgets/audioPanel.qml index 58822b9e..08bda15d 100644 --- a/default/quickshell/omarchy-shell/plugins/bar/widgets/audioPanel.qml +++ b/default/quickshell/omarchy-shell/plugins/bar/widgets/audioPanel.qml @@ -1,6 +1,7 @@ import QtQuick import QtQuick.Controls import Quickshell +import Quickshell.Io import Quickshell.Services.Pipewire import "../common" as Common @@ -82,6 +83,171 @@ Item { readonly property real inputVolume: source && source.audio ? source.audio.volume : 0 readonly property bool inputMuted: source && source.audio ? source.audio.muted : false + // Single cursor model shared by keyboard and mouse. Sections: + // "output" — output slider + sink device list + // "input" — input slider + source device list + // "streams" — per-app playback streams + // selectedIndex semantics within a section: + // -1 → on the slider row (h/l adjusts volume, m/Enter mute) + // 0..N-1 → on the Nth device/stream row + // Visuals derive from hasCursor/current via Common.CursorSurface, never + // from containsMouse — that's what keeps the highlight unique across + // keyboard + mouse like wifi does. + property string focusSection: "output" + property int selectedIndex: -1 + + readonly property color activeFill: bar + ? Qt.rgba(bar.foreground.r, bar.foreground.g, bar.foreground.b, 0.18) + : "transparent" + + function sectionCount(section) { + if (section === "output") return audioSinks.length + if (section === "input") return audioSources.length + if (section === "streams") return audioStreams.length + return 0 + } + + function sectionVisible(section) { + if (section === "output") return true + if (section === "input") return audioSources.length > 0 || !!source + if (section === "streams") return audioStreams.length > 0 + return false + } + + function sectionHasSlider(section) { + if (section === "output") return true + if (section === "input") return !!source + return false // stream rows carry their own sliders inline; not a section-level slider + } + + // Order of visible sections, recomputed reactively so dropping a section + // (e.g. no input devices) doesn't leave the cursor pointing at it. + readonly property var visibleSections: { + var list = [] + if (sectionVisible("output")) list.push("output") + if (sectionVisible("input")) list.push("input") + if (sectionVisible("streams")) list.push("streams") + return list + } + + function moveCursor(delta) { + var sections = visibleSections + if (sections.length === 0) return + var sIdx = sections.indexOf(focusSection) + if (sIdx < 0) { focusSection = sections[0]; selectedIndex = sectionHasSlider(focusSection) ? -1 : 0; return } + + var idx = selectedIndex + var max = sectionCount(focusSection) - 1 // last device index + var hasSlider = sectionHasSlider(focusSection) + var floor = hasSlider ? -1 : 0 // -1 = slider row + + if (delta > 0) { + if (idx < max) { selectedIndex = idx + 1; return } + // Fall through to next section. + if (sIdx < sections.length - 1) { + focusSection = sections[sIdx + 1] + selectedIndex = sectionHasSlider(focusSection) ? -1 : 0 + } + } else { + if (idx > floor) { selectedIndex = idx - 1; return } + // Escape upward. + if (sIdx > 0) { + focusSection = sections[sIdx - 1] + var prevMax = sectionCount(focusSection) - 1 + selectedIndex = prevMax >= 0 ? prevMax : (sectionHasSlider(focusSection) ? -1 : 0) + } + } + } + + // Adjust the slider associated with the focused section. Output and + // input sliders are real volume controls; on stream rows h/l adjusts + // that stream's volume (so keyboard parity with the inline slider). + // For device rows (selectedIndex >= 0 in output/input) h/l is a no-op + // — the cursor is on a discrete row, not on the slider, and silently + // moving the global slider would surprise the user. + function adjustVolume(delta) { + if (focusSection === "output" && selectedIndex === -1) { + setOutputVolume(outputVolume + delta) + return + } + if (focusSection === "input" && selectedIndex === -1) { + setInputVolume(inputVolume + delta) + return + } + if (focusSection === "streams" && selectedIndex >= 0 && selectedIndex < audioStreams.length) { + var s = audioStreams[selectedIndex] + if (s && s.audio) s.audio.volume = Math.max(0, Math.min(1.5, s.audio.volume + delta)) + } + } + + // Enter/Space: activate whatever the cursor is on. + function activateCursor() { + if (focusSection === "output") { + if (selectedIndex === -1) { toggleOutputMute(); return } + var sink = audioSinks[selectedIndex] + if (sink) setDefaultSink(sink) + return + } + if (focusSection === "input") { + if (selectedIndex === -1) { toggleInputMute(); return } + var src = audioSources[selectedIndex] + if (src) setDefaultSource(src) + return + } + if (focusSection === "streams" && selectedIndex >= 0) { + var st = audioStreams[selectedIndex] + if (st && st.audio) st.audio.muted = !st.audio.muted + } + } + + onPopupOpenChanged: { + if (popupOpen) { + focusSection = "output" + selectedIndex = -1 // start on the output slider + Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() }) + } + } + + // Clamp / repair the cursor whenever any list refreshes underneath us. + onAudioSinksChanged: clampCursor() + onAudioSourcesChanged: clampCursor() + onAudioStreamsChanged: clampCursor() + + // Keep the keyboard-focused row inside the visible viewport of the + // ScrollView. Each cursor target (slider rows, SinkRow, SourceRow, + // StreamRow) calls this when it gains hasCursor. Without it, j/k can + // walk the selection off-screen — wifi uses ListView.positionViewAtIndex + // for this; we don't have that affordance with a multi-section Column. + function ensureCursorVisible(item) { + if (!item || !scrollArea) return + var flick = scrollArea.contentItem + if (!flick || flick.contentY === undefined) return + var pt = item.mapToItem(flick.contentItem || flick, 0, 0) + var top = pt.y + var bottom = top + (item.height || 0) + var viewTop = flick.contentY + var viewBottom = viewTop + flick.height + var margin = 6 + if (top < viewTop + margin) flick.contentY = Math.max(0, top - margin) + else if (bottom > viewBottom - margin) + flick.contentY = bottom + margin - flick.height + } + + function clampCursor() { + var sections = visibleSections + if (!sections || !sections.length) return + if (sections.indexOf(focusSection) < 0) { + focusSection = visibleSections[0] + selectedIndex = sectionHasSlider(focusSection) ? -1 : 0 + return + } + var count = sectionCount(focusSection) + var hasSlider = sectionHasSlider(focusSection) + var floor = hasSlider ? -1 : 0 + if (selectedIndex > count - 1) selectedIndex = Math.max(floor, count - 1) + if (selectedIndex < floor) selectedIndex = floor + } + function outputIcon() { // Match the old Waybar pulseaudio glyph set. The Material Design speaker // icons render visually smaller in JetBrainsMono Nerd Font. @@ -169,6 +335,19 @@ Item { PwObjectTracker { objects: root.candidateSources } PwObjectTracker { objects: root.audioStreams } + // Lets a Hyprland keybind summon the panel without a click. Mirrors the + // networkPanel IpcHandler pattern; KeyboardPanel grants Exclusive focus + // at map-time so j/k/h/l work the moment the panel appears. + IpcHandler { + target: "audioPanel" + function toggle(): void { + if (root.popupOpen) root.closePopout() + else root.popupOpen = true + } + function show(): void { if (!root.popupOpen) root.popupOpen = true } + function hide(): void { root.closePopout() } + } + Common.WidgetButton { id: button anchors.fill: parent @@ -187,418 +366,328 @@ Item { } } - Common.PopupCard { + Common.KeyboardPanel { + id: panel anchorItem: button owner: root bar: root.bar open: root.popupOpen - // Match the width/inset of the window below so the popup outline - // overlays cleanly instead of bleeding past the underlying frame. contentWidth: 370 contentHeight: Math.min(560, panelColumn.implicitHeight + 28) - ScrollView { - id: scrollArea + Item { + id: keyCatcher anchors.fill: parent - clip: true - ScrollBar.horizontal.policy: ScrollBar.AlwaysOff - ScrollBar.vertical.policy: ScrollBar.AsNeeded - - Column { - id: panelColumn - width: scrollArea.availableWidth - spacing: 14 - - // ---- Output ---- - Column { - width: parent.width - spacing: 6 - - Row { - width: parent.width - spacing: 8 - - Text { - text: "Output" - color: Qt.darker(root.bar.foreground, 1.5) - font.family: root.bar.fontFamily - font.pixelSize: 11 - font.bold: true - anchors.verticalCenter: parent.verticalCenter - } - - Text { - text: root.sink ? "· " + root.nodeLabel(root.sink) : "" - color: Qt.darker(root.bar.foreground, 1.8) - font.family: root.bar.fontFamily - font.pixelSize: 11 - elide: Text.ElideRight - width: parent.width - 70 - anchors.verticalCenter: parent.verticalCenter - } + focus: true + Keys.priority: Keys.AfterItem + Keys.onPressed: function(event) { + if (event.key === Qt.Key_Escape) { + root.closePopout() + event.accepted = true + return + } + if (event.key === Qt.Key_Down || event.text === "j") { + root.moveCursor(1); event.accepted = true; return + } + if (event.key === Qt.Key_Up || event.text === "k") { + root.moveCursor(-1); event.accepted = true; return + } + if (event.key === Qt.Key_Right || event.text === "l") { + root.adjustVolume(0.05); event.accepted = true; return + } + if (event.key === Qt.Key_Left || event.text === "h") { + root.adjustVolume(-0.05); event.accepted = true; return + } + if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_Space) { + root.activateCursor(); event.accepted = true; return + } + // 'm' mutes whatever the cursor is on: focused section's slider for + // output/input, the focused stream for streams. Without the stream + // branch, m would mute the global output while the cursor sat on a + // per-app row — surprising and inconsistent with Enter behaviour. + if (event.text === "m" || event.text === "M") { + if (root.focusSection === "streams" && root.selectedIndex >= 0 + && root.selectedIndex < root.audioStreams.length) { + var s = root.audioStreams[root.selectedIndex] + if (s && s.audio) s.audio.muted = !s.audio.muted + } else if (root.focusSection === "input") { + root.toggleInputMute() + } else { + root.toggleOutputMute() } + event.accepted = true + } + } - Row { + ScrollView { + id: scrollArea + anchors.fill: parent + clip: true + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + ScrollBar.vertical.policy: ScrollBar.AsNeeded + + Column { + id: panelColumn + width: scrollArea.availableWidth + spacing: 14 + + // ---- Output ---- + Column { width: parent.width - spacing: 8 + spacing: 6 - Text { - id: outputIconText - text: root.outputIcon() - color: root.bar.foreground - font.family: root.bar.fontFamily - font.pixelSize: 16 - width: 22 - horizontalAlignment: Text.AlignHCenter - anchors.verticalCenter: parent.verticalCenter - opacity: root.outputMuted ? 0.5 : 1.0 + Row { + width: parent.width + spacing: 8 - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: root.toggleOutputMute() + Common.PanelSectionHeader { + text: "Output" + foreground: root.bar.foreground + fontFamily: root.bar.fontFamily + fontSize: 11 + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: root.sink ? "· " + root.nodeLabel(root.sink) : "" + color: Qt.darker(root.bar.foreground, 1.8) + font.family: root.bar.fontFamily + font.pixelSize: 11 + elide: Text.ElideRight + width: parent.width - 70 + anchors.verticalCenter: parent.verticalCenter } } - Common.Slider { - id: outputSlider - bar: root.bar - width: parent.width - outputIconText.width - outputPercent.width - 16 - anchors.verticalCenter: parent.verticalCenter - minimum: 0 - maximum: 1 - step: 0.05 - value: root.outputVolume - opacity: root.outputMuted ? 0.5 : 1.0 - enabled: !!root.sink - - onMoved: function(v) { root.setOutputVolume(v) } - } - - Text { - id: outputPercent - text: Math.round((outputSlider.dragging ? outputSlider.liveValue : root.outputVolume) * 100) + "%" - color: root.bar.foreground - font.family: root.bar.fontFamily - font.pixelSize: 11 - width: 36 - horizontalAlignment: Text.AlignRight - anchors.verticalCenter: parent.verticalCenter - opacity: root.outputMuted ? 0.5 : 1.0 - } - } - - Repeater { - model: root.audioSinks - - Rectangle { - required property var modelData - - readonly property bool active: root.sink && modelData && root.sink.id === modelData.id - - width: panelColumn.width - height: deviceRow.implicitHeight + 10 - radius: 0 - color: deviceArea.pressed - ? Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.22) - : deviceArea.containsMouse - ? Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.12) - : (active ? Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.18) : "transparent") - - Behavior on color { ColorAnimation { duration: 120 } } + // Output slider row — itself a cursor target (selectedIndex === -1 + // when focusSection === "output"). h/l adjust the value via + // root.adjustVolume; m / Enter toggle mute. + Common.CursorSurface { + id: outputSliderRow + width: parent.width + height: outputSliderInner.implicitHeight + 8 + hasCursor: root.focusSection === "output" && root.selectedIndex === -1 + onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(outputSliderRow) + foreground: root.bar.foreground + fill: root.activeFill Row { - id: deviceRow - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - anchors.leftMargin: 10 - anchors.rightMargin: 10 + id: outputSliderInner + anchors.fill: parent + anchors.leftMargin: 6 + anchors.rightMargin: 6 spacing: 8 Text { - text: root.sinkGlyph(modelData) + id: outputIconText + text: root.outputIcon() color: root.bar.foreground font.family: root.bar.fontFamily - font.pixelSize: 14 - width: 18 + font.pixelSize: 16 + width: 22 horizontalAlignment: Text.AlignHCenter anchors.verticalCenter: parent.verticalCenter - } + opacity: root.outputMuted ? 0.5 : 1.0 - Text { - text: root.nodeLabel(modelData) - color: root.bar.foreground - font.family: root.bar.fontFamily - font.pixelSize: 12 - elide: Text.ElideRight - width: parent.width - 18 - 14 - 16 - anchors.verticalCenter: parent.verticalCenter - } - - Text { - text: active ? "󰄬" : "" - color: root.bar.foreground - font.family: root.bar.fontFamily - font.pixelSize: 13 - width: 14 - horizontalAlignment: Text.AlignRight - anchors.verticalCenter: parent.verticalCenter - } - } - - MouseArea { - id: deviceArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: root.setDefaultSink(modelData) - } - } - } - } - - // ---- Input ---- - Column { - width: parent.width - spacing: 6 - visible: root.audioSources.length > 0 || !!root.source - - Row { - width: parent.width - spacing: 8 - - Text { - text: "Input" - color: Qt.darker(root.bar.foreground, 1.5) - font.family: root.bar.fontFamily - font.pixelSize: 11 - font.bold: true - anchors.verticalCenter: parent.verticalCenter - } - - Text { - text: root.source ? "· " + root.nodeLabel(root.source) : "" - color: Qt.darker(root.bar.foreground, 1.8) - font.family: root.bar.fontFamily - font.pixelSize: 11 - elide: Text.ElideRight - width: parent.width - 56 - anchors.verticalCenter: parent.verticalCenter - } - } - - Row { - width: parent.width - spacing: 8 - visible: !!root.source - - Text { - id: inputIconText - text: root.inputIcon() - color: root.bar.foreground - font.family: root.bar.fontFamily - font.pixelSize: 16 - width: 22 - horizontalAlignment: Text.AlignHCenter - anchors.verticalCenter: parent.verticalCenter - opacity: root.inputMuted ? 0.5 : 1.0 - - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: root.toggleInputMute() - } - } - - Common.Slider { - id: inputSlider - bar: root.bar - width: parent.width - inputIconText.width - inputPercent.width - 16 - anchors.verticalCenter: parent.verticalCenter - minimum: 0 - maximum: 1 - step: 0.05 - value: root.inputVolume - opacity: root.inputMuted ? 0.5 : 1.0 - enabled: !!root.source - - onMoved: function(v) { root.setInputVolume(v) } - } - - Text { - id: inputPercent - text: Math.round((inputSlider.dragging ? inputSlider.liveValue : root.inputVolume) * 100) + "%" - color: root.bar.foreground - font.family: root.bar.fontFamily - font.pixelSize: 11 - width: 36 - horizontalAlignment: Text.AlignRight - anchors.verticalCenter: parent.verticalCenter - opacity: root.inputMuted ? 0.5 : 1.0 - } - } - - Repeater { - model: root.audioSources - - Rectangle { - required property var modelData - - readonly property bool active: root.source && modelData && root.source.id === modelData.id - - width: panelColumn.width - height: sourceRow.implicitHeight + 10 - radius: 0 - color: sourceArea.pressed - ? Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.22) - : sourceArea.containsMouse - ? Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.12) - : (active ? Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.18) : "transparent") - - Behavior on color { ColorAnimation { duration: 120 } } - - Row { - id: sourceRow - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - anchors.leftMargin: 10 - anchors.rightMargin: 10 - spacing: 8 - - Text { - text: root.sourceGlyph(modelData) - color: root.bar.foreground - font.family: root.bar.fontFamily - font.pixelSize: 14 - width: 18 - horizontalAlignment: Text.AlignHCenter - anchors.verticalCenter: parent.verticalCenter - } - - Text { - text: root.nodeLabel(modelData) - color: root.bar.foreground - font.family: root.bar.fontFamily - font.pixelSize: 12 - elide: Text.ElideRight - width: parent.width - 18 - 14 - 16 - anchors.verticalCenter: parent.verticalCenter - } - - Text { - text: active ? "󰄬" : "" - color: root.bar.foreground - font.family: root.bar.fontFamily - font.pixelSize: 13 - width: 14 - horizontalAlignment: Text.AlignRight - anchors.verticalCenter: parent.verticalCenter - } - } - - MouseArea { - id: sourceArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: root.setDefaultSource(modelData) - } - } - } - } - - // ---- Per-app streams ---- - Column { - width: parent.width - spacing: 6 - visible: root.audioStreams.length > 0 - - Text { - text: "Playing" - color: Qt.darker(root.bar.foreground, 1.5) - font.family: root.bar.fontFamily - font.pixelSize: 11 - font.bold: true - } - - Repeater { - model: root.audioStreams - - Item { - required property var modelData - - readonly property real streamVolume: modelData && modelData.audio ? modelData.audio.volume : 0 - readonly property bool streamMuted: modelData && modelData.audio ? modelData.audio.muted : false - - width: panelColumn.width - height: streamColumn.implicitHeight + 4 - - Column { - id: streamColumn - width: parent.width - spacing: 2 - - Row { - width: parent.width - spacing: 6 - - Text { - id: streamMuteIcon - text: streamMuted ? "󰝟" : "󰕾" - color: root.bar.foreground - font.family: root.bar.fontFamily - font.pixelSize: 12 - width: 14 - horizontalAlignment: Text.AlignHCenter - anchors.verticalCenter: parent.verticalCenter - opacity: streamMuted ? 0.5 : 1.0 - - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: { - if (modelData && modelData.audio) modelData.audio.muted = !modelData.audio.muted - } - } - } - - Text { - text: root.streamLabel(modelData) - color: root.bar.foreground - font.family: root.bar.fontFamily - font.pixelSize: 11 - elide: Text.ElideRight - width: parent.width - streamMuteIcon.width - streamPct.width - 12 - anchors.verticalCenter: parent.verticalCenter - } - - Text { - id: streamPct - text: Math.round(streamVolume * 100) + "%" - color: Qt.darker(root.bar.foreground, 1.5) - font.family: root.bar.fontFamily - font.pixelSize: 11 - width: 36 - horizontalAlignment: Text.AlignRight - anchors.verticalCenter: parent.verticalCenter + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.toggleOutputMute() } } Common.Slider { + id: outputSlider bar: root.bar - width: parent.width + width: parent.width - outputIconText.width - outputPercent.width - 16 + anchors.verticalCenter: parent.verticalCenter minimum: 0 - maximum: 1.5 + maximum: 1 step: 0.05 - value: streamVolume - opacity: streamMuted ? 0.5 : 1.0 + value: root.outputVolume + opacity: root.outputMuted ? 0.5 : 1.0 + enabled: !!root.sink - onMoved: function(v) { - if (modelData && modelData.audio) modelData.audio.volume = v + onMoved: function(v) { root.setOutputVolume(v) } + } + + Text { + id: outputPercent + text: Math.round((outputSlider.dragging ? outputSlider.liveValue : root.outputVolume) * 100) + "%" + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 11 + width: 36 + horizontalAlignment: Text.AlignRight + anchors.verticalCenter: parent.verticalCenter + opacity: root.outputMuted ? 0.5 : 1.0 + } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + propagateComposedEvents: true + onContainsMouseChanged: if (containsMouse) { + root.focusSection = "output" + root.selectedIndex = -1 + } + } + } + + Repeater { + model: root.audioSinks + + SinkRow { + required property var modelData + required property int index + width: panelColumn.width + node: modelData + rowIndex: index + } + } + } + + // ---- Input ---- + Column { + width: parent.width + spacing: 6 + visible: root.audioSources.length > 0 || !!root.source + + Row { + width: parent.width + spacing: 8 + + Common.PanelSectionHeader { + text: "Input" + foreground: root.bar.foreground + fontFamily: root.bar.fontFamily + fontSize: 11 + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: root.source ? "· " + root.nodeLabel(root.source) : "" + color: Qt.darker(root.bar.foreground, 1.8) + font.family: root.bar.fontFamily + font.pixelSize: 11 + elide: Text.ElideRight + width: parent.width - 56 + anchors.verticalCenter: parent.verticalCenter + } + } + + Common.CursorSurface { + id: inputSliderRow + visible: !!root.source + width: parent.width + height: inputSliderInner.implicitHeight + 8 + hasCursor: root.focusSection === "input" && root.selectedIndex === -1 + onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(inputSliderRow) + foreground: root.bar.foreground + fill: root.activeFill + + Row { + id: inputSliderInner + anchors.fill: parent + anchors.leftMargin: 6 + anchors.rightMargin: 6 + spacing: 8 + + Text { + id: inputIconText + text: root.inputIcon() + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 16 + width: 22 + horizontalAlignment: Text.AlignHCenter + anchors.verticalCenter: parent.verticalCenter + opacity: root.inputMuted ? 0.5 : 1.0 + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.toggleInputMute() } } + + Common.Slider { + id: inputSlider + bar: root.bar + width: parent.width - inputIconText.width - inputPercent.width - 16 + anchors.verticalCenter: parent.verticalCenter + minimum: 0 + maximum: 1 + step: 0.05 + value: root.inputVolume + opacity: root.inputMuted ? 0.5 : 1.0 + enabled: !!root.source + + onMoved: function(v) { root.setInputVolume(v) } + } + + Text { + id: inputPercent + text: Math.round((inputSlider.dragging ? inputSlider.liveValue : root.inputVolume) * 100) + "%" + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 11 + width: 36 + horizontalAlignment: Text.AlignRight + anchors.verticalCenter: parent.verticalCenter + opacity: root.inputMuted ? 0.5 : 1.0 + } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + propagateComposedEvents: true + onContainsMouseChanged: if (containsMouse) { + root.focusSection = "input" + root.selectedIndex = -1 + } + } + } + + Repeater { + model: root.audioSources + + SourceRow { + required property var modelData + required property int index + width: panelColumn.width + node: modelData + rowIndex: index + } + } + } + + // ---- Per-app streams ---- + Column { + width: parent.width + spacing: 6 + visible: root.audioStreams.length > 0 + + Common.PanelSectionHeader { + text: "Playing" + foreground: root.bar.foreground + fontFamily: root.bar.fontFamily + fontSize: 11 + } + + Repeater { + model: root.audioStreams + + StreamRow { + required property var modelData + required property int index + width: panelColumn.width + node: modelData + rowIndex: index } } } @@ -606,4 +695,241 @@ Item { } } } + + // ---- Reusable inline components ---- + + // Output device row — cursor target inside the "output" section. Mouse + // hover updates the panel cursor at the root; visuals come entirely + // from hasCursor/current via CursorSurface, never from containsMouse. + component SinkRow: Common.CursorSurface { + id: sinkRow + required property var node + required property int rowIndex + + readonly property bool isActive: root.sink && node && root.sink.id === node.id + hasCursor: root.focusSection === "output" && root.selectedIndex === rowIndex + onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(sinkRow) + current: isActive + foreground: root.bar.foreground + fill: root.activeFill + implicitHeight: sinkInner.implicitHeight + 10 + + Row { + id: sinkInner + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 10 + anchors.rightMargin: 10 + spacing: 8 + + Text { + text: root.sinkGlyph(sinkRow.node) + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 14 + width: 18 + horizontalAlignment: Text.AlignHCenter + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: root.nodeLabel(sinkRow.node) + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 12 + elide: Text.ElideRight + width: parent.width - 18 - 14 - 16 + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: sinkRow.isActive ? "󰄬" : "" + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 13 + width: 14 + horizontalAlignment: Text.AlignRight + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onContainsMouseChanged: if (containsMouse) { + root.focusSection = "output" + root.selectedIndex = sinkRow.rowIndex + } + onClicked: root.setDefaultSink(sinkRow.node) + } + } + + // Input device row — sibling of SinkRow for the "input" section. + component SourceRow: Common.CursorSurface { + id: sourceRow + required property var node + required property int rowIndex + + readonly property bool isActive: root.source && node && root.source.id === node.id + hasCursor: root.focusSection === "input" && root.selectedIndex === rowIndex + onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(sourceRow) + current: isActive + foreground: root.bar.foreground + fill: root.activeFill + implicitHeight: sourceInner.implicitHeight + 10 + + Row { + id: sourceInner + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 10 + anchors.rightMargin: 10 + spacing: 8 + + Text { + text: root.sourceGlyph(sourceRow.node) + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 14 + width: 18 + horizontalAlignment: Text.AlignHCenter + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: root.nodeLabel(sourceRow.node) + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 12 + elide: Text.ElideRight + width: parent.width - 18 - 14 - 16 + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: sourceRow.isActive ? "󰄬" : "" + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 13 + width: 14 + horizontalAlignment: Text.AlignRight + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onContainsMouseChanged: if (containsMouse) { + root.focusSection = "input" + root.selectedIndex = sourceRow.rowIndex + } + onClicked: root.setDefaultSource(sourceRow.node) + } + } + + // Per-app stream row — cursor target inside the "streams" section. + // The stream has its own slider inline, so h/l from the keyboard + // adjusts THIS stream's volume (not the global output) when the cursor + // sits on this row. Enter/Space mutes the stream. + component StreamRow: Common.CursorSurface { + id: streamRow + required property var node + required property int rowIndex + + 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 + onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(streamRow) + foreground: root.bar.foreground + fill: root.activeFill + implicitHeight: streamColumn.implicitHeight + 8 + + Column { + id: streamColumn + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 6 + anchors.rightMargin: 6 + spacing: 2 + + Row { + width: parent.width + spacing: 6 + + Text { + id: streamMuteIcon + text: streamRow.streamMuted ? "󰝟" : "󰕾" + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 12 + width: 14 + horizontalAlignment: Text.AlignHCenter + anchors.verticalCenter: parent.verticalCenter + opacity: streamRow.streamMuted ? 0.5 : 1.0 + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + if (streamRow.node && streamRow.node.audio) + streamRow.node.audio.muted = !streamRow.node.audio.muted + } + } + } + + Text { + text: root.streamLabel(streamRow.node) + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 11 + elide: Text.ElideRight + width: parent.width - streamMuteIcon.width - streamPct.width - 12 + anchors.verticalCenter: parent.verticalCenter + } + + Text { + id: streamPct + text: Math.round(streamRow.streamVolume * 100) + "%" + color: Qt.darker(root.bar.foreground, 1.5) + font.family: root.bar.fontFamily + font.pixelSize: 11 + width: 36 + horizontalAlignment: Text.AlignRight + anchors.verticalCenter: parent.verticalCenter + } + } + + Common.Slider { + bar: root.bar + width: parent.width + minimum: 0 + maximum: 1.5 + step: 0.05 + value: streamRow.streamVolume + opacity: streamRow.streamMuted ? 0.5 : 1.0 + + onMoved: function(v) { + if (streamRow.node && streamRow.node.audio) streamRow.node.audio.volume = v + } + } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + propagateComposedEvents: true + onContainsMouseChanged: if (containsMouse) { + root.focusSection = "streams" + root.selectedIndex = streamRow.rowIndex + } + } + } } diff --git a/default/quickshell/omarchy-shell/plugins/bar/widgets/bluetoothPanel.qml b/default/quickshell/omarchy-shell/plugins/bar/widgets/bluetoothPanel.qml index d653b91a..ef92acc4 100644 --- a/default/quickshell/omarchy-shell/plugins/bar/widgets/bluetoothPanel.qml +++ b/default/quickshell/omarchy-shell/plugins/bar/widgets/bluetoothPanel.qml @@ -1,6 +1,7 @@ import QtQuick import QtQuick.Controls import Quickshell +import Quickshell.Io import Quickshell.Bluetooth import "../common" as Common @@ -65,12 +66,216 @@ Item { return "󰂯" } + // Single cursor model shared by keyboard and mouse. Sections: + // "header" — 3 action pills (scan, tui, toggle); h/l moves between + // them, Enter activates. + // "known" — paired/known device rows; Enter toggles connect. + // "discovered" — unpaired devices visible while scanning; Enter pairs. + // Visuals always come from Common.CursorSurface (hasCursor / current), + // never from containsMouse. Mouse hover updates root cursor state too, + // guaranteeing one highlight on screen. + property string focusSection: "header" + property int selectedIndex: 2 // default = toggle pill + readonly property int headerPillCount: 3 + + // Stable identity for the focused known device. The known list is sorted + // (connected-first, then alphabetical) so activating a device can shift + // its index. We track the BlueZ address here so the cursor follows the + // same device across reorders rather than the slot it used to occupy. + property string focusedKnownAddress: "" + + readonly property color activeFill: bar + ? Qt.rgba(bar.foreground.r, bar.foreground.g, bar.foreground.b, 0.18) + : "transparent" + + function sectionCount(section) { + if (section === "header") return headerPillCount + if (section === "known") return knownDevices.length + if (section === "discovered") return discoveredDevices.length + return 0 + } + + function sectionVisible(section) { + if (section === "header") return true + if (section === "known") return knownDevices.length > 0 + if (section === "discovered") return adapter && adapter.discovering && discoveredDevices.length > 0 + return false + } + + readonly property var visibleSections: { + var list = ["header"] + if (sectionVisible("known")) list.push("known") + if (sectionVisible("discovered")) list.push("discovered") + return list + } + + // j/k navigates between sections row-by-row. The header is treated as a + // SINGLE row (its pills sit on one horizontal line), so j/k from devices + // jumps to/from the header as a unit, and h/l moves between the three + // pills inside it. This matches wifi's DNS-pill behaviour. + function moveCursor(delta) { + var sections = visibleSections + if (!sections || sections.length === 0) return + var sIdx = sections.indexOf(focusSection) + if (sIdx < 0) { focusSection = sections[0]; selectedIndex = 0; return } + + var idx = selectedIndex + var inHeader = focusSection === "header" + var max = inHeader ? 0 : sectionCount(focusSection) - 1 + + if (delta > 0) { + if (!inHeader && idx < max) { selectedIndex = idx + 1; return } + if (sIdx < sections.length - 1) { + focusSection = sections[sIdx + 1] + // Entering the header from below shouldn't happen (header is first), + // but other entries start at 0. + selectedIndex = 0 + } + } else { + if (!inHeader && idx > 0) { selectedIndex = idx - 1; return } + if (sIdx > 0) { + focusSection = sections[sIdx - 1] + // Entering the header always lands on the toggle pill — the most + // common action and consistent with the on-open default. h/l from + // there moves to scan/TUI. + selectedIndex = focusSection === "header" ? 2 : sectionCount(focusSection) - 1 + } + } + } + + // h/l: only meaningful in the header. In device sections it's a no-op + // — j/k is the canonical row navigator there. + function moveCursorH(delta) { + if (focusSection !== "header") return + var next = selectedIndex + delta + if (next < 0) next = 0 + if (next > headerPillCount - 1) next = headerPillCount - 1 + selectedIndex = next + } + + function activateCursor() { + if (focusSection === "header") { + if (selectedIndex === 0) { + if (adapter && adapter.enabled) adapter.discovering = !adapter.discovering + } else if (selectedIndex === 1) { + if (bar) bar.run("omarchy-launch-bluetooth") + closePopout() + } else if (selectedIndex === 2) { + if (adapter) adapter.enabled = !adapter.enabled + } + return + } + if (focusSection === "known") { + var dev = knownDevices[selectedIndex] + if (!dev) return + if (!dev.trusted) dev.trusted = true + if (dev.connected) dev.disconnect() + else dev.connect() + return + } + if (focusSection === "discovered") { + var d = discoveredDevices[selectedIndex] + if (!d) return + pendingPairAddresses[d.address] = true + d.pair() + } + } + + // 'x' on a known row mirrors the row's X button: connected device + // disconnects, everything else forgets the pairing. Mismatching this + // (e.g. forgetting a connected device) is destructive — the X button + // tooltip says "Disconnect" for connected rows, and the keybind has + // to agree. + function deleteSelected() { + if (focusSection !== "known") return + var dev = knownDevices[selectedIndex] + if (!dev) return + if (dev.connected) dev.disconnect() + else if (dev.forget) dev.forget() + } + + onPopupOpenChanged: { + if (popupOpen) { + if (knownDevices.length > 0) { focusSection = "known"; selectedIndex = 0 } + else { focusSection = "header"; selectedIndex = 2 } + Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() }) + } + } + + // When `selectedIndex` changes inside the known section, remember which + // address it points at. Updates from re-resolution (below) are idempotent + // because we end up setting the same address. + onSelectedIndexChanged: { + if (focusSection !== "known") return + if (selectedIndex < 0 || selectedIndex >= knownDevices.length) return + var d = knownDevices[selectedIndex] + focusedKnownAddress = d ? (d.address || "") : "" + } + + onFocusSectionChanged: { + if (focusSection !== "known") focusedKnownAddress = "" + } + + onKnownDevicesChanged: { + // Try to follow the device by address before clamping. If we can't find + // the address (e.g. it was forgotten), fall through to clampCursor() + // which will pull selectedIndex back into range. + if (focusSection === "known" && focusedKnownAddress !== "") { + for (var i = 0; i < knownDevices.length; i++) { + if (knownDevices[i] && knownDevices[i].address === focusedKnownAddress) { + if (selectedIndex !== i) selectedIndex = i + clampCursor() + return + } + } + } + clampCursor() + } + onDiscoveredDevicesChanged: clampCursor() + onVisibleSectionsChanged: clampCursor() + + // Keep the keyboard-focused row inside the visible viewport of the device + // Flickable. Each DeviceRow calls this when it gains hasCursor. Without + // it, j/k can walk the selection off-screen in a long device list. + function ensureCursorVisible(item) { + if (!item || !deviceFlick) return + var pt = item.mapToItem(deviceFlick.contentItem, 0, 0) + var top = pt.y + var bottom = top + (item.height || 0) + var viewTop = deviceFlick.contentY + var viewBottom = viewTop + deviceFlick.height + var margin = 6 + if (top < viewTop + margin) deviceFlick.contentY = Math.max(0, top - margin) + else if (bottom > viewBottom - margin) + deviceFlick.contentY = bottom + margin - deviceFlick.height + } + + function clampCursor() { + var sections = visibleSections + if (!sections || !sections.length) return + if (sections.indexOf(focusSection) < 0) { + focusSection = sections[0] + selectedIndex = 0 + return + } + var count = sectionCount(focusSection) + if (count === 0) { + // Section emptied out — bounce to the previous visible one. + var sIdx = sections.indexOf(focusSection) + focusSection = sIdx > 0 ? sections[sIdx - 1] : sections[0] + selectedIndex = Math.max(0, sectionCount(focusSection) - 1) + return + } + if (selectedIndex > count - 1) selectedIndex = count - 1 + if (selectedIndex < 0) selectedIndex = 0 + } + visible: adapter !== null implicitWidth: button.implicitWidth implicitHeight: button.implicitHeight // Non-visual lifecycle watchers, one per device. Survives popup open/close - // and the discovered↔known transition that destroys row delegates. + // and the discovered-known transition that destroys row delegates. Repeater { model: root.devices Item { @@ -94,6 +299,17 @@ Item { } } + // Lets a Hyprland keybind summon the panel without a click. + IpcHandler { + target: "bluetoothPanel" + function toggle(): void { + if (root.popupOpen) root.closePopout() + else root.popupOpen = true + } + function show(): void { if (!root.popupOpen) root.popupOpen = true } + function hide(): void { root.closePopout() } + } + Common.WidgetButton { id: button anchors.fill: parent @@ -106,7 +322,8 @@ Item { } } - Common.PopupCard { + Common.KeyboardPanel { + id: panel anchorItem: button owner: root bar: root.bar @@ -114,151 +331,218 @@ Item { contentWidth: 320 contentHeight: column.implicitHeight + 28 - Column { - id: column + Item { + id: keyCatcher anchors.fill: parent - spacing: 10 - - // Header: title left, on/off toggle right. - Item { - width: parent.width - height: titleText.implicitHeight - - Text { - id: titleText - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - text: "Bluetooth" - color: root.bar.foreground - font.family: root.bar.fontFamily - font.pixelSize: 13 - font.bold: true + focus: true + Keys.priority: Keys.AfterItem + Keys.onPressed: function(event) { + if (event.key === Qt.Key_Escape) { + root.closePopout(); event.accepted = true; return } - - Row { - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - spacing: 4 - - Common.PillButton { - iconText: "󰂳" - tooltipText: !root.adapter ? "" : !root.adapter.enabled ? "Bluetooth is off" - : root.adapter.discovering ? "Stop scanning" : "Scan for devices" - tooltipBackground: root.bar.background - tooltipForeground: root.bar.foreground - foreground: root.bar.foreground - horizontalPadding: 6 - verticalPadding: 4 - iconSize: 14 - enabled: root.adapter !== null && root.adapter.enabled - opacity: enabled ? 1 : 0.4 - active: root.adapter && root.adapter.discovering - onClicked: if (root.adapter) root.adapter.discovering = !root.adapter.discovering - } - - Common.PillButton { - iconText: "󱁤" - tooltipText: "Open Impala (TUI)" - tooltipBackground: root.bar.background - tooltipForeground: root.bar.foreground - foreground: root.bar.foreground - horizontalPadding: 6 - verticalPadding: 4 - iconSize: 14 - onClicked: { root.bar.run("omarchy-launch-bluetooth"); root.popupOpen = false } - } - - Common.PillButton { - iconText: root.adapter && root.adapter.enabled ? "󰂯" : "󰂲" - text: root.adapter && root.adapter.enabled ? "On" : "Off" - tooltipText: root.adapter && root.adapter.enabled ? "Turn Bluetooth off" : "Turn Bluetooth on" - tooltipBackground: root.bar.background - tooltipForeground: root.bar.foreground - foreground: root.bar.foreground - horizontalPadding: 10 - verticalPadding: 4 - active: root.adapter && root.adapter.enabled - onClicked: if (root.adapter) root.adapter.enabled = !root.adapter.enabled - } + if (event.key === Qt.Key_Down || event.text === "j") { + root.moveCursor(1); event.accepted = true; return + } + if (event.key === Qt.Key_Up || event.text === "k") { + root.moveCursor(-1); event.accepted = true; return + } + if (event.key === Qt.Key_Right || event.text === "l") { + root.moveCursorH(1); event.accepted = true; return + } + if (event.key === Qt.Key_Left || event.text === "h") { + root.moveCursorH(-1); event.accepted = true; return + } + if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_Space) { + root.activateCursor(); event.accepted = true; return + } + if (event.text === "x" || event.text === "X") { + root.deleteSelected(); event.accepted = true } } - // Scrollable device list — capped so a noisy neighborhood doesn't - // grow the popup past the screen. - Flickable { - width: parent.width - height: Math.min(deviceList.implicitHeight, 400) - contentWidth: width - contentHeight: deviceList.implicitHeight - clip: true - boundsBehavior: Flickable.StopAtBounds + Column { + id: column + anchors.fill: parent + spacing: 10 - ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } - - Column { - id: deviceList + // Header: title left, on/off toggle + actions right. + Item { width: parent.width - spacing: 10 + height: titleText.implicitHeight - // Paired / known devices. - Repeater { - model: root.knownDevices - DeviceRow { - required property var modelData - width: deviceList.width - dev: modelData - isDiscovered: false - } - } - - // Discovered (unpaired) devices, only shown while scanning. Text { - visible: root.adapter && root.adapter.discovering && root.discoveredDevices.length > 0 - text: "Discovered" - color: Qt.darker(root.bar.foreground, 1.4) + id: titleText + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: "Bluetooth" + color: root.bar.foreground font.family: root.bar.fontFamily - font.pixelSize: 10 + font.pixelSize: 13 font.bold: true } - Repeater { - model: root.adapter && root.adapter.discovering ? root.discoveredDevices : [] - DeviceRow { - required property var modelData - width: deviceList.width - dev: modelData - isDiscovered: true + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 4 + + HeaderPill { + pillIndex: 0 + iconText: "󰂳" + tooltipText: !root.adapter ? "" : !root.adapter.enabled ? "Bluetooth is off" + : root.adapter.discovering ? "Stop scanning" : "Scan for devices" + pillEnabled: root.adapter !== null && root.adapter.enabled + active: root.adapter && root.adapter.discovering + onActivated: if (root.adapter) root.adapter.discovering = !root.adapter.discovering + } + + HeaderPill { + pillIndex: 1 + iconText: "󱁤" + tooltipText: "Open Impala (TUI)" + onActivated: { root.bar.run("omarchy-launch-bluetooth"); root.popupOpen = false } + } + + HeaderPill { + pillIndex: 2 + iconText: root.adapter && root.adapter.enabled ? "󰂯" : "󰂲" + tooltipText: root.adapter && root.adapter.enabled ? "Turn Bluetooth off" : "Turn Bluetooth on" + active: root.adapter && root.adapter.enabled + onActivated: if (root.adapter) root.adapter.enabled = !root.adapter.enabled } } + } - Text { - visible: root.knownDevices.length === 0 - && (!root.adapter || !root.adapter.discovering || root.discoveredDevices.length === 0) - text: !root.adapter ? "No Bluetooth adapter" - : !root.adapter.enabled ? "Turn Bluetooth on to scan" - : root.adapter.discovering ? "Scanning for devices…" - : "No paired devices. Tap the scan icon to find new ones." - color: Qt.darker(root.bar.foreground, 1.5) - font.family: root.bar.fontFamily - font.pixelSize: 11 - wrapMode: Text.WordWrap - width: deviceList.width + // Scrollable device list — capped so a noisy neighborhood doesn't + // grow the popup past the screen. + Flickable { + id: deviceFlick + width: parent.width + height: Math.min(deviceList.implicitHeight, 400) + contentWidth: width + contentHeight: deviceList.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + + Column { + id: deviceList + width: parent.width + spacing: 10 + + // Paired / known devices. + Repeater { + model: root.knownDevices + DeviceRow { + required property var modelData + required property int index + width: deviceList.width + dev: modelData + rowIndex: index + isDiscovered: false + } + } + + // Discovered (unpaired) devices, only shown while scanning. + Common.PanelSectionHeader { + visible: root.adapter && root.adapter.discovering && root.discoveredDevices.length > 0 + text: "Discovered" + foreground: root.bar.foreground + fontFamily: root.bar.fontFamily + } + + Repeater { + model: root.adapter && root.adapter.discovering ? root.discoveredDevices : [] + DeviceRow { + required property var modelData + required property int index + width: deviceList.width + dev: modelData + rowIndex: index + isDiscovered: true + } + } + + Text { + visible: root.knownDevices.length === 0 + && (!root.adapter || !root.adapter.discovering || root.discoveredDevices.length === 0) + text: !root.adapter ? "No Bluetooth adapter" + : !root.adapter.enabled ? "Turn Bluetooth on to scan" + : root.adapter.discovering ? "Scanning for devices…" + : "No paired devices. Tap the scan icon to find new ones." + color: Qt.darker(root.bar.foreground, 1.5) + font.family: root.bar.fontFamily + font.pixelSize: 11 + wrapMode: Text.WordWrap + width: deviceList.width + } } } } } } - // Two-line device row showing name + live status (Connected, Connecting…, - // Pairing…, Failed). Tracks pending click attempts with a Timer so a + // Header pill — wraps Common.PillButton with cursor-state binding so it + // participates in the same single-cursor model as device rows. We don't + // use Common.CursorSurface here because the pill already provides its + // own active/hover visuals and we want them to layer correctly. + component HeaderPill: Common.PillButton { + id: pill + required property int pillIndex + property bool pillEnabled: true + signal activated() + + tooltipBackground: root.bar.background + tooltipForeground: root.bar.foreground + foreground: root.bar.foreground + fontFamily: root.bar.fontFamily + horizontalPadding: 6 + verticalPadding: 4 + iconSize: 14 + enabled: pillEnabled + opacity: pillEnabled ? 1 : 0.4 + + // Hand off to PillButton's built-in cursor visuals so keyboard cursor and + // mouse hover render identically (fill + 1px border). + hasCursor: root.focusSection === "header" && root.selectedIndex === pillIndex + + onClicked: pill.activated() + + // Mouse hover updates root cursor state so keyboard + mouse share one + // selection model. + MouseArea { + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + propagateComposedEvents: true + onContainsMouseChanged: if (containsMouse) { + root.focusSection = "header" + root.selectedIndex = pill.pillIndex + } + } + } + + // Two-line device row showing name + live status (Connected, Connecting, + // Pairing, Failed). Tracks pending click attempts with a Timer so a // connect that drops back to Disconnected within 10s surfaces as "Failed". - component DeviceRow: Rectangle { + // Now a cursor target: hasCursor binds to root state, mouse hover updates + // root state. The X button on the right is a PanelActionButton. + component DeviceRow: Common.CursorSurface { id: row required property var dev + required property int rowIndex required property bool isDiscovered readonly property bool isConnected: dev && dev.connected 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 + onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(row) + current: isConnected + foreground: root.bar.foreground + fill: root.activeFill // 0 idle, 1 connecting, 2 disconnecting, 3 pairing, 4 failed. property int pendingAction: 0 @@ -300,9 +584,9 @@ Item { readonly property string statusText: { if (!dev) return "" if (pendingAction === 4) return failureReason || "Failed" - if (pendingAction === 1 || devState === 3) return "Connecting\u2026" - if (pendingAction === 2 || devState === 2) return "Disconnecting\u2026" - if (pendingAction === 3 || (dev.pairing === true)) return "Pairing\u2026" + if (pendingAction === 1 || devState === 3) return "Connecting…" + if (pendingAction === 2 || devState === 2) return "Disconnecting…" + if (pendingAction === 3 || (dev.pairing === true)) return "Pairing…" if (isConnected) { if (dev.batteryAvailable) return "Connected · " + Math.round(dev.battery * 100) + "%" return "Connected" @@ -319,12 +603,6 @@ Item { } implicitHeight: rowContent.implicitHeight + 12 - radius: 4 - color: rowMouse.containsMouse - ? Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.10) - : (isConnected ? Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.05) : "transparent") - - Behavior on color { ColorAnimation { duration: 120 } } MouseArea { id: rowMouse @@ -333,6 +611,11 @@ Item { acceptedButtons: Qt.LeftButton | Qt.RightButton cursorShape: row.dev ? Qt.PointingHandCursor : Qt.ArrowCursor + onContainsMouseChanged: if (containsMouse) { + root.focusSection = row.sectionName + root.selectedIndex = row.rowIndex + } + onClicked: function(mouse) { if (!row.dev) return if (mouse.button === Qt.RightButton) { @@ -363,10 +646,10 @@ Item { anchors.verticalCenter: parent.verticalCenter anchors.leftMargin: 10 anchors.rightMargin: 10 - implicitHeight: Math.max(icon.implicitHeight, info.implicitHeight, disconnectBtn.implicitHeight) + implicitHeight: Math.max(deviceIcon.implicitHeight, info.implicitHeight, disconnectBtn.implicitHeight) Text { - id: icon + id: deviceIcon text: row.isConnected ? "󰂱" : "󰂯" color: row.statusColor font.family: root.bar.fontFamily @@ -377,69 +660,25 @@ Item { // Explicit close button on any known device. Action depends on state: // connected -> disconnect, otherwise -> forget the pairing entirely. - Rectangle { + Common.PanelActionButton { id: disconnectBtn anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - width: 22 - height: 22 - radius: 4 visible: !row.isDiscovered - readonly property string action: row.isConnected ? "Disconnect" : "Forget" - color: disconnectMouse.containsMouse - ? Qt.rgba(root.bar.urgent.r, root.bar.urgent.g, root.bar.urgent.b, 0.20) - : "transparent" - - Behavior on color { ColorAnimation { duration: 120 } } - - Text { - anchors.centerIn: parent - text: "󰅙" - color: disconnectMouse.containsMouse ? root.bar.urgent : Qt.darker(root.bar.foreground, 1.3) - font.family: root.bar.fontFamily - font.pixelSize: 14 - } - - MouseArea { - id: disconnectMouse - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - acceptedButtons: Qt.LeftButton - - onClicked: { - if (!row.dev) return - if (row.isConnected) { - row.pendingAction = 2 - failureTimer.stop() - row.dev.disconnect() - } else if (row.dev.forget) { - row.dev.forget() - } - } - } - - ToolTip { - visible: disconnectMouse.containsMouse - text: disconnectBtn.action - delay: 400 - padding: 0 - background: Rectangle { - color: root.bar.background - border.color: root.bar.foreground - border.width: 1 - radius: 0 - opacity: 0.97 - } - contentItem: Text { - text: disconnectBtn.action - color: root.bar.foreground - font.family: root.bar.fontFamily - font.pixelSize: 11 - leftPadding: 10 - rightPadding: 10 - topPadding: 6 - bottomPadding: 6 + iconText: "󰅙" + tooltipText: row.isConnected ? "Disconnect" : "Forget" + foreground: root.bar.foreground + hoverColor: root.bar.urgent + panelBackground: root.bar.background + fontFamily: root.bar.fontFamily + onClicked: { + if (!row.dev) return + if (row.isConnected) { + row.pendingAction = 2 + failureTimer.stop() + row.dev.disconnect() + } else if (row.dev.forget) { + row.dev.forget() } } } @@ -447,7 +686,7 @@ Item { Column { id: info spacing: 1 - anchors.left: icon.right + anchors.left: deviceIcon.right anchors.leftMargin: 10 anchors.right: disconnectBtn.visible ? disconnectBtn.left : parent.right anchors.rightMargin: disconnectBtn.visible ? 8 : 0 @@ -472,6 +711,5 @@ Item { } } } - } } diff --git a/default/quickshell/omarchy-shell/plugins/bar/widgets/networkPanel.qml b/default/quickshell/omarchy-shell/plugins/bar/widgets/networkPanel.qml index fadcbf46..38054ff6 100644 --- a/default/quickshell/omarchy-shell/plugins/bar/widgets/networkPanel.qml +++ b/default/quickshell/omarchy-shell/plugins/bar/widgets/networkPanel.qml @@ -681,11 +681,9 @@ iwctl station "$station" get-networks rssi-dbms 2>/dev/null \\ } } - Rectangle { + Common.PanelSeparator { visible: !!root.info.iface - width: parent.width - height: 1 - color: Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.12) + foreground: root.bar.foreground } // Connection details: IP, gateway, link speed, etc. @@ -778,22 +776,18 @@ iwctl station "$station" get-networks rssi-dbms 2>/dev/null \\ } // DNS provider selection. - Rectangle { - width: parent.width - height: 1 - color: Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.12) + Common.PanelSeparator { + foreground: root.bar.foreground } Column { width: parent.width spacing: 8 - Text { + Common.PanelSectionHeader { text: "DNS provider" - color: Qt.darker(root.bar.foreground, 1.4) - font.family: root.bar.fontFamily - font.pixelSize: 10 - font.bold: true + foreground: root.bar.foreground + fontFamily: root.bar.fontFamily } Row { @@ -831,20 +825,16 @@ iwctl station "$station" get-networks rssi-dbms 2>/dev/null \\ } // Wi-Fi networks (only if a Wi-Fi station is available). - Rectangle { + Common.PanelSeparator { visible: root.wifiStationAvailable - width: parent.width - height: 1 - color: Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.12) + foreground: root.bar.foreground } - Text { + Common.PanelSectionHeader { visible: root.wifiStationAvailable text: root.scanning ? "Scanning Wi-Fi…" : "Wi-Fi networks" - color: Qt.darker(root.bar.foreground, 1.4) - font.family: root.bar.fontFamily - font.pixelSize: 10 - font.bold: true + foreground: root.bar.foreground + fontFamily: root.bar.fontFamily } // Scrollable network list — cap the height so a busy neighbourhood @@ -931,30 +921,12 @@ iwctl station "$station" get-networks rssi-dbms 2>/dev/null \\ onClicked: pill.clicked() } - ToolTip { + Common.PanelToolTip { visible: pill.tooltipText !== "" && pillMouse.containsMouse text: pill.tooltipText - delay: 400 - padding: 0 - - background: Rectangle { - color: root.bar.background - border.color: root.bar.foreground - border.width: 1 - radius: 0 - opacity: 0.97 - } - - contentItem: Text { - text: pill.tooltipText - color: root.bar.foreground - font.family: root.bar.fontFamily - font.pixelSize: 11 - leftPadding: 10 - rightPadding: 10 - topPadding: 6 - bottomPadding: 6 - } + panelForeground: root.bar.foreground + panelBackground: root.bar.background + fontFamily: root.bar.fontFamily } } @@ -1070,60 +1042,19 @@ iwctl known-networks list 2>/dev/null \\ anchors.verticalCenter: parent.verticalCenter } - Rectangle { + Common.PanelActionButton { id: forgetBtn anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - width: 22 - height: 22 - radius: 4 visible: row.isConnected - color: forgetMouse.containsMouse - ? Qt.rgba(root.bar.urgent.r, root.bar.urgent.g, root.bar.urgent.b, 0.20) - : "transparent" - - Behavior on color { ColorAnimation { duration: 60 } } - - Text { - anchors.centerIn: parent - text: "󰅙" - color: forgetMouse.containsMouse ? root.bar.urgent : Qt.darker(root.bar.foreground, 1.3) - font.family: root.bar.fontFamily - font.pixelSize: 14 - } - - MouseArea { - id: forgetMouse - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - enabled: !root.busy - onClicked: if (row.net) root.forget(row.net.ssid) - } - - ToolTip { - visible: forgetMouse.containsMouse - text: "Forget network" - delay: 400 - padding: 0 - background: Rectangle { - color: root.bar.background - border.color: root.bar.foreground - border.width: 1 - radius: 0 - opacity: 0.97 - } - contentItem: Text { - text: "Forget network" - color: root.bar.foreground - font.family: root.bar.fontFamily - font.pixelSize: 11 - leftPadding: 10 - rightPadding: 10 - topPadding: 6 - bottomPadding: 6 - } - } + enabled: !root.busy + iconText: "󰅙" + tooltipText: "Forget network" + foreground: root.bar.foreground + hoverColor: root.bar.urgent + panelBackground: root.bar.background + fontFamily: root.bar.fontFamily + onClicked: if (row.net) root.forget(row.net.ssid) } // Shows a lock glyph on the right for protected networks that @@ -1236,61 +1167,17 @@ iwctl known-networks list 2>/dev/null \\ // 22×22 right-anchored to line up with forgetBtn and lockIndicator // above. Esc closes the prompt (handled by pwField.Keys.onEscapePressed) // so there's no separate cancel button. - Rectangle { + Common.PanelActionButton { id: connectPwBtn anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - width: 22 - height: 22 - radius: 4 - property bool clickEnabled: !root.busy && row.net && pwField.text.length > 0 - color: connectPwMouse.containsMouse && connectPwBtn.clickEnabled - ? Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.20) - : "transparent" - - Behavior on color { ColorAnimation { duration: 60 } } - - Text { - anchors.centerIn: parent - text: "󰄬" - color: connectPwBtn.clickEnabled - ? (connectPwMouse.containsMouse ? root.bar.foreground : Qt.darker(root.bar.foreground, 1.3)) - : Qt.darker(root.bar.foreground, 2.0) - font.family: root.bar.fontFamily - font.pixelSize: 14 - } - - MouseArea { - id: connectPwMouse - anchors.fill: parent - hoverEnabled: true - cursorShape: connectPwBtn.clickEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor - onClicked: if (connectPwBtn.clickEnabled) root.connectWithPassphrase(row.net.ssid, pwField.text) - } - - ToolTip { - visible: connectPwMouse.containsMouse - text: "Connect" - delay: 400 - padding: 0 - background: Rectangle { - color: root.bar.background - border.color: root.bar.foreground - border.width: 1 - radius: 0 - opacity: 0.97 - } - contentItem: Text { - text: "Connect" - color: root.bar.foreground - font.family: root.bar.fontFamily - font.pixelSize: 11 - leftPadding: 10 - rightPadding: 10 - topPadding: 6 - bottomPadding: 6 - } - } + enabled: !root.busy && row.net && pwField.text.length > 0 + iconText: "󰄬" + tooltipText: "Connect" + foreground: root.bar.foreground + panelBackground: root.bar.background + fontFamily: root.bar.fontFamily + onClicked: if (row.net) root.connectWithPassphrase(row.net.ssid, pwField.text) } } } diff --git a/default/quickshell/omarchy-shell/plugins/dev-gallery/GalleryPanel.qml b/default/quickshell/omarchy-shell/plugins/dev-gallery/GalleryPanel.qml new file mode 100644 index 00000000..76e3da9b --- /dev/null +++ b/default/quickshell/omarchy-shell/plugins/dev-gallery/GalleryPanel.qml @@ -0,0 +1,744 @@ +import QtQuick +import QtQuick.Controls +import Quickshell +import "../bar/common" as Common +import qs.Commons + +// Visual reference + live playground for omarchy-shell's common UI +// components. Summoned via: +// omarchy-shell-ipc shell summon omarchy.dev-gallery "{}" +// +// Every section here renders the REAL component (not a copy) so the +// gallery doubles as a smoke test. When you add a new common component, +// add a section here. Maintenance discipline: this file should ONLY use +// imported common components, never inline reimplementations of them. +Item { + id: root + + // ---- plugin lifecycle --------------------------------------------------- + property bool closingFromHost: false + + function open(payloadJson) { + closingFromHost = false + window.visible = true + Qt.callLater(function() { if (focusSink) focusSink.forceActiveFocus() }) + } + + // Host-initiated close (`shell hide`). Visibility flips without + // notifying the host back — it already knows. + function close() { + closingFromHost = true + window.visible = false + closingFromHost = false + } + + // User-initiated close (Esc, window close button). Tell the shell so its + // openPanelIds map stays consistent and `toggle` works on the next call. + function requestClose() { + if (shell && typeof shell.hide === "function") shell.hide("omarchy.dev-gallery") + else window.visible = false + } + + // ---- host injections ---------------------------------------------------- + property var barWidgetRegistry: null + property var pluginRegistry: null + property var shell: null + property var manifest: null + + // ---- theme -------------------------------------------------------------- + readonly property color foreground: Color.foreground + readonly property color background: Color.background + readonly property color accent: Color.accent + readonly property color urgent: Color.urgent + readonly property string fontFamily: "monospace" + + // Fake `bar` for components that take a whole bar object (e.g. Slider). + readonly property var fakeBar: QtObject { + readonly property color foreground: root.foreground + readonly property color background: root.background + readonly property color urgent: root.urgent + readonly property string fontFamily: root.fontFamily + readonly property string position: "top" + readonly property bool vertical: false + readonly property int barSize: 26 + } + + // ---- cursor demo state -------------------------------------------------- + property int cursorDemoIndex: 1 + + FloatingWindow { + id: window + title: "Omarchy shell – dev gallery" + color: root.background + implicitWidth: 720 + implicitHeight: 760 + minimumSize: Qt.size(560, 520) + + onVisibleChanged: { + if (!visible && !root.closingFromHost && root.shell && typeof root.shell.hide === "function") + root.shell.hide("omarchy.dev-gallery") + } + + FocusScope { + id: focusScope + anchors.fill: parent + focus: true + + Item { + id: focusSink + width: 1; height: 1 + focus: true + Keys.onPressed: function(event) { + if (event.key === Qt.Key_Escape) { root.requestClose(); event.accepted = true } + if (event.key === Qt.Key_Right || event.text === "l") { + root.cursorDemoIndex = Math.min(2, root.cursorDemoIndex + 1) + event.accepted = true + } + if (event.key === Qt.Key_Left || event.text === "h") { + root.cursorDemoIndex = Math.max(0, root.cursorDemoIndex - 1) + event.accepted = true + } + } + } + + ScrollView { + anchors.fill: parent + anchors.margins: 18 + clip: true + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + + Column { + width: parent.width - 24 + spacing: 22 + + // ---- Header ------------------------------------------------------ + Column { + width: parent.width + spacing: 4 + + Text { + text: "Omarchy shell · dev gallery" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: 18 + font.bold: true + } + Text { + text: "Live previews of every component in plugins/bar/common/. Use this as the visual reference when porting panels or extracting new primitives. Press h/l to walk the cursor demo, Esc to close." + color: Qt.darker(root.foreground, 1.4) + font.family: root.fontFamily + font.pixelSize: 11 + width: parent.width + wrapMode: Text.WordWrap + } + } + + Common.PanelSeparator { foreground: root.foreground } + + // ---- PanelSectionHeader ------------------------------------------ + Column { + width: parent.width + spacing: 8 + + Text { + text: "PanelSectionHeader" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: 13 + font.bold: true + } + Text { + text: "Small-caps-style intro label for a section." + color: Qt.darker(root.foreground, 1.5) + font.family: root.fontFamily + font.pixelSize: 10 + } + + Rectangle { + width: parent.width + implicitHeight: shCol.implicitHeight + 24 + color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.04) + radius: 6 + border.color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.10) + border.width: 1 + + Column { + id: shCol + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 14 + anchors.rightMargin: 14 + spacing: 6 + + Common.PanelSectionHeader { + text: "DNS provider" + foreground: root.foreground + fontFamily: root.fontFamily + } + Common.PanelSectionHeader { + text: "Wi-Fi networks" + foreground: root.foreground + fontFamily: root.fontFamily + } + Common.PanelSectionHeader { + text: "Playing" + foreground: root.foreground + fontFamily: root.fontFamily + fontSize: 11 + } + } + } + } + + // ---- PanelSeparator ---------------------------------------------- + Column { + width: parent.width + spacing: 8 + + Text { + text: "PanelSeparator" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: 13 + font.bold: true + } + Text { + text: "1px horizontal rule. Default 0.12 alpha on foreground; tweak via strength." + color: Qt.darker(root.foreground, 1.5) + font.family: root.fontFamily + font.pixelSize: 10 + } + + Rectangle { + width: parent.width + implicitHeight: sepCol.implicitHeight + 24 + color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.04) + radius: 6 + border.color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.10) + border.width: 1 + + Column { + id: sepCol + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 14 + anchors.rightMargin: 14 + spacing: 12 + + Common.PanelSeparator { foreground: root.foreground } + Common.PanelSeparator { foreground: root.foreground; strength: 0.25 } + Common.PanelSeparator { foreground: root.foreground; strength: 0.45 } + } + } + } + + // ---- CursorSurface ----------------------------------------------- + Column { + width: parent.width + spacing: 8 + + Text { + text: "CursorSurface" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: 13 + font.bold: true + } + Text { + text: "Single highlight chrome for keyboard+mouse navigable items. Press h/l (anywhere in this window) to move the demo cursor. The middle item is also marked `current` to show how the two states layer." + color: Qt.darker(root.foreground, 1.5) + font.family: root.fontFamily + font.pixelSize: 10 + width: parent.width + wrapMode: Text.WordWrap + } + + Rectangle { + width: parent.width + implicitHeight: csCol.implicitHeight + 24 + color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.04) + radius: 6 + border.color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.10) + border.width: 1 + + Column { + id: csCol + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 14 + anchors.rightMargin: 14 + spacing: 6 + + Repeater { + model: [ + { "label": "Idle row" }, + { "label": "Currently-active row (e.g. connected wifi, default sink)" }, + { "label": "Forget / scan / disabled-ish row" } + ] + + Common.CursorSurface { + required property var modelData + required property int index + width: parent.width + implicitHeight: csLabel.implicitHeight + 16 + hasCursor: root.cursorDemoIndex === index + current: index === 1 + foreground: root.foreground + fill: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.18) + + Text { + id: csLabel + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 10 + anchors.rightMargin: 10 + text: modelData.label + color: root.foreground + font.family: root.fontFamily + font.pixelSize: 12 + elide: Text.ElideRight + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onContainsMouseChanged: if (containsMouse) root.cursorDemoIndex = parent.index + } + } + } + } + } + } + + // ---- PillButton -------------------------------------------------- + Column { + width: parent.width + spacing: 8 + + Text { + text: "PillButton" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: 13 + font.bold: true + } + Text { + text: "Compact rounded button with optional icon, label, tooltip, and `active` highlight. Used inside panels for inline actions (Refresh, DNS pills, Bluetooth header)." + color: Qt.darker(root.foreground, 1.5) + font.family: root.fontFamily + font.pixelSize: 10 + width: parent.width + wrapMode: Text.WordWrap + } + + Rectangle { + width: parent.width + implicitHeight: pillCol.implicitHeight + 24 + color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.04) + radius: 6 + border.color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.10) + border.width: 1 + + Row { + id: pillCol + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 14 + spacing: 6 + + Common.PillButton { + text: "DHCP" + tooltipText: "Use DNS from DHCP" + tooltipBackground: root.background + tooltipForeground: root.foreground + foreground: root.foreground + fontFamily: root.fontFamily + } + + Common.PillButton { + text: "Cloudflare" + tooltipText: "Set DNS to Cloudflare" + tooltipBackground: root.background + tooltipForeground: root.foreground + foreground: root.foreground + fontFamily: root.fontFamily + active: true + } + + Common.PillButton { + iconText: "󰑐" + tooltipText: "Refresh" + tooltipBackground: root.background + tooltipForeground: root.foreground + foreground: root.foreground + fontFamily: root.fontFamily + horizontalPadding: 8 + verticalPadding: 4 + } + + Common.PillButton { + iconText: "󰂯" + text: "On" + tooltipText: "Turn Bluetooth off" + tooltipBackground: root.background + tooltipForeground: root.foreground + foreground: root.foreground + fontFamily: root.fontFamily + active: true + } + } + } + } + + // ---- PanelActionButton ------------------------------------------- + Column { + width: parent.width + spacing: 8 + + Text { + text: "PanelActionButton" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: 13 + font.bold: true + } + Text { + text: "22×22 right-edge action button. Two flavors via hoverColor: default (foreground tint, e.g. confirm) and urgent (red tint, e.g. forget/unpair). Hover and click states are intrinsic; the row that owns it stays responsible for the cursor highlight." + color: Qt.darker(root.foreground, 1.5) + font.family: root.fontFamily + font.pixelSize: 10 + width: parent.width + wrapMode: Text.WordWrap + } + + Rectangle { + width: parent.width + implicitHeight: pabCol.implicitHeight + 24 + color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.04) + radius: 6 + border.color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.10) + border.width: 1 + + Row { + id: pabCol + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 14 + spacing: 18 + + Common.PanelActionButton { + iconText: "󰄬" + tooltipText: "Confirm (default flavor)" + foreground: root.foreground + panelBackground: root.background + fontFamily: root.fontFamily + } + + Common.PanelActionButton { + iconText: "󰅙" + tooltipText: "Forget network (urgent flavor)" + foreground: root.foreground + hoverColor: root.urgent + panelBackground: root.background + fontFamily: root.fontFamily + } + + Common.PanelActionButton { + iconText: "󰄬" + tooltipText: "Disabled — type a passphrase first" + foreground: root.foreground + panelBackground: root.background + fontFamily: root.fontFamily + enabled: false + } + } + } + } + + // ---- PanelToolTip ------------------------------------------------ + Column { + width: parent.width + spacing: 8 + + Text { + text: "PanelToolTip" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: 13 + font.bold: true + } + Text { + text: "Hover the swatch below to see the styled tooltip. Use this whenever a custom button or row needs a hover hint." + color: Qt.darker(root.foreground, 1.5) + font.family: root.fontFamily + font.pixelSize: 10 + width: parent.width + wrapMode: Text.WordWrap + } + + Rectangle { + 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 + radius: 4 + + Text { + anchors.centerIn: parent + text: "hover me" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: 12 + } + + MouseArea { + id: tipMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + } + + Common.PanelToolTip { + visible: tipMouse.containsMouse + text: "Styled tooltip — drop into any panel" + panelForeground: root.foreground + panelBackground: root.background + fontFamily: root.fontFamily + } + } + } + + // ---- Slider ------------------------------------------------------ + Column { + width: parent.width + spacing: 8 + + Text { + text: "Slider" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: 13 + font.bold: true + } + Text { + text: "Volume / progress slider. Drag, click anywhere on the track, or scroll the wheel." + color: Qt.darker(root.foreground, 1.5) + font.family: root.fontFamily + font.pixelSize: 10 + width: parent.width + wrapMode: Text.WordWrap + } + + Rectangle { + width: parent.width + implicitHeight: sliderRow.implicitHeight + 24 + color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.04) + radius: 6 + border.color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.10) + border.width: 1 + + Row { + id: sliderRow + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 14 + anchors.rightMargin: 14 + spacing: 10 + property real demoVolume: 0.45 + + Text { + text: "󰕾" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: 16 + width: 22 + horizontalAlignment: Text.AlignHCenter + anchors.verticalCenter: parent.verticalCenter + } + + Common.Slider { + id: demoSlider + bar: root.fakeBar + width: parent.width - 70 + anchors.verticalCenter: parent.verticalCenter + value: sliderRow.demoVolume + onMoved: function(v) { sliderRow.demoVolume = v } + } + + Text { + text: Math.round((demoSlider.dragging ? demoSlider.liveValue : sliderRow.demoVolume) * 100) + "%" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: 12 + width: 38 + horizontalAlignment: Text.AlignRight + anchors.verticalCenter: parent.verticalCenter + } + } + } + } + + // ---- Composed example ------------------------------------------- + Column { + width: parent.width + spacing: 8 + + Text { + text: "Composed example" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: 13 + font.bold: true + } + Text { + text: "A miniature wifi-style row built from CursorSurface + PanelActionButton + PanelToolTip. This is what new panel rows should look like — no inline Rectangle/Text/MouseArea reimplementation." + color: Qt.darker(root.foreground, 1.5) + font.family: root.fontFamily + font.pixelSize: 10 + width: parent.width + wrapMode: Text.WordWrap + } + + Rectangle { + width: parent.width + implicitHeight: composedCol.implicitHeight + 24 + color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.04) + radius: 6 + border.color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.10) + border.width: 1 + + Column { + id: composedCol + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 14 + anchors.rightMargin: 14 + spacing: 6 + + Common.PanelSectionHeader { + text: "Wi-Fi networks" + foreground: root.foreground + fontFamily: root.fontFamily + } + + Common.CursorSurface { + width: parent.width + implicitHeight: composedRow.implicitHeight + 12 + current: true + foreground: root.foreground + fill: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.18) + + Item { + id: composedRow + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 10 + anchors.rightMargin: 10 + implicitHeight: 36 + + Text { + id: composedIcon + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: "󰖩" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: 14 + } + + Common.PanelActionButton { + id: composedForget + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + iconText: "󰅙" + tooltipText: "Forget network" + foreground: root.foreground + hoverColor: root.urgent + panelBackground: root.background + fontFamily: root.fontFamily + } + + Column { + spacing: 1 + anchors.left: composedIcon.right + anchors.leftMargin: 10 + anchors.right: composedForget.left + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + + Text { + text: "HughesWiFi" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: 12 + elide: Text.ElideRight + width: parent.width + } + Text { + text: "Connected" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: 10 + elide: Text.ElideRight + width: parent.width + } + } + } + } + + Common.PanelSeparator { foreground: root.foreground } + + Common.CursorSurface { + width: parent.width + implicitHeight: idleRow.implicitHeight + 12 + foreground: root.foreground + fill: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.18) + + Item { + id: idleRow + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 10 + anchors.rightMargin: 10 + implicitHeight: 36 + + Text { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: "󰖩" + color: Qt.darker(root.foreground, 1.4) + font.family: root.fontFamily + font.pixelSize: 14 + } + + Text { + anchors.left: parent.left + anchors.leftMargin: 24 + anchors.verticalCenter: parent.verticalCenter + text: "HughesATT" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: 12 + } + } + } + } + } + } + + Item { width: 1; height: 12 } + } + } + } + } +} diff --git a/default/quickshell/omarchy-shell/plugins/dev-gallery/manifest.json b/default/quickshell/omarchy-shell/plugins/dev-gallery/manifest.json new file mode 100644 index 00000000..0f2fe26a --- /dev/null +++ b/default/quickshell/omarchy-shell/plugins/dev-gallery/manifest.json @@ -0,0 +1,11 @@ +{ + "schemaVersion": 1, + "id": "omarchy.dev-gallery", + "name": "Dev gallery", + "version": "1.0.0", + "author": "Omarchy", + "description": "Visual reference for omarchy-shell common UI components. Summon with: omarchy-shell-ipc shell summon omarchy.dev-gallery '{}'", + "kinds": ["panel"], + "activation": "on-demand", + "entryPoints": { "panel": "GalleryPanel.qml" } +}