From 47bab8e9e41f184fd0ae512e051eaf71d28f7247 Mon Sep 17 00:00:00 2001 From: nate Date: Sun, 6 Sep 2026 11:00:14 -0600 Subject: [PATCH] Let the theme switcher preview a theme's other wallpapers Up/Down (or the scroll wheel) cycles that theme's backgrounds in the picker. Enter still applies the theme. The label shows the wallpaper name while previewing. Assisted-By: OpenCode (openrouter/x-ai/grok-4.6) --- manual/06-themes.md | 2 +- manual/07-hotkeys.md | 2 +- shell/plugins/image-picker/ImagePicker.qml | 188 +++++++++++++++++- .../plugins/image-picker/ImagePickerModel.js | 17 ++ shell/plugins/image-picker/list-theme-bgs.sh | 36 ++++ test/shell.d/image-picker-test.sh | 46 ++++- 6 files changed, 285 insertions(+), 6 deletions(-) create mode 100755 shell/plugins/image-picker/list-theme-bgs.sh diff --git a/manual/06-themes.md b/manual/06-themes.md index 66631bab8db..82c37e7abc6 100644 --- a/manual/06-themes.md +++ b/manual/06-themes.md @@ -4,7 +4,7 @@ Omarchy comes with twenty-two beautiful themes. You can select between them via Each theme styles the desktop, terminal, neovim, activity screen (btop), Chromium, and the entire Omarchy shell: top bar, menu, notifications, OSD, and the lock screen. (For Obsidian, you must manually select the Omarchy theme via _Appearance > Themes_ inside the app). -Themes have a set of background images that you can pick between using `Super + Ctrl + Space`. +Themes have a set of background images that you can pick between using `Super + Ctrl + Space`. In the theme picker, `Left`/`Right` choose a theme and `Up`/`Down` preview that theme's other wallpapers; `Enter` still applies the theme. You can find even more themes on [the extra themes page](https://omarchy.org/themes/) or even [make your own theme](43-making-your-own-theme.md). diff --git a/manual/07-hotkeys.md b/manual/07-hotkeys.md index 5a038ff612e..baa8345e6b5 100644 --- a/manual/07-hotkeys.md +++ b/manual/07-hotkeys.md @@ -171,7 +171,7 @@ All capture options are also accessible under _Trigger > Capture_ in the Omarchy | Hotkey | Function | | ----------------------- | --------------------- | -| `Super + Ctrl + Shift + Space` | Pick a new theme | +| `Super + Ctrl + Shift + Space` | Pick a new theme (`Up`/`Down` preview that theme's wallpapers) | | `Super + Ctrl + Space` | Pick theme background | | `Super + Backspace` | Toggle transparency on a window | | `Super + Ctrl + Backspace` | Toggle single-window square aspect | diff --git a/shell/plugins/image-picker/ImagePicker.qml b/shell/plugins/image-picker/ImagePicker.qml index 5c002402a62..0537dba3aab 100644 --- a/shell/plugins/image-picker/ImagePicker.qml +++ b/shell/plugins/image-picker/ImagePicker.qml @@ -83,7 +83,7 @@ Item { var path = currentPath() if (!path) return filterText ? "No matches" : "" - return labelForPath(path) + return ImagePickerModel.themeWallpaperLabel(path, displayByIndex[selectedIndex] || "", filterText) } function itemMatches(index) { @@ -110,6 +110,8 @@ Item { if (index === selectedIndex && immediate !== true) return selectedIndex = index + if (inThemeSwitcher()) + ensureThemeWallpapers(themeSlugForIndex(index)) } function selectAdjacent(direction) { @@ -126,6 +128,133 @@ Item { } } + function inThemeSwitcher() { + if (showLabels && filterable) return true + var path = currentPath() + return String(path).indexOf("/theme-selector/previews/") !== -1 + } + + function resetWallpaperState() { + wallpaperLists = ({}) + wallpaperOffsets = ({}) + wallpaperPending = ({}) + displayByIndex = [] + wallpaperSerial += 1 + themeBgsProc.queue = [] + themeBgsProc.activeSlug = "" + themeBgsProc.activeSerial = 0 + } + + function themeSlugForIndex(index) { + if (index < 0 || index >= imageArray.length || !imageArray[index]) return "" + return nameForPath(imageArray[index].filePath) + } + + function itemForSlug(slug) { + for (var i = 0; i < imageArray.length; i++) { + if (themeSlugForIndex(i) === slug) return imageArray[i] + } + return null + } + + function previewIndexInList(list, item) { + if (!item || !list || !list.length) return -1 + var filePath = item.filePath + var thumbnailPath = item.thumbnailPath + var fileName = item.fileName + for (var i = 0; i < list.length; i++) { + if (list[i] === filePath || list[i] === thumbnailPath) return i + if (fileName && list[i].split("/").pop() === fileName) return i + } + return -1 + } + + function computeDisplayPath(index) { + if (index < 0 || index >= imageArray.length || !imageArray[index]) return "" + var item = imageArray[index] + var slug = nameForPath(item.filePath) + var list = wallpaperLists[slug] + if (!list || !list.length) return item.thumbnailPath + var idx = wallpaperOffsets[slug] || 0 + if (idx < 0 || idx >= list.length) return item.thumbnailPath + return list[idx] + } + + function refreshDisplays() { + var next = [] + for (var i = 0; i < imageArray.length; i++) + next.push(computeDisplayPath(i)) + displayByIndex = next + } + + function ensureThemeWallpapers(slug) { + if (!inThemeSwitcher() || !slug) return + if (Object.prototype.hasOwnProperty.call(wallpaperLists, slug)) return + if (themeBgsProc.activeSlug === slug || themeBgsProc.queue.indexOf(slug) >= 0) return + themeBgsProc.queue = themeBgsProc.queue.concat([slug]) + startThemeBgsQueue() + } + + function startThemeBgsQueue() { + if (themeBgsProc.running || themeBgsProc.queue.length === 0) return + var slug = themeBgsProc.queue[0] + themeBgsProc.queue = themeBgsProc.queue.slice(1) + themeBgsProc.activeSlug = slug + themeBgsProc.activeSerial = wallpaperSerial + themeBgsProc.command = ["bash", root.scriptPath("list-theme-bgs.sh"), slug] + themeBgsProc.running = true + } + + function takeThemeWallpapers(slug, lines) { + var item = itemForSlug(slug) + var list = lines.slice() + var match = previewIndexInList(list, item) + if (match < 0 && item && item.thumbnailPath) { + list.unshift(item.thumbnailPath) + match = 0 + } + if (match < 0) match = 0 + var lists = Object.assign({}, wallpaperLists) + var offsets = Object.assign({}, wallpaperOffsets) + lists[slug] = list + if (offsets[slug] === undefined) offsets[slug] = match + wallpaperLists = lists + wallpaperOffsets = offsets + var pending = wallpaperPending[slug] || 0 + if (pending) { + var pendingMap = Object.assign({}, wallpaperPending) + pendingMap[slug] = 0 + wallpaperPending = pendingMap + applyWallpaperCycle(slug, pending) + return + } + refreshDisplays() + } + + function applyWallpaperCycle(slug, direction) { + var list = wallpaperLists[slug] + if (!list || list.length < 2) return + var idx = wallpaperOffsets[slug] || 0 + var offsets = Object.assign({}, wallpaperOffsets) + offsets[slug] = (idx + direction % list.length + list.length) % list.length + wallpaperOffsets = offsets + refreshDisplays() + } + + function cycleThemeWallpaper(direction) { + if (!inThemeSwitcher()) return + var slug = themeSlugForIndex(selectedIndex) + if (!slug) return + if (Object.prototype.hasOwnProperty.call(wallpaperLists, slug)) { + applyWallpaperCycle(slug, direction) + return + } + var pending = Object.assign({}, wallpaperPending) + pending[slug] = (pending[slug] || 0) + direction + wallpaperPending = pending + ensureThemeWallpapers(slug) + } + function updateFilter(nextFilterText) { filterText = nextFilterText @@ -200,6 +329,9 @@ Item { root.selectedIndex = root.indexForSelectedImage(newImages) root.imageArray = newImages root.imagesLoaded = true + root.refreshDisplays() + if (root.inThemeSwitcher()) + root.ensureThemeWallpapers(root.themeSlugForIndex(root.selectedIndex)) if (reveal !== false) { root.opened = true @@ -223,6 +355,7 @@ Item { filterable = nextFilterable === true || nextFilterable === "true" filterText = "" layoutSettled = false + resetWallpaperState() if (imageRows && imageRows === loadedImageRows && imageArray.length > 0) { root.select(root.selectedImageIndex(), true) @@ -254,6 +387,11 @@ Item { } property var imageArray: [] + property var wallpaperLists: ({}) + property var wallpaperOffsets: ({}) + property var wallpaperPending: ({}) + property var displayByIndex: [] + property int wallpaperSerial: 0 function startImageScan(serial, dirs) { if (loadImagesProc.running) { @@ -300,6 +438,26 @@ Item { } } + Process { + id: themeBgsProc + property string activeSlug: "" + property int activeSerial: 0 + property var queue: [] + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: { + if (themeBgsProc.activeSerial !== root.wallpaperSerial) return + var lines = String(text || "").split("\n").filter(function(line) { return line.length > 0 }) + root.takeThemeWallpapers(themeBgsProc.activeSlug, lines) + } + } + onExited: { + activeSlug = "" + activeSerial = 0 + root.startThemeBgsQueue() + } + } + // Lifecycle hooks invoked by omarchy-shell summon/hide. shell.summon(id, // payloadJson) hands the JSON to open() here; shell.hide(id) calls close(). // The shell host owns the stable `image-selector` IPC target and forwards @@ -426,12 +584,28 @@ Item { } else if (event.key === Qt.Key_Right || event.key === Qt.Key_Tab) { root.selectAdjacent(1) event.accepted = true + } else if (event.key === Qt.Key_Up) { + root.cycleThemeWallpaper(-1) + event.accepted = true + } else if (event.key === Qt.Key_Down) { + root.cycleThemeWallpaper(1) + event.accepted = true } else if (root.filterable && event.text && event.text.length === 1 && event.text.charCodeAt(0) >= 32 && event.text.charCodeAt(0) !== 127 && (event.modifiers === Qt.NoModifier || event.modifiers === Qt.ShiftModifier)) { root.updateFilter(root.filterText + event.text) event.accepted = true } } + WheelHandler { + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + onWheel: function(event) { + if (!root.inThemeSwitcher()) return + if (event.angleDelta.y > 0) root.cycleThemeWallpaper(-1) + else if (event.angleDelta.y < 0) root.cycleThemeWallpaper(1) + event.accepted = true + } + } + Component.onCompleted: forceActiveFocus() Repeater { @@ -445,6 +619,7 @@ Item { readonly property string filePath: imageData ? imageData.filePath : "" readonly property string fileName: imageData ? imageData.fileName : "" readonly property string thumbnailPath: imageData ? imageData.thumbnailPath : "" + readonly property string displayPath: (root.displayByIndex[index] || thumbnailPath) readonly property bool matched: root.itemMatches(index) readonly property int relativeIndex: root.filteredPosition(index) - root.selectedFilteredPosition() @@ -505,11 +680,13 @@ Item { // Load only the initial/visited nearby images, but keep the // source once activated so Qt does not tear textures down as // selection moves through the carousel. - source: item.sourceActivated && item.thumbnailPath ? Util.fileUrl(item.thumbnailPath) : "" + source: item.sourceActivated && item.displayPath ? Util.fileUrl(item.displayPath) : "" fillMode: Image.PreserveAspectCrop asynchronous: false cache: true smooth: true + sourceSize.width: item.selected ? root.expandedWidth : root.sliceWidth + sourceSize.height: item.selected ? root.expandedHeight : root.sliceHeight } Rectangle { @@ -551,7 +728,12 @@ Item { anchors.topMargin: Style.space(16) anchors.horizontalCenter: carousel.horizontalCenter width: root.expandedWidth - text: root.currentLabel() + text: { + root.displayByIndex + root.selectedIndex + root.filterText + return root.currentLabel() + } color: root.foreground style: Text.Outline styleColor: Util.alpha(root.dimColor, 0.7) diff --git a/shell/plugins/image-picker/ImagePickerModel.js b/shell/plugins/image-picker/ImagePickerModel.js index f34fdcaba98..492896b23a5 100644 --- a/shell/plugins/image-picker/ImagePickerModel.js +++ b/shell/plugins/image-picker/ImagePickerModel.js @@ -6,6 +6,21 @@ function labelForPath(path) { return nameForPath(path).replace(/[-_]+/g, " ").replace(/\b\w/g, function(match) { return match.toUpperCase() }) } +function wallpaperLabelForPath(path) { + return labelForPath(path).replace(/^\d+\s+/, "") +} + +function themeWallpaperLabel(themePath, displayPath, filterText) { + if (!themePath) return filterText ? "No matches" : "" + + var themeLabel = labelForPath(themePath) + if (!displayPath) return themeLabel + + var wallpaperLabel = wallpaperLabelForPath(displayPath) + if (!wallpaperLabel || wallpaperLabel === themeLabel) return themeLabel + return themeLabel + " · " + wallpaperLabel +} + function loadRows(rows) { var images = [] var seen = {} @@ -86,6 +101,8 @@ if (typeof module !== "undefined") { module.exports = { nameForPath: nameForPath, labelForPath: labelForPath, + wallpaperLabelForPath: wallpaperLabelForPath, + themeWallpaperLabel: themeWallpaperLabel, loadRows: loadRows, itemMatches: itemMatches, firstMatchingIndex: firstMatchingIndex, diff --git a/shell/plugins/image-picker/list-theme-bgs.sh b/shell/plugins/image-picker/list-theme-bgs.sh new file mode 100755 index 00000000000..bc547e2ac81 --- /dev/null +++ b/shell/plugins/image-picker/list-theme-bgs.sh @@ -0,0 +1,36 @@ +#!/bin/bash + +theme_name="${1:-}" +if [[ -z $theme_name ]]; then + exit 0 +fi + +declare -A seen=() + +add_dir() { + local dir="$1" + local image real + + if [[ ! -d $dir ]]; then + return 0 + fi + + while IFS= read -r -d '' image; do + real=$(readlink -f "$image" 2>/dev/null || printf '%s' "$image") + if [[ -z $real ]]; then + continue + fi + if [[ -n ${seen[$real]+x} ]]; then + continue + fi + + seen[$real]=1 + printf '%s\n' "$image" + done < <(find -L "$dir" -maxdepth 1 -type f \ + \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \) \ + -print0 2>/dev/null | sort -z) +} + +add_dir "$HOME/.config/omarchy/backgrounds/$theme_name" +add_dir "$HOME/.config/omarchy/themes/$theme_name/backgrounds" +add_dir "$OMARCHY_PATH/themes/$theme_name/backgrounds" diff --git a/test/shell.d/image-picker-test.sh b/test/shell.d/image-picker-test.sh index a37ff2e3fc7..11b7e4b12e2 100644 --- a/test/shell.d/image-picker-test.sh +++ b/test/shell.d/image-picker-test.sh @@ -10,6 +10,17 @@ const picker = requireFromRoot('shell/plugins/image-picker/ImagePickerModel.js') assertEqual(picker.nameForPath('/themes/nord-river.png'), 'nord-river', 'image picker strips directory and extension') assertEqual(picker.labelForPath('/themes/nord_river.png'), 'Nord River', 'image picker builds display labels') +assertEqual(picker.wallpaperLabelForPath('/themes/5-neon-smoke-orb.jpg'), 'Neon Smoke Orb', 'image picker strips leading wallpaper indices') +assertEqual( + picker.themeWallpaperLabel('/cache/theme-selector/previews/sakura-mochi.jpg', '/themes/sakura-mochi/backgrounds/5-neon-smoke-orb.jpg', ''), + 'Sakura Mochi · Neon Smoke Orb', + 'image picker labels a cycled theme wallpaper' +) +assertEqual( + picker.themeWallpaperLabel('/cache/theme-selector/previews/sakura-mochi.jpg', '/cache/theme-selector/previews/sakura-mochi.jpg', ''), + 'Sakura Mochi', + 'image picker keeps the theme label when the preview is unchanged' +) const rows = [ '/themes/a/nord-river.png\t/cache/nord-river.jpg', @@ -48,7 +59,40 @@ assert( 'image picker ignores cache preloads while a request is visible' ) assert( - /source: item\.sourceActivated && item\.thumbnailPath \? Util\.fileUrl\(item\.thumbnailPath\) : ""[\s\S]*asynchronous: false/.test(imagePickerQml), + /source: item\.sourceActivated && item\.displayPath \? Util\.fileUrl\(item\.displayPath\) : ""[\s\S]*asynchronous: false/.test(imagePickerQml), 'image picker loads activated thumbnails synchronously to avoid carousel flicker' ) +assert( + /event\.key === Qt\.Key_Up[\s\S]*cycleThemeWallpaper\(-1\)[\s\S]*event\.key === Qt\.Key_Down[\s\S]*cycleThemeWallpaper\(1\)/.test(imagePickerQml), + 'theme switcher cycles wallpapers with up and down' +) +assert( + /scriptPath\("list-theme-bgs\.sh"\)/.test(imagePickerQml), + 'theme switcher lists wallpapers from the bundled theme background helper' +) JS + +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT +mkdir -p "$tmpdir/home/.config/omarchy/themes/demo/backgrounds" "$tmpdir/omarchy/themes/demo/backgrounds" "$tmpdir/home/.config/omarchy/backgrounds/demo" +printf 'user\n' >"$tmpdir/home/.config/omarchy/themes/demo/backgrounds/1-user.png" +printf 'stock\n' >"$tmpdir/omarchy/themes/demo/backgrounds/0-stock.png" +printf 'extra\n' >"$tmpdir/home/.config/omarchy/backgrounds/demo/2-extra.png" +printf 'shared\n' >"$tmpdir/omarchy/themes/demo/backgrounds/same.png" +ln -s "$tmpdir/omarchy/themes/demo/backgrounds/same.png" "$tmpdir/home/.config/omarchy/themes/demo/backgrounds/same.png" + +listed=$(HOME="$tmpdir/home" OMARCHY_PATH="$tmpdir/omarchy" "$ROOT/shell/plugins/image-picker/list-theme-bgs.sh" demo) +assert_contains() { + local needle="$1" + if [[ $listed != *"$needle"* ]]; then + fail "theme wallpaper list includes $needle" "$listed" + fi +} +assert_contains "2-extra.png" +assert_contains "1-user.png" +assert_contains "0-stock.png" +same_count=$(printf '%s\n' "$listed" | grep -c '/same.png$' || true) +if (( same_count != 1 )); then + fail "theme wallpaper list dedupes identical files" "$listed" +fi +pass "theme wallpaper list prefers extras, then the user theme, then stock"