From 24f12431205fb76d9fadb546b32d890bba69fd21 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 27 Sep 2026 15:10:49 +0200 Subject: [PATCH] Make the theme picker open instantly (#13403) * Run menu summon actions in-process A menu action that only summons another shell plugin spawned bash and a qs ipc client to ask this same shell to do it, about 60ms of the path. Call shell.summon directly instead, and fall back to bash when the call is refused or the action is anything more than a bare summon. Co-Authored-By: Claude Opus 5.5 * Open the theme picker from rows held in the shell Opening the theme picker ran omarchy-theme-switcher to rebuild its index and then made a second IPC call, about 170ms before the picker mapped. The picker now holds the theme rows itself, opens from them at once, and refreshes them behind the open via omarchy-theme-switcher --print-rows. It applies the chosen theme with omarchy-theme-set directly, so omarchy-theme-set no longer preloads the picker. From the keybinding to the overlay mapped drops from ~245ms to ~83ms. Co-Authored-By: Claude Opus 5.5 * Keep the image picker surface mapped between opens Each open mapped a fresh surface, which rendered its first frames before Hyprland sent its fractional scale: the pixel ratio stepped 2, 1, then 1.6, so the picker flashed blurry for ~130ms and re-uploaded every thumbnail texture. Keep the surface and park it transparent and input-less on the bottom layer while closed, since anything on the overlay layer blocks direct scanout for fullscreen apps. It follows the focused monitor on each open. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- bin/omarchy-menu-images | 16 ++- bin/omarchy-theme-set | 6 +- bin/omarchy-theme-switcher | 12 ++- default/omarchy/omarchy-menu.jsonc | 2 +- shell/plugins/background/Background.qml | 14 +-- shell/plugins/image-picker/ImagePicker.qml | 110 ++++++++++++++++++++- shell/plugins/menu/Menu.qml | 3 + shell/plugins/menu/MenuModel.js | 11 +++ test/acceptance.d/base-test.sh | 10 ++ test/acceptance.d/shell-surfaces-test.sh | 8 +- test/shell.d/background-test.sh | 5 +- test/shell.d/image-picker-test.sh | 32 ++++++ test/shell.d/menu-images-test.sh | 12 +++ test/shell.d/menu-test.sh | 18 ++++ 14 files changed, 232 insertions(+), 27 deletions(-) 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