diff --git a/shell/Ui/OverlayWindow.qml b/shell/Ui/OverlayWindow.qml new file mode 100644 index 00000000..43a9c32c --- /dev/null +++ b/shell/Ui/OverlayWindow.qml @@ -0,0 +1,56 @@ +import QtQuick +import Quickshell +import Quickshell.Hyprland +import Quickshell.Wayland + +// A fullscreen overlay whose surface outlives each open. A fresh surface draws +// its first frames before the compositor sends its fractional scale, so an +// overlay mapped per open flashed blurry until the scale arrived. Closed, the +// surface parks as a 1x1, input-less, content-less layer below windows: small +// enough to cost nothing, off the overlay layer so it never blocks direct +// scanout for fullscreen apps. Opening only resizes and raises it. +PanelWindow { + id: window + + // Whether the overlay is showing. Drive this instead of visible. + property bool shown: false + property int shownLayer: WlrLayer.Overlay + property int shownKeyboardFocus: WlrKeyboardFocus.Exclusive + + // The surface no longer lands on the focused output by being mapped there, + // so it follows the focused monitor each time it is shown. Unset until the + // first show lets the compositor choose. + property var targetScreen: null + property Region emptyRegion: Region {} + + function focusedScreen() { + var monitor = Hyprland.focusedMonitor + var name = monitor ? String(monitor.name || "") : "" + for (var i = 0; i < Quickshell.screens.length; i++) { + if (Quickshell.screens[i].name === name) return Quickshell.screens[i] + } + return null + } + + onShownChanged: if (shown) targetScreen = focusedScreen() || targetScreen + + visible: true + screen: targetScreen + anchors { top: true; left: true; bottom: shown; right: shown } + implicitWidth: 1 + implicitHeight: 1 + mask: shown ? null : emptyRegion + color: "transparent" + exclusionMode: ExclusionMode.Ignore + WlrLayershell.layer: shown ? shownLayer : WlrLayer.Bottom + WlrLayershell.keyboardFocus: shown ? shownKeyboardFocus : WlrKeyboardFocus.None + + // Draw nothing until the surface has actually grown. A frame drawn while it + // is still 1x1 holds only the scrim's color, which the compositor would + // stretch across the whole screen until the fullscreen frame arrives. + Binding { + target: window.contentItem + property: "visible" + value: window.shown && window.width > 1 && window.height > 1 + } +} diff --git a/shell/Ui/qmldir b/shell/Ui/qmldir index 464e25b7..9061ad6a 100644 --- a/shell/Ui/qmldir +++ b/shell/Ui/qmldir @@ -15,6 +15,7 @@ KeyboardPanel 1.0 KeyboardPanel.qml MultiSelect 1.0 MultiSelect.qml NumberField 1.0 NumberField.qml OpticalGlyph 1.0 OpticalGlyph.qml +OverlayWindow 1.0 OverlayWindow.qml Panel 1.0 Panel.qml PanelActionButton 1.0 PanelActionButton.qml PanelController 1.0 PanelController.qml diff --git a/shell/plugins/clipboard/Clipboard.qml b/shell/plugins/clipboard/Clipboard.qml index da969e65..a8feb23a 100644 --- a/shell/plugins/clipboard/Clipboard.qml +++ b/shell/plugins/clipboard/Clipboard.qml @@ -311,15 +311,10 @@ Item { } } - PanelWindow { + OverlayWindow { id: panel - visible: root.opened - anchors { top: true; bottom: true; left: true; right: true } - color: "transparent" + shown: root.opened WlrLayershell.namespace: "omarchy-clipboard" - WlrLayershell.layer: WlrLayer.Overlay - WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive - exclusionMode: ExclusionMode.Ignore Rectangle { anchors.fill: parent diff --git a/shell/plugins/emojis/Emojis.qml b/shell/plugins/emojis/Emojis.qml index 376c382e..b1bcdec0 100644 --- a/shell/plugins/emojis/Emojis.qml +++ b/shell/plugins/emojis/Emojis.qml @@ -157,15 +157,10 @@ Item { path: root.omarchyPath + "/shell/plugins/emojis/emojis.json" onLoaded: root.loadEmojis(text()) } - PanelWindow { + OverlayWindow { id: panel - visible: root.opened - anchors { top: true; bottom: true; left: true; right: true } - color: "transparent" + shown: root.opened WlrLayershell.namespace: "omarchy-emojis" - WlrLayershell.layer: WlrLayer.Overlay - WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive - exclusionMode: ExclusionMode.Ignore Rectangle { anchors.fill: parent diff --git a/shell/plugins/image-picker/ImagePicker.qml b/shell/plugins/image-picker/ImagePicker.qml index 69da2f0c..b6939fad 100644 --- a/shell/plugins/image-picker/ImagePicker.qml +++ b/shell/plugins/image-picker/ImagePicker.qml @@ -1,11 +1,11 @@ import Quickshell -import Quickshell.Hyprland import Quickshell.Io import Quickshell.Wayland import QtQuick import QtQuick.Effects import QtQuick.Shapes import qs.Commons +import qs.Ui import "ImagePickerModel.js" as ImagePickerModel Item { @@ -36,9 +36,6 @@ Item { property string themeRows: "" property bool themeMode: false property bool themeOpenPending: false - // The surface outlives each open, so it no longer lands on the focused - // output by itself. Unset until the first open lets the compositor choose. - property var targetScreen: null // Bound to the central [image-picker] section in shell.toml via Color.qml. // `dimColor` tints unselected slices and text outlines on top of the scrim; // it intentionally tracks the foundational background, not a surface role. @@ -234,7 +231,6 @@ Item { requestSerial += 1 themeMode = false themeOpenPending = false - targetScreen = focusedScreen() || targetScreen imageDirs = nextImageDirs imageRows = nextImageRows @@ -278,14 +274,6 @@ Item { property var imageArray: [] - function focusedScreen() { - var monitor = Hyprland.focusedMonitor - var name = monitor ? String(monitor.name || "") : "" - for (var i = 0; i < Quickshell.screens.length; i++) { - if (Quickshell.screens[i].name === name) return Quickshell.screens[i] - } - return null - } function currentThemePreview() { var name = String(themeNameFile.text() || "").trim() @@ -456,25 +444,11 @@ Item { onExited: root.releaseNextDoneFile() } - PanelWindow { + OverlayWindow { id: panel - - // Stay mapped between opens. A fresh surface renders its first frames - // before the compositor sends its fractional scale, so the picker flashed - // blurry, and it had to re-upload every thumbnail texture. Closed, it - // waits transparent and input-less on the bottom layer: anything left on - // the overlay layer would block direct scanout for fullscreen apps. - visible: true - screen: root.targetScreen - anchors { top: true; bottom: true; left: true; right: true } - color: "transparent" - mask: root.opened ? null : closedMask + shown: root.opened + shownKeyboardFocus: root.imagesLoaded ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None WlrLayershell.namespace: "omarchy-image-selector" - WlrLayershell.layer: root.opened ? WlrLayer.Overlay : WlrLayer.Bottom - WlrLayershell.keyboardFocus: root.opened && root.imagesLoaded ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None - exclusionMode: ExclusionMode.Ignore - - Region { id: closedMask } Rectangle { anchors.fill: parent diff --git a/shell/plugins/menu/Menu.qml b/shell/plugins/menu/Menu.qml index 384d3b2a..1625375e 100644 --- a/shell/plugins/menu/Menu.qml +++ b/shell/plugins/menu/Menu.qml @@ -1065,15 +1065,10 @@ Item { if (root.guardsPending) Qt.callLater(function() { root.evaluateGuards() }) } } - PanelWindow { + OverlayWindow { id: panel - visible: root.opened && root.rowsLoaded - anchors { top: true; bottom: true; left: true; right: true } - color: "transparent" + shown: root.opened && root.rowsLoaded WlrLayershell.namespace: "omarchy-menu" - WlrLayershell.layer: WlrLayer.Overlay - WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive - exclusionMode: ExclusionMode.Ignore // The card opens centered exactly as always. The first search keystroke // or submenu move freezes the top line where it currently sits — from @@ -1086,12 +1081,13 @@ Item { readonly property int centeredTop: Math.max(Style.gapsOut, Math.round((height - root.cardHeight) / 2)) readonly property int effectiveCardTop: cardTop >= 0 ? cardTop : centeredTop function freezeCardTop() { - if (visible && cardTop < 0) { + if (shown && cardTop < 0) { cardTop = effectiveCardTop maxRowsHeight = root.visibleRowsHeight } } - onVisibleChanged: if (!visible) { cardTop = -1; maxRowsHeight = -1 } + // The surface stays mapped between opens, so closing is shown going false. + onShownChanged: if (!shown) { cardTop = -1; maxRowsHeight = -1 } Rectangle { anchors.fill: parent diff --git a/shell/plugins/osd/Osd.qml b/shell/plugins/osd/Osd.qml index 581bfa3c..a0186880 100644 --- a/shell/plugins/osd/Osd.qml +++ b/shell/plugins/osd/Osd.qml @@ -123,15 +123,11 @@ Item { function ping(): string { return "ok" } } - PanelWindow { + OverlayWindow { id: panel - visible: root.opened - anchors { top: true; bottom: true; left: true; right: true } - color: "transparent" + shown: root.opened + shownKeyboardFocus: WlrKeyboardFocus.None WlrLayershell.namespace: "omarchy-osd" - WlrLayershell.layer: WlrLayer.Overlay - WlrLayershell.keyboardFocus: WlrKeyboardFocus.None - exclusionMode: ExclusionMode.Ignore // Visual-only surface: keep the layer-shell input region empty so the OSD // never blocks clicks to the desktop below it. mask: Region {} diff --git a/shell/plugins/panels/wifiqr/Panel.qml b/shell/plugins/panels/wifiqr/Panel.qml index 1426b5fd..1fdf4dec 100644 --- a/shell/plugins/panels/wifiqr/Panel.qml +++ b/shell/plugins/panels/wifiqr/Panel.qml @@ -210,14 +210,9 @@ Item { } } - PanelWindow { - visible: root.opened - anchors { top: true; bottom: true; left: true; right: true } - color: "transparent" - exclusionMode: ExclusionMode.Ignore + OverlayWindow { + shown: root.opened WlrLayershell.namespace: "omarchy-network-qr" - WlrLayershell.layer: WlrLayer.Overlay - WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive // Deep scrim: the floating code needs the backdrop to carry the contrast // on any wallpaper. diff --git a/shell/plugins/reminders/ReminderFlow.qml b/shell/plugins/reminders/ReminderFlow.qml index fef95cf0..efdff65c 100644 --- a/shell/plugins/reminders/ReminderFlow.qml +++ b/shell/plugins/reminders/ReminderFlow.qml @@ -92,15 +92,10 @@ Item { } } - PanelWindow { + OverlayWindow { id: panel - visible: root.opened - anchors { top: true; bottom: true; left: true; right: true } - color: "transparent" + shown: root.opened WlrLayershell.namespace: "omarchy-reminders" - WlrLayershell.layer: WlrLayer.Overlay - WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive - exclusionMode: ExclusionMode.Ignore Rectangle { anchors.fill: parent diff --git a/test/acceptance.d/base-test.sh b/test/acceptance.d/base-test.sh index 668e908a..7bc9a1af 100644 --- a/test/acceptance.d/base-test.sh +++ b/test/acceptance.d/base-test.sh @@ -81,8 +81,9 @@ layer_absent() { ! layer_present "$1" } -# The image selector stays mapped between opens, parked on the bottom layer, so -# it keeps its textures and fractional scale. Showing means on the overlay. +# Overlays built on OverlayWindow (the menu, image selector, emoji picker, +# clipboard and the like) stay mapped between opens, parked 1x1 on the bottom +# layer, so they keep their fractional scale. Showing means on the overlay. layer_on_overlay() { hyprctl -j layers | jq -e --arg ns "$1" '[.[].levels["3"][]? | select(.namespace == $ns)] | length > 0' } diff --git a/test/acceptance.d/menu-test.sh b/test/acceptance.d/menu-test.sh index 500dc1e4..64bdd0ff 100644 --- a/test/acceptance.d/menu-test.sh +++ b/test/acceptance.d/menu-test.sh @@ -63,7 +63,7 @@ source_file=$DEFAULTS_FILE original_position=$(jq -r '.bar.position // "top"' "$source_file") omarchy-shell shell summon omarchy.menu '{"menu":"root"}' >/dev/null -wait_until "root menu opens" 15 layer_present "omarchy-menu" +wait_until "root menu opens" 15 layer_on_overlay "omarchy-menu" wait_until "root menu content is visible" 15 screen_contains "Apps" screenshot "success-menu-01-root" @@ -86,7 +86,7 @@ screenshot "success-menu-05-position-submenu" wtype -k Down -k Down -k Return wait_until "menu bar position changes to left" 20 bar_position_is "left" wait_until "menu bar becomes vertical" 20 bar_is_vertical -wait_until "menu closes after selecting a position" 15 layer_absent "omarchy-menu" +wait_until "menu closes after selecting a position" 15 layer_off_overlay "omarchy-menu" screenshot "success-menu-06-bar-left" if ((config_existed)); then diff --git a/test/acceptance.d/shell-surfaces-test.sh b/test/acceptance.d/shell-surfaces-test.sh index eac0c2a0..7d7630ed 100644 --- a/test/acceptance.d/shell-surfaces-test.sh +++ b/test/acceptance.d/shell-surfaces-test.sh @@ -24,12 +24,12 @@ open_and_close() { # Search and select an emoji. The host harness separately proves the shortcut # with a QMP hardware key chord, while this test focuses on UI behavior. omarchy-shell shell summon omarchy.emojis >/dev/null -wait_until "emoji picker opens" 15 layer_present "omarchy-emojis" +wait_until "emoji picker opens" 15 layer_on_overlay "omarchy-emojis" wtype "rocket" sleep 1 screenshot "success-emoji-picker-search" wtype -k Return -wait_until "emoji picker selection closes" 15 layer_absent "omarchy-emojis" +wait_until "emoji picker selection closes" 15 layer_off_overlay "omarchy-emojis" # Seed two clipboard entries, search for the older one, and copy it back out. clipboard_token="Omarchy acceptance clipboard $(date +%s)" @@ -39,21 +39,21 @@ printf '%s' "clipboard decoy" | wl-copy sleep 1 omarchy-shell shell summon omarchy.clipboard >/dev/null -wait_until "clipboard opens" 15 layer_present "omarchy-clipboard" +wait_until "clipboard opens" 15 layer_on_overlay "omarchy-clipboard" wtype "$clipboard_token" wait_until "clipboard search finds test text" 15 screen_contains "Omarchy acceptance clipboard" screenshot "success-clipboard-search" wtype -M shift -k Return -m shift -wait_until "clipboard selection closes" 15 layer_absent "omarchy-clipboard" +wait_until "clipboard selection closes" 15 layer_off_overlay "omarchy-clipboard" wait_until "clipboard selection restores test text" 15 bash -c '[[ $(wl-paste --no-newline) == "$1" ]]' _ "$clipboard_token" # Exercise the system branch without invoking any destructive action. omarchy-shell shell summon omarchy.menu '{"menu":"system"}' >/dev/null -wait_until "system menu opens" 15 layer_present "omarchy-menu" +wait_until "system menu opens" 15 layer_on_overlay "omarchy-menu" wait_until "system menu content is visible" 15 screen_contains "Shutdown" screenshot "success-system-menu" wtype -k Escape -wait_until "system menu closes" 15 layer_absent "omarchy-menu" +wait_until "system menu closes" 15 layer_off_overlay "omarchy-menu" # Preview both visual selectors and cancel without changing user state. These # cover thumbnail generation, the image-grid overlay, and current selection. @@ -74,7 +74,7 @@ wait_until "theme selector closes" 15 layer_off_overlay "omarchy-image-selector" # Walk the reminder flow through each input screen, but dismiss before it # schedules a real timer in the test user's session. omarchy-shell shell summon omarchy.reminders >/dev/null -wait_until "reminder flow opens" 15 layer_present "omarchy-reminders" +wait_until "reminder flow opens" 15 layer_on_overlay "omarchy-reminders" screenshot "success-reminder-01-minutes-prompt" wtype "5" sleep 1 @@ -83,7 +83,7 @@ wtype -k Return wait_until "reminder message prompt opens" 15 screen_contains "Reminder message" screenshot "success-reminder-03-message-prompt" wtype -k Escape -wait_until "reminder flow closes" 15 layer_absent "omarchy-reminders" +wait_until "reminder flow closes" 15 layer_off_overlay "omarchy-reminders" # Render a real shell notification and clear it through the notification IPC. omarchy-shell notifications dismissAll >/dev/null @@ -101,7 +101,7 @@ if window_present "(?i)omawrite" >/dev/null 2>&1; then fi omarchy-menu summon apps >/dev/null -wait_until "apps menu opens" 15 layer_present "omarchy-menu" +wait_until "apps menu opens" 15 layer_on_overlay "omarchy-menu" sleep 1 screenshot "success-apps-menu-open" @@ -111,7 +111,7 @@ screenshot "success-apps-menu-search" wtype -k Return wait_until "apps menu launches the top search hit" 60 window_present "(?i)omawrite" -wait_until "apps menu closes after launching" 15 layer_absent "omarchy-menu" +wait_until "apps menu closes after launching" 15 layer_off_overlay "omarchy-menu" close_windows "(?i)omawrite" wait_until "Omawrite window closes" 30 window_absent "(?i)omawrite" diff --git a/test/shell.d/image-picker-test.sh b/test/shell.d/image-picker-test.sh index 3657e29f..d061f3bf 100644 --- a/test/shell.d/image-picker-test.sh +++ b/test/shell.d/image-picker-test.sh @@ -70,14 +70,8 @@ assert( 'image picker leaves theme mode when another caller opens it' ) assert( - /PanelWindow \{[\s\S]*?visible: true[\s\S]*?mask: root\.opened \? null : closedMask[\s\S]*?WlrLayershell\.layer: root\.opened \? WlrLayer\.Overlay : WlrLayer\.Bottom/.test(imagePickerQml) && - /Region \{ id: closedMask \}/.test(imagePickerQml), - 'image picker keeps its surface mapped, parked input-less below windows while closed' -) -assert( - /function openSelector[\s\S]*?targetScreen = focusedScreen\(\) \|\| targetScreen/.test(imagePickerQml) && - /screen: root\.targetScreen/.test(imagePickerQml), - 'image picker follows the focused monitor on each open' + /OverlayWindow \{\s*id: panel\s*shown: root\.opened\s*shownKeyboardFocus: root\.imagesLoaded \? WlrKeyboardFocus\.Exclusive : WlrKeyboardFocus\.None/.test(imagePickerQml), + 'image picker parks on OverlayWindow and takes the keyboard once images load' ) assert( /source: item\.sourceActivated && item\.thumbnailPath \? Util\.fileUrl\(item\.thumbnailPath\) : ""[\s\S]*asynchronous: false/.test(imagePickerQml), diff --git a/test/shell.d/overlay-window-test.sh b/test/shell.d/overlay-window-test.sh new file mode 100644 index 00000000..8aa61dd4 --- /dev/null +++ b/test/shell.d/overlay-window-test.sh @@ -0,0 +1,70 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +run_node_test <<'JS' +const fs = require('fs') +const read = (file) => fs.readFileSync(path.join(root, file), 'utf8') +const overlay = read('shell/Ui/OverlayWindow.qml') + +// Closed, the surface parks 1x1, input-less and without keyboard below +// windows: off the overlay layer, so it never blocks direct scanout. +assert( + overlay.includes('visible: true') && + overlay.includes('anchors { top: true; left: true; bottom: shown; right: shown }') && + overlay.includes('implicitWidth: 1') && + overlay.includes('implicitHeight: 1') && + overlay.includes('mask: shown ? null : emptyRegion') && + overlay.includes('WlrLayershell.layer: shown ? shownLayer : WlrLayer.Bottom') && + overlay.includes('WlrLayershell.keyboardFocus: shown ? shownKeyboardFocus : WlrKeyboardFocus.None'), + 'overlay window parks a 1x1 input-less surface below windows while hidden' +) + +// Content stays hidden until the surface has grown, so no frame drawn at 1x1 +// is stretched across the screen. +assert( + /target: window\.contentItem\s*property: "visible"\s*value: window\.shown && window\.width > 1 && window\.height > 1/.test(overlay), + 'overlay window draws nothing until the surface has grown' +) + +assert( + /onShownChanged: if \(shown\) targetScreen = focusedScreen\(\) \|\| targetScreen/.test(overlay) && + overlay.includes('screen: targetScreen'), + 'overlay window follows the focused monitor each time it is shown' +) + +const overlays = { + 'shell/plugins/menu/Menu.qml': 'shown: root.opened && root.rowsLoaded', + 'shell/plugins/emojis/Emojis.qml': 'shown: root.opened', + 'shell/plugins/clipboard/Clipboard.qml': 'shown: root.opened', + 'shell/plugins/osd/Osd.qml': 'shown: root.opened', + 'shell/plugins/reminders/ReminderFlow.qml': 'shown: root.opened', + 'shell/plugins/panels/wifiqr/Panel.qml': 'shown: root.opened', + 'shell/plugins/image-picker/ImagePicker.qml': 'shown: root.opened', +} +for (const [file, shown] of Object.entries(overlays)) { + const qml = read(file) + assert( + qml.includes('OverlayWindow {') && qml.includes(shown) && !/PanelWindow \{\s*(id: panel\s*)?visible: root\.opened/.test(qml), + `${file} keeps its surface through OverlayWindow` + ) +} + +// Window visibility never changes now, so close-time resets key off shown. +const menuQml = read('shell/plugins/menu/Menu.qml') +assert( + menuQml.includes('onShownChanged: if (!shown) { cardTop = -1; maxRowsHeight = -1 }') && + menuQml.includes('if (shown && cardTop < 0) {') && + !/onVisibleChanged: if \(!visible\) \{ cardTop/.test(menuQml), + 'the menu unfreezes its layout when the overlay hides' +) + +// The OSD never takes the keyboard or input, shown or not. +const osd = read('shell/plugins/osd/Osd.qml') +assert( + osd.includes('shownKeyboardFocus: WlrKeyboardFocus.None') && osd.includes('mask: Region {}'), + 'the OSD stays click-through and keyboard-free while shown' +) +JS