diff --git a/default/hypr/apps/omarchy-shell.lua b/default/hypr/apps/omarchy-shell.lua index a06630f8..e0b31856 100644 --- a/default/hypr/apps/omarchy-shell.lua +++ b/default/hypr/apps/omarchy-shell.lua @@ -6,10 +6,9 @@ hl.layer_rule({ match = { namespace = "omarchy-bar" }, no_anim = true, animation -- Launcher, image selector, emojis, clipboard overlays, the OSD, reminders, the -- Wi-Fi QR code and keyboard-driven panels should pop without compositor layer --- animations. The overlays stay mapped between opens and grow from a parked --- 1x1 when shown, which Hyprland would otherwise animate as a slide in from --- the corner. Panels keep their own QML opacity transition for normal --- open/close, and skip it for panel handoff. +-- animations. Overlays open as soon as their scale is ready, without a +-- compositor fade or slide. 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-image-selector|omarchy-emojis|omarchy-clipboard|omarchy-keyboard-panel|omarchy-osd|omarchy-reminders|omarchy-network-qr)$" }, no_anim = true, animation = "none" }) -- Dev gallery is the main shell workbench; open it maximized like diff --git a/shell/Ui/OverlayWindow.qml b/shell/Ui/OverlayWindow.qml index 43a9c32c..bbca91e6 100644 --- a/shell/Ui/OverlayWindow.qml +++ b/shell/Ui/OverlayWindow.qml @@ -3,12 +3,9 @@ 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. +// Map a fresh fullscreen surface on each open. Keep its first frames +// transparent until Qt receives the output's scale, so it opens sharp without +// retaining a parked surface that can be closed when a monitor disconnects. PanelWindow { id: window @@ -17,11 +14,31 @@ PanelWindow { 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 {} + readonly property var targetMonitor: { + var monitors = Hyprland.monitors.values + for (var i = 0; i < monitors.length; i++) { + if (targetScreen && monitors[i].name === targetScreen.name) return monitors[i] + } + return null + } + + // Wait for fullscreen geometry as well as scale so no smaller opening frame + // is stretched across the output. Fractional scale uses units of 1/120; + // compare in those units to tolerate floating point rounding. + readonly property bool contentReady: shown && backingWindowVisible && !!targetScreen + && width === targetScreen.width && height === targetScreen.height + && !!targetMonitor && Math.round(devicePixelRatio * 120) === Math.round(targetMonitor.scale * 120) + property bool contentRevealed: false + + onContentReadyChanged: { + contentRevealed = false + // Resize and scale notifications arrive while Qt is still updating the + // window. Reveal on the next turn, once its content layout has caught up. + if (contentReady) Qt.callLater(function() { + if (window.contentReady) window.contentRevealed = true + }) + } function focusedScreen() { var monitor = Hyprland.focusedMonitor @@ -29,28 +46,44 @@ PanelWindow { for (var i = 0; i < Quickshell.screens.length; i++) { if (Quickshell.screens[i].name === name) return Quickshell.screens[i] } - return null + return Quickshell.screens.length > 0 ? Quickshell.screens[0] : null } - onShownChanged: if (shown) targetScreen = focusedScreen() || targetScreen + // Live monitor scaling does not emit a Hyprland monitor event, so its cached + // scale can lag behind Qt. Refresh on opening and on scale changes while open. + onShownChanged: { + targetScreen = shown ? focusedScreen() : null + if (shown) Hyprland.refreshMonitors() + } + onDevicePixelRatioChanged: if (shown) Hyprland.refreshMonitors() - visible: true + Connections { + target: Quickshell + function onScreensChanged() { + if (window.shown && Quickshell.screens.indexOf(window.targetScreen) < 0) { + window.targetScreen = null + // Let the removed output finish closing its old window before mapping + // a replacement, rather than recreating it during Qt's teardown. + Qt.callLater(function() { + if (window.shown && !window.targetScreen) window.targetScreen = window.focusedScreen() + }) + } + } + } + + visible: shown && !!targetScreen && !!targetMonitor screen: targetScreen - anchors { top: true; left: true; bottom: shown; right: shown } - implicitWidth: 1 - implicitHeight: 1 - mask: shown ? null : emptyRegion + anchors { top: true; left: true; bottom: true; right: true } color: "transparent" exclusionMode: ExclusionMode.Ignore - WlrLayershell.layer: shown ? shownLayer : WlrLayer.Bottom - WlrLayershell.keyboardFocus: shown ? shownKeyboardFocus : WlrKeyboardFocus.None + WlrLayershell.layer: shownLayer + WlrLayershell.keyboardFocus: shownKeyboardFocus - // 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. + // Opacity preserves keyboard handling while the scale arrives, including + // search keystrokes and Escape pressed immediately after opening. Binding { target: window.contentItem - property: "visible" - value: window.shown && window.width > 1 && window.height > 1 + property: "opacity" + value: window.contentRevealed ? 1 : 0 } } diff --git a/shell/plugins/menu/Menu.qml b/shell/plugins/menu/Menu.qml index 1625375e..59ad1167 100644 --- a/shell/plugins/menu/Menu.qml +++ b/shell/plugins/menu/Menu.qml @@ -1075,7 +1075,7 @@ Item { // then on the card grows and shrinks downward instead of re-centering // on every resize, which made the menu jump around. The rows height is // frozen at the same moment, so the starting menu also caps how tall the - // card may grow from there. Closing unfreezes both. + // card may grow from there. Closing or changing screens unfreezes both. property int cardTop: -1 property int maxRowsHeight: -1 readonly property int centeredTop: Math.max(Style.gapsOut, Math.round((height - root.cardHeight) / 2)) @@ -1086,8 +1086,8 @@ Item { maxRowsHeight = root.visibleRowsHeight } } - // The surface stays mapped between opens, so closing is shown going false. onShownChanged: if (!shown) { cardTop = -1; maxRowsHeight = -1 } + onTargetScreenChanged: { cardTop = -1; maxRowsHeight = -1 } Rectangle { anchors.fill: parent diff --git a/test/acceptance.d/base-test.sh b/test/acceptance.d/base-test.sh index 7bc9a1af..23ca1402 100644 --- a/test/acceptance.d/base-test.sh +++ b/test/acceptance.d/base-test.sh @@ -81,17 +81,11 @@ layer_absent() { ! layer_present "$1" } -# 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. +# Fullscreen overlays map on the overlay layer and unmap when closed. layer_on_overlay() { hyprctl -j layers | jq -e --arg ns "$1" '[.[].levels["3"][]? | select(.namespace == $ns)] | length > 0' } -layer_off_overlay() { - ! layer_on_overlay "$1" -} - # A layer can be mapped but parked off the monitor: the bar hides that way so # revealing it does not have to rebuild the surface. Assert on geometry when # what matters is that the user can actually see it. Layer boxes are local to diff --git a/test/acceptance.d/menu-test.sh b/test/acceptance.d/menu-test.sh index 64bdd0ff..c9fa806f 100644 --- a/test/acceptance.d/menu-test.sh +++ b/test/acceptance.d/menu-test.sh @@ -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_off_overlay "omarchy-menu" +wait_until "menu closes after selecting a position" 15 layer_absent "omarchy-menu" screenshot "success-menu-06-bar-left" if ((config_existed)); then diff --git a/test/acceptance.d/overlay-monitor-test.sh b/test/acceptance.d/overlay-monitor-test.sh new file mode 100644 index 00000000..6a97113f --- /dev/null +++ b/test/acceptance.d/overlay-monitor-test.sh @@ -0,0 +1,107 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +original_monitors=$(hyprctl -j monitors) +remaining_monitor=$(jq -r 'map(select(.focused))[0].name // .[0].name' <<<"$original_monitors") +remaining_height=$(jq -r --arg name "$remaining_monitor" '.[] | select(.name == $name) | (if (.transform // 0) % 2 == 1 then .width else .height end) / .scale | ceil' <<<"$original_monitors") +# Make the source tall enough that a frozen centered menu would land below +# the remaining display, so a mapped but misplaced menu cannot pass recovery. +tall_height=$((remaining_height * 2 + 1600)) +virtual_monitor="" + +cleanup() { + omarchy-shell shell hide omarchy.menu >/dev/null 2>&1 || true + if [[ -n $virtual_monitor ]]; then + hyprctl output remove "$virtual_monitor" >/dev/null 2>&1 || true + fi + hyprctl dispatch "hl.dsp.focus({ monitor = \"$remaining_monitor\" })" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +find_virtual_monitor() { + virtual_monitor=$(hyprctl -j monitors | jq -r --argjson previous "$original_monitors" '[.[].name | select(. as $name | $previous | map(.name) | index($name) == null)][0] // empty') + [[ -n $virtual_monitor ]] +} + +monitor_is_tall() { + hyprctl -j monitors | jq -e --arg name "$virtual_monitor" --argjson height "$tall_height" 'any(.[]; .name == $name and .height == $height and .scale == 1)' +} + +monitor_is_focused() { + hyprctl -j monitors | jq -e --arg name "$1" 'any(.[]; .name == $name and .focused)' +} + +menu_on_monitor() { + hyprctl -j layers | jq -e --arg name "$1" '[.[$name].levels["3"][]? | select(.namespace == "omarchy-menu")] | length == 1' +} + +monitor_contains() { + local monitor="$1" text="$2" + local snapshot="$ARTIFACTS/monitor-text-$$.png" + local status=0 + + if timeout 10 grim -o "$monitor" -s 2 "$snapshot" 2>/dev/null; then + tesseract "$snapshot" stdout --psm 11 2>/dev/null | grep -Fi -- "$text" >/dev/null || status=$? + else + status=1 + fi + rm -f "$snapshot" + return "$status" +} + +create_source_monitor() { + hyprctl output create headless >/dev/null + wait_until "temporary overlay monitor appears" 15 find_virtual_monitor + hyprctl eval "hl.monitor({ output = \"$virtual_monitor\", mode = \"1920x${tall_height}@60\", position = \"auto\", scale = 1 })" >/dev/null + wait_until "temporary overlay monitor has tall geometry" 15 monitor_is_tall + hyprctl dispatch "hl.dsp.focus({ monitor = \"$virtual_monitor\" })" >/dev/null + wait_until "temporary overlay monitor is focused" 15 monitor_is_focused "$virtual_monitor" +} + +remove_source_monitor() { + hyprctl output remove "$virtual_monitor" >/dev/null + virtual_monitor="" + hyprctl dispatch "hl.dsp.focus({ monitor = \"$remaining_monitor\" })" >/dev/null + wait_until "remaining monitor is focused" 15 monitor_is_focused "$remaining_monitor" +} + +omarchy-shell shell hide omarchy.menu >/dev/null +wait_until "menu starts closed" 15 layer_absent "omarchy-menu" + +# A closed overlay must drop its old output and reopen on a connected one. +create_source_monitor +omarchy-shell shell summon omarchy.menu '{"menu":"root"}' >/dev/null +wait_until "menu opens on temporary monitor" 15 menu_on_monitor "$virtual_monitor" +wait_until "menu renders on temporary monitor" 15 monitor_contains "$virtual_monitor" "Apps" +screenshot "success-overlay-monitor-01-closed-source" +omarchy-shell shell hide omarchy.menu >/dev/null +wait_until "menu unmaps before monitor removal" 15 layer_absent "omarchy-menu" +remove_source_monitor +omarchy-shell shell summon omarchy.menu '{"menu":"root"}' >/dev/null +wait_until "closed menu reopens on remaining monitor" 15 menu_on_monitor "$remaining_monitor" +wait_until "reopened menu renders on remaining monitor" 15 monitor_contains "$remaining_monitor" "Apps" +screenshot "success-overlay-monitor-02-closed-recovered" +omarchy-shell shell hide omarchy.menu >/dev/null +wait_until "reopened menu closes" 15 layer_absent "omarchy-menu" + +# Typing freezes the menu's position. Recovery onto a shorter screen must +# unfreeze that position while preserving the search and keyboard handling. +create_source_monitor +omarchy-shell shell summon omarchy.menu '{"menu":"root"}' >/dev/null +wait_until "open recovery menu maps on tall monitor" 15 menu_on_monitor "$virtual_monitor" +wait_until "open recovery menu renders" 15 monitor_contains "$virtual_monitor" "Apps" +screenshot "success-overlay-monitor-03-open-root" +wtype "keybindings" +wait_until "menu search renders on tall monitor" 15 monitor_contains "$virtual_monitor" "Keybindings" +screenshot "success-overlay-monitor-04-open-search" +remove_source_monitor +wait_until "open menu moves to remaining monitor" 15 menu_on_monitor "$remaining_monitor" +wait_until "open menu search renders on shorter monitor" 15 monitor_contains "$remaining_monitor" "Keybindings" +screenshot "success-overlay-monitor-05-open-recovered" +wtype -k Escape +wait_until "recovered menu clears search through keyboard" 15 monitor_contains "$remaining_monitor" "Apps" +wtype -k Escape +wait_until "recovered menu closes through keyboard" 15 layer_absent "omarchy-menu" diff --git a/test/acceptance.d/shell-surfaces-test.sh b/test/acceptance.d/shell-surfaces-test.sh index 7d7630ed..88a0ce79 100644 --- a/test/acceptance.d/shell-surfaces-test.sh +++ b/test/acceptance.d/shell-surfaces-test.sh @@ -29,7 +29,7 @@ wtype "rocket" sleep 1 screenshot "success-emoji-picker-search" wtype -k Return -wait_until "emoji picker selection closes" 15 layer_off_overlay "omarchy-emojis" +wait_until "emoji picker selection closes" 15 layer_absent "omarchy-emojis" # Seed two clipboard entries, search for the older one, and copy it back out. clipboard_token="Omarchy acceptance clipboard $(date +%s)" @@ -44,7 +44,7 @@ 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_off_overlay "omarchy-clipboard" +wait_until "clipboard selection closes" 15 layer_absent "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. @@ -53,7 +53,7 @@ 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_off_overlay "omarchy-menu" +wait_until "system menu closes" 15 layer_absent "omarchy-menu" # Preview both visual selectors and cancel without changing user state. These # cover thumbnail generation, the image-grid overlay, and current selection. @@ -62,14 +62,14 @@ wait_until "background selector opens" 30 layer_on_overlay "omarchy-image-select sleep 1 screenshot "success-background-selector" wtype -k Escape -wait_until "background selector closes" 15 layer_off_overlay "omarchy-image-selector" +wait_until "background selector closes" 15 layer_absent "omarchy-image-selector" launch_app "omarchy-theme-switcher" wait_until "theme selector opens" 30 layer_on_overlay "omarchy-image-selector" sleep 1 screenshot "success-theme-selector" wtype -k Escape -wait_until "theme selector closes" 15 layer_off_overlay "omarchy-image-selector" +wait_until "theme selector closes" 15 layer_absent "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. @@ -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_off_overlay "omarchy-reminders" +wait_until "reminder flow closes" 15 layer_absent "omarchy-reminders" # Render a real shell notification and clear it through the notification IPC. omarchy-shell notifications dismissAll >/dev/null @@ -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_off_overlay "omarchy-menu" +wait_until "apps menu closes after launching" 15 layer_absent "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 c1cfc03e..a18ac870 100644 --- a/test/shell.d/image-picker-test.sh +++ b/test/shell.d/image-picker-test.sh @@ -147,7 +147,7 @@ assert( ) assert( /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' + 'image picker uses OverlayWindow and takes the keyboard once images load' ) assert( /model: visibleImages/.test(imagePickerQml) && diff --git a/test/shell.d/overlay-window-test.sh b/test/shell.d/overlay-window-test.sh index 969d2f86..51b57c5d 100644 --- a/test/shell.d/overlay-window-test.sh +++ b/test/shell.d/overlay-window-test.sh @@ -9,31 +9,122 @@ 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. +// Closing must unmap, not leave a surface on an output that may disappear. 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' + overlay.includes('visible: shown && !!targetScreen && !!targetMonitor') && + overlay.includes('anchors { top: true; left: true; bottom: true; right: true }') && + !overlay.includes('implicitWidth: 1') && !overlay.includes('WlrLayer.Bottom'), + 'closed overlays unmap instead of parking a surface' ) -// Content stays hidden until the surface has grown, so no frame drawn at 1x1 -// is stretched across the screen. +const readyExpression = /readonly property bool contentReady: ([^\n]*(?:\n &&[^\n]*)*)/.exec(overlay)[1] +const ready = new Function('shown', 'backingWindowVisible', 'width', 'height', 'targetScreen', 'targetMonitor', 'devicePixelRatio', `return ${readyExpression}`) +const screen = { name: 'eDP-1', width: 1200, height: 750 } +const monitor = { name: 'eDP-1', scale: 1.6 } +for (const scale of [2, 1, 1.6]) { + assertEqual(ready(true, true, 1200, 750, screen, monitor, scale), scale === 1.6, + `opening frames at ${scale}x wait for the monitor's 1.6x scale`) +} +for (const scale of [1, 1.25, 1.5, 2]) { + assert(ready(true, true, 1200, 750, screen, { scale }, scale), + `overlay reveals at the correct ${scale}x scale`) +} +assert(ready(true, true, 1200, 750, screen, { scale: 1.333333 }, 160 / 120), + 'scale comparison tolerates compositor floating point rounding') +assert(!ready(true, true, 500, 500, screen, monitor, 1.6), + 'initial window geometry never stretches content across the screen') +assert(!ready(true, false, 1200, 750, screen, monitor, 1.6), + 'content waits for a mapped backing window') +assert(!ready(false, true, 1200, 750, screen, monitor, 1.6), + 'closed content stays transparent even at the correct scale') +assert(!ready(true, true, 1200, 750, null, monitor, 1.6) && + !ready(true, true, 1200, 750, screen, null, 1.6), + 'content waits for a connected screen and its monitor information') 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' + /target: window\.contentItem\s*property: "opacity"\s*value: window\.contentRevealed \? 1 : 0/.test(overlay), + 'transparent opening frames preserve immediate keyboard handling' ) -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 revealBody = /onContentReadyChanged: \{([\s\S]*?)\n \}/.exec(overlay)[1] +const reveal = new Function('window', 'Qt', `with (window) { ${revealBody} }`) +const reveals = [] +const revealQt = { callLater(callback) { reveals.push(callback) } } +const content = { contentReady: true, contentRevealed: false } +reveal(content, revealQt) +assert(!content.contentRevealed, 'scale and resize notifications finish before content reveals') +reveals.shift()() +assert(content.contentRevealed, 'settled content reveals on the next event-loop turn') +content.contentReady = false +reveal(content, revealQt) +assert(!content.contentRevealed, 'losing readiness immediately hides content') +content.contentReady = true +reveal(content, revealQt) +content.contentReady = false +reveal(content, revealQt) +reveals.shift()() +assert(!content.contentRevealed, 'a close or screen loss cancels a pending reveal') + +const focusedScreenBody = /function focusedScreen\(\) \{([\s\S]*?)\n \}/.exec(overlay)[1] +const focusedScreen = new Function('Hyprland', 'Quickshell', focusedScreenBody) +const external = { name: 'DP-1', width: 1920, height: 1080 } +assertEqual(focusedScreen({ focusedMonitor: { name: 'DP-1' } }, { screens: [screen, external] }), external, + 'overlay selects the focused output') +assertEqual(focusedScreen({ focusedMonitor: { name: 'DP-1' } }, { screens: [screen] }), screen, + 'disconnecting the focused output falls back to a connected screen') +assertEqual(focusedScreen({ focusedMonitor: null }, { screens: [] }), null, + 'no outputs leaves the overlay unmapped') + +const shownHandler = /onShownChanged: \{([\s\S]*?)\n \}/.exec(overlay)[1] +let refreshed = 0 +const Hyprland = { refreshMonitors() { refreshed++ } } +const changeShown = new Function('shown', 'focusedScreen', 'Hyprland', `let targetScreen = null; ${shownHandler}; return targetScreen`) +assertEqual(changeShown(false, () => external, Hyprland), null, 'closing drops the previous screen reference') +assertEqual(refreshed, 0, 'closing does not refresh monitor data') +assertEqual(changeShown(true, () => screen, Hyprland), screen, 'reopening selects a current screen') +assertEqual(refreshed, 1, 'opening refreshes monitor scales after a live change while closed') +const scaleHandler = /onDevicePixelRatioChanged: ([^\n]+)/.exec(overlay)[1] +const changeScale = new Function('shown', 'Hyprland', scaleHandler) +changeScale(false, Hyprland) +assertEqual(refreshed, 1, 'scale changes on hidden windows do not refresh monitor data') +const changedScreen = { name: 'eDP-1', width: 960, height: 600 } +assert(!ready(true, true, 960, 600, changedScreen, monitor, 2), + 'a stale monitor scale keeps content transparent after a live scale change') +Hyprland.refreshMonitors = () => { refreshed++; monitor.scale = 2 } +changeScale(true, Hyprland) +assertEqual(refreshed, 2, 'a showing window refreshes monitor scales when Qt receives a live change') +assert(ready(true, true, 960, 600, changedScreen, monitor, 2), + 'fresh monitor data lets content reveal at the new scale') + +const screensChangedBody = /function onScreensChanged\(\) \{([\s\S]*?)\n \}/.exec(overlay)[1] +const screensChanged = new Function('window', 'Quickshell', 'Qt', screensChangedBody) +const pending = [] +const Qt = { callLater(callback) { pending.push(callback) } } +const window = { shown: true, targetScreen: external, focusedScreen: () => screen } +screensChanged(window, { screens: [screen] }, Qt) +assertEqual(window.targetScreen, null, 'screen removal unmaps before Qt finishes destroying the old window') +pending.shift()() +assertEqual(window.targetScreen, screen, 'a showing overlay moves off a disconnected screen') +window.targetScreen = screen +window.focusedScreen = () => external +screensChanged(window, { screens: [screen, external] }, Qt) +assertEqual(window.targetScreen, screen, 'connecting another screen does not move a showing overlay') +window.shown = false +window.targetScreen = null +screensChanged(window, { screens: [screen, external] }, Qt) +assertEqual(window.targetScreen, null, 'screen changes leave a closed overlay unmapped') +window.shown = true +window.targetScreen = external +screensChanged(window, { screens: [screen] }, Qt) +window.shown = false +pending.shift()() +assertEqual(window.targetScreen, null, 'closing before a deferred remap prevents a stale reopen') +window.shown = true +window.targetScreen = external +screensChanged(window, { screens: [screen] }, Qt) +window.targetScreen = screen +window.focusedScreen = () => external +pending.shift()() +assertEqual(window.targetScreen, screen, 'a deferred remap preserves the screen chosen by a newer open') const overlays = { 'shell/plugins/menu/Menu.qml': 'shown: root.opened && root.rowsLoaded', @@ -48,11 +139,11 @@ 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` + `${file} uses the shared overlay scale and lifecycle handling` ) } -// Window visibility never changes now, so close-time resets key off shown. +// Layout resets on a requested close, independent of mapping readiness. const menuQml = read('shell/plugins/menu/Menu.qml') assert( menuQml.includes('onShownChanged: if (!shown) { cardTop = -1; maxRowsHeight = -1 }') && @@ -60,9 +151,15 @@ assert( !/onVisibleChanged: if \(!visible\) \{ cardTop/.test(menuQml), 'the menu unfreezes its layout when the overlay hides' ) +const screenHandler = /onTargetScreenChanged: \{([^\n]*)\}/.exec(menuQml)[1] +const changeScreen = new Function('panel', `with (panel) { ${screenHandler} }`) +const menuPanel = { shown: true, cardTop: 945, maxRowsHeight: 510 } +changeScreen(menuPanel) +assertEqual(menuPanel.cardTop, -1, 'moving an open menu off a disconnected output unfreezes its old top edge') +assertEqual(menuPanel.maxRowsHeight, -1, 'moving an open menu drops the old output row-height limit') +assert(menuPanel.shown, 'resetting the layout keeps the menu open') -// A kept surface grows from its parked 1x1 when shown, which Hyprland animates -// as a slide in from the corner unless its layer rule turns animation off. +// Fullscreen overlays open without an additional compositor animation. const shellRules = read('default/hypr/apps/omarchy-shell.lua') const noAnim = /namespace = "\^\(([^)]*)\)\$" \}, no_anim = true/.exec(shellRules) assert(noAnim, 'the shell overlays share one no-animation layer rule')