diff --git a/bin/omarchy-menu-images b/bin/omarchy-menu-images index 8305cb0f..6b41e65a 100755 --- a/bin/omarchy-menu-images +++ b/bin/omarchy-menu-images @@ -1,7 +1,7 @@ #!/bin/bash # omarchy:summary=Open a generic image selector menu -# omarchy:args=[--selected ] [--print-name] [--show-labels] [--filterable] [--lazy-thumbnails] [--preload] [--cache-only] ... +# omarchy:args=[--selected ] [--print-name] [--show-labels] [--filterable] [--lazy-thumbnails] [--preload] [--cache-only] [--print-rows] ... selected_image="" print_name=false @@ -11,10 +11,11 @@ lazy_thumbnails=false prepare_only=false preload=false cache_only=false +print_rows=false image_dirs=() usage() { - echo "Usage: omarchy-menu-images [--selected ] [--print-name] [--show-labels] [--filterable] [--lazy-thumbnails] [--preload] [--cache-only] ..." + echo "Usage: omarchy-menu-images [--selected ] [--print-name] [--show-labels] [--filterable] [--lazy-thumbnails] [--preload] [--cache-only] [--print-rows] ..." } while (( $# > 0 )); do @@ -56,6 +57,10 @@ while (( $# > 0 )); do cache_only=true shift ;; + --print-rows) + print_rows=true + shift + ;; --help|-h) usage exit 0 @@ -328,6 +333,13 @@ if [[ $cache_only == true || $prepare_only == true ]]; then exit 0 fi +# The shell keeps a copy of these rows so its picker can open without waiting +# on this script, and reads them back from stdout to refresh that copy. +if [[ $print_rows == true ]]; then + printf '%s\n' "$rows" + exit 0 +fi + # Image rows can contain newlines and tabs, which don't survive positional # shell IPC arguments. Base64-encode for transit; the ImagePicker plugin # Qt.atob()s on the other side. diff --git a/bin/omarchy-theme-set b/bin/omarchy-theme-set index e440ee43..86a04e1e 100755 --- a/bin/omarchy-theme-set +++ b/bin/omarchy-theme-set @@ -423,9 +423,7 @@ if [[ $THEME_HEADLESS != "1" ]]; then # Call hook on theme set omarchy-hook theme-set "$THEME_NAME" >/dev/null - # Warm selector caches after the theme is applied. The shell hot-reloads theme - # colors/backgrounds, so keep the running instance alive and preload the picker - # rows/selection to avoid first-open carousel settling after a theme change. - omarchy-theme-switcher --preload >/dev/null 2>&1 + # Warm the background selector's thumbnails. The theme picker keeps its own + # rows in the shell and refreshes them each time it opens. omarchy-theme-bg-cache >/dev/null 2>&1 & fi diff --git a/bin/omarchy-theme-switcher b/bin/omarchy-theme-switcher index 49bc127c..51719fb9 100755 --- a/bin/omarchy-theme-switcher +++ b/bin/omarchy-theme-switcher @@ -2,10 +2,12 @@ # omarchy:summary=Open the Omarchy theme switcher -preload=false +print_rows=false -if [[ $1 == "--preload" ]]; then - preload=true +# The shell's picker opens from rows it already holds and runs this to refresh +# them, so the theme list never waits on the index below. +if [[ $1 == "--print-rows" ]]; then + print_rows=true shift fi @@ -121,8 +123,8 @@ menu_args=( --selected "$selected_preview" ) -if [[ $preload == true ]]; then - menu_args+=(--preload) +if [[ $print_rows == true ]]; then + menu_args+=(--print-rows) fi exec omarchy-menu-images "${menu_args[@]}" "$preview_dir" diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index 415212e3..37251a7d 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -101,7 +101,7 @@ "trigger.tests.disk-speedtest": {"icon":"󰋊","label":"Disk Speed Test","action":"omarchy-shell shell summon omarchy.disk-speedtest"}, // Style - "style.theme": {"icon":"󰸌","label":"Theme","aliases":["theme","themes"],"action":"theme=$(omarchy-theme-switcher); [[ -n $theme ]] && omarchy-theme-set \"$theme\""}, + "style.theme": {"icon":"󰸌","label":"Theme","aliases":["theme","themes"],"action":"omarchy-shell shell summon omarchy.image-picker '{\"source\":\"themes\"}'"}, "style.background": {"icon":"","label":"Background","aliases":["background","wallpaper"],"action":"background=$(omarchy-theme-bg-switcher); [[ -n $background ]] && omarchy-theme-bg-set \"$background\""}, "style.unlock": {"icon":"󰟵","label":"Unlock","aliases":["unlock"],"action":"unlock=$(omarchy-plymouth-switcher); if [[ $unlock == default ]]; then omarchy-launch-floating-terminal-with-presentation omarchy-plymouth-reset; elif [[ -n $unlock ]]; then omarchy-launch-floating-terminal-with-presentation \"omarchy-plymouth-set-by-theme $(printf %q \"$unlock\")\"; fi"}, "style.font": {"icon":"","label":"Font","provider":"fonts"}, diff --git a/shell/plugins/background/Background.qml b/shell/plugins/background/Background.qml index b3dd7dab..7a02c686 100644 --- a/shell/plugins/background/Background.qml +++ b/shell/plugins/background/Background.qml @@ -10,6 +10,7 @@ import qs.Ui Item { id: root + property var shell: null readonly property string home: Quickshell.env("HOME") readonly property string stateHome: home + "/.local/state" readonly property string currentBackgroundLink: stateHome + "/omarchy/current/background" @@ -111,7 +112,12 @@ Item { } function openThemeSwitcher() { - if (!themeSwitchProc.running) themeSwitchProc.running = true + var payload = JSON.stringify({ source: "themes" }) + + // A cloned background may not summon the picker in-process, so it takes + // the IPC route instead. + if (!root.shell || !root.shell.summon("omarchy.image-picker", payload)) + Util.execArgv(["omarchy-shell", "shell", "summon", "omarchy.image-picker", payload]) } Process { @@ -120,12 +126,6 @@ Item { onExited: root.refreshBackground() } - Process { - id: themeSwitchProc - command: ["bash", "-c", "theme=$(omarchy-theme-switcher); [[ -n $theme ]] && omarchy-theme-set \"$theme\" >/dev/null 2>&1 &"] - onExited: root.refreshBackground() - } - Process { id: readlinkProc command: ["readlink", "-f", root.currentBackgroundLink] diff --git a/shell/plugins/image-picker/ImagePicker.qml b/shell/plugins/image-picker/ImagePicker.qml index 5c002402..69da2f0c 100644 --- a/shell/plugins/image-picker/ImagePicker.qml +++ b/shell/plugins/image-picker/ImagePicker.qml @@ -1,4 +1,5 @@ import Quickshell +import Quickshell.Hyprland import Quickshell.Io import Quickshell.Wayland import QtQuick @@ -30,6 +31,14 @@ Item { property string doneFile: "" property string filterText: "" property var doneFilesToRelease: [] + // Themes open from rows the shell already holds, so the picker shows without + // waiting on omarchy-theme-switcher; each open refreshes them behind it. + 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. @@ -151,6 +160,14 @@ Item { function applySelected() { var path = currentPath() + + if (themeMode) { + themeMode = false + root.opened = false + if (path) Util.execArgv(["omarchy-theme-set", nameForPath(path)]) + return + } + if (!path || !selectionFile) { cancel() return @@ -168,6 +185,8 @@ Item { } function cancel() { + themeOpenPending = false + if (requestActive) finishDoneFile(doneFile) @@ -179,6 +198,7 @@ Item { function closeSelector(nextDoneFile) { requestSerial += 1 + themeOpenPending = false if (requestActive) finishDoneFile(doneFile) @@ -212,6 +232,9 @@ Item { finishDoneFile(doneFile) requestSerial += 1 + themeMode = false + themeOpenPending = false + targetScreen = focusedScreen() || targetScreen imageDirs = nextImageDirs imageRows = nextImageRows @@ -255,6 +278,76 @@ 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() + var images = ImagePickerModel.loadRows(themeRows) + for (var i = 0; i < images.length; i++) { + if (nameForPath(images[i].filePath) === name) return images[i].filePath + } + return "" + } + + function openThemes() { + if (themeRows) { + openThemeRows() + } else { + // First open before the startup refresh has landed. + themeOpenPending = true + } + refreshThemeRows() + } + + function openThemeRows() { + openSelector("", themeRows, currentThemePreview(), "", "", true, true) + themeMode = true + } + + function refreshThemeRows() { + if (!themeRowsProc.running) themeRowsProc.running = true + } + + function updateThemeRows(rows) { + var changed = rows !== themeRows + themeRows = rows + + if (themeOpenPending) { + themeOpenPending = false + if (rows) openThemeRows() + } else if (changed && rows && themeMode && opened) { + // A theme was added or removed since the rows were last read. Keep the + // user's place in the carousel rather than jumping back to the current. + selectedImage = currentPath() || currentThemePreview() + imageRows = rows + loadRows(rows, false) + } + } + + FileView { + id: themeNameFile + path: root.stateHome + "/omarchy/current/theme.name" + watchChanges: true + onFileChanged: reload() + } + + Process { + id: themeRowsProc + command: [root.omarchyPath + "/bin/omarchy-theme-switcher", "--print-rows"] + stdout: StdioCollector { + onStreamFinished: root.updateThemeRows(String(text || "").trim()) + } + } + + Component.onCompleted: refreshThemeRows() + function startImageScan(serial, dirs) { if (loadImagesProc.running) { loadImagesProc.queuedSerial = serial @@ -309,6 +402,10 @@ Item { if (payload) { try { args = JSON.parse(payload) || {} } catch (e) { args = {} } } + if (args.source === "themes") { + openThemes() + return + } var dirs = String(args.imageDirs || imageDirs) var rows = String(args.imageRows || "") var sel = String(args.selectedImage || selectedImage) @@ -362,14 +459,23 @@ Item { PanelWindow { id: panel - visible: root.opened + // 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 WlrLayershell.namespace: "omarchy-image-selector" - WlrLayershell.layer: WlrLayer.Overlay + 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 visible: root.opened && root.imagesLoaded diff --git a/shell/plugins/menu/Menu.qml b/shell/plugins/menu/Menu.qml index aa879c18..384d3b2a 100644 --- a/shell/plugins/menu/Menu.qml +++ b/shell/plugins/menu/Menu.qml @@ -138,6 +138,9 @@ Item { var command = String(action || "") if (!command) return + var summon = MenuModel.summonAction(command) + if (summon && root.shell && root.shell.summon(summon.id, summon.payload)) return + Util.execDetached(command) } diff --git a/shell/plugins/menu/MenuModel.js b/shell/plugins/menu/MenuModel.js index 28c995ea..53c49768 100644 --- a/shell/plugins/menu/MenuModel.js +++ b/shell/plugins/menu/MenuModel.js @@ -490,8 +490,19 @@ function guardScript(items) { return guards ? guardPrelude(guards) + guards : "" } +// An action that only asks this shell to summon one of its own plugins can be +// run in-process, skipping the bash and qs ipc spawns. Anything more than the +// bare call, such as a second command or a shell-expanded payload, is left to +// bash. +function summonAction(action) { + var match = /^omarchy-shell shell summon ([A-Za-z0-9._-]+)(?: '([^']*)')?$/.exec(String(action || "")) + if (!match) return null + return { id: match[1], payload: match[2] || "{}" } +} + if (typeof module !== "undefined") { module.exports = { + summonAction: summonAction, guardReaders: GUARD_READERS, guardScript: guardScript, stripJsonc: stripJsonc, diff --git a/test/acceptance.d/base-test.sh b/test/acceptance.d/base-test.sh index 023223a1..668e908a 100644 --- a/test/acceptance.d/base-test.sh +++ b/test/acceptance.d/base-test.sh @@ -81,6 +81,16 @@ 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. +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/shell-surfaces-test.sh b/test/acceptance.d/shell-surfaces-test.sh index 9e10e85c..eac0c2a0 100644 --- a/test/acceptance.d/shell-surfaces-test.sh +++ b/test/acceptance.d/shell-surfaces-test.sh @@ -58,18 +58,18 @@ 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. launch_app "omarchy-theme-bg-switcher" -wait_until "background selector opens" 30 layer_present "omarchy-image-selector" +wait_until "background selector opens" 30 layer_on_overlay "omarchy-image-selector" sleep 1 screenshot "success-background-selector" wtype -k Escape -wait_until "background selector closes" 15 layer_absent "omarchy-image-selector" +wait_until "background selector closes" 15 layer_off_overlay "omarchy-image-selector" launch_app "omarchy-theme-switcher" -wait_until "theme selector opens" 30 layer_present "omarchy-image-selector" +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_absent "omarchy-image-selector" +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. diff --git a/test/shell.d/background-test.sh b/test/shell.d/background-test.sh index 9762e6d3..c448c34e 100644 --- a/test/shell.d/background-test.sh +++ b/test/shell.d/background-test.sh @@ -7,8 +7,9 @@ const fs = require('fs') const backgroundQml = fs.readFileSync(path.join(root, 'shell/plugins/background/Background.qml'), 'utf8') assert( - /theme=\$\(omarchy-theme-switcher\); \[\[ -n \$theme \]\] && omarchy-theme-set \\"\$theme\\" >\/dev\/null 2>&1 &/.test(backgroundQml), - 'background theme switcher starts theme application asynchronously after selection' + /function openThemeSwitcher\(\) \{[\s\S]*if \(!root\.shell \|\| !root\.shell\.summon\("omarchy\.image-picker", payload\)\)\s*Util\.execArgv\(\["omarchy-shell", "shell", "summon", "omarchy\.image-picker", payload\]\)/.test(backgroundQml) && + !backgroundQml.includes('omarchy-theme-switcher'), + 'background opens the in-shell theme picker instead of spawning the switcher script' ) assert( diff --git a/test/shell.d/image-picker-test.sh b/test/shell.d/image-picker-test.sh index a37ff2e3..3657e29f 100644 --- a/test/shell.d/image-picker-test.sh +++ b/test/shell.d/image-picker-test.sh @@ -47,6 +47,38 @@ assert( /function preloadRows[\s\S]*if \(opened \|\| requestActive\) return/.test(imagePickerQml), 'image picker ignores cache preloads while a request is visible' ) +assert( + /if \(args\.source === "themes"\) \{\s*openThemes\(\)/.test(imagePickerQml) && + /function openThemes\(\) \{\s*if \(themeRows\) \{\s*openThemeRows\(\)[\s\S]*refreshThemeRows\(\)/.test(imagePickerQml), + 'image picker opens themes from held rows before refreshing them' +) +assert( + /command: \[root\.omarchyPath \+ "\/bin\/omarchy-theme-switcher", "--print-rows"\]/.test(imagePickerQml), + 'image picker refreshes theme rows from the theme switcher' +) +assert( + /if \(themeMode\) \{[\s\S]*Util\.execArgv\(\["omarchy-theme-set", nameForPath\(path\)\]\)/.test(imagePickerQml), + 'image picker applies a chosen theme itself' +) +assert( + /function cancel\(\) \{\s*themeOpenPending = false/.test(imagePickerQml) && + /function closeSelector\(nextDoneFile\) \{\s*requestSerial \+= 1\s*themeOpenPending = false/.test(imagePickerQml), + 'image picker drops a pending theme open once dismissed' +) +assert( + /function openSelector[\s\S]*?themeMode = false/.test(imagePickerQml), + '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' +) assert( /source: item\.sourceActivated && item\.thumbnailPath \? Util\.fileUrl\(item\.thumbnailPath\) : ""[\s\S]*asynchronous: false/.test(imagePickerQml), 'image picker loads activated thumbnails synchronously to avoid carousel flicker' diff --git a/test/shell.d/menu-images-test.sh b/test/shell.d/menu-images-test.sh index 179dcb6b..7f162a16 100644 --- a/test/shell.d/menu-images-test.sh +++ b/test/shell.d/menu-images-test.sh @@ -139,3 +139,15 @@ PATH="$stub_bin:$PATH" XDG_CACHE_HOME="$cache_home" VIPSTHUMBNAIL_CALLS_FILE="$t (( $(wc -l <"$tmp/calls") == 6 )) || fail "image menu releases thumbnail locks after generation" pass "image menu owns locks for exactly one generator lifetime" + +rm -rf "$cache_home" +mkdir -p "$cache_home" +rows=$(PATH="$stub_bin:$PATH" XDG_CACHE_HOME="$cache_home" \ + "$ROOT/bin/omarchy-menu-images" --print-rows "$images") + +(( $(wc -l <<<"$rows") == 3 )) || fail "image menu prints one row per image" +while IFS=$'\t' read -r row_image row_thumbnail; do + [[ $row_image == "$images"/* && -f $row_thumbnail ]] || + fail "image menu prints each image with its generated thumbnail" +done <<<"$rows" +pass "image menu prints its rows for the shell to hold" diff --git a/test/shell.d/menu-test.sh b/test/shell.d/menu-test.sh index a424379b..f752ffc8 100644 --- a/test/shell.d/menu-test.sh +++ b/test/shell.d/menu-test.sh @@ -10,6 +10,24 @@ const menu = requireFromRoot('shell/plugins/menu/MenuModel.js') const menuQml = fs.readFileSync(path.join(root, 'shell/plugins/menu/Menu.qml'), 'utf8') const defaultMenuJsonc = fs.readFileSync(path.join(root, 'default/omarchy/omarchy-menu.jsonc'), 'utf8') +assertDeepEqual( + menu.summonAction("omarchy-shell shell summon omarchy.speedtest"), + { id: 'omarchy.speedtest', payload: '{}' }, + 'menu runs a bare summon action in-process' +) +assertDeepEqual( + menu.summonAction(`omarchy-shell shell summon omarchy.image-picker '{"source":"themes"}'`), + { id: 'omarchy.image-picker', payload: '{"source":"themes"}' }, + 'menu keeps a single-quoted summon payload' +) +assertEqual(menu.summonAction("omarchy-shell shell summon omarchy.speedtest && echo done"), null, 'menu leaves compound summon commands to bash') +assertEqual(menu.summonAction(`omarchy-shell shell summon omarchy.x "$(id)"`), null, 'menu leaves shell-expanded payloads to bash') +assertEqual(menu.summonAction("omarchy-theme-set nord"), null, 'menu leaves ordinary actions to bash') +assert( + /var summon = MenuModel\.summonAction\(command\)\s*if \(summon && root\.shell && root\.shell\.summon\(summon\.id, summon\.payload\)\) return\s*Util\.execDetached\(command\)/.test(menuQml), + 'menu falls back to bash when an in-process summon is refused' +) + const parsed = menu.parseMenuJsonc(` { // comment