From 008f0cf7dd42f714e6bdb8836612a84610d7cbc9 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 11 May 2026 16:30:22 +0200 Subject: [PATCH] Extract generic switcher --- bin/omarchy-menu-images | 94 +++++++++++++++++++++++ bin/omarchy-theme-bg-switcher | 16 ++-- default/hypr/apps/system.lua | 3 + default/quickshell/wallpaper-switcher.qml | 91 ++++++++++++++-------- 4 files changed, 162 insertions(+), 42 deletions(-) create mode 100755 bin/omarchy-menu-images diff --git a/bin/omarchy-menu-images b/bin/omarchy-menu-images new file mode 100755 index 00000000..c4787f45 --- /dev/null +++ b/bin/omarchy-menu-images @@ -0,0 +1,94 @@ +#!/bin/bash + +# omarchy:summary=Open a generic image selector menu +# omarchy:group=menu +# omarchy:name=images +# omarchy:args=[--selected ] [--namespace ] [--colors-file ] ... + +OMARCHY_PATH=${OMARCHY_PATH:-$HOME/.local/share/omarchy} + +selected_image="" +namespace="omarchy-image-selector" +colors_file="" +image_dirs=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --selected) + selected_image="$2" + shift 2 + ;; + --namespace) + namespace="$2" + shift 2 + ;; + --colors-file) + colors_file="$2" + shift 2 + ;; + --help|-h) + echo "Usage: omarchy-menu-images [--selected ] [--namespace ] [--colors-file ] ..." + exit 0 + ;; + *) + image_dirs+=("$1") + shift + ;; + esac +done + +if (( ${#image_dirs[@]} == 0 )); then + echo "Usage: omarchy-menu-images [--selected ] [--namespace ] [--colors-file ] ..." >&2 + exit 1 +fi + +selection_file=$(mktemp) +trap 'rm -f "$selection_file"' EXIT + +image_dirs_env="" +for dir in "${image_dirs[@]}"; do + if [[ -z $image_dirs_env ]]; then + image_dirs_env="$dir" + else + image_dirs_env+=$'\n'"$dir" + fi +done + +current_image=$(readlink -f "$selected_image" 2>/dev/null) +selected_list_image="" + +if [[ -n $current_image ]]; then + for dir in "${image_dirs[@]}"; do + if [[ -d $dir ]]; then + selected_list_image=$(find -L "$dir" -maxdepth 1 -type f -samefile "$current_image" -print -quit 2>/dev/null) + [[ -n $selected_list_image ]] && break + fi + done +fi + +cache_dir=${XDG_CACHE_HOME:-$HOME/.cache}/omarchy/image-selector +mkdir -p "$cache_dir" + +for dir in "${image_dirs[@]}"; do + if [[ -d $dir ]]; then + while IFS= read -r -d '' image; do + hash=$(md5sum "$image" | cut -d ' ' -f 1) + thumbnail="$cache_dir/$hash.jpg" + + if [[ ! -f $thumbnail ]]; then + magick "$image" -auto-orient -resize '1536x864^' -gravity center -extent '1536x864' -strip -quality 82 "$thumbnail" + fi + done < <(find -L "$dir" -maxdepth 1 -type f \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.webp' \) -print0 2>/dev/null) + fi +done + +OMARCHY_IMAGE_SELECTOR_SELECTION_FILE=$selection_file \ + OMARCHY_IMAGE_SELECTOR_DIRS="$image_dirs_env" \ + OMARCHY_IMAGE_SELECTOR_SELECTED="$selected_list_image" \ + OMARCHY_IMAGE_SELECTOR_NAMESPACE="$namespace" \ + OMARCHY_IMAGE_SELECTOR_COLORS_FILE="$colors_file" \ + quickshell -p "$OMARCHY_PATH/default/quickshell/wallpaper-switcher.qml" >/dev/null + +if [[ -s $selection_file ]]; then + cat "$selection_file" +fi diff --git a/bin/omarchy-theme-bg-switcher b/bin/omarchy-theme-bg-switcher index a39c30fc..06037639 100755 --- a/bin/omarchy-theme-bg-switcher +++ b/bin/omarchy-theme-bg-switcher @@ -6,15 +6,11 @@ # omarchy:aliases=omarchy wallpaper OMARCHY_PATH=${OMARCHY_PATH:-$HOME/.local/share/omarchy} -selection_file=$(mktemp) -trap 'rm -f "$selection_file"' EXIT theme_name=$(cat "$HOME/.config/omarchy/current/theme.name" 2>/dev/null) +current_background=$(readlink -f "$HOME/.config/omarchy/current/background" 2>/dev/null) -OMARCHY_WALLPAPER_SELECTION_FILE=$selection_file \ - OMARCHY_STOCK_BACKGROUNDS_DIR="$HOME/.config/omarchy/current/theme/backgrounds" \ - OMARCHY_USER_BACKGROUNDS_DIR="$HOME/.config/omarchy/backgrounds/$theme_name" \ - quickshell -p "$OMARCHY_PATH/default/quickshell/wallpaper-switcher.qml" >/dev/null - -if [[ -s $selection_file ]]; then - cat "$selection_file" -fi +omarchy-menu-images \ + --selected "$current_background" \ + --namespace "omarchy-wallpaper-switcher" \ + "$HOME/.config/omarchy/current/theme/backgrounds" \ + "$HOME/.config/omarchy/backgrounds/$theme_name" diff --git a/default/hypr/apps/system.lua b/default/hypr/apps/system.lua index 71e828fe..9ce1fcd1 100644 --- a/default/hypr/apps/system.lua +++ b/default/hypr/apps/system.lua @@ -21,3 +21,6 @@ hl.window_rule({ match = { tag = "pop" }, rounding = 8 }) -- Prevent idle while open. hl.window_rule({ match = { tag = "noidle" }, idle_inhibit = "always" }) + +-- Image selector. +hl.layer_rule({ match = { namespace = "omarchy-image-selector" }, no_anim = true }) diff --git a/default/quickshell/wallpaper-switcher.qml b/default/quickshell/wallpaper-switcher.qml index 66cde71c..f54a4d4e 100644 --- a/default/quickshell/wallpaper-switcher.qml +++ b/default/quickshell/wallpaper-switcher.qml @@ -8,11 +8,13 @@ import QtQuick.Shapes ShellRoot { id: root - property string stockBackgroundsDir: Quickshell.env("OMARCHY_STOCK_BACKGROUNDS_DIR") || (Quickshell.env("HOME") + "/.config/omarchy/current/theme/backgrounds") - property string userBackgroundsDir: Quickshell.env("OMARCHY_USER_BACKGROUNDS_DIR") - property string currentBackground: Quickshell.env("HOME") + "/.config/omarchy/current/background" - property string selectionFile: Quickshell.env("OMARCHY_WALLPAPER_SELECTION_FILE") + property string imageDirs: Quickshell.env("OMARCHY_IMAGE_SELECTOR_DIRS") || Quickshell.env("OMARCHY_IMAGE_SELECTOR_DIR") || Quickshell.env("OMARCHY_STOCK_BACKGROUNDS_DIR") || (Quickshell.env("HOME") + "/.config/omarchy/current/theme/backgrounds") + property string selectionFile: Quickshell.env("OMARCHY_IMAGE_SELECTOR_SELECTION_FILE") || Quickshell.env("OMARCHY_WALLPAPER_SELECTION_FILE") + property string selectedImage: Quickshell.env("OMARCHY_IMAGE_SELECTOR_SELECTED") + property string colorsFile: Quickshell.env("OMARCHY_IMAGE_SELECTOR_COLORS_FILE") || (Quickshell.env("HOME") + "/.config/omarchy/current/theme/wallpaper-switcher-colors.json") + property string layerNamespace: Quickshell.env("OMARCHY_IMAGE_SELECTOR_NAMESPACE") || "omarchy-image-selector" property int selectedIndex: 0 + property bool imagesLoaded: false property color accent: "#798186" property color background: "#101315" property color foreground: "#cacccc" @@ -31,18 +33,18 @@ ShellRoot { } function currentPath() { - if (wallpaperModel.count === 0) return "" - return wallpaperModel.get(selectedIndex).filePath + if (imageModel.count === 0) return "" + return imageModel.get(selectedIndex).filePath } function select(index) { - if (wallpaperModel.count === 0) return - if (index < 0) index = wallpaperModel.count - 1 - else if (index >= wallpaperModel.count) index = 0 + if (imageModel.count === 0) return + if (index < 0) index = imageModel.count - 1 + else if (index >= imageModel.count) index = 0 selectedIndex = index list.currentIndex = index - list.positionViewAtIndex(index, ListView.Center) + list.centerSelected() } function applySelected() { @@ -53,46 +55,61 @@ ShellRoot { } ListModel { - id: wallpaperModel + id: imageModel onCountChanged: { if (count > 0 && list.currentIndex < 0) - root.select(0) + root.select(0) } } - function addBackground(path) { + function addImage(path, thumbnailPath) { if (!path) return var fileName = path.split("/").pop() - for (var i = wallpaperModel.count - 1; i >= 0; i--) { - if (wallpaperModel.get(i).fileName === fileName) - wallpaperModel.remove(i) + for (var i = imageModel.count - 1; i >= 0; i--) { + if (imageModel.get(i).fileName === fileName) + imageModel.remove(i) } - wallpaperModel.append({ filePath: path, fileName: fileName }) + imageModel.append({ filePath: path, fileName: fileName, thumbnailPath: thumbnailPath || path }) + } + + function selectedImageIndex() { + for (var i = 0; i < imageModel.count; i++) { + if (imageModel.get(i).filePath === selectedImage) + return i + } + + return 0 } Process { - id: loadBackgroundsProc + id: loadImagesProc property string output: "" - command: ["bash", "-lc", "for dir in " + shellQuote(root.stockBackgroundsDir) + " " + shellQuote(root.userBackgroundsDir) + "; do [[ -d $dir ]] && find -L \"$dir\" -maxdepth 1 -type f \\( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.webp' \\) -print | sort; done"] + command: ["bash", "-lc", "cache_dir=${XDG_CACHE_HOME:-$HOME/.cache}/omarchy/image-selector; while IFS= read -r dir; do [[ -d $dir ]] && find -L \"$dir\" -maxdepth 1 -type f \\( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.webp' \\) -print0; done <<< " + shellQuote(root.imageDirs) + " | sort -z | while IFS= read -r -d '' image; do hash=$(md5sum \"$image\" | cut -d ' ' -f 1); thumb=\"$cache_dir/$hash.jpg\"; [[ -f $thumb ]] || thumb=$image; printf '%s\\t%s\\n' \"$image\" \"$thumb\"; done"] stdout: SplitParser { onRead: function(data) { - loadBackgroundsProc.output += data + "\n" + loadImagesProc.output += data + "\n" } } onExited: { var paths = output.split("\n") - for (var i = 0; i < paths.length; i++) - root.addBackground(paths[i].trim()) - root.select(0) + for (var i = 0; i < paths.length; i++) { + var row = paths[i].trim() + if (!row) continue + + var columns = row.split("\t") + root.addImage(columns[0], columns[1]) } + root.select(root.selectedImageIndex()) + root.imagesLoaded = true + } } - Component.onCompleted: loadBackgroundsProc.running = true + Component.onCompleted: loadImagesProc.running = true FileView { - path: Quickshell.env("HOME") + "/.config/omarchy/current/theme/wallpaper-switcher-colors.json" + path: root.colorsFile watchChanges: true onLoaded: root.loadColors(text()) onFileChanged: { reload(); root.loadColors(text()) } @@ -114,9 +131,10 @@ ShellRoot { PanelWindow { id: panel + visible: root.imagesLoaded anchors { top: true; bottom: true; left: true; right: true } color: "transparent" - WlrLayershell.namespace: "omarchy-wallpaper-switcher" + WlrLayershell.namespace: root.layerNamespace WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive exclusionMode: ExclusionMode.Ignore @@ -148,19 +166,27 @@ ShellRoot { anchors.horizontalCenter: parent.horizontalCenter width: root.expandedWidth + 13 * (root.sliceWidth + root.sliceSpacing) orientation: ListView.Horizontal - model: wallpaperModel + model: imageModel spacing: root.sliceSpacing clip: false focus: true currentIndex: 0 preferredHighlightBegin: (width - root.expandedWidth) / 2 preferredHighlightEnd: (width + root.expandedWidth) / 2 - highlightRangeMode: ListView.StrictlyEnforceRange + highlightRangeMode: ListView.NoHighlightRange highlightMoveDuration: 120 highlight: Item {} header: Item { width: (list.width - root.expandedWidth) / 2; height: 1 } footer: Item { width: (list.width - root.expandedWidth) / 2; height: 1 } + function centerSelected() { + Qt.callLater(function() { + var selectedItem = list.itemAtIndex(root.selectedIndex) + if (selectedItem) + list.contentX = selectedItem.x + selectedItem.width / 2 - list.width / 2 + }) + } + Keys.priority: Keys.BeforeItem Keys.onPressed: function(event) { if (event.key === Qt.Key_Escape) { @@ -185,6 +211,7 @@ ShellRoot { required property int index required property string filePath required property string fileName + required property string thumbnailPath readonly property bool selected: index === root.selectedIndex @@ -192,6 +219,8 @@ ShellRoot { height: list.height z: selected ? 100 : 50 - Math.min(Math.abs(index - root.selectedIndex), 40) + onWidthChanged: if (selected) list.centerSelected() + readonly property real skAbs: Math.abs(root.skewOffset) readonly property real topLeft: root.skewOffset >= 0 ? skAbs : 0 readonly property real topRight: root.skewOffset >= 0 ? width : width - skAbs @@ -255,13 +284,11 @@ ShellRoot { Image { id: image anchors.fill: parent - source: root.fileUrl(item.filePath) + source: root.fileUrl(item.thumbnailPath) fillMode: Image.PreserveAspectCrop - asynchronous: true + asynchronous: false cache: true smooth: true - opacity: status === Image.Ready ? 1 : 0 - Behavior on opacity { NumberAnimation { duration: 120 } } } Rectangle {