diff --git a/bin/omarchy-dev-benchmark-theme-switcher b/bin/omarchy-dev-benchmark-theme-switcher new file mode 100755 index 00000000..97460bed --- /dev/null +++ b/bin/omarchy-dev-benchmark-theme-switcher @@ -0,0 +1,163 @@ +#!/bin/bash + +# omarchy:summary=Measure theme switcher cache and selector prep times +# omarchy:args=[--repeat=] [--keep-cache] +# omarchy:examples=omarchy dev benchmark theme switcher | omarchy dev benchmark theme-switcher --repeat=10 +# omarchy:aliases=omarchy dev benchmark theme-switcher + +set -euo pipefail + +OMARCHY_BIN_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +OMARCHY_PATH=${OMARCHY_PATH:-$(cd -- "$OMARCHY_BIN_DIR/.." && pwd)} +REPEAT=5 +KEEP_CACHE=false + +show_help() { + cat <<'EOF' +Usage: + omarchy dev benchmark theme switcher [--repeat=] [--keep-cache] + +Measure the non-interactive parts of the theme switcher: +- theme preview index build (omarchy-theme-switcher before UI handoff) +- lazy selector row prep used by the interactive theme switcher +- full thumbnail cache warmup cost (omarchy-menu-images --cache-only) + +Options: + --repeat= Number of warm runs to measure for each case (default: 5) + --keep-cache Keep the temporary benchmark cache and print its path +EOF +} + +now_us() { + local now="${EPOCHREALTIME/./}" + printf '%s' "$now" +} + +format_ms() { + local us="$1" + printf '%d.%03d' "$(( us / 1000 ))" "$(( us % 1000 ))" +} + +measure_once() { + local start_us end_us + start_us=$(now_us) + "$@" >/dev/null + end_us=$(now_us) + printf '%s' "$(( end_us - start_us ))" +} + +run_case() { + local label="$1" + shift + local total_us=0 + local min_us=0 + local max_us=0 + local elapsed_us=0 + + for (( i = 1; i <= REPEAT; i++ )); do + elapsed_us=$(measure_once "$@") + total_us=$(( total_us + elapsed_us )) + + if (( i == 1 || elapsed_us < min_us )); then + min_us=$elapsed_us + fi + + if (( elapsed_us > max_us )); then + max_us=$elapsed_us + fi + done + + printf '%-34s avg %8s ms min %8s ms max %8s ms\n' \ + "$label" \ + "$(format_ms "$(( total_us / REPEAT ))")" \ + "$(format_ms "$min_us")" \ + "$(format_ms "$max_us")" +} + +while (( $# > 0 )); do + case "$1" in + --repeat=*) + REPEAT="${1#*=}" + ;; + --keep-cache) + KEEP_CACHE=true + ;; + --help | -h) + show_help + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + show_help >&2 + exit 2 + ;; + esac + shift +done + +if [[ ! $REPEAT =~ ^[0-9]+$ ]] || (( REPEAT < 1 )); then + echo "--repeat must be a positive integer" >&2 + exit 2 +fi + +benchmark_cache=$(mktemp -d) +thumbnail_cache=$(mktemp -d) +stub_bin=$(mktemp -d) + +cleanup() { + rm -rf "$stub_bin" + + if [[ $KEEP_CACHE == "true" ]]; then + printf 'Benchmark cache: %s\n' "$benchmark_cache" + printf 'Thumbnail cache: %s\n' "$thumbnail_cache" + else + rm -rf "$benchmark_cache" "$thumbnail_cache" + fi +} +trap cleanup EXIT + +cat >"$stub_bin/omarchy-menu-images" <<'EOF' +#!/bin/bash +exit 0 +EOF +chmod +x "$stub_bin/omarchy-menu-images" + +benchmark_env=( + env + "OMARCHY_PATH=$OMARCHY_PATH" + "XDG_CACHE_HOME=$benchmark_cache" + "PATH=$stub_bin:$OMARCHY_BIN_DIR:$PATH" +) + +thumbnail_env=( + env + "OMARCHY_PATH=$OMARCHY_PATH" + "XDG_CACHE_HOME=$thumbnail_cache" + "PATH=$stub_bin:$OMARCHY_BIN_DIR:$PATH" +) + +preview_dir="$benchmark_cache/omarchy/theme-selector/previews" +thumbnail_preview_dir="$thumbnail_cache/omarchy/theme-selector/previews" + +build_theme_index() { + "${benchmark_env[@]}" "$OMARCHY_BIN_DIR/omarchy-theme-switcher" +} + +prepare_selector_lazy() { + "${benchmark_env[@]}" "$OMARCHY_BIN_DIR/omarchy-menu-images" --prepare-only --lazy-thumbnails --show-labels --filterable "$preview_dir" +} + +prepare_image_cache() { + "${thumbnail_env[@]}" "$OMARCHY_BIN_DIR/omarchy-menu-images" --cache-only "$thumbnail_preview_dir" +} + +printf 'Theme switcher benchmark (%d warm runs each)\n\n' "$REPEAT" +printf '%-34s %s ms\n' "theme index cold" "$(format_ms "$(measure_once build_theme_index)")" +run_case "theme index warm" build_theme_index +printf '%-34s %s ms\n' "selector prep cold (lazy)" "$(format_ms "$(measure_once prepare_selector_lazy)")" +run_case "selector prep warm (lazy)" prepare_selector_lazy +"${thumbnail_env[@]}" "$OMARCHY_BIN_DIR/omarchy-theme-switcher" >/dev/null +printf '%-34s %s ms\n' "thumbnail cache cold" "$(format_ms "$(measure_once prepare_image_cache)")" +run_case "thumbnail cache warm" prepare_image_cache + +printf '\nTheme previews: %d\n' "$(find -L "$preview_dir" -maxdepth 1 -type f 2>/dev/null | wc -l)" diff --git a/bin/omarchy-menu b/bin/omarchy-menu index dfac208f..9486a3d1 100755 --- a/bin/omarchy-menu +++ b/bin/omarchy-menu @@ -322,11 +322,13 @@ show_screensaver_menu() { } show_theme_menu() { - omarchy-launch-walker -m menus:omarchythemes --width 800 --minheight 400 + theme=$(omarchy-theme-switcher) + [[ -n $theme ]] && omarchy-theme-set "$theme" } show_background_menu() { - omarchy-launch-walker -m menus:omarchyBackgroundSelector --width 800 --minheight 400 + background=$(omarchy-theme-bg-switcher) + [[ -n $background ]] && omarchy-theme-bg-set "$background" } show_font_menu() { diff --git a/bin/omarchy-menu-images b/bin/omarchy-menu-images new file mode 100755 index 00000000..970f9392 --- /dev/null +++ b/bin/omarchy-menu-images @@ -0,0 +1,285 @@ +#!/bin/bash + +# omarchy:summary=Open a generic image selector menu +# omarchy:args=[--selected ] [--colors-file ] [--print-name] [--show-labels] [--filterable] [--lazy-thumbnails] [--cache-only] ... + +OMARCHY_PATH=${OMARCHY_PATH:-$HOME/.local/share/omarchy} + +selected_image="" +colors_file="" +print_name=false +show_labels=false +filterable=false +lazy_thumbnails=false +prepare_only=false +cache_only=false +image_dirs=() + +usage() { + echo "Usage: omarchy-menu-images [--selected ] [--colors-file ] [--print-name] [--show-labels] [--filterable] [--lazy-thumbnails] [--cache-only] ..." +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --selected) + if (( $# < 2 )); then + usage >&2 + exit 1 + fi + + selected_image="$2" + shift 2 + ;; + --colors-file) + if (( $# < 2 )); then + usage >&2 + exit 1 + fi + + colors_file="$2" + shift 2 + ;; + --print-name) + print_name=true + shift + ;; + --show-labels) + show_labels=true + shift + ;; + --filterable) + filterable=true + shift + ;; + --lazy-thumbnails) + lazy_thumbnails=true + shift + ;; + --prepare-only) + prepare_only=true + shift + ;; + --cache-only) + cache_only=true + shift + ;; + --help|-h) + usage + exit 0 + ;; + *) + image_dirs+=("$1") + shift + ;; + esac +done + +if (( ${#image_dirs[@]} == 0 )); then + usage >&2 + exit 1 +fi + +selection_file=$(mktemp) +done_file=$(mktemp) +rm -f "$done_file" +trap 'rm -f "$selection_file" "$done_file"' EXIT +socket_path="${XDG_RUNTIME_DIR:-/run/user/$UID}/omarchy-image-selector.sock" +selector_qml="$OMARCHY_PATH/default/quickshell/background-switcher.qml" + +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 && -f $selected_image && ${selected_image%/*} == "$dir" ]]; then + selected_list_image="$selected_image" + break + elif [[ -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 +index_file="$cache_dir/index.tsv" +rows="" +mkdir -p "$cache_dir" + +cache_key=$(printf '%s' "$image_dirs_env" | md5sum | cut -d ' ' -f 1) +rows_cache_file="$cache_dir/$cache_key.rows" +rows_signature_file="$cache_dir/$cache_key.signature" +rows_signature="v2"$'\n' +rows_cacheable=true +image_files=() + +for dir in "${image_dirs[@]}"; do + if [[ -d $dir ]]; then + rows_signature+="$dir:$(stat -Lc '%Y' "$dir")"$'\n' + + while IFS= read -r -d '' image; do + image_files+=("$image") + image_signature=$(stat -Lc '%s:%Y' "$image") || continue + rows_signature+="$image:$image_signature"$'\n' + 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) + fi +done + +generate_thumbnail() { + local image="$1" + local thumbnail="$2" + local lock="$thumbnail.lock" + local tmp="$thumbnail.$$.jpg" + + if mkdir "$lock" 2>/dev/null; then + if magick "${image}[0]" -auto-orient -resize '1536x864^' -gravity center -extent '1536x864' -strip -quality 82 "$tmp"; then + mv -f "$tmp" "$thumbnail" + else + rm -f "$tmp" "$thumbnail" + fi + + rmdir "$lock" 2>/dev/null || true + else + for ((i = 0; i < 3000; i++)); do + [[ -f $thumbnail ]] && return + [[ -d $lock ]] || break + sleep 0.01 + done + fi +} + +thumbnail_for() { + local image="$1" + local signature hash thumbnail + + signature=$(stat -Lc '%s:%Y' "$image") || return + hash=$(awk -F '\t' -v path="$image" -v sig="$signature" '$1 == path && $2 == sig { print $3; exit }' "$index_file" 2>/dev/null) + + if [[ -z $hash ]]; then + hash=$(printf '%s\t%s' "$image" "$signature" | md5sum | cut -d ' ' -f 1) + printf '%s\t%s\t%s\n' "$image" "$signature" "$hash" >>"$index_file" + fi + + thumbnail="$cache_dir/$hash.jpg" + + if [[ ! -f $thumbnail ]]; then + if [[ $lazy_thumbnails == true && $cache_only != true ]]; then + rows_cacheable=false + + if [[ $prepare_only != true ]]; then + generate_thumbnail "$image" "$thumbnail" >/dev/null 2>&1 & + fi + + printf '%s' "$image" + return + fi + + generate_thumbnail "$image" "$thumbnail" + fi + + [[ -f $thumbnail ]] && printf '%s' "$thumbnail" +} + +if [[ -f $rows_cache_file && -f $rows_signature_file ]] && cmp -s "$rows_signature_file" <(printf '%s' "$rows_signature"); then + rows=$(<"$rows_cache_file") +else + for image in "${image_files[@]}"; do + thumbnail=$(thumbnail_for "$image") + [[ -n $thumbnail ]] || continue + if [[ $lazy_thumbnails == true && $cache_only != true && $thumbnail == "$image" ]]; then + rows_cacheable=false + fi + + if [[ -z $rows ]]; then + rows="$image"$'\t'"$thumbnail" + else + rows+=$'\n'"$image"$'\t'"$thumbnail" + fi + done + + if [[ $rows_cacheable == true ]]; then + printf '%s' "$rows" >"$rows_cache_file" + printf '%s' "$rows_signature" >"$rows_signature_file" + else + rm -f "$rows_cache_file" "$rows_signature_file" + fi +fi + +rows_payload=${rows//$'\t'/$'\f'} +rows_payload=${rows_payload//$'\n'/$'\v'} +colors_file=${colors_file:-$HOME/.config/omarchy/current/theme/background-switcher-colors.json} +colors_payload="" + +if [[ -f $colors_file ]]; then + colors_payload=$(<"$colors_file") + colors_payload=${colors_payload//$'\t'/$'\f'} + colors_payload=${colors_payload//$'\n'/$'\v'} +fi + +if [[ $cache_only == true || $prepare_only == true ]]; then + exit 0 +fi + +ensure_selector() { + if [[ -S $socket_path && $selector_qml -nt $socket_path ]]; then + quickshell kill -p "$selector_qml" >/dev/null 2>&1 || true + rm -f "$socket_path" + fi + + if [[ ! -S $socket_path ]]; then + quickshell kill -p "$selector_qml" >/dev/null 2>&1 || true + quickshell -d -p "$selector_qml" >/dev/null + + for ((i = 0; i < 100; i++)); do + if [[ -S $socket_path ]]; then + break + fi + + sleep 0.01 + done + fi + + [[ -S $socket_path ]] +} + +send_request() { + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$rows_payload" "$selected_list_image" "$selection_file" "$done_file" "$colors_payload" "$show_labels" "$filterable" | + socat -u - "UNIX-CONNECT:$socket_path" +} + +if ! ensure_selector; then + echo "Image selector failed to start" >&2 + exit 1 +fi + +if ! send_request; then + rm -f "$socket_path" + + if ! ensure_selector || ! send_request; then + echo "Image selector failed to accept request" >&2 + exit 1 + fi +fi + +while [[ ! -e $done_file ]]; do + sleep 0.01 +done + +if [[ -s $selection_file ]]; then + if [[ $print_name == true ]]; then + selection=$(<"$selection_file") + selection=${selection##*/} + printf '%s\n' "${selection%.*}" + else + cat "$selection_file" + fi +fi diff --git a/bin/omarchy-theme-bg-cache b/bin/omarchy-theme-bg-cache new file mode 100755 index 00000000..4564e5bd --- /dev/null +++ b/bin/omarchy-theme-bg-cache @@ -0,0 +1,10 @@ +#!/bin/bash + +# omarchy:summary=Cache background switcher thumbnails for the current theme + +theme_name=$(cat "$HOME/.config/omarchy/current/theme.name" 2>/dev/null) + +omarchy-menu-images \ + --cache-only \ + "$HOME/.config/omarchy/current/theme/backgrounds" \ + "$HOME/.config/omarchy/backgrounds/$theme_name" diff --git a/bin/omarchy-theme-bg-next b/bin/omarchy-theme-bg-next index e2d0bd4a..0356d6f6 100755 --- a/bin/omarchy-theme-bg-next +++ b/bin/omarchy-theme-bg-next @@ -12,9 +12,7 @@ mapfile -d '' -t BACKGROUNDS < <(find -L "$USER_BACKGROUNDS_PATH" "$THEME_BACKGR TOTAL=${#BACKGROUNDS[@]} if (( TOTAL == 0 )); then - notify-send "No background was found for theme" -t 2000 - pkill -x swaybg - setsid uwsm-app -- swaybg --color '#000000' >/dev/null 2>&1 & + omarchy-notification-send "No background was found for theme" -t 2000 else # Get current background from symlink if [[ -L $CURRENT_BACKGROUND_LINK ]]; then @@ -42,10 +40,5 @@ else NEW_BACKGROUND="${BACKGROUNDS[$NEXT_INDEX]}" fi - # Set new background symlink - ln -nsf "$NEW_BACKGROUND" "$CURRENT_BACKGROUND_LINK" - - # Relaunch swaybg - pkill -x swaybg - setsid uwsm-app -- swaybg -i "$CURRENT_BACKGROUND_LINK" -m fill >/dev/null 2>&1 & + omarchy-theme-bg-set "$NEW_BACKGROUND" fi diff --git a/bin/omarchy-theme-bg-set b/bin/omarchy-theme-bg-set index 9c695d0e..803bea36 100755 --- a/bin/omarchy-theme-bg-set +++ b/bin/omarchy-theme-bg-set @@ -2,7 +2,7 @@ # omarchy:summary=Set the current background image # omarchy:args= -# omarchy:examples=omarchy theme bg set ~/Pictures/wallpaper.png +# omarchy:examples=omarchy theme bg set ~/Pictures/background.png if [[ -z $1 ]]; then echo "Usage: omarchy-theme-bg-set " >&2 diff --git a/bin/omarchy-theme-bg-switcher b/bin/omarchy-theme-bg-switcher new file mode 100755 index 00000000..d3846027 --- /dev/null +++ b/bin/omarchy-theme-bg-switcher @@ -0,0 +1,15 @@ +#!/bin/bash + +# omarchy:summary=Open the Omarchy background switcher +# omarchy:group=theme +# omarchy:name=bg-switcher +# omarchy:aliases=omarchy background + +OMARCHY_PATH=${OMARCHY_PATH:-$HOME/.local/share/omarchy} +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-menu-images \ + --selected "$current_background" \ + "$HOME/.config/omarchy/current/theme/backgrounds" \ + "$HOME/.config/omarchy/backgrounds/$theme_name" diff --git a/bin/omarchy-theme-set b/bin/omarchy-theme-set index fe57c2af..8dafed8b 100755 --- a/bin/omarchy-theme-set +++ b/bin/omarchy-theme-set @@ -71,3 +71,6 @@ omarchy-theme-set-keyboard # Call hook on theme set omarchy-hook theme-set "$THEME_NAME" >/dev/null + +# Warm the background selector cache after the theme is applied, off the critical path. +omarchy-theme-bg-cache >/dev/null 2>&1 & diff --git a/bin/omarchy-theme-switcher b/bin/omarchy-theme-switcher new file mode 100755 index 00000000..4a1e55e5 --- /dev/null +++ b/bin/omarchy-theme-switcher @@ -0,0 +1,98 @@ +#!/bin/bash + +# omarchy:summary=Open the Omarchy theme switcher + +OMARCHY_PATH=${OMARCHY_PATH:-$HOME/.local/share/omarchy} +USER_THEMES_PATH="$HOME/.config/omarchy/themes" +OMARCHY_THEMES_PATH="$OMARCHY_PATH/themes" +CACHE_PATH="${XDG_CACHE_HOME:-$HOME/.cache}/omarchy/theme-selector" +preview_dir="$CACHE_PATH/previews" +signature_file="$CACHE_PATH/signature" + +mkdir -p "$preview_dir" + +find_preview() { + local theme_path="$1" + local preview preview_name + + for preview_name in preview.png preview.jpg preview.jpeg preview.webp preview.gif preview.bmp; do + preview=$(find -L "$theme_path" -maxdepth 1 -type f -iname "$preview_name" -print -quit 2>/dev/null) + + if [[ -n $preview ]]; then + printf '%s\n' "$preview" + return + fi + done + + if [[ -d $theme_path/backgrounds ]]; then + find -L "$theme_path/backgrounds" -maxdepth 1 -type f \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \) -print 2>/dev/null | sort | head -n 1 + fi +} + +add_theme_preview() { + local theme_name="$1" + local preview="$2" + local extension="${preview##*.}" + extension="${extension,,}" + + [[ -n $preview ]] || return + [[ -e $preview_dir/$theme_name.$extension ]] && return + + ln -s "$preview" "$preview_dir/$theme_name.$extension" +} + +theme_signature="" +for theme_dir in "$USER_THEMES_PATH" "$OMARCHY_THEMES_PATH"; do + if [[ -d $theme_dir ]]; then + theme_signature+="$theme_dir:$(stat -Lc '%Y' "$theme_dir")"$'\n' + + while IFS= read -r -d '' theme_path; do + preview=$(find_preview "$theme_path") + theme_signature+="$theme_path:$(stat -Lc '%Y' "$theme_path")"$'\n' + + if [[ -n $preview ]]; then + theme_signature+="$preview:$(stat -Lc '%s:%Y' "$preview")"$'\n' + fi + done < <(find -L "$theme_dir" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) -print0 2>/dev/null | sort -z) + fi +done + +if [[ ! -f $signature_file ]] || ! cmp -s "$signature_file" <(printf '%s' "$theme_signature"); then + rm -rf "$preview_dir" + mkdir -p "$preview_dir" + + while IFS= read -r theme_path; do + theme_name=${theme_path##*/} + preview=$(find_preview "$theme_path") + + if [[ -z $preview ]]; then + preview=$(find_preview "$OMARCHY_THEMES_PATH/$theme_name") + fi + + add_theme_preview "$theme_name" "$preview" + done < <(find -L "$USER_THEMES_PATH" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) -print 2>/dev/null | sort) + + while IFS= read -r theme_path; do + theme_name=${theme_path##*/} + preview=$(find_preview "$theme_path") + add_theme_preview "$theme_name" "$preview" + done < <(find -L "$OMARCHY_THEMES_PATH" -mindepth 1 -maxdepth 1 -type d -print 2>/dev/null | sort) + + printf '%s' "$theme_signature" >"$signature_file" +fi + +current_theme=$(cat "$HOME/.config/omarchy/current/theme.name" 2>/dev/null) +selected_preview="" +for extension in png jpg jpeg webp gif bmp; do + if [[ -e $preview_dir/$current_theme.$extension ]]; then + selected_preview="$preview_dir/$current_theme.$extension" + break + fi +done +exec omarchy-menu-images \ + --print-name \ + --show-labels \ + --filterable \ + --lazy-thumbnails \ + --selected "$selected_preview" \ + "$preview_dir" diff --git a/bin/omarchy-transcode b/bin/omarchy-transcode index 24103f56..a8b3d8ad 100755 --- a/bin/omarchy-transcode +++ b/bin/omarchy-transcode @@ -4,7 +4,7 @@ # omarchy:name= # omarchy:summary=Transcode pictures and videos for sharing # omarchy:args=[--path path] [input] [format] [resolution] -# omarchy:examples=omarchy transcode|omarchy transcode --path ~/Downloads|omarchy transcode ~/Videos/demo.mov mp4 1080p|omarchy transcode ~/Pictures/wallpaper.heic jpg medium +# omarchy:examples=omarchy transcode|omarchy transcode --path ~/Downloads|omarchy transcode ~/Videos/demo.mov mp4 1080p|omarchy transcode ~/Pictures/background.heic jpg medium set -euo pipefail diff --git a/default/elephant/omarchy_background_selector.lua b/default/elephant/omarchy_background_selector.lua index 38bc2bf1..4b96f4a6 100644 --- a/default/elephant/omarchy_background_selector.lua +++ b/default/elephant/omarchy_background_selector.lua @@ -44,9 +44,9 @@ function GetEntries() -- Track added files to avoid duplicates local seen = {} - for _, wallpaper_dir in ipairs(dirs) do + for _, background_dir in ipairs(dirs) do local handle = io.popen( - "find -L " .. ShellEscape(wallpaper_dir) + "find -L " .. ShellEscape(background_dir) .. " -maxdepth 1 -type f \\( -name '*.jpg' -o -name '*.jpeg' -o -name '*.png' -o -name '*.gif' -o -name '*.bmp' -o -name '*.webp' \\) 2>/dev/null | sort" ) if handle then 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/hypr/bindings/utilities.lua b/default/hypr/bindings/utilities.lua index dc9f0824..ef068b02 100644 --- a/default/hypr/bindings/utilities.lua +++ b/default/hypr/bindings/utilities.lua @@ -15,7 +15,7 @@ hl.bind("SUPER + SHIFT + CTRL + UP", hl.dsp.exec_cmd("omarchy-style-waybar-posit hl.bind("SUPER + SHIFT + CTRL + DOWN", hl.dsp.exec_cmd("omarchy-style-waybar-position bottom"), { description = "Move Waybar to bottom" }) hl.bind("SUPER + SHIFT + CTRL + LEFT", hl.dsp.exec_cmd("omarchy-style-waybar-position left"), { description = "Move Waybar to left" }) hl.bind("SUPER + SHIFT + CTRL + RIGHT", hl.dsp.exec_cmd("omarchy-style-waybar-position right"), { description = "Move Waybar to right" }) -hl.bind("SUPER + CTRL + SPACE", hl.dsp.exec_cmd("omarchy-menu background"), { description = "Theme background menu" }) +hl.bind("SUPER + CTRL + SPACE", hl.dsp.exec_cmd("omarchy-menu background"), { description = "Background switcher" }) hl.bind("SUPER + SHIFT + CTRL + SPACE", hl.dsp.exec_cmd("omarchy-menu theme"), { description = "Theme menu" }) hl.bind("SUPER + BACKSPACE", hl.dsp.exec_cmd("omarchy-hyprland-window-transparency-toggle"), { description = "Toggle window transparency" }) hl.bind("SUPER + SHIFT + BACKSPACE", hl.dsp.exec_cmd("omarchy-hyprland-window-gaps-toggle"), { description = "Toggle window gaps" }) diff --git a/default/omarchy-skill/SKILL.md b/default/omarchy-skill/SKILL.md index 37ae3d12..b843bae3 100644 --- a/default/omarchy-skill/SKILL.md +++ b/default/omarchy-skill/SKILL.md @@ -6,7 +6,7 @@ description: > ~/.config/alacritty/, ~/.config/foot/, ~/.config/kitty/, ~/.config/ghostty/, ~/.config/mako/, or ~/.config/omarchy/. Triggers: Hyprland, window rules, animations, keybindings, monitors, gaps, borders, blur, opacity, waybar, walker, terminal config, themes, - wallpaper, night light, idle, lock screen, screenshots, reminders, layer rules, + background, night light, idle, lock screen, screenshots, reminders, layer rules, workspace settings, display config, and user-facing omarchy commands. Excludes Omarchy source development in ~/.local/share/omarchy/ and `omarchy dev` workflows. --- @@ -28,7 +28,7 @@ It is not for contributing to Omarchy source code. - Editing ANY file in `~/.config/omarchy/` - Window behavior, animations, opacity, blur, gaps, borders - Layer rules, workspace settings, display/monitor configuration -- Themes, wallpapers, fonts, appearance changes +- Themes, backgrounds, fonts, appearance changes - User-facing `omarchy` commands (`omarchy theme ...`, `omarchy refresh ...`, `omarchy restart ...`, etc.) - Screenshots, screen recording, reminders, night light, idle behavior, lock screen @@ -254,7 +254,7 @@ omarchy refresh hyprland omarchy theme list # Show available themes omarchy theme current # Show current theme omarchy theme set # Apply theme (use "Tokyo Night" not "tokyo-night") -omarchy theme bg next # Cycle wallpaper +omarchy theme bg next # Cycle background omarchy theme install # Install from git repo ``` diff --git a/default/quickshell/background-switcher.qml b/default/quickshell/background-switcher.qml new file mode 100644 index 00000000..65e6915b --- /dev/null +++ b/default/quickshell/background-switcher.qml @@ -0,0 +1,587 @@ +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import QtQuick +import QtQuick.Effects +import QtQuick.Shapes + +ShellRoot { + id: root + + 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 imageRows: "" + property string selectionFile: Quickshell.env("OMARCHY_IMAGE_SELECTOR_SELECTION_FILE") || Quickshell.env("OMARCHY_BACKGROUND_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/background-switcher-colors.json") + property int selectedIndex: 0 + property bool imagesLoaded: false + property bool opened: false + property bool showLabels: false + property bool filterable: false + property bool requestActive: false + property int requestSerial: 0 + property int applySerial: 0 + property string doneFile: "" + property string filterText: "" + property var doneFilesToRelease: [] + property string socketPath: (Quickshell.env("XDG_RUNTIME_DIR") || ("/run/user/" + Quickshell.env("UID"))) + "/omarchy-image-selector.sock" + property color accent: "#798186" + property color background: "#101315" + property color foreground: "#cacccc" + property int expandedWidth: 768 + property int sliceWidth: 108 + property int sliceHeight: 432 + property int sliceSpacing: -30 + property int skewOffset: 28 + property int bottomChromeHeight: showLabels ? (filterable ? 104 : 74) : 30 + + function fileUrl(path) { + return "file://" + path.split("/").map(encodeURIComponent).join("/") + } + + function shellQuote(value) { + return "'" + String(value).replace(/'/g, "'\\''") + "'" + } + + function decodeField(value) { + return String(value || "").replace(/\v/g, "\n").replace(/\f/g, "\t") + } + + function withAlpha(color, alpha) { + return Qt.rgba(color.r, color.g, color.b, alpha) + } + + function currentPath() { + if (imageModel.count === 0 || !itemMatches(selectedIndex)) return "" + return imageModel.get(selectedIndex).filePath + } + + function nameForPath(path) { + return path.split("/").pop().replace(/\.[^/.]+$/, "") + } + + function labelForPath(path) { + return nameForPath(path).replace(/[-_]+/g, " ").replace(/\b\w/g, function(match) { return match.toUpperCase() }) + } + + function currentLabel() { + var path = currentPath() + if (!path) return filterText ? "No matches" : "" + + return labelForPath(path) + } + + function itemMatches(index) { + if (index < 0 || index >= imageModel.count) return false + if (!filterText) return true + + var path = imageModel.get(index).filePath + var needle = filterText.toLowerCase() + return nameForPath(path).toLowerCase().indexOf(needle) !== -1 || labelForPath(path).toLowerCase().indexOf(needle) !== -1 + } + + function matchingCount() { + if (!filterText) return imageModel.count + + var count = 0 + for (var i = 0; i < imageModel.count; i++) { + if (itemMatches(i)) count++ + } + + return count + } + + function firstMatchingIndex() { + for (var i = 0; i < imageModel.count; i++) { + if (itemMatches(i)) return i + } + + return -1 + } + + function filteredPosition(index) { + if (!filterText) return index + + var position = 0 + for (var i = 0; i < index; i++) { + if (itemMatches(i)) position++ + } + + return position + } + + function selectedFilteredPosition() { + if (!filterText) return selectedIndex + + return itemMatches(selectedIndex) ? filteredPosition(selectedIndex) : 0 + } + + function select(index, immediate) { + if (imageModel.count === 0) return + if (index < 0) index = 0 + else if (index >= imageModel.count) index = imageModel.count - 1 + if (!itemMatches(index)) return + if (index === selectedIndex && immediate !== true) return + + selectedIndex = index + } + + function selectAdjacent(direction) { + if (!filterText) { + select(selectedIndex + direction) + return + } + + var index = selectedIndex + direction + while (index >= 0 && index < imageModel.count) { + if (itemMatches(index)) { + select(index) + return + } + + index += direction + } + } + + function updateFilter(nextFilterText) { + filterText = nextFilterText + + if (!itemMatches(selectedIndex)) { + var first = firstMatchingIndex() + if (first >= 0) selectedIndex = first + } + } + + function releaseNextDoneFile() { + if (releaseProc.running || doneFilesToRelease.length === 0) return + + var path = doneFilesToRelease.shift() + releaseProc.command = ["bash", "-lc", ": > " + shellQuote(path)] + releaseProc.running = true + } + + function finishDoneFile(path) { + if (!path) return + doneFilesToRelease.push(path) + releaseNextDoneFile() + } + + function applySelected() { + var path = currentPath() + if (!path || !selectionFile) { + cancel() + return + } + + var activeSelectionFile = selectionFile + var activeDoneFile = doneFile + applySerial = requestSerial + requestActive = false + selectionFile = "" + doneFile = "" + + applyProc.command = ["bash", "-lc", "printf '%s\\n' " + shellQuote(path) + " > " + shellQuote(activeSelectionFile) + "; : > " + shellQuote(activeDoneFile)] + applyProc.running = true + } + + function cancel() { + if (requestActive) + finishDoneFile(doneFile) + + requestActive = false + selectionFile = "" + doneFile = "" + root.opened = false + } + + function closeSelector(nextDoneFile) { + requestSerial += 1 + + if (requestActive) + finishDoneFile(doneFile) + + if (nextDoneFile && nextDoneFile !== doneFile) + finishDoneFile(nextDoneFile) + + requestActive = false + selectionFile = "" + doneFile = "" + filterText = "" + root.opened = false + } + + function loadRows(rows) { + var paths = rows.split("\n") + for (var i = 0; i < paths.length; i++) { + var row = paths[i] + if (!row) continue + + var columns = row.split("\t") + root.addImage(columns[0], columns[1]) + } + + root.select(root.selectedImageIndex(), true) + root.imagesLoaded = true + root.opened = true + carousel.forceActiveFocus() + } + + function openSelector(nextImageDirs, nextImageRows, nextSelectedImage, nextSelectionFile, nextDoneFile, nextColorsFile, nextColorsRaw, nextShowLabels, nextFilterable) { + if (requestActive && doneFile && doneFile !== nextDoneFile) + finishDoneFile(doneFile) + + requestSerial += 1 + + imageDirs = nextImageDirs + imageRows = nextImageRows + selectedImage = nextSelectedImage + selectionFile = nextSelectionFile + doneFile = nextDoneFile + requestActive = !!doneFile + showLabels = nextShowLabels === true || nextShowLabels === "true" + filterable = nextFilterable === true || nextFilterable === "true" + filterText = "" + colorsFile = nextColorsFile || (Quickshell.env("HOME") + "/.config/omarchy/current/theme/background-switcher-colors.json") + if (nextColorsRaw) + loadColors(nextColorsRaw) + imageModel.clear() + selectedIndex = 0 + imagesLoaded = false + opened = false + if (imageRows) { + loadRows(imageRows) + } else { + loadImagesProc.output = "" + loadImagesProc.running = true + } + } + + ListModel { id: imageModel } + + function addImage(path, thumbnailPath) { + if (!path) return + var fileName = path.split("/").pop() + + for (var i = imageModel.count - 1; i >= 0; i--) { + if (imageModel.get(i).fileName === fileName) + imageModel.remove(i) + } + + 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: loadImagesProc + property string output: "" + command: ["bash", "-lc", "cache_dir=${XDG_CACHE_HOME:-$HOME/.cache}/omarchy/image-selector; while IFS= read -r dir; do [[ -n $dir && -d $dir ]] && 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; 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) { + loadImagesProc.output += data + "\n" + } + } + onExited: { + root.loadRows(output) + } + } + + Component.onCompleted: { + if (selectionFile) + openSelector(imageDirs, "", selectedImage, selectionFile, Quickshell.env("OMARCHY_IMAGE_SELECTOR_DONE_FILE"), colorsFile, "", false, false) + } + + IpcHandler { + target: "image-selector" + + function open(imageDirs: string, imageRows: string, selectedImage: string, selectionFile: string, doneFile: string, colorsFile: string): void { + root.openSelector(imageDirs, imageRows, selectedImage, selectionFile, doneFile, colorsFile, "", false, false) + } + } + + SocketServer { + active: true + path: root.socketPath + + handler: Socket { + id: clientSocket + parser: SplitParser { + onRead: function(message) { + var fields = message.split("\t") + if (root.opened) { + root.closeSelector(fields[3] || "") + clientSocket.connected = false + return + } + + root.openSelector("", root.decodeField(fields[0]), fields[1] || "", fields[2] || "", fields[3] || "", "", root.decodeField(fields[4]), fields[5] || "false", fields[6] || "false") + clientSocket.connected = false + } + } + } + } + + FileView { + path: root.colorsFile + watchChanges: true + onLoaded: root.loadColors(text()) + onFileChanged: { reload(); root.loadColors(text()) } + } + + function loadColors(raw) { + try { + var colors = JSON.parse(raw || "{}") + root.accent = colors.primary || root.accent + root.background = colors.background || root.background + root.foreground = colors.backgroundText || root.foreground + } catch (e) {} + } + + Process { + id: applyProc + onExited: { + if (root.applySerial === root.requestSerial) + root.opened = false + } + } + + Process { + id: releaseProc + onExited: root.releaseNextDoneFile() + } + + PanelWindow { + id: panel + visible: root.opened && root.imagesLoaded + anchors { top: true; bottom: true; left: true; right: true } + color: "transparent" + WlrLayershell.namespace: "omarchy-image-selector" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + exclusionMode: ExclusionMode.Ignore + + Rectangle { + anchors.fill: parent + color: root.withAlpha(root.background, 0.72) + } + + MouseArea { + anchors.fill: parent + onClicked: root.cancel() + } + + Item { + id: card + width: Math.min(parent.width - 80, root.expandedWidth + 13 * (root.sliceWidth + root.sliceSpacing) + 40) + height: root.sliceHeight + 30 + root.bottomChromeHeight + anchors.centerIn: parent + + MouseArea { anchors.fill: parent; onClicked: {} } + + Item { + id: carousel + anchors.top: parent.top + anchors.topMargin: 30 + anchors.bottom: parent.bottom + anchors.bottomMargin: root.bottomChromeHeight + anchors.horizontalCenter: parent.horizontalCenter + width: root.expandedWidth + 13 * (root.sliceWidth + root.sliceSpacing) + clip: false + focus: true + + readonly property real itemStep: root.sliceWidth + root.sliceSpacing + readonly property real previewX: (width - root.expandedWidth) / 2 + + Keys.priority: Keys.BeforeItem + Keys.onPressed: function(event) { + if (event.key === Qt.Key_Escape) { + if (root.filterText) { + root.updateFilter("") + } else { + root.cancel() + } + event.accepted = true + } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + root.applySelected() + event.accepted = true + } else if (event.key === Qt.Key_Backspace && root.filterable) { + if (root.filterText.length > 0) + root.updateFilter(root.filterText.slice(0, -1)) + event.accepted = true + } else if (event.key === Qt.Key_Left) { + root.selectAdjacent(-1) + event.accepted = true + } else if (event.key === Qt.Key_Right) { + root.selectAdjacent(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 + } + } + + Component.onCompleted: forceActiveFocus() + + Repeater { + model: imageModel + + delegate: Item { + id: item + required property int index + required property string filePath + required property string fileName + required property string thumbnailPath + + readonly property bool matched: root.itemMatches(index) + readonly property int relativeIndex: root.filteredPosition(index) - root.selectedFilteredPosition() + readonly property bool selected: matched && index === root.selectedIndex + readonly property bool nearby: matched && Math.abs(relativeIndex) <= 16 + + visible: nearby + x: selected ? carousel.previewX : (relativeIndex < 0 ? carousel.previewX + relativeIndex * carousel.itemStep : carousel.previewX + root.expandedWidth + root.sliceSpacing + (relativeIndex - 1) * carousel.itemStep) + width: selected ? root.expandedWidth : root.sliceWidth + height: carousel.height + z: selected ? 100 : 50 - Math.min(Math.abs(relativeIndex), 40) + + 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 + readonly property real bottomRight: root.skewOffset >= 0 ? width - skAbs : width + readonly property real bottomLeft: root.skewOffset >= 0 ? 0 : skAbs + + Item { + id: maskShape + anchors.fill: parent + visible: false + layer.enabled: true + + Shape { + anchors.fill: parent + antialiasing: true + preferredRendererType: Shape.CurveRenderer + ShapePath { + fillColor: "white" + strokeColor: "transparent" + startX: item.topLeft; startY: 0 + PathLine { x: item.topRight; y: 0 } + PathLine { x: item.bottomRight; y: item.height } + PathLine { x: item.bottomLeft; y: item.height } + PathLine { x: item.topLeft; y: 0 } + } + } + } + + Shape { + x: item.selected ? 4 : 2 + y: item.selected ? 10 : 5 + width: item.width + height: item.height + opacity: item.selected ? 0.5 : 0.32 + antialiasing: true + preferredRendererType: Shape.CurveRenderer + ShapePath { + fillColor: root.background + strokeColor: "transparent" + startX: item.topLeft; startY: 0 + PathLine { x: item.topRight; y: 0 } + PathLine { x: item.bottomRight; y: item.height } + PathLine { x: item.bottomLeft; y: item.height } + PathLine { x: item.topLeft; y: 0 } + } + } + + Item { + anchors.fill: parent + layer.enabled: true + layer.smooth: true + layer.effect: MultiEffect { + maskEnabled: true + maskSource: maskShape + maskThresholdMin: 0.3 + maskSpreadAtMin: 0.3 + } + + Image { + id: image + anchors.fill: parent + source: item.nearby ? root.fileUrl(item.thumbnailPath) : "" + fillMode: Image.PreserveAspectCrop + asynchronous: false + cache: true + smooth: true + } + + Rectangle { + anchors.fill: parent + color: root.withAlpha(root.background, item.selected ? 0 : 0.42) + Behavior on color { ColorAnimation { duration: 120 } } + } + } + + Shape { + anchors.fill: parent + antialiasing: true + preferredRendererType: Shape.CurveRenderer + ShapePath { + fillColor: "transparent" + strokeColor: item.selected ? root.accent : root.withAlpha(root.foreground, 0.28) + strokeWidth: item.selected ? 3 : 1 + startX: item.topLeft; startY: 0 + PathLine { x: item.topRight; y: 0 } + PathLine { x: item.bottomRight; y: item.height } + PathLine { x: item.bottomLeft; y: item.height } + PathLine { x: item.topLeft; y: 0 } + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: item.selected ? root.applySelected() : root.select(index) + } + } + } + } + + Text { + id: selectedLabel + visible: root.showLabels + anchors.top: carousel.bottom + anchors.topMargin: 16 + anchors.horizontalCenter: carousel.horizontalCenter + width: root.expandedWidth + text: root.currentLabel() + color: root.foreground + style: Text.Outline + styleColor: root.withAlpha(root.background, 0.7) + font.pixelSize: 24 + font.weight: Font.DemiBold + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + } + + Text { + visible: root.filterable + anchors.top: selectedLabel.bottom + anchors.topMargin: 8 + anchors.horizontalCenter: carousel.horizontalCenter + width: root.expandedWidth + text: root.filterText ? ("Filter: " + root.filterText + " (" + root.matchingCount() + ")") : "Type to filter" + color: root.foreground + opacity: root.filterText ? 0.85 : 0.55 + style: Text.Outline + styleColor: root.withAlpha(root.background, 0.7) + font.pixelSize: 14 + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + } + } + } +} diff --git a/default/themed/background-switcher-colors.json.tpl b/default/themed/background-switcher-colors.json.tpl new file mode 100644 index 00000000..96a92436 --- /dev/null +++ b/default/themed/background-switcher-colors.json.tpl @@ -0,0 +1,5 @@ +{ + "primary": "{{ accent }}", + "background": "{{ background }}", + "backgroundText": "{{ foreground }}" +} diff --git a/install/omarchy-base.packages b/install/omarchy-base.packages index acdddc47..a84f73b1 100644 --- a/install/omarchy-base.packages +++ b/install/omarchy-base.packages @@ -106,6 +106,7 @@ python-gobject python-poetry-core python-terminaltexteffects qt5-wayland +quickshell ripgrep ruby rust diff --git a/migrations/1778506941.sh b/migrations/1778506941.sh new file mode 100644 index 00000000..e9e4f638 --- /dev/null +++ b/migrations/1778506941.sh @@ -0,0 +1,3 @@ +echo "Install quickshell for the image selector" + +omarchy-pkg-add quickshell