From 7b6a03fa0899edb10b0554729648375cefe92717 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 22 May 2026 11:22:42 +0200 Subject: [PATCH] Refine shell panel transitions and layout --- default/hypr/apps/omarchy-shell.lua | 6 +- shell/Ui/KeyboardPanel.qml | 39 ++++++- shell/Ui/Panel.qml | 7 ++ shell/plugins/bar/Bar.qml | 5 +- shell/plugins/osd/Osd.qml | 2 +- shell/plugins/panels/Audio.qml | 163 +++++++++++----------------- shell/plugins/panels/Bluetooth.qml | 2 +- shell/plugins/panels/Monitor.qml | 115 +++++++++++--------- shell/plugins/panels/Power.qml | 4 - 9 files changed, 180 insertions(+), 163 deletions(-) diff --git a/default/hypr/apps/omarchy-shell.lua b/default/hypr/apps/omarchy-shell.lua index 04f01b2d..cfcffd10 100644 --- a/default/hypr/apps/omarchy-shell.lua +++ b/default/hypr/apps/omarchy-shell.lua @@ -5,8 +5,10 @@ -- Keep the bar instant: no layer-shell fade/slide animation. hl.layer_rule({ match = { namespace = "omarchy-bar" }, no_anim = true, animation = "none" }) --- Launcher, image selector, emojis, and clipboard overlays should also pop without animation. -hl.layer_rule({ match = { namespace = "^(omarchy-menu|omarchy-launcher|omarchy-image-selector|omarchy-emojis|omarchy-clipboard)$" }, no_anim = true, animation = "none" }) +-- Launcher, image selector, emojis, clipboard overlays, and keyboard-driven +-- panels should pop without compositor layer fades. Panels keep their own +-- QML opacity transition for normal open/close, and skip it for panel handoff. +hl.layer_rule({ match = { namespace = "^(omarchy-menu|omarchy-launcher|omarchy-image-selector|omarchy-emojis|omarchy-clipboard|omarchy-keyboard-panel)$" }, no_anim = true, animation = "none" }) -- Bar settings floats centered with a sensible default size instead of -- tiling — it's a transient dialog, not a workspace surface. diff --git a/shell/Ui/KeyboardPanel.qml b/shell/Ui/KeyboardPanel.qml index fc7f0d96..46a460a1 100644 --- a/shell/Ui/KeyboardPanel.qml +++ b/shell/Ui/KeyboardPanel.qml @@ -44,6 +44,8 @@ PanelWindow { property int contentHeight: 200 property bool open: false property int gap: Style.gapsOut // distance between bar edge and panel + property bool popoutSwitching: false + property bool popoutSwitchClosing: false // Item that should take keyboard focus once the panel maps. Typically a // PanelKeyCatcher inside the panel content. Layer-shell grants focus to @@ -67,7 +69,7 @@ PanelWindow { // --- screen + lifetime --------------------------------------------------- screen: anchorWindow ? anchorWindow.screen : null - visible: open || card.opacity > 0 + visible: open || card.opacity > 0 || popoutSwitching color: "transparent" exclusionMode: ExclusionMode.Ignore @@ -199,8 +201,29 @@ PanelWindow { if (root.open && root.focusTarget) root.focusTarget.forceActiveFocus() }) if (!bar) return - if (open) bar.requestPopout(coordinatorKey) - else if (bar.activePopout === coordinatorKey) bar.releasePopout(coordinatorKey) + if (open) { + popoutSwitchClosing = false + popoutSwitching = bar.activePopout && bar.activePopout !== coordinatorKey + bar.requestPopout(coordinatorKey) + if (popoutSwitching) popoutSwitchTimer.restart() + } else { + popoutSwitchClosing = !!(owner && owner.popoutSwitchClosing) + popoutSwitching = false + if (bar.activePopout === coordinatorKey) bar.releasePopout(coordinatorKey) + if (popoutSwitchClosing) closeSwitchTimer.restart() + } + } + + Timer { + id: popoutSwitchTimer + interval: 150 + onTriggered: root.popoutSwitching = false + } + + Timer { + id: closeSwitchTimer + interval: 1 + onTriggered: root.popoutSwitchClosing = false } // --- outside-click dismissal -------------------------------------------- @@ -272,8 +295,10 @@ PanelWindow { border.color: Color.popups.border border.width: Math.max(1, Style.space(2)) radius: Style.cornerRadius - opacity: root.open ? 1.0 : 0 + opacity: root.open || root.popoutSwitching ? 1.0 : 0 + Behavior on opacity { + enabled: !root.popoutSwitching && !root.popoutSwitchClosing NumberAnimation { duration: 140; easing.type: Easing.OutCubic } } @@ -285,6 +310,12 @@ PanelWindow { id: contentHolder anchors.fill: parent anchors.margins: root.padding + opacity: root.popoutSwitching ? (root.open ? 1.0 : 0) : 1.0 + + Behavior on opacity { + enabled: root.popoutSwitching + NumberAnimation { duration: 140; easing.type: Easing.OutCubic } + } } } } diff --git a/shell/Ui/Panel.qml b/shell/Ui/Panel.qml index be51587c..3bf5a4e5 100644 --- a/shell/Ui/Panel.qml +++ b/shell/Ui/Panel.qml @@ -14,11 +14,18 @@ Item { property string ipcTarget: "" property bool manageIpc: true property alias controller: panelController + property bool popoutSwitching: false + property bool popoutSwitchClosing: false readonly property bool opened: panelController.open function open() { panelController.show() } function close() { panelController.hide() } + function closeForPopoutSwitch() { + popoutSwitchClosing = true + close() + Qt.callLater(function() { popoutSwitchClosing = false }) + } function toggle() { opened ? close() : open() } // Read a single value from this panel's inline shell.json entry, with a diff --git a/shell/plugins/bar/Bar.qml b/shell/plugins/bar/Bar.qml index 19a75074..bae9b89b 100644 --- a/shell/plugins/bar/Bar.qml +++ b/shell/plugins/bar/Bar.qml @@ -95,7 +95,10 @@ Item { function requestPopout(owner) { if (activePopout === owner) return - if (activePopout && "close" in activePopout) activePopout.close() + if (activePopout) { + if ("closeForPopoutSwitch" in activePopout) activePopout.closeForPopoutSwitch() + else if ("close" in activePopout) activePopout.close() + } activePopout = owner } diff --git a/shell/plugins/osd/Osd.qml b/shell/plugins/osd/Osd.qml index 6e853ac0..5e678d84 100644 --- a/shell/plugins/osd/Osd.qml +++ b/shell/plugins/osd/Osd.qml @@ -27,7 +27,7 @@ Item { if (n === "microphone-muted" || n === "microphone-off" || n === "mic-muted" || n === "mic-off") return "󰍭" if (n === "microphone" || n === "mic") return "󰍬" if (n === "keyboard") return "󰌌" - if (n === "brightness" || n === "display") return "󰃠" + if (n === "brightness" || n === "display") return "󰍹" if (n === "touchpad") return "󰟸" if (n === "touch" || n === "touchscreen") return "󰜉" if (n === "media" || n === "player") return "󰝚" diff --git a/shell/plugins/panels/Audio.qml b/shell/plugins/panels/Audio.qml index 5e653d74..d2f205ba 100644 --- a/shell/plugins/panels/Audio.qml +++ b/shell/plugins/panels/Audio.qml @@ -573,7 +573,7 @@ Panel { bar: root.bar open: root.opened focusTarget: keyCatcher - contentWidth: panel.fittedContentWidth(Style.space(370)) + contentWidth: panel.fittedContentWidth(Style.space(380)) contentHeight: panel.fittedContentHeight(panelColumn.implicitHeight, Style.space(560)) PanelKeyCatcher { @@ -616,11 +616,11 @@ Panel { width: scrollArea.availableWidth spacing: Style.space(14) - // ---------- Hero: speaker icon · title/status · % ---------- + // ---------- Hero: speaker icon · title/status ---------- Item { id: heroItem width: parent.width - implicitHeight: Math.max(heroIcon.implicitHeight, heroLabels.implicitHeight, heroPercent.implicitHeight) + implicitHeight: Math.max(heroIcon.implicitHeight, heroLabels.implicitHeight) Text { id: heroIcon @@ -643,8 +643,7 @@ Panel { id: heroLabels anchors.left: heroIcon.right anchors.leftMargin: Style.space(14) - anchors.right: heroPercent.left - anchors.rightMargin: Style.space(10) + anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter spacing: Style.space(2) @@ -673,37 +672,40 @@ Panel { width: parent.width } } - - Text { - id: heroPercent - text: Math.round((outputSlider.dragging ? outputSlider.liveValue : root.outputVolume) * 100) + "%" - color: root.bar.foreground - font.family: root.bar.fontFamily - font.pixelSize: Style.font.displayLarge - font.bold: true - opacity: root.outputMuted ? 0.5 : 1.0 - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - } } - // ---- Output ---- + // ---- Output devices ---- Column { width: parent.width spacing: Style.space(6) - Text { - text: root.sink ? "Output · " + root.nodeLabel(root.sink) : "Output" - color: Qt.darker(root.bar.foreground, 1.6) - font.family: root.bar.fontFamily - font.pixelSize: Style.font.bodySmall - elide: Text.ElideRight + Item { width: parent.width + implicitHeight: Math.max(outputHeader.implicitHeight, outputPercent.implicitHeight) + + PanelSectionHeader { + id: outputHeader + text: "OUTPUT" + foreground: root.bar.foreground + fontFamily: root.bar.fontFamily + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + } + + Text { + id: outputPercent + text: Math.round((outputSlider.dragging ? outputSlider.liveValue : root.outputVolume) * 100) + "%" + color: Qt.darker(root.bar.foreground, 1.4) + font.family: root.bar.fontFamily + font.pixelSize: Style.font.caption + font.bold: true + anchors.right: parent.right + anchors.rightMargin: Style.space(6) + anchors.verticalCenter: parent.verticalCenter + opacity: root.outputMuted ? 0.5 : 1.0 + } } - // Output slider row — keyboard cursor target (selectedIndex === -1 - // when focusSection === "output"). Icon/percent live in the hero - // above, so the slider takes the full row width. CursorSurface { id: outputSliderRow width: parent.width @@ -751,95 +753,67 @@ Panel { } } - PanelSeparator { - visible: root.audioSources.length > 0 || !!root.source - foreground: root.bar.foreground - } - // ---- Input ---- Column { width: parent.width spacing: Style.space(6) visible: root.audioSources.length > 0 || !!root.source - Text { - text: root.source ? "Input · " + root.nodeLabel(root.source) : "Input" - color: Qt.darker(root.bar.foreground, 1.6) - font.family: root.bar.fontFamily - font.pixelSize: Style.font.bodySmall - elide: Text.ElideRight + Item { width: parent.width + implicitHeight: Math.max(microphoneHeader.implicitHeight, microphonePercent.implicitHeight) + + PanelSectionHeader { + id: microphoneHeader + text: "INPUT" + foreground: root.bar.foreground + fontFamily: root.bar.fontFamily + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + } + + Text { + id: microphonePercent + text: Math.round((inputSlider.dragging ? inputSlider.liveValue : root.inputVolume) * 100) + "%" + color: Qt.darker(root.bar.foreground, 1.4) + font.family: root.bar.fontFamily + font.pixelSize: Style.font.caption + font.bold: true + anchors.right: parent.right + anchors.rightMargin: Style.space(6) + anchors.verticalCenter: parent.verticalCenter + opacity: root.inputMuted ? 0.5 : 1.0 + } } CursorSurface { id: inputSliderRow visible: !!root.source width: parent.width - height: inputSliderInner.implicitHeight + Style.spacing.controlGap + height: inputSlider.implicitHeight + Style.spacing.controlGap hasCursor: root.cursorActive && root.focusSection === "input" && root.selectedIndex === -1 onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(inputSliderRow) foreground: root.bar.foreground outline: true - Row { - id: inputSliderInner + PanelSlider { + id: inputSlider + bar: root.bar anchors.fill: parent anchors.leftMargin: Style.space(6) anchors.rightMargin: Style.space(6) - spacing: Style.space(8) + minimum: 0 + maximum: 1 + step: 0.05 + value: root.inputVolume + opacity: root.inputMuted ? 0.5 : 1.0 + enabled: !!root.source - Text { - id: inputIconText - text: root.inputIcon() - color: root.bar.foreground - font.family: root.bar.fontFamily - font.pixelSize: Style.font.heading - width: Style.space(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() - } - } - - PanelSlider { - id: inputSlider - bar: root.bar - width: parent.width - inputIconText.width - inputPercent.width - Style.space(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: Style.font.bodySmall - width: Style.space(36) - horizontalAlignment: Text.AlignRight - anchors.verticalCenter: parent.verticalCenter - opacity: root.inputMuted ? 0.5 : 1.0 - } + onMoved: function(v) { root.setInputVolume(v) } } - MouseArea { - anchors.fill: parent - hoverEnabled: true - acceptedButtons: Qt.NoButton - propagateComposedEvents: true - onContainsMouseChanged: if (containsMouse) { + HoverHandler { + onHoveredChanged: if (hovered) { root.cursorActive = true root.focusSection = "input" root.selectedIndex = -1 @@ -860,11 +834,6 @@ Panel { } } - PanelSeparator { - visible: root.audioStreams.length > 0 - foreground: root.bar.foreground - } - // ---- Per-app streams ---- Column { width: parent.width diff --git a/shell/plugins/panels/Bluetooth.qml b/shell/plugins/panels/Bluetooth.qml index 2f7cc81e..a5cb9485 100644 --- a/shell/plugins/panels/Bluetooth.qml +++ b/shell/plugins/panels/Bluetooth.qml @@ -455,7 +455,7 @@ Panel { bar: root.bar open: root.opened focusTarget: keyCatcher - contentWidth: panel.fittedContentWidth(Style.space(320)) + contentWidth: panel.fittedContentWidth(Style.space(380)) contentHeight: panel.fittedContentHeight(column.implicitHeight) PanelKeyCatcher { diff --git a/shell/plugins/panels/Monitor.qml b/shell/plugins/panels/Monitor.qml index 62c0a071..3e3dc5b0 100644 --- a/shell/plugins/panels/Monitor.qml +++ b/shell/plugins/panels/Monitor.qml @@ -399,11 +399,11 @@ Panel { width: scrollArea.availableWidth spacing: Style.space(14) - // ---------- Hero: display icon · title/status · percentage ---------- + // ---------- Hero: display icon · title/status ---------- Item { width: parent.width visible: root.brightnessAvailable - implicitHeight: Math.max(heroIcon.implicitHeight, heroLabels.implicitHeight, heroPercent.implicitHeight) + implicitHeight: Math.max(heroIcon.implicitHeight, heroLabels.implicitHeight) Text { id: heroIcon @@ -419,8 +419,7 @@ Panel { id: heroLabels anchors.left: heroIcon.right anchors.leftMargin: Style.space(14) - anchors.right: heroPercent.left - anchors.rightMargin: Style.space(10) + anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter spacing: Style.space(2) @@ -446,53 +445,73 @@ Panel { width: parent.width } } - - Text { - id: heroPercent - text: Math.round(brightnessSlider.dragging ? brightnessSlider.liveValue : root.brightnessPercent) + "%" - color: root.bar.foreground - font.family: root.bar.fontFamily - font.pixelSize: Style.font.displayLarge - font.bold: true - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - } } - // ---------- Brightness slider (full width below hero) ---------- - CursorSurface { - id: brightnessRow + // ---------- Brightness ---------- + Column { visible: root.brightnessAvailable width: parent.width - height: brightnessSlider.implicitHeight + Style.spacing.controlGap - hasCursor: root.cursorActive && root.focusSection === "brightness" && root.selectedIndex === -1 - onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(brightnessRow) - foreground: root.bar.foreground - outline: true + spacing: Style.space(6) - PanelSlider { - id: brightnessSlider - bar: root.bar - anchors.fill: parent - anchors.leftMargin: Style.space(6) - anchors.rightMargin: Style.space(6) - minimum: 1 - maximum: 100 - step: 1 - value: root.brightnessPercent - integer: true - onMoved: function(v) { root.previewBrightness(v) } - onReleased: function(v) { - brightnessDebounce.stop() - root.setBrightness(v) + Item { + width: parent.width + implicitHeight: Math.max(brightnessHeader.implicitHeight, brightnessPercent.implicitHeight) + + PanelSectionHeader { + id: brightnessHeader + text: "BRIGHTNESS" + foreground: root.bar.foreground + fontFamily: root.bar.fontFamily + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + } + + Text { + id: brightnessPercent + text: Math.round(brightnessSlider.dragging ? brightnessSlider.liveValue : root.brightnessPercent) + "%" + color: Qt.darker(root.bar.foreground, 1.4) + font.family: root.bar.fontFamily + font.pixelSize: Style.font.caption + font.bold: true + anchors.right: parent.right + anchors.rightMargin: Style.space(6) + anchors.verticalCenter: parent.verticalCenter } } - HoverHandler { - onHoveredChanged: if (hovered) { - root.cursorActive = true - root.focusSection = "brightness" - root.selectedIndex = -1 + CursorSurface { + id: brightnessRow + width: parent.width + height: brightnessSlider.implicitHeight + Style.spacing.controlGap + hasCursor: root.cursorActive && root.focusSection === "brightness" && root.selectedIndex === -1 + onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(brightnessRow) + foreground: root.bar.foreground + outline: true + + PanelSlider { + id: brightnessSlider + bar: root.bar + anchors.fill: parent + anchors.leftMargin: Style.space(6) + anchors.rightMargin: Style.space(6) + minimum: 1 + maximum: 100 + step: 1 + value: root.brightnessPercent + integer: true + onMoved: function(v) { root.previewBrightness(v) } + onReleased: function(v) { + brightnessDebounce.stop() + root.setBrightness(v) + } + } + + HoverHandler { + onHoveredChanged: if (hovered) { + root.cursorActive = true + root.focusSection = "brightness" + root.selectedIndex = -1 + } } } } @@ -505,11 +524,6 @@ Panel { font.pixelSize: Style.font.bodySmall } - PanelSeparator { - visible: root.brightnessAvailable - foreground: root.bar.foreground - } - // ---------- Scale ---------- Column { width: parent.width @@ -545,11 +559,6 @@ Panel { } } - PanelSeparator { - visible: root.displays.length > 1 - foreground: root.bar.foreground - } - // ---------- Monitors ---------- Column { width: parent.width diff --git a/shell/plugins/panels/Power.qml b/shell/plugins/panels/Power.qml index e82a5453..d65329d6 100644 --- a/shell/plugins/panels/Power.qml +++ b/shell/plugins/panels/Power.qml @@ -418,10 +418,6 @@ Panel { } } - PanelSeparator { - foreground: root.bar.foreground - } - // ---------- Power profile picker ---------- Column { width: parent.width