diff --git a/agents/skills/shell-dev.md b/agents/skills/shell-dev.md index 2c1137d0..ff32edc6 100644 --- a/agents/skills/shell-dev.md +++ b/agents/skills/shell-dev.md @@ -49,3 +49,7 @@ unicode characters. Agent file-editing tools can strip multi-byte codepoints in some positions — do **not** rewrite widget files wholesale through those tools. For glyph fixes, make a targeted edit with the surrounding context, or use a Python script that inserts codepoints via `chr(0xXXXXX)`. + +## Palette references + +Import `qs.Commons as Commons` and use `Commons.Color` for the palette singleton, including `Connections.target`. Qt 6.12 introduces a `Color` type in `QtQuick` that shadows unqualified palette references. This applies to first-party components and third-party plugin QML alike. Keep the unqualified `qs.Commons` import when the file also uses `Style`, `Util`, or `Border`. diff --git a/bin/omarchy b/bin/omarchy index d91be6b4..444db43d 100755 --- a/bin/omarchy +++ b/bin/omarchy @@ -46,6 +46,7 @@ GROUP_DESCRIPTIONS[finalize]="Finalize user setup" GROUP_DESCRIPTIONS[default]="Default application selection" GROUP_DESCRIPTIONS[dev]="Omarchy development tools" GROUP_DESCRIPTIONS[disk]="Disk performance helpers" +GROUP_DESCRIPTIONS[dictation]="Dictation recording" GROUP_DESCRIPTIONS[display]="Display and text scaling" GROUP_DESCRIPTIONS[dns]="DNS resolver configuration" GROUP_DESCRIPTIONS[drive]="Drive selection and encryption" diff --git a/bin/omarchy-default-dictation b/bin/omarchy-default-dictation new file mode 100755 index 00000000..ce22b7fc --- /dev/null +++ b/bin/omarchy-default-dictation @@ -0,0 +1,37 @@ +#!/bin/bash + +# omarchy:summary=Get or set the default dictation backend +# omarchy:args=[backend] +# omarchy:examples=omarchy default dictation | omarchy default dictation superwhisper + +if (($# > 1)); then + echo "Usage: omarchy default dictation [backend]" >&2 + exit 1 +fi + +if (($# == 0)); then + config="${XDG_CONFIG_HOME:-$HOME/.config}/omarchy/defaults/dictation" + if [[ ! -s $config ]]; then + echo "No dictation backend configured. Select one in Setup > Defaults > Dictation." >&2 + exit 1 + fi + read -r backend < "$config" +else + backend=$1 +fi + +if [[ ! $backend =~ ^[a-z0-9][a-z0-9-]*$ ]]; then + echo "Invalid dictation backend: $backend" >&2 + exit 1 +fi + +if (($# == 0)); then + echo "$backend" +else + installer="omarchy-install-dictation-$backend" + if omarchy-cmd-missing "$installer"; then + echo "No installer available for dictation backend: $backend" >&2 + exit 1 + fi + omarchy-launch-floating-terminal-with-presentation "$installer" +fi diff --git a/bin/omarchy-dictation b/bin/omarchy-dictation new file mode 100755 index 00000000..dd5e43c1 --- /dev/null +++ b/bin/omarchy-dictation @@ -0,0 +1,26 @@ +#!/bin/bash + +# omarchy:summary=Record dictation with the configured backend +# omarchy:args= +# omarchy:examples=omarchy dictation start | omarchy dictation toggle + +if (($# != 1)) || [[ $1 != "start" && $1 != "stop" && $1 != "toggle" ]]; then + echo "Usage: omarchy dictation " >&2 + exit 1 +fi + +config="${XDG_CONFIG_HOME:-$HOME/.config}/omarchy/defaults/dictation" +mkdir -p "${config%/*}" +exec 9> "$config.lock" +if ! flock -w 5 9; then + echo "Dictation is busy; try again." >&2 + exit 1 +fi + +backend=$(omarchy-default-dictation) || exit 1 +adapter="omarchy-dictation-$backend" +if omarchy-cmd-missing "$adapter"; then + echo "The selected dictation backend ($backend) is not installed." >&2 + exit 1 +fi +"$adapter" "$1" 9>&- diff --git a/bin/omarchy-dictation-superwhisper b/bin/omarchy-dictation-superwhisper new file mode 100755 index 00000000..a6a8c9ad --- /dev/null +++ b/bin/omarchy-dictation-superwhisper @@ -0,0 +1,17 @@ +#!/bin/bash + +# omarchy:summary=Record dictation using Superwhisper +# omarchy:args= + +case "${1:-}" in + start) superwhisper start ;; + stop) + if omarchy-cmd-missing superwhisper || ! systemctl --user is-active --quiet superwhisper.service; then + exit 0 + else + superwhisper stop + fi + ;; + toggle) superwhisper record ;; + *) echo "Usage: omarchy dictation superwhisper " >&2; exit 1 ;; +esac diff --git a/bin/omarchy-dictation-use b/bin/omarchy-dictation-use new file mode 100755 index 00000000..db8e3405 --- /dev/null +++ b/bin/omarchy-dictation-use @@ -0,0 +1,33 @@ +#!/bin/bash + +# omarchy:hidden=true +# omarchy:summary=Save a configured dictation backend after stopping the previous backend +# omarchy:args= + +set -e + +if (($# != 1)) || [[ ! $1 =~ ^[a-z0-9][a-z0-9-]*$ ]]; then + echo "Usage: omarchy-dictation-use " >&2 + exit 1 +fi + +config="${XDG_CONFIG_HOME:-$HOME/.config}/omarchy/defaults/dictation" +mkdir -p "${config%/*}" +exec 9> "$config.lock" +if ! flock -w 5 9; then + echo "Dictation is busy; try again." >&2 + exit 1 +fi + +previous=$(omarchy-default-dictation 2>/dev/null) || previous="" +if [[ -n $previous && $previous != "$1" ]] && omarchy-cmd-present "omarchy-dictation-$previous"; then + if ! "omarchy-dictation-$previous" stop 9>&-; then + echo "Could not stop $previous; keeping the existing dictation backend." >&2 + exit 1 + fi +fi + +temporary=$(mktemp "$config.XXXXXX") +trap 'rm -f "$temporary"' EXIT +printf '%s\n' "$1" > "$temporary" +mv -f "$temporary" "$config" diff --git a/bin/omarchy-dictation-voxtype b/bin/omarchy-dictation-voxtype new file mode 100755 index 00000000..33f27869 --- /dev/null +++ b/bin/omarchy-dictation-voxtype @@ -0,0 +1,16 @@ +#!/bin/bash + +# omarchy:summary=Record dictation using Voxtype +# omarchy:args= + +case "${1:-}" in + start|toggle) voxtype record "$1" ;; + stop) + if omarchy-cmd-missing voxtype || [[ $(voxtype status) == "stopped" ]]; then + exit 0 + else + voxtype record stop + fi + ;; + *) echo "Usage: omarchy dictation voxtype " >&2; exit 1 ;; +esac diff --git a/bin/omarchy-install-dictation-superwhisper b/bin/omarchy-install-dictation-superwhisper new file mode 100755 index 00000000..c3359600 --- /dev/null +++ b/bin/omarchy-install-dictation-superwhisper @@ -0,0 +1,27 @@ +#!/bin/bash + +# omarchy:summary=Install and configure Superwhisper dictation +# omarchy:requires-sudo=true + +set -e + +omarchy-pkg-add superwhisper-bin +bash /usr/share/superwhisper/setup-user +# Existing profiles may still use Right Alt, which conflicts with Omarchy. +# Keep a recording shortcut active while moving away from that binding. +shortcuts=$(superwhisper shortcuts show) +if [[ $(jq -r '.shortcuts.hold' <<< "$shortcuts") == "RightAlt" ]]; then + if [[ -z $(jq -r '.shortcuts.toggle' <<< "$shortcuts") ]]; then + superwhisper shortcuts set hold "RightSuper" >/dev/null + else + superwhisper shortcuts set hold none >/dev/null + fi +fi +# Superwhisper requires a native recording shortcut; keep it separate from +# Omarchy's push-to-talk keys. +superwhisper shortcuts set toggle "Alt+Space" >/dev/null +superwhisper shortcuts set hold none >/dev/null +omarchy-dictation-use superwhisper +hyprctl reload >/dev/null +omarchy-notification-send -g  "$(omarchy-text "Superwhisper Dictation Ready")" "$(omarchy-text "Hold Right Alt or F9 to dictate (or toggle with Super + Ctrl + X).")" -t 10000 +superwhisper settings diff --git a/bin/omarchy-install-dictation-voxtype b/bin/omarchy-install-dictation-voxtype new file mode 100755 index 00000000..dfe7050b --- /dev/null +++ b/bin/omarchy-install-dictation-voxtype @@ -0,0 +1,33 @@ +#!/bin/bash + +# omarchy:summary=Install and configure Voxtype dictation +# omarchy:requires-sudo=true + +set -e + +if omarchy-cmd-missing voxtype; then + if gum confirm "Install Voxtype + AI model (~150MB) to enable dictation?"; then + omarchy-pkg-add wtype voxtype-bin + else + exit 0 + fi +fi + +config_home="${XDG_CONFIG_HOME:-$HOME/.config}" +mkdir -p "$config_home/voxtype" +if [[ ! -e $config_home/voxtype/config.toml ]]; then + cp "$OMARCHY_PATH/default/voxtype/config.toml" "$config_home/voxtype/" + if omarchy-hw-vulkan; then + voxtype setup gpu --enable || true + fi +fi + +# Setup reuses the configured model and downloads it only when missing. +# Retry these steps even when an earlier attempt already installed the binary. +voxtype setup --download --no-post-install +voxtype setup systemd +systemctl --user enable --now voxtype.service + +omarchy-dictation-use voxtype +hyprctl reload >/dev/null +omarchy-notification-send -g  "$(omarchy-text "Voxtype Dictation Ready")" "$(omarchy-text "Hold Right Alt or F9 to dictate (or toggle with Super + Ctrl + X).")" -t 10000 diff --git a/bin/omarchy-launch-shell b/bin/omarchy-launch-shell index c183eb0b..6bc7c891 100755 --- a/bin/omarchy-launch-shell +++ b/bin/omarchy-launch-shell @@ -15,7 +15,9 @@ # against a half-written tree, and that failed reload leaves a second engine # generation behind that turns the next restart's IPC kill into a crash. run_shell() { - QS_DISABLE_FILE_WATCHER=1 QS_NO_RELOAD_POPUP=1 \ + local startup_background + startup_background=$(readlink -f "$HOME/.local/state/omarchy/current/background" 2>/dev/null) + OMARCHY_STARTUP_BACKGROUND="$startup_background" QS_DISABLE_FILE_WATCHER=1 QS_NO_RELOAD_POPUP=1 \ systemd-cat -t omarchy-shell -- quickshell -n -p "$OMARCHY_PATH/shell" & shell_pid=$! diff --git a/bin/omarchy-provision-first-run b/bin/omarchy-provision-first-run index 98764291..eec307ec 100755 --- a/bin/omarchy-provision-first-run +++ b/bin/omarchy-provision-first-run @@ -68,8 +68,6 @@ run_first_run_step() { fi } -run_first_run_step "install Voxtype post-update hook" \ - omarchy-hook-install post-update "$OMARCHY_PATH/install/user/first-run/install-voxtype.hook" run_first_run_step "install fingerprint setup post-update hook" \ omarchy-hook-install post-update "$OMARCHY_PATH/install/user/first-run/setup-fingerprint.hook" run_first_run_step "install agent setup post-update hook" \ diff --git a/bin/omarchy-remove-dictation-superwhisper b/bin/omarchy-remove-dictation-superwhisper new file mode 100755 index 00000000..eeff247c --- /dev/null +++ b/bin/omarchy-remove-dictation-superwhisper @@ -0,0 +1,40 @@ +#!/bin/bash + +# omarchy:summary=Remove Superwhisper dictation and its desktop integration +# omarchy:requires-sudo=true + +set -e + +config_home="${XDG_CONFIG_HOME:-$HOME/.config}" +data_home="${XDG_DATA_HOME:-$HOME/.local/share}" + +systemctl --user disable --now superwhisper.service 2>/dev/null || true +omarchy-shell -q shell setPluginEnabled superwhisper.panel false >/dev/null +omarchy-pkg-drop superwhisper-bin +systemctl --user daemon-reload + +# Remove the package setup's links, preserving independently installed copies +# and the user's preferences, history, and models. +unlink_package_link() { + if [[ -L $1 && $(readlink "$1") == "$2" ]]; then + rm "$1" + fi +} + +unlink_package_link "$HOME/.local/bin/superwhisper" /usr/bin/superwhisper +unlink_package_link "$data_home/superwhisper/app/current" /opt/superwhisper +unlink_package_link "$config_home/omarchy/plugins/superwhisper-panel" /opt/superwhisper/assets/omarchy-plugin/superwhisper-panel +for agent in .agents .claude .codex .pi/agent; do + unlink_package_link "$HOME/$agent/skills/superwhisper" /opt/superwhisper/assets/agent-skill/superwhisper +done + +backend_config="$config_home/omarchy/defaults/dictation" +if [[ -f $backend_config && $(< "$backend_config") == "superwhisper" ]]; then + rm "$backend_config" +fi + +omarchy-shell -q shell rescanPlugins +hyprctl reload >/dev/null +if systemctl --user -q is-active omarchy-fcitx5.service; then + systemctl --user restart omarchy-fcitx5.service +fi diff --git a/bin/omarchy-voxtype-remove b/bin/omarchy-remove-dictation-voxtype similarity index 75% rename from bin/omarchy-voxtype-remove rename to bin/omarchy-remove-dictation-voxtype index 08634e60..59abafcf 100755 --- a/bin/omarchy-voxtype-remove +++ b/bin/omarchy-remove-dictation-voxtype @@ -18,6 +18,10 @@ if omarchy-cmd-present voxtype; then omarchy-pkg-drop voxtype-bin rm -rf ~/.config/voxtype rm -rf ~/.local/share/voxtype + backend_config="${XDG_CONFIG_HOME:-$HOME/.config}/omarchy/defaults/dictation" + if [[ -f $backend_config && $(< "$backend_config") == "voxtype" ]]; then + rm "$backend_config" + fi hyprctl reload >/dev/null else echo "Voxtype was not installed." diff --git a/bin/omarchy-theme-bg-boot-intro b/bin/omarchy-theme-bg-boot-intro new file mode 100755 index 00000000..b1c03243 --- /dev/null +++ b/bin/omarchy-theme-bg-boot-intro @@ -0,0 +1,201 @@ +#!/bin/bash + +# omarchy:summary=Play the current background's matching theme intro +# omarchy:hidden=true + +set -euo pipefail + +state_home="$HOME/.local/state" +current_dir="$state_home/omarchy/current" +current_background_link="$current_dir/background" +marker="$state_home/omarchy/background-intro.session-id" +toggle="$state_home/omarchy/toggles/background-intros-off" +video_extensions=(mp4 m4v mov webm mkv avi) + +resolve_intro() { + local background + local stem + local extension + local intro + + background=$(readlink -f "${1:-$current_background_link}" 2>/dev/null) || return 1 + [[ -f $background ]] || return 1 + case ${background,,} in + *.jpg | *.jpeg | *.png | *.bmp | *.webp) ;; + *) return 1 ;; + esac + + stem=${background##*/} + stem=${stem%.*} + for extension in "${video_extensions[@]}"; do + intro="${background%/*}/intros/$stem.$extension" + if [[ -f $intro ]]; then + printf '%s\n' "$intro" + return 0 + fi + done + return 1 +} + +# Preparation is speculative: the selected staged clip is compared again by OWE. +prepare_theme_intro() { + local theme="$1" background remembered name source intro + local -a names=() + [[ -n $theme && $theme != .* && $theme != */* ]] || return 0 + [[ ! -f $toggle ]] && ! animations_off || return 0 + owe warm >/dev/null 2>&1 || return 0 + [[ $(cat "$current_dir/theme.name" 2>/dev/null) != "$theme" ]] || return 0 + remembered=$(cat "$state_home/omarchy/theme-backgrounds/$theme" 2>/dev/null || true) + if [[ ${remembered%/*} == "$current_dir/theme/backgrounds" ]]; then + name=${remembered##*/} + elif [[ -f $remembered ]]; then + background="$remembered" + else + mapfile -d '' -t names < <( + find -L "$HOME/.config/omarchy/backgrounds/$theme" -maxdepth 1 -type f -print0 2>/dev/null | sort -z + ) + if (( ${#names[@]} > 0 )); then + background=${names[0]} + else + mapfile -d '' -t names < <( + find -L "$OMARCHY_PATH/themes/$theme/backgrounds" "$HOME/.config/omarchy/themes/$theme/backgrounds" -maxdepth 1 -type f \ + \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.bmp' -o -iname '*.webp' \) -printf '%f\0' 2>/dev/null | sort -zu + ) + (( ${#names[@]} > 0 )) || return 0 + name=${names[0]} + fi + fi + if [[ -n ${name:-} ]]; then + for source in "$HOME/.config/omarchy/themes/$theme" "$OMARCHY_PATH/themes/$theme"; do + if [[ -f $source/backgrounds/$name ]]; then + background="$source/backgrounds/$name" + break + fi + done + fi + [[ -n ${background:-} ]] || return 0 + intro=$(resolve_intro "$background" || true) + [[ -z $intro ]] || owe intro-prepare "$intro" >/dev/null 2>&1 || true +} + +animations_off() { + [[ $(hyprctl -j getoption animations:enabled 2>/dev/null | jq -r '.bool' 2>/dev/null) == "false" ]] +} + +theme_switch=false +intro_prepared=false +expected_background="" +expected_theme="" +case ${1:-} in + "") ;; + --prepare-theme) + prepare_theme_intro "${2:-}" + exit 0 + ;; + --resolve-only) + resolve_intro "${2:-}" || true + exit 0 + ;; + --resolve-startup) + if [[ ! -f $toggle ]] && ! animations_off; then + resolve_intro || true + fi + exit 0 + ;; + --theme-switch) + theme_switch=true + expected_background=$(readlink -f "${2:-}" 2>/dev/null) || exit 1 + expected_theme=${3:-} + intro_prepared=${4:-false} + [[ -n $expected_theme ]] || exit 1 + ;; + *) + echo "Usage: omarchy-theme-bg-boot-intro [--resolve-only [background]|--resolve-startup|--prepare-theme theme|--theme-switch background theme-id [prepared]]" >&2 + exit 1 + ;; +esac + +if [[ $theme_switch == false ]]; then + mkdir -p "$(dirname "$marker")" + exec 9>"$state_home/omarchy/background-intro.lock" + flock 9 + + session_id=${OMARCHY_SESSION_ID:-${HYPRLAND_INSTANCE_SIGNATURE:-}} + [[ -n $session_id ]] || exit 0 + if [[ -f $marker && $(<"$marker") == "$session_id" ]]; then + exit 0 + fi + + marker_staged=$(mktemp "$state_home/omarchy/.background-intro.session-id.XXXXXX") + trap 'rm -f "$marker_staged"' EXIT + printf '%s\n' "$session_id" >"$marker_staged" + mv -f "$marker_staged" "$marker" + trap - EXIT + exec 9>&- +fi + +selection_current() { + [[ $(readlink -f "$current_background_link") == "$expected_background" && + $(stat -Lc '%d:%i' "$current_dir/theme" 2>/dev/null) == "$expected_theme" ]] +} + +if [[ $theme_switch == true ]] && ! selection_current; then + exit 0 +fi + +if [[ -f $toggle ]] || animations_off; then + [[ $theme_switch == false ]] || exit 1 + exit 0 +fi + +intro=$(resolve_intro || true) +if [[ -z $intro ]]; then + [[ $theme_switch == false ]] || exit 1 + exit 0 +fi + +if [[ $theme_switch == true && $intro_prepared != "true" ]]; then + owe refresh >/dev/null || exit 1 +elif [[ $theme_switch == false ]]; then + deadline=$((SECONDS + 5)) + while ! timeout 1 owe status >/dev/null 2>&1; do + if (( SECONDS >= deadline )); then + exit 0 + fi + sleep 0.2 + done + (( SECONDS < deadline )) || exit 0 +fi + +# The asynchronous outgoing cover must reach the compositor before playback +# can hide the shell background. Older shells have no readiness method. +if [[ $theme_switch == true ]]; then + deadline=$((SECONDS + 3)) + while true; do + cover_status=$(omarchy-shell shell themeIntroCoverStatus "$expected_theme" 2>/dev/null || true) + case $cover_status in + loading) + (( SECONDS < deadline )) || exit 1 + sleep 0.01 + ;; + error) exit 1 ;; + superseded) exit 0 ;; + *) break ;; + esac + done +fi + +if [[ $theme_switch == true ]] && ! selection_current; then + exit 0 +fi + +# A selection or setting changed while OWE was starting no longer permits this intro. +if [[ ! -f $toggle && $intro == "$(resolve_intro || true)" ]] && ! animations_off; then + if [[ $theme_switch == true && $intro_prepared == "true" ]]; then + owe intro --start first-frame --refresh "$intro" + else + owe intro --start first-frame "$intro" + fi +elif [[ $theme_switch == true ]]; then + exit 1 +fi diff --git a/bin/omarchy-theme-bg-intro b/bin/omarchy-theme-bg-intro new file mode 100755 index 00000000..6d0dba56 --- /dev/null +++ b/bin/omarchy-theme-bg-intro @@ -0,0 +1,34 @@ +#!/bin/bash + +# omarchy:summary=Enable or disable background intros +# omarchy:args= +# omarchy:examples=omarchy theme bg intro toggle | omarchy theme bg intro disable + +set -euo pipefail + +toggle="$HOME/.local/state/omarchy/toggles/background-intros-off" +case ${1:-} in + enable) + rm -f "$toggle" + omarchy-notification-send "$(omarchy-text "Background Intros")" "$(omarchy-text "Enabled")" + ;; + disable) + mkdir -p "$(dirname "$toggle")" + touch "$toggle" + omarchy-notification-send "$(omarchy-text "Background Intros")" "$(omarchy-text "Disabled")" + ;; + toggle) + if [[ -f $toggle ]]; then + omarchy-theme-bg-intro enable + else + omarchy-theme-bg-intro disable + fi + ;; + status) + [[ ! -f $toggle ]] + ;; + *) + echo "Usage: omarchy-theme-bg-intro " >&2 + exit 1 + ;; +esac diff --git a/bin/omarchy-theme-set b/bin/omarchy-theme-set index ea5a3d21..70449438 100755 --- a/bin/omarchy-theme-set +++ b/bin/omarchy-theme-set @@ -50,6 +50,13 @@ shell_ipc() { timeout 2 omarchy-shell "$@" >/dev/null 2>&1 } +wait_for_theme_intro() { + local method="$1" token="$2" pending="$3" deadline=$((SECONDS + 3)) + while (( SECONDS < deadline )) && [[ $(omarchy-shell shell "$method" "$token" 2>/dev/null) == "$pending" ]]; do + sleep 0.02 + done +} + is_video_path() { [[ ${1,,} =~ \.(mp4|m4v|mov|webm|mkv|avi)$ ]] } @@ -70,10 +77,18 @@ snapshot_background_path() { } snapshot_current_background() { - local current_background + local current_background snapshot - current_background=$(readlink -f "$CURRENT_BACKGROUND_LINK" 2>/dev/null || true) - snapshot_background_path "$current_background" "previous" + # An interrupted intro is still moving; its final still would flash as a + # cover. Capture the frame already playing instead, when OWE supports it. + mkdir -p "$BACKGROUND_TRANSITION_CACHE" + snapshot="$BACKGROUND_TRANSITION_CACHE/previous-$$.ppm" + if owe render "$(jq -nc --arg path "$snapshot" '{cmd: "snapshot", path: $path}')" >/dev/null 2>&1; then + echo "$snapshot" + else + current_background=$(readlink -f "$CURRENT_BACKGROUND_LINK" 2>/dev/null || true) + snapshot_background_path "$current_background" "previous" + fi } theme_background_state_file() { @@ -171,7 +186,7 @@ set_theme_background_link() { } set_theme_background() { - local new_background new_background_snapshot + local new_background new_background_snapshot theme_id intro_prepared=false if [[ -z $CHOSEN_THEME_BACKGROUND || ! -f $CHOSEN_THEME_BACKGROUND ]]; then if ! choose_theme_background; then @@ -182,6 +197,37 @@ set_theme_background() { fi new_background="$CHOSEN_THEME_BACKGROUND" + if [[ -n $THEME_BACKGROUND_INTRO ]]; then + if [[ -n $INTRO_PREPARE_PID ]] && wait "$INTRO_PREPARE_PID"; then + intro_prepared=true + fi + theme_id=$(stat -Lc '%d:%i' "$CURRENT_THEME_PATH") + THEME_INTRO_TOKEN="$theme_id" + shell_ipc shell prepareThemeIntro "$OLD_BACKGROUND_SNAPSHOT" "$theme_id" "$colors_payload" "$shell_payload" || \ + shell_ipc shell applyTheme "$colors_payload" "$shell_payload" || true + ln -nsf "$new_background" "$CURRENT_BACKGROUND_LINK" + ( + if ! omarchy-theme-bg-boot-intro --theme-switch "$new_background" "$theme_id" "$intro_prepared"; then + if [[ $(readlink -f "$CURRENT_BACKGROUND_LINK") == "$(readlink -f "$new_background")" && + $(stat -Lc '%d:%i' "$CURRENT_THEME_PATH" 2>/dev/null) == "$theme_id" ]]; then + shell_ipc background setInstant "$new_background" || true + fi + fi + shell_ipc shell finishThemeIntro "$theme_id" || true + wait_for_theme_intro themeIntroStatus "$theme_id" "pending" + # Reloading Hyprland interrupts video presentation. Wait for playback, + # then serialize with theme selection so an old intro cannot reload + # the compositor during the next theme's video. + ( + flock 9 + if [[ $(stat -Lc '%d:%i' "$CURRENT_THEME_PATH" 2>/dev/null) == "$theme_id" ]]; then + omarchy-restart-hyprctl + fi + ) 9>"$THEME_SET_LOCK" + [[ -z $OLD_BACKGROUND_SNAPSHOT ]] || rm -f "$OLD_BACKGROUND_SNAPSHOT" + ) 9>&- & + return + fi new_background_snapshot="" if [[ -n $PREPARED_BACKGROUND_SNAPSHOT && $PREPARED_BACKGROUND == "$new_background" ]]; then new_background_snapshot="$PREPARED_BACKGROUND_SNAPSHOT" @@ -337,6 +383,12 @@ fi exec 9>"$THEME_SET_LOCK" flock 9 +# A current video frame only represents the screen after its opening fade. +# Finish that short reveal before capturing the next outgoing cover. +if [[ $THEME_HEADLESS != "1" && $OMARCHY_THEME_SKIP_BACKGROUND != "1" ]]; then + wait_for_theme_intro themeIntroStatus "$(stat -Lc '%d:%i' "$CURRENT_THEME_PATH" 2>/dev/null)" "pending" +fi + PREVIOUS_THEME_NAME=$(cat "$CURRENT_THEME_NAME_PATH" 2>/dev/null || true) remember_current_theme_background @@ -365,11 +417,24 @@ OLD_BACKGROUND_SNAPSHOT="" PREPARED_BACKGROUND="" PREPARED_BACKGROUND_SNAPSHOT="" BACKGROUND_TRANSITION_SNAPSHOTS=true +THEME_BACKGROUND_INTRO="" +INTRO_PREPARE_PID="" +THEME_INTRO_TOKEN="" if [[ $THEME_HEADLESS != "1" && $OMARCHY_THEME_SKIP_BACKGROUND != "1" ]]; then # Resolve the staged choice while the old theme still exists. Video changes # switch directly to the durable path in QML, so neither side needs a copy. if choose_staged_theme_background; then - if background_transition_uses_snapshots "$CHOSEN_THEME_BACKGROUND"; then + if [[ $PREVIOUS_THEME_NAME != "$THEME_NAME" && ! -f $HOME/.local/state/omarchy/toggles/background-intros-off ]] && + [[ $(hyprctl -j getoption animations:enabled 2>/dev/null | jq -r '.bool') == "true" ]]; then + THEME_BACKGROUND_INTRO=$(omarchy-theme-bg-boot-intro --resolve-only "$STAGED_THEME_BACKGROUND") + fi + if [[ -n $THEME_BACKGROUND_INTRO ]]; then + OLD_BACKGROUND_SNAPSHOT=$(snapshot_current_background) + # OWE retains the opened clip across the staged directory's rename. + owe intro-prepare "$THEME_BACKGROUND_INTRO" >/dev/null 2>&1 9>&- & + INTRO_PREPARE_PID=$! + shell_ipc shell prepareThemeIntro "$OLD_BACKGROUND_SNAPSHOT" "$(stat -Lc '%d:%i' "$NEXT_THEME_PATH")" "" "" || true + elif background_transition_uses_snapshots "$CHOSEN_THEME_BACKGROUND"; then OLD_BACKGROUND_SNAPSHOT=$(snapshot_current_background) # Hand the shell the next background now, so it decodes while the @@ -389,6 +454,12 @@ fi # Generate dynamic configs omarchy-theme-set-templates +# Replacing the theme's still can cancel an active OWE intro even before the +# background link changes. Present its cover before replacing either path. +if [[ -n $THEME_BACKGROUND_INTRO ]]; then + wait_for_theme_intro themeIntroCoverStatus "$(stat -Lc '%d:%i' "$NEXT_THEME_PATH")" "loading" +fi + # Swap next theme in as current rm -rf "$CURRENT_THEME_PATH" mv "$NEXT_THEME_PATH" "$CURRENT_THEME_PATH" @@ -418,7 +489,6 @@ flock -u 9 post_theme_commands=( omarchy-restart-terminal - omarchy-restart-hyprctl omarchy-restart-btop omarchy-restart-opencode omarchy-restart-helix @@ -437,6 +507,14 @@ post_theme_commands=( ) if [[ $THEME_HEADLESS != "1" ]]; then + # Let the wallpaper and bar finish their reveal before app retints compete + # with decoding and the shell's frame rendering. An intro reloads Hyprland + # after playback; ordinary theme changes reload it with the app retints. + if [[ -n $THEME_INTRO_TOKEN ]]; then + wait_for_theme_intro themeIntroStatus "$THEME_INTRO_TOKEN" "pending" + else + post_theme_commands+=(omarchy-restart-hyprctl) + fi run_parallel "${post_theme_commands[@]}" # Call hook on theme set diff --git a/bin/omarchy-theme-set-herdr-machines b/bin/omarchy-theme-set-herdr-machines index 95d184a1..6d5be95b 100755 --- a/bin/omarchy-theme-set-herdr-machines +++ b/bin/omarchy-theme-set-herdr-machines @@ -29,6 +29,8 @@ if [[ -n $OMARCHY_THEME_SYNC_FROM ]] || ! omarchy-toggle-enabled herdr-theme-syn fi theme=$(cat "$HOME/.local/state/omarchy/current/theme.name" 2>/dev/null) +control_dir="$XDG_RUNTIME_DIR/omarchy-theme-sync" +mkdir -m 700 -p "$control_dir" # Runs on the remote machine under a login shell, so OMARCHY_PATH and PATH are set there. # SSH carries no desktop session, so the environment comes from the live Hyprland instance. @@ -66,10 +68,13 @@ read -r -d '' REMOTE_SCRIPT <<'EOF' } &1 | tail -n 1) + timeout 60 ssh -o BatchMode=yes -o ConnectTimeout=5 \ + -o ControlMaster=auto -o ControlPersist=600 -o "ControlPath=$control_dir/%C" \ + "$target" bash -l -s 2>&1 | tail -n 1) echo "$(date '+%F %T') $target: ${result:-unreachable}" done diff --git a/bin/omarchy-voxtype-install b/bin/omarchy-voxtype-install deleted file mode 100755 index 9c26957b..00000000 --- a/bin/omarchy-voxtype-install +++ /dev/null @@ -1,26 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Install and configure Voxtype dictation -# omarchy:requires-sudo=true - -set -e - -# Install voxtype and configure it for use. - -if gum confirm "Install Voxtype + AI model (~150MB) to enable dictation?"; then - omarchy-pkg-add wtype voxtype-bin - - # Setup voxtype - mkdir -p ~/.config/voxtype - cp "$OMARCHY_PATH/default/voxtype/config.toml" ~/.config/voxtype/ - - voxtype setup --download --no-post-install - if omarchy-hw-vulkan; then - voxtype setup gpu --enable || true - fi - voxtype setup systemd - - hyprctl reload >/dev/null - omarchy-restart-shell - omarchy-notification-send -g  "$(omarchy-text "Voxtype Dictation Ready")" "$(omarchy-text "Hold F9 to dictate (or toggle with Super + Ctrl + X).")" -t 10000 -fi diff --git a/bin/omarchy-voxtype-status b/bin/omarchy-voxtype-status deleted file mode 100755 index f3131483..00000000 --- a/bin/omarchy-voxtype-status +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Stream voxtype --follow status as bar-friendly JSON - -if omarchy-cmd-missing voxtype; then - echo '{"alt": "", "class": "idle", "tooltip": ""}' - exit 0 -fi - -# Keep the status follower as the direct child of the bar. Quickshell -# clean up their direct child when they restart; a shell pipeline/coproc would -# leave `voxtype status --follow` orphaned under the user systemd instance. -exec setpriv --pdeathsig TERM voxtype status --follow --extended --format json diff --git a/config/chromium-flags.conf b/config/chromium-flags.conf index 1fe08716..8c6065c7 100644 --- a/config/chromium-flags.conf +++ b/config/chromium-flags.conf @@ -1,5 +1,5 @@ --ozone-platform=wayland --ozone-platform-hint=wayland --password-store=gnome-libsecret ---enable-features=TouchpadOverscrollHistoryNavigation +--enable-features=TouchpadOverscrollHistoryNavigation,OverlayScrollbar --load-extension=/usr/share/omarchy/default/chromium/extensions/copy-url,/usr/share/omarchy/default/chromium/extensions/yt-dlp,/usr/share/omarchy/default/chromium/extensions/whatsapp-slim diff --git a/config/omarchy/defaults/dictation b/config/omarchy/defaults/dictation new file mode 100644 index 00000000..b56362f0 --- /dev/null +++ b/config/omarchy/defaults/dictation @@ -0,0 +1 @@ +superwhisper diff --git a/config/omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample b/config/omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample index fdc3a871..4c91ecd3 100644 --- a/config/omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample +++ b/config/omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample @@ -11,7 +11,7 @@ # # To put it into use, remove .sample from this file name. -# Example: add an Include line above [core]. +# Example: add an Include line above the first repository, [omarchy]. # Maintain the repo entries in /etc/pacman.d/custom-repos.conf yourself. CONF=/etc/pacman.conf @@ -21,4 +21,4 @@ MARKER="Include = $SNIPPET" [[ -r $SNIPPET ]] || exit 0 grep -qxF "$MARKER" "$CONF" && exit 0 -sudo sed -i "0,/^\[core\]/s||$MARKER\n\n[core]|" "$CONF" +sudo sed -i "0,/^\[omarchy\]/s||$MARKER\n\n[omarchy]|" "$CONF" diff --git a/default/hypr/apps/omarchy-shell.lua b/default/hypr/apps/omarchy-shell.lua index a06630f8..e756d0d7 100644 --- a/default/hypr/apps/omarchy-shell.lua +++ b/default/hypr/apps/omarchy-shell.lua @@ -6,12 +6,16 @@ hl.layer_rule({ match = { namespace = "omarchy-bar" }, no_anim = true, animation -- Launcher, image selector, emojis, clipboard overlays, the OSD, reminders, the -- Wi-Fi QR code and keyboard-driven panels should pop without compositor layer --- animations. The overlays stay mapped between opens and grow from a parked --- 1x1 when shown, which Hyprland would otherwise animate as a slide in from --- the corner. Panels keep their own QML opacity transition for normal --- open/close, and skip it for panel handoff. +-- animations. Overlays open as soon as their scale is ready, without a +-- compositor fade or slide. Panels keep their own QML opacity transition +-- for normal open/close, and skip it for panel handoff. hl.layer_rule({ match = { namespace = "^(omarchy-menu|omarchy-image-selector|omarchy-emojis|omarchy-clipboard|omarchy-keyboard-panel|omarchy-osd|omarchy-reminders|omarchy-network-qr)$" }, no_anim = true, animation = "none" }) +-- A boot intro hands the desktop between OWE's video layer and the shell's +-- background layer. The media fades itself, so a compositor fade on either +-- layer only dips the screen toward the empty desktop during the handoff. +hl.layer_rule({ match = { namespace = "^(omarchy-background|owe-background)$" }, no_anim = true, animation = "none" }) + -- Dev gallery is the main shell workbench; open it maximized like -- SUPER+ALT+F so component previews have the whole workspace. o.window({ class = "^org.quickshell$", title = "^Omarchy shell – dev gallery$" }, { maximize = true }) diff --git a/default/hypr/autostart.lua b/default/hypr/autostart.lua index d6797290..d570ac58 100644 --- a/default/hypr/autostart.lua +++ b/default/hypr/autostart.lua @@ -1,4 +1,12 @@ +require("default.hypr.startup-cursor") + hl.on("hyprland.start", function() + -- Applications inherit the user's cursor; only the compositor starts blank. + if omarchy_startup_cursor_pending then + hl.env("XCURSOR_PATH", omarchy_startup_cursor.path) + hl.env("XCURSOR_THEME", omarchy_startup_cursor.xcursor) + end + -- Slow app launch fix -- set systemd vars before starting session services. hl.exec_cmd("systemctl --user import-environment $(env | cut -d'=' -f 1)") hl.exec_cmd("dbus-update-activation-environment --systemd --all") diff --git a/default/hypr/bindings/dictation.lua b/default/hypr/bindings/dictation.lua new file mode 100644 index 00000000..b486d794 --- /dev/null +++ b/default/hypr/bindings/dictation.lua @@ -0,0 +1,9 @@ +if o.shell_succeeds("omarchy-default-dictation") then + o.bind("SUPER + CTRL + X", "Toggle dictation", "omarchy-dictation toggle") + o.bind("F9", "Start dictation (push-to-talk)", "omarchy-dictation start") + o.bind("F9", "Stop dictation (push-to-talk)", "omarchy-dictation stop", { release = true }) + -- A modifier's mask changes between its press and release. Match the keysym + -- independently of that mask; AltGr layouts use a different keysym. + o.bind("ALT + Alt_R", "Start dictation (push-to-talk)", "omarchy-dictation start", { ignore_mods = true }) + o.bind("ALT + Alt_R", "Stop dictation (push-to-talk)", "omarchy-dictation stop", { release = true, ignore_mods = true }) +end diff --git a/default/hypr/bindings/voxtype.lua b/default/hypr/bindings/voxtype.lua deleted file mode 100644 index df594b83..00000000 --- a/default/hypr/bindings/voxtype.lua +++ /dev/null @@ -1,5 +0,0 @@ -if o.cmd_present("voxtype") then - o.bind("SUPER + CTRL + X", "Toggle dictation", "voxtype record toggle") - o.bind("F9", "Start dictation (push-to-talk)", "voxtype record start") - o.bind("F9", "Stop dictation (push-to-talk)", "voxtype record stop", { release = true }) -end diff --git a/default/hypr/cursors/omarchy-startup/cursors/left_ptr b/default/hypr/cursors/omarchy-startup/cursors/left_ptr new file mode 100644 index 00000000..42e2dc42 Binary files /dev/null and b/default/hypr/cursors/omarchy-startup/cursors/left_ptr differ diff --git a/default/hypr/dictation-backend.lua b/default/hypr/dictation-backend.lua new file mode 100644 index 00000000..38f22605 --- /dev/null +++ b/default/hypr/dictation-backend.lua @@ -0,0 +1,24 @@ +-- Backends may supply their own desktop integration without editing the +-- user's Hyprland config. Recording bindings remain owned by Omarchy. +local paths = require("default.hypr.paths") +local pipe = io.popen("omarchy-default-dictation 2>/dev/null", "r") +if not pipe then + return +end + +local backend = pipe:read("*l") +pipe:close() + +if not backend or not backend:match("^[%w%-]+$") or o.cmd_missing("omarchy-dictation-" .. backend) then + return +end + +local integration = paths.config_home .. "/" .. backend .. "/shortcuts.lua" +local file = io.open(integration, "r") +if file then + file:close() + local ok, error = pcall(dofile, integration) + if not ok then + print("Could not load dictation backend " .. backend .. ": " .. tostring(error)) + end +end diff --git a/default/hypr/looknfeel.lua b/default/hypr/looknfeel.lua index f912169d..4a0bd42a 100644 --- a/default/hypr/looknfeel.lua +++ b/default/hypr/looknfeel.lua @@ -104,6 +104,7 @@ hl.config({ misc = { disable_hyprland_logo = true, disable_splash_rendering = true, + background_color = "rgb(000000)", disable_scale_notification = true, focus_on_activate = true, anr_missed_pings = 3, diff --git a/default/hypr/omarchy.lua b/default/hypr/omarchy.lua index 1e84058a..2439e914 100644 --- a/default/hypr/omarchy.lua +++ b/default/hypr/omarchy.lua @@ -12,7 +12,7 @@ if _G.omarchy_default_bindings ~= false then require("default.hypr.bindings.jis") require("default.hypr.bindings.tiling") require("default.hypr.bindings.utilities") - require("default.hypr.bindings.voxtype") + require("default.hypr.bindings.dictation") require_optional.module("default.hypr.bindings.applications") end require("default.hypr.envs") @@ -20,6 +20,7 @@ require("default.hypr.looknfeel") require("default.hypr.qconsole") require("default.hypr.input") require("default.hypr.windows") +require("default.hypr.dictation-backend") -- Current theme overrides. require_optional.module("omarchy.current.theme.hyprland") diff --git a/default/hypr/startup-cursor.lua b/default/hypr/startup-cursor.lua new file mode 100644 index 00000000..f80e0e1c --- /dev/null +++ b/default/hypr/startup-cursor.lua @@ -0,0 +1,46 @@ +-- Hyprland polls cursor.invisible, so its first frames need a blank cursor too. +function omarchy_startup_cursor_restore(loaded) + if not omarchy_startup_cursor_pending then return end + local cursor = omarchy_startup_cursor + if loaded then + omarchy_startup_cursor_pending = false + hl.config({ cursor = cursor.config }) + if cursor.config.enable_hyprcursor and cursor.hyprcursor then + hl.exec_cmd("hyprctl setcursor " .. o.shell_quote(cursor.hyprcursor) .. " " .. cursor.size) + end + elseif not cursor.restoring then + -- Reload the normal Xcursor fallback before enabling Hyprcursor or GSettings. + cursor.restoring = true + hl.exec_cmd("hyprctl setcursor " .. o.shell_quote(cursor.xcursor) .. " " .. cursor.size + .. " && hyprctl eval 'omarchy_startup_cursor_restore(true)'") + end +end + +hl.on("config.reloaded", function() + if omarchy_startup_cursor_pending == nil then + -- Config updates in an existing compositor must not hide its pointer. + omarchy_startup_cursor_pending = #hl.get_monitors() == 0 + if omarchy_startup_cursor_pending then + local hyprcursor = hl.get_config("cursor.enable_hyprcursor") + omarchy_startup_cursor = { + config = { + invisible = hl.get_config("cursor.invisible"), + enable_hyprcursor = hyprcursor, + sync_gsettings_theme = hl.get_config("cursor.sync_gsettings_theme"), + }, + hyprcursor = os.getenv("HYPRCURSOR_THEME"), + size = tonumber((hyprcursor and os.getenv("HYPRCURSOR_SIZE")) or os.getenv("XCURSOR_SIZE")) or 24, + path = os.getenv("XCURSOR_PATH") or "~/.local/share/icons:~/.icons:/usr/share/icons:/usr/share/pixmaps", + xcursor = os.getenv("XCURSOR_THEME") or "default", + } + if omarchy_startup_cursor.size <= 0 then omarchy_startup_cursor.size = 24 end + hl.env("XCURSOR_PATH", os.getenv("OMARCHY_PATH") .. "/default/hypr/cursors:" .. omarchy_startup_cursor.path) + hl.env("XCURSOR_THEME", "omarchy-startup") + end + end + if omarchy_startup_cursor_pending then + -- Keep the temporary cursor private to the compositor, including GSettings. + hl.config({ cursor = { invisible = true, enable_hyprcursor = false, sync_gsettings_theme = false } }) + hl.timer(function() omarchy_startup_cursor_restore() end, { timeout = 15000, type = "oneshot" }) + end +end) diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index 7a56bb71..1e1da3f7 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -163,6 +163,9 @@ "setup.default.browser.edge": {"icon":"󰇩","label":"Edge","checked":"[[ \"$(omarchy-default-browser)\" == \"edge\" ]]","action":"omarchy-default-browser edge"}, "setup.default.browser.firefox": {"icon":"","label":"Firefox","checked":"[[ \"$(omarchy-default-browser)\" == \"firefox\" ]]","action":"omarchy-default-browser firefox"}, "setup.default.browser.zen": {"icon":"󰖟","label":"Zen","checked":"[[ \"$(omarchy-default-browser)\" == \"zen\" ]]","action":"omarchy-default-browser zen"}, + "setup.default.dictation": {"icon":"","label":"Dictation","title":"Default Dictation"}, + "setup.default.dictation.superwhisper": {"icon":"","label":"Superwhisper","checked":"[[ \"$(omarchy-default-dictation)\" == \"superwhisper\" ]]","action":"omarchy-default-dictation superwhisper"}, + "setup.default.dictation.voxtype": {"icon":"","label":"Voxtype","checked":"[[ \"$(omarchy-default-dictation)\" == \"voxtype\" ]]","action":"omarchy-default-dictation voxtype"}, "setup.default.terminal": {"icon":"","label":"Terminal","title":"Default Terminal"}, "setup.default.terminal.alacritty": {"icon":"","label":"Alacritty","checked":"[[ \"$(omarchy-default-terminal)\" == \"alacritty\" ]]","action":"omarchy-default-terminal alacritty"}, "setup.default.terminal.foot": {"icon":"","label":"Foot","checked":"[[ \"$(omarchy-default-terminal)\" == \"foot\" ]]","action":"omarchy-default-terminal foot"}, @@ -267,7 +270,6 @@ "install.terminal.kitty": {"icon":"","label":"Kitty","disabled":"omarchy-pkg-present kitty","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-terminal kitty'"}, "install.ai.chatgpt": {"icon":"","iconFont":"omarchy","label":"ChatGPT Desktop","disabled":"omarchy-pkg-present openai-codex-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-ai-chatgpt"}, "install.ai.claude": {"icon":"","iconFont":"omarchy","label":"Claude Desktop","disabled":"omarchy-pkg-present claude-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-ai-claude"}, - "install.ai.dictation": {"icon":"","label":"Dictation","disabled":"omarchy-pkg-present voxtype-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-voxtype-install"}, "install.ai.grok-bot": {"icon":"","iconFont":"omarchy","label":"Grok Bot","disabled":"omarchy-pkg-present grok-bot","action":"omarchy-install-and-launch 'Grok Bot' grok-bot grok-bot"}, "install.ai.hermes": {"icon":"","iconFont":"omarchy","label":"Hermes Desktop","disabled":"omarchy-pkg-present hermes-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-ai-hermes"}, "install.ai.lm-studio": {"icon":"","iconFont":"omarchy","label":"LM Studio","disabled":"omarchy-pkg-present lmstudio-bin","action":"omarchy-install-app 'LM Studio' lmstudio-bin"}, @@ -311,6 +313,7 @@ // Remove "remove.package": {"icon":"󰣇","label":"Package","action":"xdg-terminal-exec --app-id=org.omarchy.terminal omarchy-pkg-remove"}, "remove.ai": {"icon":"󱚤","label":"AI","title":"Remove"}, + "remove.dictation": {"icon":"","label":"Dictation","when":"omarchy-pkg-present voxtype-bin || omarchy-pkg-present superwhisper-bin"}, "remove.service": {"icon":"","label":"Services","title":"Remove"}, "remove.development": {"icon":"󰵮","label":"Development","title":"Remove"}, "remove.theme": {"icon":"󰸌","label":"Theme","action":"omarchy-theme-remove"}, @@ -338,7 +341,8 @@ "remove.service.ssh-agent": {"icon":"󰣀","label":"SSH Agent","when":"systemctl --user -q is-enabled gcr-ssh-agent.socket","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-service-ssh-agent"}, "remove.ai.chatgpt": {"icon":"","iconFont":"omarchy","label":"ChatGPT Desktop","when":"omarchy-pkg-present openai-codex-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-ai-chatgpt"}, "remove.ai.claude": {"icon":"","iconFont":"omarchy","label":"Claude Desktop","when":"omarchy-pkg-present claude-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-ai-claude"}, - "remove.ai.dictation": {"icon":"","label":"Dictation","when":"omarchy-pkg-present voxtype-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-voxtype-remove"}, + "remove.dictation.superwhisper": {"icon":"","label":"Superwhisper","when":"omarchy-pkg-present superwhisper-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-dictation-superwhisper"}, + "remove.dictation.voxtype": {"icon":"","label":"Voxtype","when":"omarchy-pkg-present voxtype-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-dictation-voxtype"}, "remove.ai.grok-bot": {"icon":"","iconFont":"omarchy","label":"Grok Bot","when":"omarchy-pkg-present grok-bot","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-ai-grok-bot"}, "remove.ai.lm-studio": {"icon":"","iconFont":"omarchy","label":"LM Studio","when":"omarchy-pkg-present lmstudio-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-ai-lm-studio"}, "remove.ai.ollama": {"icon":"","iconFont":"omarchy","label":"Ollama","when":"omarchy-pkg-present ollama","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-ai-ollama"}, diff --git a/default/omarchy/omarchy-menu.zh-cn.jsonc b/default/omarchy/omarchy-menu.zh-cn.jsonc index 1242e5da..f0ddd17a 100644 --- a/default/omarchy/omarchy-menu.zh-cn.jsonc +++ b/default/omarchy/omarchy-menu.zh-cn.jsonc @@ -95,6 +95,7 @@ "setup.network.qr": {"label":"二维码"}, "setup.default": {"label":"默认程序"}, "setup.default.agent": {"title":"默认 Agent"}, + "setup.default.dictation": {"label":"语音听写","title":"默认语音听写"}, "setup.default.browser": {"label":"浏览器","title":"默认浏览器"}, "setup.default.terminal": {"label":"终端","title":"默认终端"}, "setup.default.editor": {"label":"编辑器","title":"默认编辑器"}, @@ -125,7 +126,6 @@ "install.terminal": {"label":"终端"}, "install.preinstalls": {"label":"预装应用"}, "install.service.chromium-account": {"label":"Chromium 账号"}, - "install.ai.dictation": {"label":"语音听写"}, "install.gaming.xbox-cloud": {"label":"Xbox 云游戏"}, "install.gaming.xbox-controllers": {"label":"Xbox 手柄"}, "install.gaming.retro-launcher": {"label":"RetroArch 启动器"}, @@ -142,7 +142,7 @@ "remove.security": {"label":"安全","title":"移除"}, "remove.security.fingerprint": {"label":"指纹"}, "remove.security.sudoless-docker": {"label":"免 sudo Docker"}, - "remove.ai.dictation": {"label":"语音听写"}, + "remove.dictation": {"label":"语音听写"}, "remove.gaming.xbox-cloud": {"label":"Xbox 云游戏"}, "remove.gaming.xbox-controllers": {"label":"Xbox 手柄 (󰂯)"}, "remove.development.javascript": {"title":"移除"}, diff --git a/default/pacman/pacman-edge.conf b/default/pacman/pacman-edge.conf index a968d26d..b65d065e 100644 --- a/default/pacman/pacman-edge.conf +++ b/default/pacman/pacman-edge.conf @@ -16,6 +16,9 @@ SigLevel = Required DatabaseOptional LocalFileSigLevel = Optional # pacman searches repositories in the order defined here +[omarchy] +Server = https://pkgs.omarchy.org/edge/$arch + [core] Include = /etc/pacman.d/mirrorlist @@ -25,9 +28,6 @@ Include = /etc/pacman.d/mirrorlist [multilib] Include = /etc/pacman.d/mirrorlist -[omarchy] -Server = https://pkgs.omarchy.org/edge/$arch - # Repositories for debug symbol packages. # # These upstream debug packages will only match core/extra/multilib packages from the Omarchy edge diff --git a/default/pacman/pacman-rc.conf b/default/pacman/pacman-rc.conf index 66ad3fb4..b79b14a0 100644 --- a/default/pacman/pacman-rc.conf +++ b/default/pacman/pacman-rc.conf @@ -16,6 +16,9 @@ SigLevel = Required DatabaseOptional LocalFileSigLevel = Optional # pacman searches repositories in the order defined here +[omarchy] +Server = https://pkgs.omarchy.org/rc/$arch + [core] Include = /etc/pacman.d/mirrorlist @@ -24,6 +27,3 @@ Include = /etc/pacman.d/mirrorlist [multilib] Include = /etc/pacman.d/mirrorlist - -[omarchy] -Server = https://pkgs.omarchy.org/rc/$arch diff --git a/default/pacman/pacman-stable.conf b/default/pacman/pacman-stable.conf index 7e4b5538..b7fc74ba 100644 --- a/default/pacman/pacman-stable.conf +++ b/default/pacman/pacman-stable.conf @@ -16,6 +16,9 @@ SigLevel = Required DatabaseOptional LocalFileSigLevel = Optional # pacman searches repositories in the order defined here +[omarchy] +Server = https://pkgs.omarchy.org/stable/$arch + [core] Include = /etc/pacman.d/mirrorlist @@ -24,6 +27,3 @@ Include = /etc/pacman.d/mirrorlist [multilib] Include = /etc/pacman.d/mirrorlist - -[omarchy] -Server = https://pkgs.omarchy.org/stable/$arch diff --git a/default/sddm/hyprland.lua b/default/sddm/hyprland.lua index 42c19bcc..c0e7e11f 100644 --- a/default/sddm/hyprland.lua +++ b/default/sddm/hyprland.lua @@ -53,6 +53,7 @@ hl.config({ misc = { disable_hyprland_logo = true, disable_splash_rendering = true, + background_color = "rgb(000000)", force_default_wallpaper = 0, }, diff --git a/default/voxtype/config.toml b/default/voxtype/config.toml index ca80e7ee..9ae56db7 100644 --- a/default/voxtype/config.toml +++ b/default/voxtype/config.toml @@ -11,7 +11,7 @@ state_file = "auto" [hotkey] -# Hotkey is configured in Hyprland. Default is Super + Ctrl + X +# Hotkeys are configured in Hyprland: hold Right Alt or F9, or toggle with Super + Ctrl + X. enabled = false [audio] diff --git a/docs/file-layout.md b/docs/file-layout.md index 5caaba56..1bf34747 100644 --- a/docs/file-layout.md +++ b/docs/file-layout.md @@ -267,8 +267,8 @@ first runs `omarchy-provision-user || true` so finalize catches up if it never ran, then handles the steps that need a running graphical session and/or a working user systemd instance: -- `omarchy-hook-install post-update` for the three shipped hooks - (`install-voxtype.hook`, `setup-fingerprint.hook`, `setup-agent.hook`). +- `omarchy-hook-install post-update` for the two shipped hooks + (`setup-fingerprint.hook`, `setup-agent.hook`). - `install/user/first-run/enable-user-units.sh` — daemon-reload, then `systemctl --user enable --now` the shipped user units (`bt-agent`, `omarchy-sleep-lock`, `omarchy-recover-internal-monitor`, diff --git a/docs/omarchy-shell.md b/docs/omarchy-shell.md index 32d7af9f..c834d879 100644 --- a/docs/omarchy-shell.md +++ b/docs/omarchy-shell.md @@ -189,6 +189,20 @@ overrides like `omarchy display text size` survive theme switches. ## Theme tokens +Shell and plugin QML must qualify the palette singleton because Qt 6.12 introduces its own `Color` type. Import `qs.Commons` with an alias and use `Commons.Color` for palette properties and signal connections: + +```qml +import QtQuick +import qs.Commons +import qs.Commons as Commons + +Rectangle { + color: Commons.Color.background +} +``` + +The unqualified import can remain for `Style`, `Util`, and `Border`. Existing third-party plugins that use bare `Color` must make the same change; fixing the host shell does not change a plugin's import scope. + See [`theming.md`](theming.md) for the full theme/template workflow, including generated `*.tpl` files, gradient helpers, and shell border syntax. @@ -200,17 +214,17 @@ or override a single section with `shell.
.toml`, merged in by `omarchy-theme-set-templates` (see [`theming.md`](theming.md)). `colors.toml` uses `foreground` and `background` for the foundational -text/background palette, exposed to QML as `Color.foreground` and -`Color.background`. +text/background palette, exposed to QML as `Commons.Color.foreground` and +`Commons.Color.background`. The shell exposes these tokens to QML via three singletons in `qs.Commons`: - `Color` — palette (`foreground`, `background`, `accent`, `urgent`) - and per-surface roles (`Color.bar.*`, `Color.popups.*`, - `Color.tooltip.*`, `Color.notifications.*`, `Color.menu.*`, - `Color.polkit.*`, `Color.lock.*`, `Color.imagePicker.*`). Clipboard - and emojis share `Color.menu.*`; the `[launcher]` section is consumed + and per-surface roles (`Commons.Color.bar.*`, `Commons.Color.popups.*`, + `Commons.Color.tooltip.*`, `Commons.Color.notifications.*`, `Commons.Color.menu.*`, + `Commons.Color.polkit.*`, `Commons.Color.lock.*`, `Commons.Color.imagePicker.*`). Clipboard + and emojis share `Commons.Color.menu.*`; the `[launcher]` section is consumed by the launcher outside shell QML. - `Style` — structural tokens (`cornerRadius`), shared interactive state tokens/helpers, spacing (`Style.spacing.*` / `Style.space(px)`), @@ -218,7 +232,7 @@ The shell exposes these tokens to QML via three singletons in (`Style.bar.sizeHorizontal` / `Style.bar.sizeVertical`). - `Border` — border-spec helpers for QML surfaces. Use with `BorderSurface` from `qs.Ui` when a border should honor shell theme - gradients or per-side widths. `Color.
.border` is only the + gradients or per-side widths. `Commons.Color.
.border` is only the flat-color fallback for code that cannot render a real border. ### Interactive states diff --git a/docs/theming.md b/docs/theming.md index 29986291..6f6fdc38 100644 --- a/docs/theming.md +++ b/docs/theming.md @@ -6,12 +6,11 @@ Omarchy themes live under `themes//` in the source tree (installed at `colors.toml`; Omarchy generates the active theme files from `default/themed/*.tpl` when `omarchy-theme-set ` runs. -Beyond `colors.toml` and hand-written config overrides, a first-party theme can -ship `backgrounds/` (users overlay their own via -`~/.config/omarchy/backgrounds//`; the active image is the -`~/.local/state/omarchy/current/background` symlink), `preview.png` and -`preview-unlock.png` for the theme switcher, `icons.theme`, `keyboard.rgb`, -`unlock.png`, and a `light.mode` marker file. +Beyond `colors.toml` and hand-written config overrides, a first-party theme can ship `backgrounds/` (users overlay their own via `~/.config/omarchy/backgrounds//`; the active image is the `~/.local/state/omarchy/current/background` symlink), `preview.png` and `preview-unlock.png` for the theme switcher, `icons.theme`, `keyboard.rgb`, `unlock.png`, and a `light.mode` marker file. + +A theme can pair a still background with a silent intro video using matching filenames: `backgrounds/0-winding-road.webp` and `backgrounds/intros/0-winding-road.mp4`. The video plays automatically at login and when switching to that theme with this background selected. No registration, checksum file or manual setting is needed. Supported video extensions are `mp4`, `m4v`, `mov`, `webm`, `mkv` and `avi`. Intro videos stay inside the nested `intros/` directory so the background picker does not list them as looping wallpapers. An image selected from another directory looks for its intro in that directory's own `intros/`, so a same-named custom image cannot inherit a theme's video. + +Aim for a video between five and seven seconds long that opens on its scene setup and ends on the matching still. The shell provides the opening crossfade, so an added fade from black is unnecessary. OWE owns decoding, mute and the handoff back to that image. The theme picker prepares the highlighted theme's remembered intro offscreen, paused at its opening frame. OWE keeps one prepared clip and an idle renderer for up to one minute, then releases them. A theme switch also starts preparation while its templates render; OWE compares the selected video with the prepared clip before resuming it. Older OWE versions use the ordinary startup path. Hyprland and the SDDM greeter use a black compositor background. On a fresh login, the persistent shell host keeps an opaque black cover above the bar and wallpaper until the bar has loaded and OWE has a moving video frame, then fades both in together over 420 ms. Hyprland initializes with a transparent cursor so its visibility polling cannot expose a pointer in the first frames. The temporary Xcursor theme stays private to the compositor; applications inherit the normal cursor environment. The opening fade restores the selected cursor and settings. Shell restarts and a fifteen-second compositor timeout recover a cursor left hidden by an interrupted startup. With intros disabled or unavailable, it waits for the still to be ready instead. A ten-second deadline releases the cover if a missing wallpaper or failed plugin never becomes ready. Ordinary shell restarts preload the selected still alongside the bar without mapping that login cover. Login intros use the Hyprland instance signature, so logging out and back in plays the intro again. The launcher consumes the current session before waiting up to five seconds for OWE. Shell restarts and a daemon starting after that window cannot play a delayed intro. Theme switches synchronize OWE with the selected image and use its first-frame start mode. An interrupted video supplies its currently playing frame as the outgoing snapshot. Rapid selections wait for the previous opening fade to finish before capturing it. Theme replacement and playback wait for the outgoing cover to decode and reach the compositor on every screen. A persistent layer holds that outgoing wallpaper until OWE hides the shell background and reports a ready video with an advancing playback position and no outgoing still transition, then crossfades it over 420 ms while applying the new bar palette. The shell retains its background service and decoded wallpaper while it is hidden, so handing the still back does not recreate them. App retints wait until the opening crossfade completes, with a three-second bound, so they do not compete with its first frames. The compositor reload waits until playback finishes because reloading Hyprland pauses video presentation. A superseded intro skips that reload. Failed playback releases the pending palette, and a newer theme invalidates the older handoff. Refreshing a theme, headless setup, and cycling backgrounds within the same theme do not trigger intros. Toggle > Animations suppresses playback along with the other animations. The `omarchy theme bg intro toggle` command can toggle intros separately. A theme installed from a git repo is held to a much shorter list; see [What an installed theme may not ship](#what-an-installed-theme-may-not-ship). @@ -198,7 +197,7 @@ The filename decides the target section, so the `[lock]` header is optional. The running shell reads `shell.toml` into two QML singletons: -- `Color` for palette and surface roles like `Color.menu.border`. +- `Color` for palette and surface roles like `Commons.Color.menu.border`. - `Style` for controls, spacing, font scale, corner radius, and bar sizing. ### Borders @@ -327,11 +326,12 @@ Plugin and shell QML should use `BorderSurface` for theme-aware borders: ```qml import qs.Commons +import qs.Commons as Commons import qs.Ui BorderSurface { - color: Color.popups.background - borderSpec: Border.surfaceSpec("popups", "border", Color.popups.border, 2) + color: Commons.Color.popups.background + borderSpec: Border.surfaceSpec("popups", "border", Commons.Color.popups.border, 2) padding: Style.spacing.popupPadding Item { @@ -348,7 +348,7 @@ Use `Border.surfaceSpec(section, token, fallbackColor, fallbackWidth, alphaKey)` for shell theme tokens (the optional `alphaKey` names the alpha token, e.g. `"border-alpha"`), `Border.controlSpec(state, foreground, accent, urgent)` for shared controls, and `Border.flat(color, width)` for a deliberate local border -that should not be overridden by the active theme. `Color.
.border` is the +that should not be overridden by the active theme. `Commons.Color.
.border` is the flat first-stop color for consumers that cannot render full border specs. ## Hyprland templates diff --git a/etc/systemd/user/wayland-wm@hyprland.desktop.service.d/10-keep-shell.conf b/etc/systemd/user/wayland-wm@hyprland.desktop.service.d/10-keep-shell.conf new file mode 100644 index 00000000..168b92f7 --- /dev/null +++ b/etc/systemd/user/wayland-wm@hyprland.desktop.service.d/10-keep-shell.conf @@ -0,0 +1,4 @@ +[Service] +# Let start-hyprland stop the compositor before killing its shell clients. +# Sending SIGTERM to the whole group removes the wallpaper during teardown. +KillMode=mixed diff --git a/install/omarchy-base.packages b/install/omarchy-base.packages index fc991a09..76912aff 100644 --- a/install/omarchy-base.packages +++ b/install/omarchy-base.packages @@ -131,6 +131,7 @@ sddm slurp socat starship +superwhisper-bin sushi system-config-printer tesseract diff --git a/install/user/first-run/install-voxtype.hook b/install/user/first-run/install-voxtype.hook deleted file mode 100644 index 476e4aad..00000000 --- a/install/user/first-run/install-voxtype.hook +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/bash - -set -e - -if omarchy-done ensure voxtype-install-invitation; then - omarchy-notification-send -u critical -g  "$(omarchy-text "Install Dictation with Voxtype")" \ - "$(omarchy-text "Click to install voice dictation for Omarchy.")" \ - --exec omarchy-launch-floating-terminal-with-presentation omarchy-voxtype-install -fi diff --git a/install/user/mise-work.sh b/install/user/mise-work.sh index 75ca0916..17479d0f 100644 --- a/install/user/mise-work.sh +++ b/install/user/mise-work.sh @@ -12,7 +12,12 @@ case ${OMARCHY_SETUP_CONTEXT:-runtime} in esac if [[ -n $NODE_PACKAGE_DIR ]]; then - NODE_TARBALL=$(find "$NODE_PACKAGE_DIR" -name "node-v*-linux-x64.tar.gz" -type f 2>/dev/null | head -n1) + # The ISO bundles the tarball for its own architecture. + case $(uname -m) in + aarch64) NODE_PLATFORM=linux-arm64 ;; + *) NODE_PLATFORM=linux-x64 ;; + esac + NODE_TARBALL=$(find "$NODE_PACKAGE_DIR" -name "node-v*-$NODE_PLATFORM.tar.gz" -type f 2>/dev/null | head -n1) if [[ -z $NODE_TARBALL ]]; then if [[ ${OMARCHY_SETUP_CONTEXT:-} == "provision-owner" ]]; then # A factory snapshot predating the bundled tarball may not have it staged. @@ -24,7 +29,7 @@ if [[ -n $NODE_PACKAGE_DIR ]]; then exit 1 fi else - NODE_VERSION=$(basename "$NODE_TARBALL" | sed 's/node-v\(.*\)-linux-x64.tar.gz/\1/') + NODE_VERSION=$(basename "$NODE_TARBALL" | sed "s/node-v\(.*\)-$NODE_PLATFORM.tar.gz/\1/") NODE_INSTALL_DIR="$HOME/.local/share/mise/installs/node/$NODE_VERSION" mkdir -p "$NODE_INSTALL_DIR" diff --git a/manual/07-hotkeys.md b/manual/07-hotkeys.md index 5431c8fc..1373d928 100644 --- a/manual/07-hotkeys.md +++ b/manual/07-hotkeys.md @@ -150,8 +150,8 @@ Usually on Linux, you need `Ctrl + Shift + C/V` to copy'n'paste in the terminal | `Super + Alt + ]` | Make webcam overlay larger while recording | | `Alt + Shift + L` | Copy current URL from webapp or Chromium | | `Alt + Shift + D` | Download the video on the current page to `~/Videos` | -| `Super + Ctrl + X` | Start/stop dictation (requires _Install > AI > Dictation_) | -| `F9` | Push-to-talk dictation (requires _Install > AI > Dictation_) | +| `Super + Ctrl + X` | Start/stop dictation (requires _Setup > Defaults > Dictation_) | +| `Right Alt` / `F9` | Push-to-talk dictation (requires Voxtype or Superwhisper) | With screenrecordings, the hotkey first asks which audio you want, then starts recording. Hit it again to stop. See [screenshots and recording](12-screenshots-recording.md) for the details. diff --git a/manual/11-text-extraction-dictation.md b/manual/11-text-extraction-dictation.md index 2d222a67..76d665ab 100644 --- a/manual/11-text-extraction-dictation.md +++ b/manual/11-text-extraction-dictation.md @@ -10,6 +10,14 @@ This is very helpful for grabbing addresses out of image footers or phone number ### Dictation -Omarchy offers AI dictation via [Voxtype](https://voxtype.io/). You install it via _Install > AI > Dictation_ through the Omarchy menu. By default, it'll load a base English model that takes up 150MB. But you can tweak which model you'd like to use by running `voxtype setup model` in the terminal. And you can tweak all the settings via `~/.config/voxtype/config.toml`. +On fresh installs, [Superwhisper](https://superwhisper.com/) is the default dictation backend, using cloud processing without offline model downloads. Choose Superwhisper or [Voxtype](https://voxtype.io/) through _Setup > Defaults > Dictation_ in the Omarchy menu. Selecting a backend installs it if needed and makes it the default for dictation. Voxtype loads a base English model that takes up 150MB; change the model with `voxtype setup model` and other settings through `~/.config/voxtype/config.toml`. Superwhisper opens its settings to choose cloud or local processing, and OPR keeps the application updated. -Once installed, you dictate by holding down `F9` or by toggling with `Super + Ctrl + X`, and the dictated text will appear in the focused input area. +You dictate by holding down `Right Alt` or `F9`, or by toggling with `Super + Ctrl + X`, and the dictated text will appear in the focused input area. These shortcuts use the selected backend. Right Alt also starts dictation when used in a modifier chord; use Left Alt for those chords. On keyboard layouts where Right Alt is AltGr, use `F9` to keep AltGr available for typing. + +The same shortcuts work with either backend. Select your backend through Setup > Defaults > Dictation. The backend installer configures it and saves the selection. Run `omarchy default dictation` to see which one is selected. The selection is saved in `~/.config/omarchy/defaults/dictation`. Fresh installs select Superwhisper automatically. + +Omarchy loads the selected backend's desktop integration automatically. No Superwhisper configuration is needed in `~/.config/hypr/`. + +Custom bindings and scripts can use `omarchy dictation start`, `omarchy dictation stop`, and `omarchy dictation toggle`. The selected backend handles the recording and transcription. Selecting Superwhisper disables its native hold shortcut and sets its native toggle to `Alt + Space`, keeping it separate from Omarchy's recording keys. Superwhisper requires at least one native recording shortcut, and its cancellation shortcut stays available. + +Additional backends can integrate without changing Omarchy. Provide an executable named `omarchy-dictation-` on PATH that accepts `start`, `stop`, and `toggle` and returns promptly, with a nonzero status on failure. Its installer configures the provider and calls `omarchy-dictation-use ` after successful setup. This stops the previous backend and saves the selection atomically; `stop` must succeed when the provider is idle or unavailable. Backend names use lowercase letters, digits, and hyphens. Provide `omarchy-install-dictation-` for setup; `omarchy default dictation ` launches it. Add a Defaults menu entry through an Omarchy menu extension to call that selector. Optional desktop integration can live in `${XDG_CONFIG_HOME:-$HOME/.config}//shortcuts.lua`. diff --git a/manual/39-backgrounds.md b/manual/39-backgrounds.md index 3bc1f77b..857f5cb4 100644 --- a/manual/39-backgrounds.md +++ b/manual/39-backgrounds.md @@ -6,6 +6,10 @@ You can do this most easily by going to _Install > Style > Background_ in the Om Backgrounds can be videos as well as stills. Drop an `mp4`, `m4v`, `mov`, `webm`, `mkv`, or `avi` file in the same folder and it appears alongside the images. Videos are played by the OWE wallpaper engine. It decodes the video once for all monitors and plays its sound through the default audio output, and it stops playback whenever nothing can see it. The lock screen draws the same decode, muted, through OWE. A video wallpaper still costs far more power than a still one. +Themes can include a short intro for a still background. For `backgrounds/road.webp`, the matching video lives at `backgrounds/intros/road.mp4`. It plays automatically at boot and when switching to a theme with that background selected, then returns to the still. Intro videos stay out of the background picker and never loop. + +Turning off _Toggle > Animations_ also turns off background intros. Omarchy does this by default in a virtual machine. The `omarchy theme bg intro toggle` command can toggle intros separately. Refreshing a theme or choosing another background within the same theme does not play an intro. + You can find a huge collection of cool curated backgrounds on https://github.com/dharmx/walls. Video backgrounds keep a cached still on the lock screen while playback is paused or unavailable. Animated GIFs play on the desktop and show a still frame on the lock screen. diff --git a/migrations/1788281348.sh b/migrations/1788281348.sh new file mode 100644 index 00000000..0b588712 --- /dev/null +++ b/migrations/1788281348.sh @@ -0,0 +1,26 @@ +echo "Re-stage the active theme with packaged intro videos" + +theme_name_path="$HOME/.local/state/omarchy/current/theme.name" + +[[ -s $theme_name_path ]] || exit 0 + +theme_name=$(<"$theme_name_path") +[[ $theme_name =~ ^[[:alnum:]_][[:alnum:].+_-]*$ ]] || exit 0 +[[ -d $OMARCHY_PATH/themes/$theme_name/backgrounds/intros ]] || exit 0 + +omarchy-theme-refresh + +state_dir="$HOME/.local/state/omarchy" +marker="$state_dir/background-intro.session-id" +session_id=${OMARCHY_SESSION_ID:-${HYPRLAND_INSTANCE_SIGNATURE:-}} +if [[ -z $session_id ]]; then + hypr_dir=$(find "${XDG_RUNTIME_DIR:-/run/user/$UID}/hypr" -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\n' 2>/dev/null | sort -n | tail -n 1 | cut -d' ' -f2- || true) + session_id=${hypr_dir##*/} +fi +[[ -n $session_id ]] || exit 0 +mkdir -p "$state_dir" +marker_staged=$(mktemp "$state_dir/.background-intro.session-id.XXXXXX") +trap 'rm -f "$marker_staged"' EXIT +printf '%s\n' "$session_id" >"$marker_staged" +mv -f "$marker_staged" "$marker" +trap - EXIT diff --git a/migrations/1791376887.sh b/migrations/1791376887.sh new file mode 100644 index 00000000..6f666a7d --- /dev/null +++ b/migrations/1791376887.sh @@ -0,0 +1,15 @@ +echo "Enable overlay scrollbars in Chromium-based browsers" + +for flags_file in "$HOME"/.config/{chromium,chrome,microsoft-edge-stable,brave,brave-origin}-flags.conf; do + [[ -f $flags_file ]] || continue + grep -Eq -- '^--enable-features=([^,]+,)*OverlayScrollbar([,:]|$)' "$flags_file" && continue + + if grep -q -- '^--enable-features=' "$flags_file"; then + sed -i --follow-symlinks \ + -e '/^--enable-features=/ s/$/,OverlayScrollbar/' \ + -e '/^--enable-features=/ s/=,/=/' "$flags_file" + else + [[ -n $(tail -c1 "$flags_file") ]] && echo >>"$flags_file" + echo '--enable-features=OverlayScrollbar' >>"$flags_file" + fi +done diff --git a/migrations/1791403252.sh b/migrations/1791403252.sh new file mode 100644 index 00000000..c87f3a11 --- /dev/null +++ b/migrations/1791403252.sh @@ -0,0 +1,28 @@ +echo "Prioritize the Omarchy package repository over Arch repositories" + +# Move the existing section rather than replacing pacman.conf so channel URLs, +# custom repositories, and administrator options survive the migration. +pacman_config=$(mktemp) +trap 'rm -f "$pacman_config"' EXIT + +awk ' + /^[[:space:]]*\[[^]]+\]/ { + section = $0 + sub(/^[[:space:]]*\[/, "", section) + sub(/\].*$/, "", section) + if (section != "options") repositories = 1 + } + !repositories { prefix = prefix $0 "\n"; next } + section == "omarchy" { omarchy = omarchy $0 "\n"; next } + { others = others $0 "\n" } + END { printf "%s%s%s", prefix, omarchy, others } +' /etc/pacman.conf >"$pacman_config" + +if ! cmp -s /etc/pacman.conf "$pacman_config"; then + sudo cp -a /etc/pacman.conf /etc/pacman.conf.bak + sudo install -m 644 -o root -g root "$pacman_config" /etc/pacman.conf +fi + +# Refresh databases and upgrade against the new repository priority. Run this +# even when the config is already ordered so a failed upgrade can be retried. +omarchy-update-system-pkgs diff --git a/migrations/1791479273.sh b/migrations/1791479273.sh new file mode 100644 index 00000000..c2f38e9f --- /dev/null +++ b/migrations/1791479273.sh @@ -0,0 +1,9 @@ +echo "Preserve Voxtype as the default for existing dictation users" + +backend_config="${XDG_CONFIG_HOME:-$HOME/.config}/omarchy/defaults/dictation" +if [[ ! -e $backend_config ]] && omarchy-pkg-present voxtype-bin; then + mkdir -p "${backend_config%/*}" + temporary=$(mktemp "$backend_config.XXXXXX") + printf '%s\n' voxtype > "$temporary" + mv -f "$temporary" "$backend_config" +fi diff --git a/shell/Commons/Border.qml b/shell/Commons/Border.qml index 948aa782..b4e803c8 100644 --- a/shell/Commons/Border.qml +++ b/shell/Commons/Border.qml @@ -1,5 +1,6 @@ pragma Singleton import QtQuick +import qs.Commons as Commons import "BorderGeometry.js" as Geometry // Central border-spec factory for shell surfaces and controls. A spec carries @@ -22,7 +23,7 @@ QtObject { } function value(section, key) { - var v = Color.shellValues[section + "." + key] + var v = Commons.Color.shellValues[section + "." + key] return (v === undefined || v === null) ? "" : v } @@ -39,7 +40,7 @@ QtObject { var seen = {} while (s.match(/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/) && !seen[s]) { seen[s] = true - var next = Color.shellValues[s] + var next = Commons.Color.shellValues[s] if (next === undefined || next === null || String(next).length === 0) break s = String(next).replace(/^\s+|\s+$/g, "") } @@ -68,10 +69,10 @@ QtObject { var s = String(color || "").replace(/^\s+|\s+$/g, "") var role = s.toLowerCase() - if (role === "foreground" || role === "text") return cssColor(Color.foreground, a) - if (role === "accent") return cssColor(Color.accent, a) - if (role === "urgent") return cssColor(Color.urgent, a) - if (role === "background") return cssColor(Color.background, a) + if (role === "foreground" || role === "text") return cssColor(Commons.Color.foreground, a) + if (role === "accent") return cssColor(Commons.Color.accent, a) + if (role === "urgent") return cssColor(Commons.Color.urgent, a) + if (role === "background") return cssColor(Commons.Color.background, a) if (role === "transparent") return "transparent" return Geometry.canonicalColor(s, a) } diff --git a/shell/Commons/I18nZh.js b/shell/Commons/I18nZh.js index 30f45c40..c1685c31 100644 --- a/shell/Commons/I18nZh.js +++ b/shell/Commons/I18nZh.js @@ -396,7 +396,6 @@ var table = { "Pin": "固定", "Show": "显示", "Hide": "隐藏", - "Dictate": "语音输入", "Allow Notifications": "允许通知", "Silence Notifications": "静默通知", "Day Light": "日间光线", @@ -434,11 +433,9 @@ var table = { "Download failed": "下载失败", "Editing config file": "正在编辑配置文件", "Extended mode restored": "已恢复扩展模式", - "FIDO2 authfile needs attention": "FIDO2 认证文件需要处理", "Failed to start Windows VM": "无法启动 Windows 虚拟机", "Herdr theme sync disabled": "Herdr 主题同步已关闭", "Herdr theme sync enabled": "Herdr 主题同步已开启", - "Hold F9 to dictate (or toggle with Super + Ctrl + X).": "按住 F9 语音输入(或用 Super + Ctrl + X 切换)。", "Laptop display disabled": "笔记本屏幕已关闭", "Laptop display enabled": "笔记本屏幕已开启", "Logging out": "正在注销", @@ -514,8 +511,6 @@ var table = { "Enable sudo and unlocking with your fingerprint.": "用指纹进行 sudo 和解锁。", "Set your default agent": "设置默认 AI 代理", "Let your favorite agent help with Omarchy.": "让你喜欢的 AI 代理帮你使用 Omarchy。", - "Install Dictation with Voxtype": "安装 Voxtype 语音输入", - "Click to install voice dictation for Omarchy.": "点击为 Omarchy 安装语音输入。", "All %1 accounts are over %2%": "所有 %1 账户都超过 %2%", "Staying on %1. %2 resets in %3.": "继续使用 %1。%2 将在 %3 后重置。", "Staying on %1.": "继续使用 %1。", @@ -616,5 +611,10 @@ var table = { "Log out now to switch the language?": "立即注销以切换语言?", "Log back in to switch the language.": "重新登录后切换语言。", "Log out now to switch the developer mirrors?": "立即注销以切换开发工具镜像?", - "Log back in to switch the developer mirrors.": "重新登录后切换开发工具镜像。" + "Log back in to switch the developer mirrors.": "重新登录后切换开发工具镜像。", + "Superwhisper Dictation Ready": "Superwhisper 语音输入就绪", + "Hold Right Alt or F9 to dictate (or toggle with Super + Ctrl + X).": "按住右 Alt 或 F9 说话(或用 Super + Ctrl + X 切换)。", + "Background Intros": "背景开场动画", + "Enabled": "已开启", + "Disabled": "已关闭" } diff --git a/shell/Commons/Style.qml b/shell/Commons/Style.qml index 0582356c..87849c18 100644 --- a/shell/Commons/Style.qml +++ b/shell/Commons/Style.qml @@ -1,5 +1,6 @@ pragma Singleton import QtQuick +import qs.Commons as Commons import Quickshell import Quickshell.Hyprland import Quickshell.Io @@ -114,27 +115,27 @@ QtObject { } function resolveStateColor(token, foreground, accent, urgent, fallback) { - var fb = fallback || foreground || Color.foreground + var fb = fallback || foreground || Commons.Color.foreground var s = String(token || "").replace(/^\s+|\s+$/g, "") var role = s.toLowerCase() - if (role === "foreground" || role === "text") return foreground || Color.foreground - if (role === "accent") return accent || Color.accent - if (role === "urgent") return urgent || Color.urgent - if (role === "background") return Color.background + if (role === "foreground" || role === "text") return foreground || Commons.Color.foreground + if (role === "accent") return accent || Commons.Color.accent + if (role === "urgent") return urgent || Commons.Color.urgent + if (role === "background") return Commons.Color.background if (role === "transparent") return Qt.rgba(0, 0, 0, 0) return colorFromHex(s, fb) } function normalStateColor(foreground, accent, urgent) { - return resolveStateColor(normalColorToken, foreground, accent, urgent, foreground || Color.foreground) + return resolveStateColor(normalColorToken, foreground, accent, urgent, foreground || Commons.Color.foreground) } function hoverStateColor(foreground, accent, urgent) { - return resolveStateColor(hoverColorToken, foreground, accent, urgent, foreground || Color.foreground) + return resolveStateColor(hoverColorToken, foreground, accent, urgent, foreground || Commons.Color.foreground) } function selectedStateColor(foreground, accent, urgent) { - return resolveStateColor(selectedColorToken, foreground, accent, urgent, foreground || Color.foreground) + return resolveStateColor(selectedColorToken, foreground, accent, urgent, foreground || Commons.Color.foreground) } function pressedStateColor(foreground, accent, urgent) { @@ -149,7 +150,7 @@ QtObject { } function selectionStateColor(foreground, accent, urgent) { - return resolveStateColor(selectionColorToken, foreground, accent, urgent, foreground || Color.foreground) + return resolveStateColor(selectionColorToken, foreground, accent, urgent, foreground || Commons.Color.foreground) } function normalFillFor(foreground, accent, urgent) { return Util.alpha(normalStateColor(foreground, accent, urgent), normalFillAlpha) } @@ -187,17 +188,17 @@ QtObject { } // Convenience colors resolved against the foundational palette. - readonly property color normalFill: normalFillFor(Color.foreground, Color.accent, Color.urgent) - readonly property color hoverFill: hoverFillFor(Color.foreground, Color.accent, Color.urgent) - readonly property color selectedFill: selectedFillFor(Color.foreground, Color.accent, Color.urgent) - readonly property color pressedFill: pressedFillFor(Color.foreground, Color.accent, Color.urgent) - readonly property color focusFillColor: focusFillFor(Color.foreground, Color.accent, Color.urgent) - readonly property color normalBorderColor: normalBorderFor(Color.foreground, Color.accent, Color.urgent) - readonly property color hoverBorderColor: hoverBorderFor(Color.foreground, Color.accent, Color.urgent) - readonly property color selectedBorderColor: selectedBorderFor(Color.foreground, Color.accent, Color.urgent) - readonly property color focusBorderColor: focusBorderFor(Color.foreground, Color.accent, Color.urgent) - readonly property color selectedAccentFill: Util.alpha(Color.accent, selectedFillAlpha) - readonly property color selectionFill: selectionFillFor(Color.foreground, Color.accent, Color.urgent) + readonly property color normalFill: normalFillFor(Commons.Color.foreground, Commons.Color.accent, Commons.Color.urgent) + readonly property color hoverFill: hoverFillFor(Commons.Color.foreground, Commons.Color.accent, Commons.Color.urgent) + readonly property color selectedFill: selectedFillFor(Commons.Color.foreground, Commons.Color.accent, Commons.Color.urgent) + readonly property color pressedFill: pressedFillFor(Commons.Color.foreground, Commons.Color.accent, Commons.Color.urgent) + readonly property color focusFillColor: focusFillFor(Commons.Color.foreground, Commons.Color.accent, Commons.Color.urgent) + readonly property color normalBorderColor: normalBorderFor(Commons.Color.foreground, Commons.Color.accent, Commons.Color.urgent) + readonly property color hoverBorderColor: hoverBorderFor(Commons.Color.foreground, Commons.Color.accent, Commons.Color.urgent) + readonly property color selectedBorderColor: selectedBorderFor(Commons.Color.foreground, Commons.Color.accent, Commons.Color.urgent) + readonly property color focusBorderColor: focusBorderFor(Commons.Color.foreground, Commons.Color.accent, Commons.Color.urgent) + readonly property color selectedAccentFill: Util.alpha(Commons.Color.accent, selectedFillAlpha) + readonly property color selectionFill: selectionFillFor(Commons.Color.foreground, Commons.Color.accent, Commons.Color.urgent) // ---------------------------------------------------------- spacing // @@ -390,7 +391,7 @@ QtObject { } // Pull typography, bar dimensions, state tokens, and spacing out of the - // shell.toml dict that Color already parsed. Called by Color.loadShell so + // shell.toml dict that Color already parsed. Called by Commons.Color.loadShell so // a single parse pass feeds both singletons. function applyShellValues(values) { var fontOut = {} diff --git a/shell/Ui/Button.qml b/shell/Ui/Button.qml index 09c5806c..1f53dbec 100644 --- a/shell/Ui/Button.qml +++ b/shell/Ui/Button.qml @@ -1,6 +1,7 @@ import QtQuick import QtQuick.Controls import qs.Commons +import qs.Commons as Commons // The button. One component for every clickable thing in the kit. // States compose independently and are applied in priority order: @@ -32,9 +33,9 @@ BorderSurface { property bool bordered: false // Colors. Defaults track the theme; per-instance overrides are honored. - property color foreground: Color.foreground + property color foreground: Commons.Color.foreground property color background: "transparent" - property color accent: Color.accent + property color accent: Commons.Color.accent // Sizing. property string fontFamily: Style.font.family @@ -54,9 +55,9 @@ BorderSurface { // Tooltip palette. Auto-rendered if tooltipText is set. Defaults pull // from [tooltip] in shell.toml; override per-instance only when a button // intentionally wants a tooltip that diverges from the theme. - property color tooltipBackground: Color.tooltip.background - property color tooltipForeground: Color.tooltip.text - property color tooltipBorder: Color.tooltip.border + property color tooltipBackground: Commons.Color.tooltip.background + property color tooltipForeground: Commons.Color.tooltip.text + property color tooltipBorder: Commons.Color.tooltip.border signal clicked() signal rightClicked() @@ -77,7 +78,7 @@ BorderSurface { readonly property bool hot: mouseArea.containsMouse || hasCursor readonly property bool _showFocusRing: focusable && activeFocus readonly property color _selectedColor: Style.selectedStateColor(root.foreground, root.accent) - readonly property var _tooltipBorderSpec: Border.localOrSurfaceSpec("tooltip", "border", root.tooltipBorder, Color.tooltip.border, Math.max(1, Style.normalBorderWidth)) + readonly property var _tooltipBorderSpec: Border.localOrSurfaceSpec("tooltip", "border", root.tooltipBorder, Commons.Color.tooltip.border, Math.max(1, Style.normalBorderWidth)) readonly property var _focusBorderSpec: Border.controlSpec("focus", root.foreground, root.accent) readonly property var _hoverBorderSpec: Border.controlSpec("hover-cursor", root.foreground, root.accent) readonly property var _selectedBorderSpec: Border.controlSpec("selected", root.foreground, root.accent) diff --git a/shell/Ui/ButtonGroup.qml b/shell/Ui/ButtonGroup.qml index f4870dea..8657fe6a 100644 --- a/shell/Ui/ButtonGroup.qml +++ b/shell/Ui/ButtonGroup.qml @@ -1,5 +1,6 @@ import QtQuick import qs.Commons +import qs.Commons as Commons // Mutually-exclusive row of Buttons — the form-style "pick one of N" // pattern (bar position top/right/bottom/left, theme preset chips, etc.). @@ -25,9 +26,9 @@ Row { property var options: [] property string value: "" - property color foreground: Color.foreground - property color background: Color.background - property color accent: Color.accent + property color foreground: Commons.Color.foreground + property color background: Commons.Color.background + property color accent: Commons.Color.accent property string fontFamily: Style.font.family property real fontSize: Style.font.body property bool focusable: true diff --git a/shell/Ui/ConfirmDialog.qml b/shell/Ui/ConfirmDialog.qml index 30c9871c..08207623 100644 --- a/shell/Ui/ConfirmDialog.qml +++ b/shell/Ui/ConfirmDialog.qml @@ -1,5 +1,6 @@ import QtQuick import qs.Commons +import qs.Commons as Commons Item { id: root @@ -9,11 +10,11 @@ Item { property string cancelText: I18n.tr("Cancel") property string confirmText: I18n.tr("Confirm") property int selectedIndex: 1 - property color background: Color.background - property color foreground: Color.foreground - property color scrim: Util.alpha(Color.background, 0.7) - property color selectedBackground: Util.alpha(Color.foreground, 0.08) - property color selectedText: Color.accent + property color background: Commons.Color.background + property color foreground: Commons.Color.foreground + property color scrim: Util.alpha(Commons.Color.background, 0.7) + property color selectedBackground: Util.alpha(Commons.Color.foreground, 0.08) + property color selectedText: Commons.Color.accent property string fontFamily: Style.font.family property int cornerRadius: Style.cornerRadius @@ -98,10 +99,10 @@ Item { width: Style.space(88) height: Style.space(34) color: selected - ? (destructive ? Util.alpha(Color.urgent, 0.22) : root.selectedBackground) + ? (destructive ? Util.alpha(Commons.Color.urgent, 0.22) : root.selectedBackground) : "transparent" borderSpec: Border.flat(destructive - ? (selected ? Color.urgent : Util.alpha(Color.urgent, 0.56)) + ? (selected ? Commons.Color.urgent : Util.alpha(Commons.Color.urgent, 0.56)) : (selected ? root.selectedText : Util.alpha(root.foreground, 0.38)), Style.normalBorderWidth) radius: 0 @@ -109,7 +110,7 @@ Item { textFormat: Text.PlainText anchors.centerIn: parent text: modelData - color: destructive ? (selected ? Color.urgent : root.foreground) : (selected ? root.selectedText : root.foreground) + color: destructive ? (selected ? Commons.Color.urgent : root.foreground) : (selected ? root.selectedText : root.foreground) font.family: root.fontFamily font.pixelSize: Style.font.caption } diff --git a/shell/Ui/CursorSurface.qml b/shell/Ui/CursorSurface.qml index cb715df9..1563f982 100644 --- a/shell/Ui/CursorSurface.qml +++ b/shell/Ui/CursorSurface.qml @@ -1,5 +1,6 @@ import QtQuick import qs.Commons +import qs.Commons as Commons // Shared visual chrome for keyboard-and-mouse-navigable items inside a panel. // Contract: items must NOT read `containsMouse` for color/border. Mouse @@ -19,8 +20,8 @@ BorderSurface { property bool outline: false property bool bordered: false - property color foreground: Color.foreground - property color accent: Color.accent + property color foreground: Commons.Color.foreground + property color accent: Commons.Color.accent property color fill: Style.hoverFillFor(foreground, accent) property color currentFill: Style.selectedFillFor(foreground, accent) diff --git a/shell/Ui/Dropdown.qml b/shell/Ui/Dropdown.qml index 58386c9b..6d240928 100644 --- a/shell/Ui/Dropdown.qml +++ b/shell/Ui/Dropdown.qml @@ -1,10 +1,11 @@ import QtQuick import QtQuick.Controls import qs.Commons +import qs.Commons as Commons // Themed single-select dropdown. Trigger row paints with the kit's focus -// chrome; the popup anchors below and uses Color.popups.background + -// Color.popups.border so it reads as a panel surface rather than the +// chrome; the popup anchors below and uses Commons.Color.popups.background + +// Commons.Color.popups.border so it reads as a panel surface rather than the // platform-native ComboBox look. // // `options` accepts either a plain string[] or an array of @@ -22,11 +23,11 @@ Item { property string value: "" property var options: [] - property color foreground: Color.popups.text - property color background: Color.popups.background - property color popupBorder: Color.popups.border - property color accent: Color.accent - readonly property var popupBorderSpec: Border.localOrSurfaceSpec("popups", "border", popupBorder, Color.popups.border, Style.normalBorderWidth) + property color foreground: Commons.Color.popups.text + property color background: Commons.Color.popups.background + property color popupBorder: Commons.Color.popups.border + property color accent: Commons.Color.accent + readonly property var popupBorderSpec: Border.localOrSurfaceSpec("popups", "border", popupBorder, Commons.Color.popups.border, Style.normalBorderWidth) property string fontFamily: Style.font.family property int rowHeight: Style.spacing.controlHeight property int popupRowHeight: Style.spacing.popupRowHeight diff --git a/shell/Ui/KeyboardPanel.qml b/shell/Ui/KeyboardPanel.qml index 38a2979c..6f917e5b 100644 --- a/shell/Ui/KeyboardPanel.qml +++ b/shell/Ui/KeyboardPanel.qml @@ -2,6 +2,7 @@ import QtQuick import Quickshell import Quickshell.Wayland import qs.Commons +import qs.Commons as Commons // Layer-shell popup attached to a bar widget icon, designed for // click-driven AND keyboard-driven panels (e.g. SUPER+CTRL+W summon). @@ -44,7 +45,7 @@ PanelWindow { property int padding: Style.spacing.popupPadding property int contentWidth: Style.space(280) property int contentHeight: Style.space(200) - property var borderSpec: Border.surfaceSpec("popups", "border", Color.popups.border, Math.max(1, Style.space(2))) + property var borderSpec: Border.surfaceSpec("popups", "border", Commons.Color.popups.border, Math.max(1, Style.space(2))) property bool centerOnBar: false property bool open: false property int gap: Style.gapsOut // distance between bar edge and panel @@ -382,7 +383,7 @@ PanelWindow { y: root.cardOrigin.y width: root.contentWidth height: root.contentHeight - color: Color.popups.background + color: Commons.Color.popups.background borderSpec: root.borderSpec padding: root.padding radius: Style.cornerRadius diff --git a/shell/Ui/MultiSelect.qml b/shell/Ui/MultiSelect.qml index 4da69119..c73bbf90 100644 --- a/shell/Ui/MultiSelect.qml +++ b/shell/Ui/MultiSelect.qml @@ -3,6 +3,7 @@ import QtQuick.Controls as QQC import QtQuick.Window import Quickshell.Io import qs.Commons +import qs.Commons as Commons // Searchable multi-select dropdown. Trigger shape matches Dropdown / // SearchableDropdown; the popup shows a search field, an optional refresh @@ -33,11 +34,11 @@ Item { property string triggerLabel: "" property bool showLabel: true - property color foreground: Color.popups.text - property color background: Color.popups.background - property color popupBorder: Color.popups.border - property color accent: Color.accent - readonly property var popupBorderSpec: Border.localOrSurfaceSpec("popups", "border", popupBorder, Color.popups.border, Style.normalBorderWidth) + property color foreground: Commons.Color.popups.text + property color background: Commons.Color.popups.background + property color popupBorder: Commons.Color.popups.border + property color accent: Commons.Color.accent + readonly property var popupBorderSpec: Border.localOrSurfaceSpec("popups", "border", popupBorder, Commons.Color.popups.border, Style.normalBorderWidth) property string fontFamily: Style.font.family property int rowHeight: Style.spacing.controlHeight property int popupRowHeight: Style.spacing.popupRowHeight diff --git a/shell/Ui/NumberField.qml b/shell/Ui/NumberField.qml index 985c9f7e..57a19c2c 100644 --- a/shell/Ui/NumberField.qml +++ b/shell/Ui/NumberField.qml @@ -1,6 +1,7 @@ import QtQuick import QtQuick.Controls as QQC import qs.Commons +import qs.Commons as Commons Column { id: root @@ -10,8 +11,8 @@ Column { property int from: 0 property int to: 100 property int stepSize: 1 - property color foreground: Color.foreground - property color accent: Color.accent + property color foreground: Commons.Color.foreground + property color accent: Commons.Color.accent property string fontFamily: Style.font.family property real fontSize: Style.font.body property real fieldWidth: Style.spacing.numberFieldWidth diff --git a/shell/Ui/OpticalGlyph.qml b/shell/Ui/OpticalGlyph.qml index 201c2d01..d3751c51 100644 --- a/shell/Ui/OpticalGlyph.qml +++ b/shell/Ui/OpticalGlyph.qml @@ -1,5 +1,6 @@ import QtQuick import qs.Commons +import qs.Commons as Commons Item { id: root @@ -7,7 +8,7 @@ Item { property string text: "" property string fontFamily: Style.font.family property real fontSize: Style.font.body - property color color: Color.foreground + property color color: Commons.Color.foreground property bool debugBounds: false readonly property int renderedFontSize: Math.max(1, Math.round(fontSize)) diff --git a/shell/Ui/OverlayWindow.qml b/shell/Ui/OverlayWindow.qml index 43a9c32c..bbca91e6 100644 --- a/shell/Ui/OverlayWindow.qml +++ b/shell/Ui/OverlayWindow.qml @@ -3,12 +3,9 @@ import Quickshell import Quickshell.Hyprland import Quickshell.Wayland -// A fullscreen overlay whose surface outlives each open. A fresh surface draws -// its first frames before the compositor sends its fractional scale, so an -// overlay mapped per open flashed blurry until the scale arrived. Closed, the -// surface parks as a 1x1, input-less, content-less layer below windows: small -// enough to cost nothing, off the overlay layer so it never blocks direct -// scanout for fullscreen apps. Opening only resizes and raises it. +// Map a fresh fullscreen surface on each open. Keep its first frames +// transparent until Qt receives the output's scale, so it opens sharp without +// retaining a parked surface that can be closed when a monitor disconnects. PanelWindow { id: window @@ -17,11 +14,31 @@ PanelWindow { property int shownLayer: WlrLayer.Overlay property int shownKeyboardFocus: WlrKeyboardFocus.Exclusive - // The surface no longer lands on the focused output by being mapped there, - // so it follows the focused monitor each time it is shown. Unset until the - // first show lets the compositor choose. property var targetScreen: null - property Region emptyRegion: Region {} + readonly property var targetMonitor: { + var monitors = Hyprland.monitors.values + for (var i = 0; i < monitors.length; i++) { + if (targetScreen && monitors[i].name === targetScreen.name) return monitors[i] + } + return null + } + + // Wait for fullscreen geometry as well as scale so no smaller opening frame + // is stretched across the output. Fractional scale uses units of 1/120; + // compare in those units to tolerate floating point rounding. + readonly property bool contentReady: shown && backingWindowVisible && !!targetScreen + && width === targetScreen.width && height === targetScreen.height + && !!targetMonitor && Math.round(devicePixelRatio * 120) === Math.round(targetMonitor.scale * 120) + property bool contentRevealed: false + + onContentReadyChanged: { + contentRevealed = false + // Resize and scale notifications arrive while Qt is still updating the + // window. Reveal on the next turn, once its content layout has caught up. + if (contentReady) Qt.callLater(function() { + if (window.contentReady) window.contentRevealed = true + }) + } function focusedScreen() { var monitor = Hyprland.focusedMonitor @@ -29,28 +46,44 @@ PanelWindow { for (var i = 0; i < Quickshell.screens.length; i++) { if (Quickshell.screens[i].name === name) return Quickshell.screens[i] } - return null + return Quickshell.screens.length > 0 ? Quickshell.screens[0] : null } - onShownChanged: if (shown) targetScreen = focusedScreen() || targetScreen + // Live monitor scaling does not emit a Hyprland monitor event, so its cached + // scale can lag behind Qt. Refresh on opening and on scale changes while open. + onShownChanged: { + targetScreen = shown ? focusedScreen() : null + if (shown) Hyprland.refreshMonitors() + } + onDevicePixelRatioChanged: if (shown) Hyprland.refreshMonitors() - visible: true + Connections { + target: Quickshell + function onScreensChanged() { + if (window.shown && Quickshell.screens.indexOf(window.targetScreen) < 0) { + window.targetScreen = null + // Let the removed output finish closing its old window before mapping + // a replacement, rather than recreating it during Qt's teardown. + Qt.callLater(function() { + if (window.shown && !window.targetScreen) window.targetScreen = window.focusedScreen() + }) + } + } + } + + visible: shown && !!targetScreen && !!targetMonitor screen: targetScreen - anchors { top: true; left: true; bottom: shown; right: shown } - implicitWidth: 1 - implicitHeight: 1 - mask: shown ? null : emptyRegion + anchors { top: true; left: true; bottom: true; right: true } color: "transparent" exclusionMode: ExclusionMode.Ignore - WlrLayershell.layer: shown ? shownLayer : WlrLayer.Bottom - WlrLayershell.keyboardFocus: shown ? shownKeyboardFocus : WlrKeyboardFocus.None + WlrLayershell.layer: shownLayer + WlrLayershell.keyboardFocus: shownKeyboardFocus - // Draw nothing until the surface has actually grown. A frame drawn while it - // is still 1x1 holds only the scrim's color, which the compositor would - // stretch across the whole screen until the fullscreen frame arrives. + // Opacity preserves keyboard handling while the scale arrives, including + // search keystrokes and Escape pressed immediately after opening. Binding { target: window.contentItem - property: "visible" - value: window.shown && window.width > 1 && window.height > 1 + property: "opacity" + value: window.contentRevealed ? 1 : 0 } } diff --git a/shell/Ui/Panel.qml b/shell/Ui/Panel.qml index 371d2c6e..2f8c31ab 100644 --- a/shell/Ui/Panel.qml +++ b/shell/Ui/Panel.qml @@ -1,6 +1,7 @@ import QtQuick import Quickshell.Io import qs.Commons +import qs.Commons as Commons // Base item for plugin popup widgets. Many first-party plugins expose a bar // button plus a popup from one QML entry point; this base owns the shared @@ -19,7 +20,7 @@ Item { property bool popoutSwitchClosing: false readonly property bool opened: panelController.open - readonly property color barForeground: bar ? bar.barForeground : Color.foreground + readonly property color barForeground: bar ? bar.barForeground : Commons.Color.foreground function open() { panelController.show() } function close() { panelController.hide() } diff --git a/shell/Ui/PanelActionButton.qml b/shell/Ui/PanelActionButton.qml index d4115e2b..d93d1be5 100644 --- a/shell/Ui/PanelActionButton.qml +++ b/shell/Ui/PanelActionButton.qml @@ -1,5 +1,6 @@ import QtQuick import qs.Commons +import qs.Commons as Commons // Small (22×22 by default) icon button used at the right edge of panel rows // for inline actions — forget network, confirm passphrase, unpair device, @@ -29,7 +30,7 @@ BorderSurface { property string iconText: "" property string tooltipText: "" - property color foreground: Color.foreground + property color foreground: Commons.Color.foreground property color hoverColor: foreground property string fontFamily: Style.font.family property real fontSize: Style.font.icon @@ -57,7 +58,7 @@ BorderSurface { ? Border.controlSpec("focus", hoverColor, hoverColor) : (_hot && bordered ? Border.controlSpec("hover-cursor", hoverColor, hoverColor) - : (bordered ? Border.controlSpec("normal", foreground, Color.accent) : Border.none())) + : (bordered ? Border.controlSpec("normal", foreground, Commons.Color.accent) : Border.none())) color: _showFocusRing ? Style.focusFillFor(hoverColor, hoverColor) diff --git a/shell/Ui/PanelHero.qml b/shell/Ui/PanelHero.qml index 4d13cf14..1458ed78 100644 --- a/shell/Ui/PanelHero.qml +++ b/shell/Ui/PanelHero.qml @@ -1,5 +1,6 @@ import QtQuick import qs.Commons +import qs.Commons as Commons Item { id: root @@ -8,7 +9,7 @@ Item { property string title: "" property string meta: "" property string detail: "" - property color foreground: Color.foreground + property color foreground: Commons.Color.foreground property string fontFamily: Style.font.family property real iconSize: Style.font.display property real iconOpacity: 1.0 @@ -71,7 +72,7 @@ Item { implicitHeight: detailText.implicitHeight + Style.space(4) anchors.verticalCenter: parent.verticalCenter color: "transparent" - borderSpec: Border.controlSpec("normal", root.foreground, Color.accent) + borderSpec: Border.controlSpec("normal", root.foreground, Commons.Color.accent) radius: Style.cornerRadius Text { diff --git a/shell/Ui/PanelSectionHeader.qml b/shell/Ui/PanelSectionHeader.qml index f0d54fb9..7c2096ee 100644 --- a/shell/Ui/PanelSectionHeader.qml +++ b/shell/Ui/PanelSectionHeader.qml @@ -1,5 +1,6 @@ import QtQuick import qs.Commons +import qs.Commons as Commons // Small-caps-style label that introduces a panel section ("DNS provider", // "Wi-Fi networks", "Output device", "Paired devices"). Sits between a @@ -7,7 +8,7 @@ import qs.Commons Text { id: root - property color foreground: Color.foreground + property color foreground: Commons.Color.foreground property string fontFamily: Style.font.family property real fontSize: Style.font.caption diff --git a/shell/Ui/PanelSeparator.qml b/shell/Ui/PanelSeparator.qml index 27a81ec3..887aa571 100644 --- a/shell/Ui/PanelSeparator.qml +++ b/shell/Ui/PanelSeparator.qml @@ -1,5 +1,6 @@ import QtQuick import qs.Commons +import qs.Commons as Commons // 1px horizontal divider for panel sections. The alpha-on-foreground tint // keeps the rule legible against the panel background without competing @@ -7,7 +8,7 @@ import qs.Commons Rectangle { id: root - property color foreground: Color.foreground + property color foreground: Commons.Color.foreground property real strength: 0.12 width: parent ? parent.width : implicitWidth diff --git a/shell/Ui/PanelSlider.qml b/shell/Ui/PanelSlider.qml index 64396713..2dd85a33 100644 --- a/shell/Ui/PanelSlider.qml +++ b/shell/Ui/PanelSlider.qml @@ -1,5 +1,6 @@ import QtQuick import qs.Commons +import qs.Commons as Commons Item { id: root @@ -10,9 +11,9 @@ Item { property real maximum: 1 property real step: 0.05 property bool integer: false - property color trackColor: bar ? Style.selectedFillFor(bar.foreground, Color.accent) : "#333" - property color fillColor: bar ? bar.foreground : Color.foreground - property color knobColor: bar ? bar.foreground : Color.foreground + property color trackColor: bar ? Style.selectedFillFor(bar.foreground, Commons.Color.accent) : "#333" + property color fillColor: bar ? bar.foreground : Commons.Color.foreground + property color knobColor: bar ? bar.foreground : Commons.Color.foreground property bool dragging: false property real trackHeight: Math.max(4, Math.round(Style.spacing.controlHeight * 0.11)) property real knobSize: Math.max(14, Math.round(Style.spacing.controlHeight * 0.38)) @@ -23,7 +24,7 @@ Item { // crossing the track shows). Purely visual — snapping is the caller's job via // `integer`/`step` or an index-based value. Default 0 leaves the track plain. property int tickCount: 0 - property color tickColor: bar ? bar.background : Color.background + property color tickColor: bar ? bar.background : Commons.Color.background onValueChanged: if (!dragging) liveValue = value diff --git a/shell/Ui/PanelToolTip.qml b/shell/Ui/PanelToolTip.qml index 90d3133a..06e2ac53 100644 --- a/shell/Ui/PanelToolTip.qml +++ b/shell/Ui/PanelToolTip.qml @@ -1,6 +1,7 @@ import QtQuick import QtQuick.Controls import qs.Commons +import qs.Commons as Commons // Styled wrapper around Qt Quick Controls ToolTip. Drop-in: declare inside // the hovered item and bind `visible` to the hover state, e.g. @@ -9,7 +10,7 @@ import qs.Commons // text: "Forget network" // } // -// Defaults pull from [tooltip] in shell.toml via Color.tooltip.*. Override +// Defaults pull from [tooltip] in shell.toml via Commons.Color.tooltip.*. Override // the panel* properties per-instance only when you need a tooltip that // intentionally diverges from the theme. // @@ -18,13 +19,13 @@ import qs.Commons ToolTip { id: root - property color panelForeground: Color.tooltip.text - property color panelBackground: Color.tooltip.background - property color panelBorder: Color.tooltip.border + property color panelForeground: Commons.Color.tooltip.text + property color panelBackground: Commons.Color.tooltip.background + property color panelBorder: Commons.Color.tooltip.border property string fontFamily: Style.font.family property real fontSize: Style.font.bodySmall - readonly property var panelBorderSpec: Border.localOrSurfaceSpec("tooltip", "border", panelBorder, Color.tooltip.border, Style.normalBorderWidth) + readonly property var panelBorderSpec: Border.localOrSurfaceSpec("tooltip", "border", panelBorder, Commons.Color.tooltip.border, Style.normalBorderWidth) delay: 400 padding: 0 diff --git a/shell/Ui/PopupCard.qml b/shell/Ui/PopupCard.qml index 3bc8193a..6551237d 100644 --- a/shell/Ui/PopupCard.qml +++ b/shell/Ui/PopupCard.qml @@ -2,6 +2,7 @@ import QtQuick import Quickshell import Quickshell.Hyprland import qs.Commons +import qs.Commons as Commons PopupWindow { id: root @@ -13,8 +14,8 @@ PopupWindow { property int padding: Style.spacing.popupPadding property int contentWidth: Style.space(280) property int contentHeight: Style.space(200) - property color borderColor: Color.popups.border - property var borderSpec: Border.localOrSurfaceSpec("popups", "border", borderColor, Color.popups.border, Math.max(1, Style.space(2))) + property color borderColor: Commons.Color.popups.border + property var borderSpec: Border.localOrSurfaceSpec("popups", "border", borderColor, Commons.Color.popups.border, Math.max(1, Style.space(2))) property bool open: false property bool centerOnBar: false // "click" — uses HyprlandFocusGrab so clicking outside dismisses the popup. @@ -150,7 +151,7 @@ PopupWindow { BorderSurface { id: card anchors.fill: parent - color: Color.popups.background + color: Commons.Color.popups.background borderSpec: root.borderSpec padding: root.padding radius: Style.cornerRadius diff --git a/shell/Ui/SearchableDropdown.qml b/shell/Ui/SearchableDropdown.qml index 0f221ee2..339d2206 100644 --- a/shell/Ui/SearchableDropdown.qml +++ b/shell/Ui/SearchableDropdown.qml @@ -1,6 +1,7 @@ import QtQuick import QtQuick.Controls as QQC import qs.Commons +import qs.Commons as Commons // Searchable single-select dropdown. Same trigger shape as Dropdown, but // the popup leads with an embedded TextField that filters the option @@ -25,11 +26,11 @@ Item { property string emptyText: I18n.tr("No matches") property string triggerLabel: "" - property color foreground: Color.popups.text - property color background: Color.popups.background - property color popupBorder: Color.popups.border - property color accent: Color.accent - readonly property var popupBorderSpec: Border.localOrSurfaceSpec("popups", "border", popupBorder, Color.popups.border, Style.normalBorderWidth) + property color foreground: Commons.Color.popups.text + property color background: Commons.Color.popups.background + property color popupBorder: Commons.Color.popups.border + property color accent: Commons.Color.accent + readonly property var popupBorderSpec: Border.localOrSurfaceSpec("popups", "border", popupBorder, Commons.Color.popups.border, Style.normalBorderWidth) property string fontFamily: Style.font.family property int rowHeight: Style.spacing.controlHeight property int popupRowHeight: Style.spacing.popupRowHeight diff --git a/shell/Ui/SpeedTestOverlay.qml b/shell/Ui/SpeedTestOverlay.qml index 7697212b..827c73aa 100644 --- a/shell/Ui/SpeedTestOverlay.qml +++ b/shell/Ui/SpeedTestOverlay.qml @@ -4,6 +4,7 @@ import QtQuick.Shapes import Quickshell import Quickshell.Wayland import qs.Commons +import qs.Commons as Commons import qs.Ui // Centered speed test overlay shared by the network and disk speed tests. No @@ -288,7 +289,7 @@ PanelWindow { // round caps would leave a stray dot at the foot of the scale. ShapePath { strokeWidth: dial.arcWidth * 3 - strokeColor: dial.arcVisible ? Qt.rgba(Color.accent.r, Color.accent.g, Color.accent.b, 0.18) : "transparent" + strokeColor: dial.arcVisible ? Qt.rgba(Commons.Color.accent.r, Commons.Color.accent.g, Commons.Color.accent.b, 0.18) : "transparent" fillColor: "transparent" capStyle: ShapePath.RoundCap @@ -305,7 +306,7 @@ PanelWindow { // Value: fills behind the needle. ShapePath { strokeWidth: dial.arcWidth - strokeColor: dial.arcVisible ? Color.accent : "transparent" + strokeColor: dial.arcVisible ? Commons.Color.accent : "transparent" fillColor: "transparent" capStyle: ShapePath.RoundCap @@ -356,8 +357,8 @@ PanelWindow { radius: width / 2 gradient: Gradient { - GradientStop { position: 0.0; color: Color.accent } - GradientStop { position: 0.55; color: Color.accent } + GradientStop { position: 0.0; color: Commons.Color.accent } + GradientStop { position: 0.55; color: Commons.Color.accent } GradientStop { position: 1.0; color: "transparent" } } } diff --git a/shell/Ui/TextField.qml b/shell/Ui/TextField.qml index 8d4183f6..96cac226 100644 --- a/shell/Ui/TextField.qml +++ b/shell/Ui/TextField.qml @@ -1,6 +1,7 @@ import QtQuick import QtQuick.Controls import qs.Commons +import qs.Commons as Commons // Single-line text input with the kit's focus + selection styling. Inherits // from Qt Quick Controls TextField so the underlying type's API (text, @@ -18,8 +19,8 @@ import qs.Commons TextField { id: root - property color foreground: Color.foreground - property color accent: Color.accent + property color foreground: Commons.Color.foreground + property color accent: Commons.Color.accent property color selectionTint: Style.selectionFillFor(foreground, accent) property bool password: false property real horizontalPadding: Style.spacing.controlPaddingX diff --git a/shell/Ui/Toggle.qml b/shell/Ui/Toggle.qml index 0f2c9b7c..f96d33eb 100644 --- a/shell/Ui/Toggle.qml +++ b/shell/Ui/Toggle.qml @@ -1,5 +1,6 @@ import QtQuick import qs.Commons +import qs.Commons as Commons // Labeled toggle row: title + optional description on the left, a // `ToggleSwitch` on the right. Clicking anywhere on the row emits `clicked()`; @@ -28,8 +29,8 @@ BorderSurface { // Override per-instance if a caller wants the opposite. property bool rounded: Style.cornerRadius > 0 - property color foreground: Color.foreground - property color accent: Color.accent + property color foreground: Commons.Color.foreground + property color accent: Commons.Color.accent property string fontFamily: Style.font.family property real titleSize: Style.font.subtitle property real descriptionSize: Style.font.caption diff --git a/shell/Ui/ToggleSwitch.qml b/shell/Ui/ToggleSwitch.qml index f1aa80fa..48b7afba 100644 --- a/shell/Ui/ToggleSwitch.qml +++ b/shell/Ui/ToggleSwitch.qml @@ -1,5 +1,6 @@ import QtQuick import qs.Commons +import qs.Commons as Commons // Bare on/off switch: a track with a sliding knob and no label. This is the // switch `Toggle` parks at the end of its labeled row, factored out so panel @@ -40,8 +41,8 @@ Item { property bool cursorRing: interactive property int cursorPad: Style.space(6) property bool rounded: Style.cornerRadius > 0 - property color foreground: Color.foreground - property color accent: Color.accent + property color foreground: Commons.Color.foreground + property color accent: Commons.Color.accent signal toggled() signal hovered(bool isHovered) diff --git a/shell/Ui/WidgetButton.qml b/shell/Ui/WidgetButton.qml index 107fcac6..9267f1d5 100644 --- a/shell/Ui/WidgetButton.qml +++ b/shell/Ui/WidgetButton.qml @@ -1,5 +1,6 @@ import QtQuick import qs.Commons +import qs.Commons as Commons Item { id: root @@ -8,8 +9,8 @@ Item { property string text: "" property string fontFamily: bar ? bar.fontFamily : Style.font.family property real fontSize: Style.font.body - property color foreground: bar ? bar.barForeground : Color.foreground - property color activeColor: bar ? bar.urgent : Color.urgent + property color foreground: bar ? bar.barForeground : Commons.Color.foreground + property color activeColor: bar ? bar.urgent : Commons.Color.urgent property bool active: false property real horizontalMargin: 8.5 property real verticalPadding: 6 diff --git a/shell/plugins/agents/Panel.qml b/shell/plugins/agents/Panel.qml index f488b783..3d6762a8 100644 --- a/shell/plugins/agents/Panel.qml +++ b/shell/plugins/agents/Panel.qml @@ -3,6 +3,7 @@ import QtQuick.Controls import Quickshell import Quickshell.Io import qs.Commons +import qs.Commons as Commons import qs.Ui Panel { @@ -11,11 +12,11 @@ Panel { ipcTarget: "omarchy.agents" manageIpc: false - readonly property color foreground: bar ? bar.foreground : Color.foreground - readonly property color urgent: bar ? bar.urgent : Color.urgent + readonly property color foreground: bar ? bar.foreground : Commons.Color.foreground + readonly property color urgent: bar ? bar.urgent : Commons.Color.urgent readonly property color dim: Qt.darker(foreground, 1.55) - readonly property color surface: Color.popups.background - readonly property color track: Style.selectedFillFor(foreground, Color.accent) + readonly property color surface: Commons.Color.popups.background + readonly property color track: Style.selectedFillFor(foreground, Commons.Color.accent) readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family // Every subscription on one page, limits first: the question this panel @@ -725,7 +726,7 @@ Panel { function iconCandidatesForProvider(p, surfaceColor) { if (!p) return [] var candidates = [] - if (colorLuminance(surfaceColor || Color.background) >= 0.5) + if (colorLuminance(surfaceColor || Commons.Color.background) >= 0.5) candidates.push(Qt.resolvedUrl("assets/" + p.providerId + "-light.svg")) candidates.push(Qt.resolvedUrl("assets/" + p.providerId + ".svg")) return candidates @@ -1116,7 +1117,7 @@ Panel { anchors.horizontalCenter: parent.horizontalCenter textFormat: Text.PlainText text: choice.modelData.providerName - color: choice.hasCursor ? Color.accent : root.foreground + color: choice.hasCursor ? Commons.Color.accent : root.foreground font.family: root.fontFamily font.pixelSize: Style.font.body font.bold: true @@ -1196,7 +1197,7 @@ Panel { Text { textFormat: Text.PlainText text: root.addCode - color: Color.accent + color: Commons.Color.accent font.family: root.fontFamily font.pixelSize: Style.font.display font.bold: true @@ -1538,7 +1539,7 @@ Panel { property bool hasCursor: false implicitHeight: tileBody.implicitHeight + Style.space(20) radius: Style.cornerRadius - color: hasCursor ? root.alpha(Color.accent, 0.14) : root.alpha(root.foreground, 0.05) + color: hasCursor ? root.alpha(Commons.Color.accent, 0.14) : root.alpha(root.foreground, 0.05) onHasCursorChanged: if (hasCursor) root.revealItem(tile) Row { @@ -1550,7 +1551,7 @@ Panel { anchors.verticalCenter: parent.verticalCenter textFormat: Text.PlainText text: tile.glyph - color: Color.accent + color: Commons.Color.accent font.family: root.fontFamily font.pixelSize: Style.font.heading } @@ -1559,7 +1560,7 @@ Panel { anchors.verticalCenter: parent.verticalCenter textFormat: Text.PlainText text: tile.title - color: tile.hasCursor ? Color.accent : root.foreground + color: tile.hasCursor ? Commons.Color.accent : root.foreground font.family: root.fontFamily font.pixelSize: Style.font.body font.bold: true @@ -1593,16 +1594,16 @@ Panel { implicitHeight: implicitWidth radius: Style.cornerRadius // The cursor needs more than a shade deeper to read on a tinted square. - color: root.alpha(Color.accent, hasCursor ? 0.3 : 0.12) + color: root.alpha(Commons.Color.accent, hasCursor ? 0.3 : 0.12) border.width: hasCursor ? Math.max(1, Style.hoverBorderWidth) : 0 - border.color: Color.accent + border.color: Commons.Color.accent onHasCursorChanged: if (hasCursor) root.revealItem(heroButton) Text { anchors.centerIn: parent textFormat: Text.PlainText text: heroButton.glyph - color: Color.accent + color: Commons.Color.accent font.family: root.fontFamily font.pixelSize: Style.font.heading } @@ -1634,7 +1635,7 @@ Panel { readonly property bool hot: linkMouse.containsMouse || picked onPickedChanged: if (picked) root.revealItem(link) textFormat: Text.PlainText - color: current ? Color.accent : (hot ? root.foreground : idleColor) + color: current ? Commons.Color.accent : (hot ? root.foreground : idleColor) font.family: root.fontFamily font.pixelSize: Style.font.caption font.bold: current @@ -1715,7 +1716,7 @@ Panel { anchors.verticalCenter: parent.verticalCenter width: Math.min(implicitWidth, parent.width * 0.6) text: head.label - color: head.picked ? Color.accent : root.foreground + color: head.picked ? Commons.Color.accent : root.foreground font.family: root.fontFamily font.pixelSize: Style.font.body font.bold: head.isActive @@ -1821,7 +1822,7 @@ Panel { anchors.right: parent.right textFormat: Text.PlainText text: I18n.tr("ACTIVE") - color: Color.accent + color: Commons.Color.accent font.family: root.fontFamily font.pixelSize: Style.font.caption font.bold: true diff --git a/shell/plugins/background/Background.qml b/shell/plugins/background/Background.qml index 7dee71dc..6f692677 100644 --- a/shell/plugins/background/Background.qml +++ b/shell/plugins/background/Background.qml @@ -5,12 +5,14 @@ import QtQuick import QtQuick.Effects import QtQuick.Shapes import qs.Commons +import qs.Commons as Commons import qs.Ui Item { id: root property var shell: null + property bool suspended: false readonly property string home: Quickshell.env("HOME") readonly property string stateHome: home + "/.local/state" readonly property string currentBackgroundLink: stateHome + "/omarchy/current/background" @@ -37,11 +39,20 @@ Item { property var sizeQueue: [] property bool finishingTransition: false property int backgroundVersion: 0 + property int reloadVersion: 0 property int revealStartedVersion: -1 property int pendingThemeVersion: -1 property string pendingColorsRaw: "" property string pendingShellRaw: "" property real revealProgress: 1 + readonly property bool ready: { + if (isVideo(displayedBackground)) return true + if (backgrounds.instances.length === 0) return false + for (var panel of backgrounds.instances) { + if (!panel.backgroundReady) return false + } + return true + } function isVideo(path) { return Util.isVideoPath(path) @@ -56,7 +67,8 @@ Item { } function setBackground(path, instant) { - transitionBackground("", path, path, instant, false) + if (instant) reloadVersion += 1 + transitionBackground("", path, path, instant, instant) } function transitionBackground(fromPath, path, finalPath, instant, force) { @@ -106,10 +118,10 @@ Item { // pending; the latest theme payload should still apply. if (pendingThemeVersion < 0) return pendingThemeFallbackTimer.stop() - Color.loadColors(pendingColorsRaw) - // Color.loadShell also refreshes Style so the type scale flips with the + Commons.Color.loadColors(pendingColorsRaw) + // Commons.Color.loadShell also refreshes Style so the type scale flips with the // background reveal instead of waiting for a separate reload path. - Color.loadShell(pendingShellRaw) + Commons.Color.loadShell(pendingShellRaw) Style.scheduleRefresh() pendingThemeVersion = -1 pendingColorsRaw = "" @@ -223,6 +235,10 @@ Item { root.setBackground(path, true) } + function setSuspended(value: string): void { + root.suspended = value === "true" + } + function transition(fromPath: string, path: string): void { root.transitionBackground(fromPath, path, path, false, false) } @@ -259,7 +275,7 @@ Item { from: 0 to: 1 duration: Style.duration(420) - easing.type: Easing.InOutCubic + easing.type: Easing.OutCubic onFinished: { if (root.incomingBackground) { root.displayedBackground = root.currentBackground || root.incomingBackground @@ -272,6 +288,7 @@ Item { Component.onCompleted: refreshBackground() Variants { + id: backgrounds model: Quickshell.screens PanelWindow { @@ -279,7 +296,7 @@ Item { required property var modelData screen: modelData - visible: !remapGuard.remapping + visible: !remapGuard.remapping && !root.suspended anchors { top: true; bottom: true; left: true; right: true } ScreenMoveRemap { @@ -294,6 +311,13 @@ Item { updatesEnabled: true property bool maskReady: false + property int readyFrames: 0 + readonly property bool backgroundReady: base.ready && readyFrames >= 2 + + FrameAnimation { + running: base.ready && panel.readyFrames < 2 + onTriggered: panel.readyFrames += 1 + } // Decode the wallpaper at the size this screen can show, not the size // it was shipped at. With PreserveAspectCrop Qt takes sourceSize as the @@ -337,9 +361,12 @@ Item { id: base anchors.fill: parent path: root.displayedBackground + version: root.reloadVersion + cached: true constrainDecode: true decodeSize: panel.decodeSize(root.displayedBackground) onReadyChanged: { + panel.readyFrames = 0 if (ready && root.finishingTransition) { root.incomingBackground = "" root.oldBackground = "" diff --git a/shell/plugins/bar/Bar.qml b/shell/plugins/bar/Bar.qml index 745adc66..84680cb5 100644 --- a/shell/plugins/bar/Bar.qml +++ b/shell/plugins/bar/Bar.qml @@ -5,6 +5,7 @@ import Quickshell.Wayland import QtQuick import QtQuick.Layouts import qs.Commons +import qs.Commons as Commons import qs.Ui import "BarModel.js" as BarModel @@ -71,18 +72,18 @@ Item { property string fontFamily: Style.font.family // Bound to the central Color singleton so the bar tracks shell.toml's // [bar] section. Property names kept for the rest of this file's bindings. - property color themeForeground: Color.bar.text - property color themeContrastForeground: Color.background - property color transparentForeground: Color.bar.text + property color themeForeground: Commons.Color.bar.text + property color themeContrastForeground: Commons.Color.background + property color transparentForeground: Commons.Color.bar.text property color foreground: themeForeground property color barForeground: useTransparentForeground ? transparentForeground : themeForeground property bool foregroundAnimationEnabled: true - property color background: Color.bar.background - property color urgent: Color.bar.active + property color background: Commons.Color.bar.background + property color urgent: Commons.Color.bar.active - Behavior on barForeground { enabled: root.foregroundAnimationEnabled; ColorAnimation { duration: Style.duration(420); easing.type: Easing.InOutCubic } } - Behavior on background { ColorAnimation { duration: Style.duration(420); easing.type: Easing.InOutCubic } } - Behavior on urgent { ColorAnimation { duration: Style.duration(420); easing.type: Easing.InOutCubic } } + Behavior on barForeground { enabled: root.foregroundAnimationEnabled; ColorAnimation { duration: Style.duration(420); easing.type: Easing.OutCubic } } + Behavior on background { ColorAnimation { duration: Style.duration(420); easing.type: Easing.OutCubic } } + Behavior on urgent { ColorAnimation { duration: Style.duration(420); easing.type: Easing.OutCubic } } property var tooltipTarget: null property var pendingTooltipTarget: null property string tooltipText: "" @@ -1342,8 +1343,8 @@ Item { id: tooltipBubble implicitWidth: tooltipLabel.implicitWidth + 20 implicitHeight: tooltipLabel.implicitHeight + 14 - color: Color.tooltip.background - borderSpec: Border.surfaceSpec("tooltip", "border", Color.tooltip.border, 1) + color: Commons.Color.tooltip.background + borderSpec: Border.surfaceSpec("tooltip", "border", Commons.Color.tooltip.border, 1) radius: Style.cornerRadius Text { @@ -1351,7 +1352,7 @@ Item { textFormat: Text.PlainText anchors.centerIn: parent text: root.tooltipText - color: Color.tooltip.text + color: Commons.Color.tooltip.text font.family: root.fontFamily font.pixelSize: Style.font.body horizontalAlignment: Text.AlignHCenter @@ -1470,7 +1471,7 @@ Item { y: targetRect ? Math.round(targetRect.y) : 0 width: targetRect ? targetRect.width : 0 height: targetRect ? targetRect.height : 0 - color: Color.accent + color: Commons.Color.accent radius: Math.min(width, height) / 2 } } @@ -1905,7 +1906,7 @@ Item { visible: opacity > 0 opacity: slot.panelOpen && !slot.dragSource ? 0.9 : 0 - color: Color.accent + color: Commons.Color.accent radius: Math.min(width, height) / 2 width: root.vertical ? Style.space(2) : slot.panelIndicatorExtent height: root.vertical ? slot.panelIndicatorExtent : Style.space(2) diff --git a/shell/plugins/bar/indicators/Dictation.qml b/shell/plugins/bar/indicators/Dictation.qml deleted file mode 100644 index 1d001aab..00000000 --- a/shell/plugins/bar/indicators/Dictation.qml +++ /dev/null @@ -1,39 +0,0 @@ -import QtQuick -import Quickshell.Io -import qs.Commons -import qs.Ui - -BarIndicator { - id: root - - property string state: "idle" - property string icon: "" - - active: state === "recording" - activeText: icon - inactiveText: "󰍬" - activeTooltipText: state - inactiveTooltipText: I18n.tr("Dictate") - - function update(raw) { - var data = extractData(raw) - - state = String(data.alt || data.class || "idle") - if (state === "recording") icon = "󰍬" - else if (state === "transcribing") icon = "󰔟" - else icon = "" - } - - Process { - command: ["bash", "-c", "omarchy-voxtype-status"] - running: true - stdout: SplitParser { - onRead: function(data) { root.update(data) } - } - } - - onPressed: function() { - if (!root.bar) return - root.bar.run("omarchy-voxtype-config") - } -} diff --git a/shell/plugins/bar/indicators/PasswordlessSudo.qml b/shell/plugins/bar/indicators/PasswordlessSudo.qml index d06a1cb8..bf26e03f 100644 --- a/shell/plugins/bar/indicators/PasswordlessSudo.qml +++ b/shell/plugins/bar/indicators/PasswordlessSudo.qml @@ -2,6 +2,7 @@ import QtQuick import Quickshell import Quickshell.Io import qs.Commons +import qs.Commons as Commons import qs.Ui BarIndicator { @@ -16,7 +17,7 @@ BarIndicator { activeTooltipText: I18n.tr("Disable Passwordless Sudo") inactiveTooltipText: I18n.tr("Passwordless Sudo") useActiveColor: true - activeColor: Color.urgent + activeColor: Commons.Color.urgent function refresh() { if (!root.bar) return diff --git a/shell/plugins/bar/widgets/ActiveWindow.qml b/shell/plugins/bar/widgets/ActiveWindow.qml index 251d48ab..b803e7f4 100644 --- a/shell/plugins/bar/widgets/ActiveWindow.qml +++ b/shell/plugins/bar/widgets/ActiveWindow.qml @@ -2,6 +2,7 @@ import QtQuick import Quickshell import Quickshell.Wayland import qs.Commons +import qs.Commons as Commons import qs.Ui BarWidget { @@ -34,7 +35,7 @@ BarWidget { anchors.left: parent.left width: parent.width text: root.title - color: root.bar ? root.bar.barForeground : Color.foreground + color: root.bar ? root.bar.barForeground : Commons.Color.foreground font.family: root.bar ? root.bar.fontFamily : Style.font.family font.pixelSize: Style.font.body elide: Text.ElideRight diff --git a/shell/plugins/bar/widgets/Indicators.manifest.json b/shell/plugins/bar/widgets/Indicators.manifest.json index 4296c7f3..9e9ee40b 100644 --- a/shell/plugins/bar/widgets/Indicators.manifest.json +++ b/shell/plugins/bar/widgets/Indicators.manifest.json @@ -31,11 +31,6 @@ "label": "Passwordless sudo", "description": "Warning while passwordless root access is enabled" }, - { - "value": "Dictation", - "label": "Dictation", - "description": "Voice typing status" - }, { "value": "ScreenRecording", "label": "Screen recording", diff --git a/shell/plugins/bar/widgets/Indicators.qml b/shell/plugins/bar/widgets/Indicators.qml index c2e05cb2..d5679267 100644 --- a/shell/plugins/bar/widgets/Indicators.qml +++ b/shell/plugins/bar/widgets/Indicators.qml @@ -8,7 +8,7 @@ BarWidget { id: root moduleName: "omarchy.indicators" - readonly property var defaultIndicatorEntries: [ "PasswordlessSudo", "Dictation", "ScreenRecording", "Reminder", "NightLight", "Dnd", "StayAwake" ] + readonly property var defaultIndicatorEntries: [ "PasswordlessSudo", "ScreenRecording", "Reminder", "NightLight", "Dnd", "StayAwake" ] readonly property var indicatorEntries: indicatorEntriesFromSettings(settings) property var activeIndicatorIds: [] property var indicatorActiveStates: ({}) diff --git a/shell/plugins/bar/widgets/Tray.qml b/shell/plugins/bar/widgets/Tray.qml index d1c7bb0b..2cb7b706 100644 --- a/shell/plugins/bar/widgets/Tray.qml +++ b/shell/plugins/bar/widgets/Tray.qml @@ -4,6 +4,7 @@ import QtQuick.Controls import QtQuick.Effects import Quickshell.Services.SystemTray import qs.Commons +import qs.Commons as Commons import qs.Ui import "TrayModel.js" as TrayModel @@ -16,7 +17,7 @@ BarWidget { property bool trayMenuOpen: false property var activeTrayItem: null property var activeTrayAnchor: null - readonly property color foreground: bar ? bar.foreground : Color.foreground + readonly property color foreground: bar ? bar.foreground : Commons.Color.foreground readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family readonly property var pinnedIds: settings.pinned instanceof Array ? settings.pinned : [] readonly property var hiddenIds: settings.hidden instanceof Array ? settings.hidden : [] @@ -620,7 +621,7 @@ BarWidget { anchors.rightMargin: Style.space(10) anchors.verticalCenter: parent.verticalCenter height: 1 - color: Color.popups.border + color: Commons.Color.popups.border opacity: 0.45 } } @@ -674,7 +675,7 @@ BarWidget { anchors.rightMargin: Style.space(10) anchors.verticalCenter: parent.verticalCenter height: 1 - color: Color.popups.border + color: Commons.Color.popups.border opacity: 0.45 } diff --git a/shell/plugins/clipboard/Clipboard.qml b/shell/plugins/clipboard/Clipboard.qml index b08aaee5..a74032b3 100644 --- a/shell/plugins/clipboard/Clipboard.qml +++ b/shell/plugins/clipboard/Clipboard.qml @@ -3,6 +3,7 @@ import Quickshell.Io import Quickshell.Wayland import QtQuick import qs.Commons +import qs.Commons as Commons import qs.Ui import "ClipboardHistory.js" as ClipboardHistory @@ -22,13 +23,13 @@ Item { // Shares the [menu] surface tokens — themes that style the menu also // style the clipboard. Selected-row colors composed in the // singleton so consumers drop them straight into Rectangle bindings. - property color background: Color.menu.background - property color foreground: Color.menu.text - property color border: Color.menu.border + property color background: Commons.Color.menu.background + property color foreground: Commons.Color.menu.text + property color border: Commons.Color.menu.border property var borderSpec: Border.surfaceSpec("menu", "border", border, Math.max(1, Style.space(2))) - property color scrim: Color.menu.scrim - property color selectedBackground: Color.menu.selectedBackground - property color selectedText: Color.menu.selectedText + property color scrim: Commons.Color.menu.scrim + property color selectedBackground: Commons.Color.menu.selectedBackground + property color selectedText: Commons.Color.menu.selectedText readonly property int cornerRadius: Style.cornerRadius property string fontFamily: Style.font.menuFamily property int contentMargin: Style.spacing.panelPadding diff --git a/shell/plugins/dev-gallery/GalleryPanel.qml b/shell/plugins/dev-gallery/GalleryPanel.qml index 680bdbef..af4a660f 100644 --- a/shell/plugins/dev-gallery/GalleryPanel.qml +++ b/shell/plugins/dev-gallery/GalleryPanel.qml @@ -3,6 +3,7 @@ import QtQuick.Controls import Quickshell import qs.Ui import qs.Commons +import qs.Commons as Commons // Visual reference + live playground for omarchy-shell's common UI // components. Summon with `omarchy dev ui-preview`, or directly via: @@ -66,10 +67,10 @@ Item { property var shell: null // ---- theme -------------------------------------------------------------- - readonly property color foreground: Color.foreground - readonly property color background: Color.background - readonly property color accent: Color.accent - readonly property color urgent: Color.urgent + readonly property color foreground: Commons.Color.foreground + readonly property color background: Commons.Color.background + readonly property color accent: Commons.Color.accent + readonly property color urgent: Commons.Color.urgent readonly property string fontFamily: "monospace" // Fake `bar` for components that take a whole bar object (e.g. Slider). diff --git a/shell/plugins/emojis/Emojis.qml b/shell/plugins/emojis/Emojis.qml index 4311ba15..fe65d431 100644 --- a/shell/plugins/emojis/Emojis.qml +++ b/shell/plugins/emojis/Emojis.qml @@ -3,6 +3,7 @@ import Quickshell.Io import Quickshell.Wayland import QtQuick import qs.Commons +import qs.Commons as Commons import qs.Ui import "EmojiSearch.js" as EmojiSearch @@ -23,13 +24,13 @@ Item { // Shares the [menu] surface tokens — themes that style the menu also // style emojis. Selected-cell colors composed in the // singleton so consumers drop them straight into Rectangle bindings. - property color background: Color.menu.background - property color foreground: Color.menu.text - property color border: Color.menu.border + property color background: Commons.Color.menu.background + property color foreground: Commons.Color.menu.text + property color border: Commons.Color.menu.border property var borderSpec: Border.surfaceSpec("menu", "border", border, Math.max(1, Style.space(2))) - property color scrim: Color.menu.scrim - property color selectedBackground: Color.menu.selectedBackground - property color selectedText: Color.menu.selectedText + property color scrim: Commons.Color.menu.scrim + property color selectedBackground: Commons.Color.menu.selectedBackground + property color selectedText: Commons.Color.menu.selectedText readonly property int cornerRadius: Style.cornerRadius property string fontFamily: Style.font.menuFamily property int contentMargin: Style.spacing.panelPadding diff --git a/shell/plugins/image-picker/ImagePicker.qml b/shell/plugins/image-picker/ImagePicker.qml index ceb1a696..399959c9 100644 --- a/shell/plugins/image-picker/ImagePicker.qml +++ b/shell/plugins/image-picker/ImagePicker.qml @@ -5,6 +5,7 @@ import QtQuick import QtQuick.Effects import QtQuick.Shapes import qs.Commons +import qs.Commons as Commons import qs.Ui import "ImagePickerModel.js" as ImagePickerModel @@ -37,14 +38,14 @@ Item { property string themeRows: "" property bool themeMode: false property bool themeOpenPending: false - // Bound to the central [image-picker] section in shell.toml via Color.qml. + // Bound to the central [image-picker] section in shell.toml via Commons.Color.qml. // `dimColor` tints unselected slices and text outlines on top of the scrim; // it intentionally tracks the foundational background, not a surface role. - property color dimColor: Color.background - property color foreground: Color.imagePicker.text - property color scrim: Color.imagePicker.scrim - property color selectedBorder: Color.imagePicker.selectedBorder - property color unselectedBorder: Color.imagePicker.unselectedBorder + property color dimColor: Commons.Color.background + property color foreground: Commons.Color.imagePicker.text + property color scrim: Commons.Color.imagePicker.scrim + property color selectedBorder: Commons.Color.imagePicker.selectedBorder + property color unselectedBorder: Commons.Color.imagePicker.unselectedBorder property int expandedWidth: 768 property int expandedHeight: 475 property int sliceWidth: 108 @@ -274,7 +275,29 @@ Item { property var imageArray: [] readonly property var matchingImageIndices: ImagePickerModel.matchingIndices(imageArray, filterText) onMatchingImageIndicesChanged: updateVisibleItems() - onSelectedIndexChanged: updateVisibleItems() + onSelectedIndexChanged: { + updateVisibleItems() + introPrepareTimer.restart() + } + onThemeModeChanged: if (themeMode) introPrepareTimer.restart() + + Timer { + id: introPrepareTimer + interval: 75 + onTriggered: { + if (root.opened && root.themeMode && !introPrepare.running) { + introPrepare.theme = root.nameForPath(root.currentPath()) + introPrepare.command = ["omarchy-theme-bg-boot-intro", "--prepare-theme", root.nameForPath(root.currentPath())] + introPrepare.running = true + } + } + } + + Process { + id: introPrepare + property string theme: "" + onExited: if (root.opened && root.themeMode && theme !== root.nameForPath(root.currentPath())) introPrepareTimer.restart() + } function updateVisibleItems() { ImagePickerModel.syncWindow(visibleImages, ImagePickerModel.visibleWindow(matchingImageIndices, selectedIndex, previewRadius)) diff --git a/shell/plugins/lock/LockView.qml b/shell/plugins/lock/LockView.qml index 49663701..63cbf251 100644 --- a/shell/plugins/lock/LockView.qml +++ b/shell/plugins/lock/LockView.qml @@ -1,6 +1,7 @@ import QtQuick import QtQuick.Effects import qs.Commons +import qs.Commons as Commons import qs.Ui Item { @@ -42,8 +43,8 @@ Item { readonly property bool showPasswordCursor: inputEnabled && !authenticatingPassword && failureMessage.length === 0 readonly property bool errorState: failureMessage.length > 0 readonly property var inputBorderSpec: errorState - ? Border.surfaceSpec("lock", "border-error", Color.lock.borderError, root.outlineThickness, "border-alpha") - : Border.surfaceSpec("lock", "border-active", Color.lock.borderActive, root.outlineThickness, "border-alpha") + ? Border.surfaceSpec("lock", "border-error", Commons.Color.lock.borderError, root.outlineThickness, "border-alpha") + : Border.surfaceSpec("lock", "border-active", Commons.Color.lock.borderActive, root.outlineThickness, "border-alpha") readonly property bool video: Util.isVideoPath(root.backgroundPath) readonly property bool feedActive: root.video && root.loadBackground && !root.displaysBlank && !root.powerSaverActive @@ -97,7 +98,7 @@ Item { Rectangle { anchors.fill: parent - color: Color.background + color: Commons.Color.background BackgroundMedia { id: wallpaper @@ -156,7 +157,7 @@ Item { width: root.fieldWidth height: root.fieldHeight anchors.centerIn: parent - color: Color.lock.background + color: Commons.Color.lock.background borderSpec: root.inputBorderSpec radius: Style.cornerRadius clip: true @@ -179,16 +180,16 @@ Item { echoMode: TextInput.Password passwordCharacter: "\u25CF" passwordMaskDelay: 0 - color: Color.lock.text - selectionColor: Color.lock.selection - selectedTextColor: Color.lock.text + color: Commons.Color.lock.text + selectionColor: Commons.Color.lock.selection + selectedTextColor: Commons.Color.lock.text font.family: Style.font.family font.pixelSize: text.length > 0 ? Math.max(1, Math.floor(root.passwordDotFontSize * root.passwordDotScale)) : root.fieldFontSize font.letterSpacing: text.length > 0 ? root.passwordDotLetterSpacing * root.passwordDotScale : 0 cursorVisible: activeFocus && root.showPasswordCursor && text.length > 0 cursorDelegate: Rectangle { width: 2 - color: Color.lock.text + color: Commons.Color.lock.text visible: passwordInput.cursorVisible } @@ -224,7 +225,7 @@ Item { anchors.fill: passwordInput text: root.authenticatingPassword ? I18n.tr("Checking…") : (root.failureMessage.length > 0 ? root.failureMessage : root.placeholderText) visible: passwordInput.text.length === 0 - color: root.authenticatingPassword ? Color.lock.text : (root.failureMessage.length > 0 ? Color.lock.textError : Color.lock.placeholder) + color: root.authenticatingPassword ? Commons.Color.lock.text : (root.failureMessage.length > 0 ? Commons.Color.lock.textError : Commons.Color.lock.placeholder) font.family: Style.font.family font.pixelSize: root.fieldFontSize font.italic: !root.authenticatingPassword && root.failureMessage.length > 0 @@ -247,7 +248,7 @@ Item { anchors.verticalCenter: parent.verticalCenter visible: root.fingerprintConfigured text: root.fingerprintUnavailable ? "󰺱" : "󰈷" - color: root.fingerprintUnavailable ? Color.lock.textError : Color.lock.placeholder + color: root.fingerprintUnavailable ? Commons.Color.lock.textError : Commons.Color.lock.placeholder font.family: Style.font.family font.pixelSize: Math.round(root.fieldFontSize * 1.1) horizontalAlignment: Text.AlignHCenter @@ -266,7 +267,7 @@ Item { anchors.horizontalCenter: inputField.horizontalCenter visible: root.fingerprintConfigured && root.fingerprintUnavailable text: I18n.tr("Fingerprint reader unavailable") - color: Color.lock.textError + color: Commons.Color.lock.textError font.family: Style.font.family font.pixelSize: Style.font.heading font.italic: true diff --git a/shell/plugins/lock/Service.qml b/shell/plugins/lock/Service.qml index f1c5304a..3f13e098 100644 --- a/shell/plugins/lock/Service.qml +++ b/shell/plugins/lock/Service.qml @@ -4,6 +4,7 @@ import Quickshell.Io import Quickshell.Services.Pam import Quickshell.Wayland import qs.Commons +import qs.Commons as Commons import "FingerprintModel.js" as FingerprintModel Item { @@ -467,7 +468,7 @@ Item { WlSessionLockSurface { id: lockSurface - color: Color.background + color: Commons.Color.background LockView { id: lockView diff --git a/shell/plugins/menu/Menu.qml b/shell/plugins/menu/Menu.qml index 63cc99c8..a52151fc 100644 --- a/shell/plugins/menu/Menu.qml +++ b/shell/plugins/menu/Menu.qml @@ -3,6 +3,7 @@ import Quickshell.Io import Quickshell.Wayland import QtQuick import qs.Commons +import qs.Commons as Commons import qs.Ui import "MenuModel.js" as MenuModel @@ -84,17 +85,17 @@ Item { property bool deleteConfirmOpen: false property var deleteTarget: null onOpenedChanged: if (!opened) { deleteConfirmOpen = false; deleteTarget = null } - // Bound to the central [menu] section in shell.toml via Color.qml. + // Bound to the central [menu] section in shell.toml via Commons.Color.qml. // Each color already includes its alpha companion (composed in the // singleton), so consumers can drop them straight into a Rectangle. - property color background: Color.menu.background - property color foreground: Color.menu.text - property color border: Color.menu.border + property color background: Commons.Color.menu.background + property color foreground: Commons.Color.menu.text + property color border: Commons.Color.menu.border property var borderSpec: Border.surfaceSpec("menu", "border", border, Math.max(1, Style.space(2))) - property color scrim: Color.menu.scrim - property color selectedBackground: Color.menu.selectedBackground - property color selectedText: Color.menu.selectedText - property color selectedBorder: Color.menu.selectedBorder + property color scrim: Commons.Color.menu.scrim + property color selectedBackground: Commons.Color.menu.selectedBackground + property color selectedText: Commons.Color.menu.selectedText + property color selectedBorder: Commons.Color.menu.selectedBorder property var selectedBorderSpec: Border.surfaceSpec("menu", "selected-border", selectedBorder, 0) readonly property real rowReservedBorderLeft: Border.left(selectedBorderSpec) readonly property real rowReservedBorderRight: Border.right(selectedBorderSpec) @@ -1088,7 +1089,7 @@ Item { // then on the card grows and shrinks downward instead of re-centering // on every resize, which made the menu jump around. The rows height is // frozen at the same moment, so the starting menu also caps how tall the - // card may grow from there. Closing unfreezes both. + // card may grow from there. Closing or changing screens unfreezes both. property int cardTop: -1 property int maxRowsHeight: -1 readonly property int centeredTop: Math.max(Style.gapsOut, Math.round((height - root.cardHeight) / 2)) @@ -1099,8 +1100,8 @@ Item { maxRowsHeight = root.visibleRowsHeight } } - // The surface stays mapped between opens, so closing is shown going false. onShownChanged: if (!shown) { cardTop = -1; maxRowsHeight = -1 } + onTargetScreenChanged: { cardTop = -1; maxRowsHeight = -1 } Rectangle { anchors.fill: parent diff --git a/shell/plugins/menu/MenuModel.js b/shell/plugins/menu/MenuModel.js index 29e4784f..92a7b0b4 100644 --- a/shell/plugins/menu/MenuModel.js +++ b/shell/plugins/menu/MenuModel.js @@ -413,6 +413,7 @@ var GUARD_READERS = [ "omarchy-default-browser", "omarchy-default-editor", "omarchy-default-terminal", + "omarchy-default-dictation", "omarchy-dns", "omarchy-region", "omarchy-language" diff --git a/shell/plugins/notifications/components/NotificationCard.qml b/shell/plugins/notifications/components/NotificationCard.qml index 6b3a916d..8f6eb88c 100644 --- a/shell/plugins/notifications/components/NotificationCard.qml +++ b/shell/plugins/notifications/components/NotificationCard.qml @@ -6,6 +6,7 @@ import QtQuick import QtQuick.Layouts import Quickshell import qs.Commons +import qs.Commons as Commons import qs.Ui import "../NotificationLogic.js" as NotificationLogic @@ -48,10 +49,10 @@ BorderSurface { readonly property string sanitizedBody: sanitizeBody(body) readonly property string styledBody: NotificationLogic.styledBody(body, app, appIcon) - readonly property color dimColor: Qt.darker(Color.notifications.text, 1.4) - readonly property color bodyColor: Qt.darker(Color.notifications.text, 1.15) - readonly property color accentColor: urgency === 2 ? Color.urgent : (urgency === 0 ? dimColor : Color.notifications.countdown) - readonly property var cardBorderSpec: Border.surfaceSpec("notifications", "border", Color.notifications.border, Math.max(1, Style.space(2))) + readonly property color dimColor: Qt.darker(Commons.Color.notifications.text, 1.4) + readonly property color bodyColor: Qt.darker(Commons.Color.notifications.text, 1.15) + readonly property color accentColor: urgency === 2 ? Commons.Color.urgent : (urgency === 0 ? dimColor : Commons.Color.notifications.countdown) + readonly property var cardBorderSpec: Border.surfaceSpec("notifications", "border", Commons.Color.notifications.border, Math.max(1, Style.space(2))) function sanitizeBody(s) { return NotificationLogic.sanitizeBody(s, app, appIcon) @@ -70,7 +71,7 @@ BorderSurface { // doesn't push content under the bottom edge. implicitHeight: mainColumn.implicitHeight + borderTop + borderBottom radius: cornerRadius - color: Color.notifications.background + color: Commons.Color.notifications.background borderSpec: cardBorderSpec clip: true @@ -139,7 +140,7 @@ BorderSurface { anchors.centerIn: parent visible: root.hasGlyph && smallIconImage.status !== Image.Ready text: root.glyph - color: Color.notifications.text + color: Commons.Color.notifications.text font.family: root.fontFamily font.pixelSize: Style.font.displayLarge } @@ -150,7 +151,7 @@ BorderSurface { Layout.alignment: Qt.AlignVCenter visible: root.compactGlyph text: root.glyph - color: Color.notifications.text + color: Commons.Color.notifications.text font.family: root.fontFamily font.pixelSize: Style.font.icon } @@ -172,7 +173,7 @@ BorderSurface { visible: root.summary.length > 0 text: root.summary font.family: "Liberation Sans" - color: Color.notifications.text + color: Commons.Color.notifications.text font.pixelSize: Style.font.title font.bold: true wrapMode: Text.WordWrap @@ -210,7 +211,7 @@ BorderSurface { required property var modelData text: modelData.text bordered: true - foreground: Color.notifications.text + foreground: Commons.Color.notifications.text fontFamily: root.fontFamily onClicked: root.actionClicked(modelData.identifier) } @@ -235,7 +236,7 @@ BorderSurface { Text { anchors.centerIn: parent text: "✕" - color: closeArea.containsMouse ? Color.notifications.text : root.dimColor + color: closeArea.containsMouse ? Commons.Color.notifications.text : root.dimColor font.pixelSize: Math.round(Style.font.caption * 1.44) } diff --git a/shell/plugins/osd/Osd.qml b/shell/plugins/osd/Osd.qml index 69ddc25b..44592581 100644 --- a/shell/plugins/osd/Osd.qml +++ b/shell/plugins/osd/Osd.qml @@ -3,6 +3,7 @@ import Quickshell import Quickshell.Io import Quickshell.Wayland import qs.Commons +import qs.Commons as Commons import qs.Ui import "OsdModel.js" as OsdModel @@ -139,8 +140,8 @@ Item { anchors.horizontalCenter: parent.horizontalCenter anchors.bottom: parent.bottom anchors.bottomMargin: Style.space(67) - color: Util.alpha(Color.background, 0.97) - borderSpec: Border.surfaceSpec("popups", "border", Color.popups.border, Math.max(1, Style.space(2))) + color: Util.alpha(Commons.Color.background, 0.97) + borderSpec: Border.surfaceSpec("popups", "border", Commons.Color.popups.border, Math.max(1, Style.space(2))) radius: Style.cornerRadius opacity: root.opened ? 1 : 0 @@ -162,7 +163,7 @@ Item { anchors.verticalCenter: parent.verticalCenter text: root.icon font: iconMetrics.font - color: Color.popups.text + color: Commons.Color.popups.text } } Rectangle { @@ -170,11 +171,11 @@ Item { width: root.barWidth height: Math.max(Style.space(6), Style.spacing.sm) anchors.verticalCenter: parent.verticalCenter - color: Util.alpha(Color.popups.text, 0.45) + color: Util.alpha(Commons.Color.popups.text, 0.45) Rectangle { height: parent.height width: parent.width * (root.hasProgress ? root.value / root.maxValue : 0) - color: Color.accent + color: Commons.Color.accent Behavior on width { enabled: root.opened @@ -192,7 +193,7 @@ Item { anchors.verticalCenter: parent.verticalCenter text: root.message font: messageMetrics.font - color: Color.popups.text + color: Commons.Color.popups.text elide: Text.ElideRight maximumLineCount: 1 } diff --git a/shell/plugins/panels/audio/Panel.qml b/shell/plugins/panels/audio/Panel.qml index 54cc879f..8615fd9f 100644 --- a/shell/plugins/panels/audio/Panel.qml +++ b/shell/plugins/panels/audio/Panel.qml @@ -6,6 +6,7 @@ import Quickshell.Services.Mpris import Quickshell.Services.Pipewire import qs.Ui import qs.Commons +import qs.Commons as Commons import "Model.js" as Model Panel { @@ -179,10 +180,10 @@ Panel { readonly property string toggleHint: anyAudible ? I18n.tr("Mute") : I18n.tr("Unmute") readonly property color hoverFill: bar - ? Style.hoverFillFor(bar.foreground, Color.accent) + ? Style.hoverFillFor(bar.foreground, Commons.Color.accent) : "transparent" readonly property color selectedFill: bar - ? Style.selectedFillFor(bar.foreground, Color.accent) + ? Style.selectedFillFor(bar.foreground, Commons.Color.accent) : "transparent" function sectionCount(section) { diff --git a/shell/plugins/panels/bluetooth/Panel.qml b/shell/plugins/panels/bluetooth/Panel.qml index 72276c12..bde33cd8 100644 --- a/shell/plugins/panels/bluetooth/Panel.qml +++ b/shell/plugins/panels/bluetooth/Panel.qml @@ -6,6 +6,7 @@ import Quickshell.Bluetooth import Quickshell.Services.Pipewire import qs.Ui import qs.Commons +import qs.Commons as Commons import "Model.js" as Model Panel { @@ -104,10 +105,10 @@ Panel { readonly property string toggleHint: root.adapter && root.adapter.enabled ? I18n.tr("Turn Bluetooth off") : I18n.tr("Turn Bluetooth on") readonly property color hoverFill: bar - ? Style.hoverFillFor(bar.foreground, Color.accent) + ? Style.hoverFillFor(bar.foreground, Commons.Color.accent) : "transparent" readonly property color selectedFill: bar - ? Style.selectedFillFor(bar.foreground, Color.accent) + ? Style.selectedFillFor(bar.foreground, Commons.Color.accent) : "transparent" function sectionCount(section) { diff --git a/shell/plugins/panels/clock/Panel.qml b/shell/plugins/panels/clock/Panel.qml index bde3fdad..8b6b89a0 100644 --- a/shell/plugins/panels/clock/Panel.qml +++ b/shell/plugins/panels/clock/Panel.qml @@ -1,6 +1,7 @@ import QtQuick import Quickshell import qs.Commons +import qs.Commons as Commons import qs.Ui import "Model.js" as Model @@ -73,7 +74,7 @@ Panel { // Guarded so the widget renders before the bar is injected (the bar-widget // contract instantiates it bare). - readonly property color contentForeground: bar ? bar.foreground : Color.foreground + readonly property color contentForeground: bar ? bar.foreground : Commons.Color.foreground readonly property string contentFontFamily: bar ? bar.fontFamily : Style.font.family readonly property int cellWidth: Style.space(52) @@ -300,7 +301,7 @@ Panel { anchors.baseline: heroDate.baseline text: "󰃭" color: heroMouse.containsMouse - ? Style.hoverStateColor(root.contentForeground, Color.accent) + ? Style.hoverStateColor(root.contentForeground, Commons.Color.accent) : root.contentForeground font.family: root.contentFontFamily // Decorative, and deliberately outside the Style.font.* @@ -315,7 +316,7 @@ Panel { anchors.verticalCenter: parent.verticalCenter text: I18n.chinese ? root.labelLocale.toString(root.today, "M月d日") : Qt.formatDate(root.today, "MMMM d") color: heroMouse.containsMouse - ? Style.hoverStateColor(root.contentForeground, Color.accent) + ? Style.hoverStateColor(root.contentForeground, Commons.Color.accent) : root.contentForeground font.family: root.contentFontFamily font.pixelSize: 52 @@ -455,7 +456,7 @@ Panel { width: Math.round(parent.width * root.yearDone) height: parent.height radius: parent.radius - color: Style.selectedStateColor(root.contentForeground, Color.accent) + color: Style.selectedStateColor(root.contentForeground, Commons.Color.accent) Behavior on width { NumberAnimation { duration: Style.duration(160); easing.type: Easing.OutCubic } } } @@ -514,7 +515,7 @@ Panel { width: Math.round(parent.width * root.lifeDone) height: parent.height radius: parent.radius - color: Style.selectedStateColor(root.contentForeground, Color.accent) + color: Style.selectedStateColor(root.contentForeground, Commons.Color.accent) Behavior on width { NumberAnimation { duration: Style.duration(160); easing.type: Easing.OutCubic } } } @@ -576,14 +577,14 @@ Panel { height: Style.space(16) radius: Style.cornerRadius color: weekStartMouse.containsMouse - ? Style.hoverFillFor(root.contentForeground, Color.accent) + ? Style.hoverFillFor(root.contentForeground, Commons.Color.accent) : "transparent" Text { anchors.centerIn: parent text: "W" color: weekStartMouse.containsMouse - ? Style.hoverStateColor(root.contentForeground, Color.accent) + ? Style.hoverStateColor(root.contentForeground, Commons.Color.accent) : Qt.darker(root.contentForeground, 1.9) font.family: root.contentFontFamily font.pixelSize: Style.font.caption @@ -668,7 +669,7 @@ Panel { // over a grid this quiet. color: "transparent" border.width: modelData.today ? Style.spacing.hairline : 0 - border.color: Style.normalBorderFor(root.contentForeground, Color.accent) + border.color: Style.normalBorderFor(root.contentForeground, Commons.Color.accent) Text { textFormat: Text.PlainText diff --git a/shell/plugins/panels/dropbox/DropboxIcon.qml b/shell/plugins/panels/dropbox/DropboxIcon.qml index 6302b87c..488346bc 100644 --- a/shell/plugins/panels/dropbox/DropboxIcon.qml +++ b/shell/plugins/panels/dropbox/DropboxIcon.qml @@ -1,12 +1,13 @@ import QtQuick import QtQuick.Shapes import qs.Commons +import qs.Commons as Commons Item { id: root property real iconSize: Style.font.icon - property color color: Color.foreground + property color color: Commons.Color.foreground width: iconSize * 1.18 height: iconSize diff --git a/shell/plugins/panels/dropbox/Panel.qml b/shell/plugins/panels/dropbox/Panel.qml index 4774cfbf..55e3e818 100644 --- a/shell/plugins/panels/dropbox/Panel.qml +++ b/shell/plugins/panels/dropbox/Panel.qml @@ -4,6 +4,7 @@ import QtQuick.Layouts import Quickshell import Quickshell.Io import qs.Commons +import qs.Commons as Commons import qs.Ui import "Model.js" as Model @@ -32,8 +33,8 @@ Panel { I18n.tr("Cataloging chaos") ] readonly property string heroPhraseText: activePhrases[phraseIndex % activePhrases.length] - readonly property color foreground: bar ? bar.foreground : Color.foreground - readonly property color urgent: bar ? bar.urgent : Color.urgent + readonly property color foreground: bar ? bar.foreground : Commons.Color.foreground + readonly property color urgent: bar ? bar.urgent : Commons.Color.urgent readonly property color dim: Qt.darker(foreground, 1.55) readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family readonly property color iconColor: dropbox.authenticated && dropbox.active ? foreground : dim diff --git a/shell/plugins/panels/elsewhen/ArcText.qml b/shell/plugins/panels/elsewhen/ArcText.qml index b4ae6ea1..ae18e807 100644 --- a/shell/plugins/panels/elsewhen/ArcText.qml +++ b/shell/plugins/panels/elsewhen/ArcText.qml @@ -1,5 +1,6 @@ import QtQuick import qs.Commons +import qs.Commons as Commons import "Arc.js" as Arc // One line of text arched over what sits below it, each character turned to @@ -13,7 +14,7 @@ Item { property real rise: 6 property string fontFamily: Style.font.family property int pixelSize: Style.font.caption - property color color: Color.foreground + property color color: Commons.Color.foreground FontMetrics { id: metrics diff --git a/shell/plugins/panels/elsewhen/CityRow.qml b/shell/plugins/panels/elsewhen/CityRow.qml index 8293a7a1..cecd988c 100644 --- a/shell/plugins/panels/elsewhen/CityRow.qml +++ b/shell/plugins/panels/elsewhen/CityRow.qml @@ -1,6 +1,7 @@ import QtQuick import QtQuick.Layouts import qs.Commons +import qs.Commons as Commons import qs.Ui import "Model.js" as Model import "Greetings.js" as Greet @@ -62,7 +63,7 @@ Rectangle { // arrow keys never reads darker than the daytime rows around it. readonly property bool picked: panel.focusIndex === index && !panel.addSelected readonly property bool lit: rowHover.hovered || picked - color: Model.mix(Color.popups.background, foreground, + color: Model.mix(Commons.Color.popups.background, foreground, picked ? dayFill + hoverLift : rowHover.hovered ? phaseFill + hoverLift : phaseFill) // Transforms leave the Column's layout alone: the knock that clears the @@ -307,8 +308,8 @@ Rectangle { anchors.verticalCenter: parent.top size: fontSize + (Style.space(22) - fontSize) * 0.85 radius: size / 2 - color: Model.mix(Color.popups.background, row.foreground, _hot ? 0.24 : 0.14) - borderSpec: Border.flat(Model.mix(Color.popups.background, row.foreground, _hot ? 0.65 : 0.35), Style.space(1)) + color: Model.mix(Commons.Color.popups.background, row.foreground, _hot ? 0.24 : 0.14) + borderSpec: Border.flat(Model.mix(Commons.Color.popups.background, row.foreground, _hot ? 0.65 : 0.35), Style.space(1)) iconText: "\u00d7" tooltipText: I18n.tr("Remove") foreground: row.dim diff --git a/shell/plugins/panels/elsewhen/CitySearch.qml b/shell/plugins/panels/elsewhen/CitySearch.qml index 06421a1e..b01e8cfe 100644 --- a/shell/plugins/panels/elsewhen/CitySearch.qml +++ b/shell/plugins/panels/elsewhen/CitySearch.qml @@ -1,5 +1,6 @@ import QtQuick import qs.Commons +import qs.Commons as Commons import qs.Ui import "Model.js" as Model @@ -20,7 +21,7 @@ Column { property string placeholderText: I18n.tr("Search cities…") property string loadingText: I18n.tr("Loading zones…") property real fontSize: Style.font.bodySmall - property color foreground: Color.foreground + property color foreground: Commons.Color.foreground property color dim: Qt.darker(foreground, 1.55) property color fainter: Qt.darker(foreground, 2.1) property string fontFamily: Style.font.family diff --git a/shell/plugins/panels/elsewhen/DaylightStrip.qml b/shell/plugins/panels/elsewhen/DaylightStrip.qml index f899a1c2..de9228e4 100644 --- a/shell/plugins/panels/elsewhen/DaylightStrip.qml +++ b/shell/plugins/panels/elsewhen/DaylightStrip.qml @@ -1,6 +1,7 @@ import QtQuick import QtQuick.Controls import qs.Commons +import qs.Commons as Commons import qs.Ui import "Model.js" as Model import "Sun.js" as Sun @@ -22,7 +23,7 @@ Item { property bool hovered: false property bool hour24: false property real moonPhase: 0 - property color foreground: Color.foreground + property color foreground: Commons.Color.foreground property color fainter: Qt.darker(foreground, 2.1) property color daylightMarker: "#E5C736" property string fontFamily: Style.font.family diff --git a/shell/plugins/panels/elsewhen/Globe.qml b/shell/plugins/panels/elsewhen/Globe.qml index cb514960..4fda37aa 100644 --- a/shell/plugins/panels/elsewhen/Globe.qml +++ b/shell/plugins/panels/elsewhen/Globe.qml @@ -2,6 +2,7 @@ import QtQuick import Quickshell import Quickshell.Io import qs.Commons +import qs.Commons as Commons import qs.Ui import "GlobeModel.js" as Solar import "Model.js" as Model @@ -11,14 +12,14 @@ import "Model.js" as Model Item { id: root - property color foreground: Color.foreground + property color foreground: Commons.Color.foreground property color dim: Qt.darker(foreground, 1.55) property color fainter: Qt.darker(foreground, 2.1) property color daylightMarker // Passed in so the footer's moon agrees with the rows' strips. property real moonPhase: 0.5 // Night dots are dark so they read against the bright continents. - readonly property color nightMarker: Util.alpha(Color.background, 0.92) + readonly property color nightMarker: Util.alpha(Commons.Color.background, 0.92) property string fontFamily: Style.font.family property bool hour24: false // The panel's offset setting, and the offset "home" is measured from. @@ -30,7 +31,7 @@ Item { property real chromeOpacity: 1 // Opaque tones mixed against the background, so the rows behind never show through. - readonly property color surfaceBase: Color.popups.background + readonly property color surfaceBase: Commons.Color.popups.background // Light names over sea, dark over land, split at the coastline. readonly property color seaInk: foreground readonly property color landInk: surfaceBase @@ -470,26 +471,26 @@ Item { ctx.fillStyle = day ? root.daylightMarker : root.nightMarker ctx.fill() ctx.lineWidth = root.scaled(1.2) - ctx.strokeStyle = day ? Util.alpha(Color.background, 0.7) : Util.alpha(fg, 0.85) + ctx.strokeStyle = day ? Util.alpha(Commons.Color.background, 0.7) : Util.alpha(fg, 0.85) ctx.stroke() if (root.isHome(i)) { ctx.beginPath() ctx.arc(p.x, p.y, root.scaled(3.4), 0, Math.PI * 2) - ctx.fillStyle = Color.accent + ctx.fillStyle = Commons.Color.accent ctx.fill() ctx.lineWidth = root.scaled(1.2) - ctx.strokeStyle = Util.alpha(Color.background, 0.5) + ctx.strokeStyle = Util.alpha(Commons.Color.background, 0.5) ctx.stroke() ctx.beginPath() ctx.arc(p.x, p.y, root.scaled(6.4), 0, Math.PI * 2) ctx.lineWidth = root.scaled(1.4) - ctx.strokeStyle = Util.alpha(Color.accent, 0.65) + ctx.strokeStyle = Util.alpha(Commons.Color.accent, 0.65) ctx.stroke() } else if (root.isTracked(i)) { ctx.beginPath() ctx.arc(p.x, p.y, root.scaled(4.6), 0, Math.PI * 2) ctx.lineWidth = root.scaled(1.3) - ctx.strokeStyle = Color.accent + ctx.strokeStyle = Commons.Color.accent ctx.stroke() } if (isSel) { diff --git a/shell/plugins/panels/elsewhen/GlobeFooter.qml b/shell/plugins/panels/elsewhen/GlobeFooter.qml index 5dd1ad2d..16f43592 100644 --- a/shell/plugins/panels/elsewhen/GlobeFooter.qml +++ b/shell/plugins/panels/elsewhen/GlobeFooter.qml @@ -1,5 +1,6 @@ import QtQuick import qs.Commons +import qs.Commons as Commons // The selected city under the globe: name, local time and badge, then its // zone and offset. Blank without a selection; the parent reserves the height. @@ -12,7 +13,7 @@ Item { property string offsetLabel: "" property string badge: "" // "home", "tracked" or "" property real moonPhase: 0.5 - property color foreground: Color.foreground + property color foreground: Commons.Color.foreground property color dim: Qt.darker(foreground, 1.55) property color fainter: Qt.darker(foreground, 2.1) property color daylightMarker @@ -90,7 +91,7 @@ Item { textFormat: Text.PlainText text: root.badge visible: root.has && text !== "" - color: Color.accent + color: Commons.Color.accent font.family: root.fontFamily font.pixelSize: Style.font.caption } diff --git a/shell/plugins/panels/elsewhen/HeroTitle.qml b/shell/plugins/panels/elsewhen/HeroTitle.qml index 238ea63c..090b4711 100644 --- a/shell/plugins/panels/elsewhen/HeroTitle.qml +++ b/shell/plugins/panels/elsewhen/HeroTitle.qml @@ -1,5 +1,6 @@ import QtQuick import qs.Commons +import qs.Commons as Commons import "GlobeModel.js" as Solar // "World [globe] Clock" under an arched caption. The little globe is the door @@ -10,7 +11,7 @@ Column { property string caption: "" property color captionColor: dim property bool captionClickable: false - property color foreground: Color.foreground + property color foreground: Commons.Color.foreground property color dim: Qt.darker(foreground, 1.55) property string fontFamily: Style.font.family diff --git a/shell/plugins/panels/elsewhen/MiniGlobe.qml b/shell/plugins/panels/elsewhen/MiniGlobe.qml index 7214c6c8..932f8aac 100644 --- a/shell/plugins/panels/elsewhen/MiniGlobe.qml +++ b/shell/plugins/panels/elsewhen/MiniGlobe.qml @@ -2,6 +2,7 @@ import QtQuick import Quickshell import Quickshell.Io import qs.Commons +import qs.Commons as Commons import "GlobeModel.js" as Solar import "Model.js" as Model @@ -11,7 +12,7 @@ Item { id: root property real spin: 0 - property color color: Color.foreground + property color color: Commons.Color.foreground // Below this size land turns to noise; draw the graticule alone. readonly property bool showLand: width >= 22 @@ -24,7 +25,7 @@ Item { property bool showMarker: false property real markerLat: 0 property real markerLon: 0 - property color markerColor: Color.accent + property color markerColor: Commons.Color.accent readonly property real radius: Math.min(width, height) / 2 - 1 readonly property string pluginDir: Quickshell.env("OMARCHY_PATH") + "/shell/plugins/panels/elsewhen" @@ -68,7 +69,7 @@ Item { // The ocean, opaque like the large globe. ctx.beginPath() ctx.arc(0, 0, r, 0, Math.PI * 2) - ctx.fillStyle = Model.mix(Color.popups.background, c, root.bold ? 0.16 : 0.13) + ctx.fillStyle = Model.mix(Commons.Color.popups.background, c, root.bold ? 0.16 : 0.13) ctx.fill() ctx.save() @@ -117,7 +118,7 @@ Item { ctx.fill() // Edged so it does not dissolve into a continent of similar lightness. ctx.lineWidth = Math.max(1, r * 0.04) - ctx.strokeStyle = Util.alpha(Color.background, 0.5) + ctx.strokeStyle = Util.alpha(Commons.Color.background, 0.5) ctx.stroke() ctx.beginPath() diff --git a/shell/plugins/panels/elsewhen/MoonDot.qml b/shell/plugins/panels/elsewhen/MoonDot.qml index 9645d167..8f654e61 100644 --- a/shell/plugins/panels/elsewhen/MoonDot.qml +++ b/shell/plugins/panels/elsewhen/MoonDot.qml @@ -1,5 +1,6 @@ import QtQuick import qs.Commons +import qs.Commons as Commons import "GlobeModel.js" as Solar // The night marker drawn as the moon's current phase. The faint full disc @@ -8,7 +9,7 @@ Item { id: root property real phase: 0 // 0 new, 0.25 first quarter, 0.5 full - property color color: Color.foreground + property color color: Commons.Color.foreground onPhaseChanged: canvas.requestPaint() onColorChanged: canvas.requestPaint() diff --git a/shell/plugins/panels/elsewhen/Panel.qml b/shell/plugins/panels/elsewhen/Panel.qml index 88284008..8de7cdef 100644 --- a/shell/plugins/panels/elsewhen/Panel.qml +++ b/shell/plugins/panels/elsewhen/Panel.qml @@ -2,6 +2,7 @@ import QtQuick import Quickshell import Quickshell.Io import qs.Commons +import qs.Commons as Commons import qs.Ui import "Model.js" as Model import "GlobeModel.js" as Solar @@ -80,7 +81,7 @@ Panel { implicitWidth: button.implicitWidth implicitHeight: button.implicitHeight - readonly property color foreground: bar ? bar.foreground : Color.foreground + readonly property color foreground: bar ? bar.foreground : Commons.Color.foreground readonly property color dim: Qt.darker(foreground, 1.55) readonly property color fainter: Qt.darker(foreground, 2.1) // A literal gold: several themes' "yellow" is not yellow. @@ -830,7 +831,7 @@ Panel { width: parent.width caption: root.hereLine // Accent while scrubbed, so a shifted time is never taken for now. - captionColor: root.scrubMinutes !== 0 ? Color.accent : root.dim + captionColor: root.scrubMinutes !== 0 ? Commons.Color.accent : root.dim captionClickable: root.focusIndex >= 0 foreground: root.foreground dim: root.dim diff --git a/shell/plugins/panels/monitor/Panel.qml b/shell/plugins/panels/monitor/Panel.qml index dffd10c7..15ee417c 100644 --- a/shell/plugins/panels/monitor/Panel.qml +++ b/shell/plugins/panels/monitor/Panel.qml @@ -4,6 +4,7 @@ import Quickshell import Quickshell.Io import qs.Ui import qs.Commons +import qs.Commons as Commons import "Model.js" as Model Panel { @@ -874,8 +875,8 @@ Panel { onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(monitorRow) current: isFocused foreground: root.bar.foreground - fill: Style.hoverFillFor(root.bar.foreground, Color.accent) - currentFill: Style.selectedFillFor(root.bar.foreground, Color.accent) + fill: Style.hoverFillFor(root.bar.foreground, Commons.Color.accent) + currentFill: Style.selectedFillFor(root.bar.foreground, Commons.Color.accent) implicitHeight: monitorInner.implicitHeight + Style.spacing.xl opacity: canToggle ? 1.0 : 0.45 diff --git a/shell/plugins/panels/network/Panel.qml b/shell/plugins/panels/network/Panel.qml index cdcbab01..479f2fa8 100644 --- a/shell/plugins/panels/network/Panel.qml +++ b/shell/plugins/panels/network/Panel.qml @@ -6,6 +6,7 @@ import Quickshell.Io import Quickshell.Networking import qs.Ui import qs.Commons +import qs.Commons as Commons import "Model.js" as Model Panel { @@ -295,8 +296,8 @@ Panel { // `selectedIndex`). Mouse hover and keyboard nav both mutate this state // at the root; items never read containsMouse for visuals. See // CursorSurface for the shared chrome shared by rows and pills. - readonly property color hoverFill: bar ? Style.hoverFillFor(bar.foreground, Color.accent) : "transparent" - readonly property color selectedFill: bar ? Style.selectedFillFor(bar.foreground, Color.accent) : "transparent" + readonly property color hoverFill: bar ? Style.hoverFillFor(bar.foreground, Commons.Color.accent) : "transparent" + readonly property color selectedFill: bar ? Style.selectedFillFor(bar.foreground, Commons.Color.accent) : "transparent" // scannerEnabled lives on the shared WifiDevice, which has no reference // counting, and a bar widget is instantiated once per monitor. Tracking the @@ -2082,7 +2083,7 @@ Panel { anchors.verticalCenter: parent.verticalCenter height: Style.spacing.controlHeight color: Style.normalFillFor(root.bar.foreground) - borderSpec: Border.controlSpec("normal", root.bar.foreground, Color.accent) + borderSpec: Border.controlSpec("normal", root.bar.foreground, Commons.Color.accent) radius: Style.cornerRadius Text { diff --git a/shell/plugins/panels/power/Panel.qml b/shell/plugins/panels/power/Panel.qml index c08ea206..2f1d9fb6 100644 --- a/shell/plugins/panels/power/Panel.qml +++ b/shell/plugins/panels/power/Panel.qml @@ -3,6 +3,7 @@ import Quickshell import Quickshell.Io import Quickshell.Services.UPower import qs.Commons +import qs.Commons as Commons import qs.Ui import "Model.js" as Model @@ -88,7 +89,7 @@ Panel { } readonly property color batteryFillColor: { - return root.bar ? root.bar.foreground : Color.foreground + return root.bar ? root.bar.foreground : Commons.Color.foreground } // Cute agent-flavored phrases shown in the hero status line, rotated on a diff --git a/shell/plugins/panels/tailscale/Panel.qml b/shell/plugins/panels/tailscale/Panel.qml index c23ab77b..ce49dc80 100644 --- a/shell/plugins/panels/tailscale/Panel.qml +++ b/shell/plugins/panels/tailscale/Panel.qml @@ -4,6 +4,7 @@ import QtQuick.Layouts import Quickshell import Quickshell.Io import qs.Commons +import qs.Commons as Commons import qs.Ui Panel { @@ -37,8 +38,8 @@ Panel { ] readonly property string heroPhraseText: activePhrases[phraseIndex % activePhrases.length] - readonly property color foreground: bar ? bar.foreground : Color.foreground - readonly property color urgent: bar ? bar.urgent : Color.urgent + readonly property color foreground: bar ? bar.foreground : Commons.Color.foreground + readonly property color urgent: bar ? bar.urgent : Commons.Color.urgent readonly property color dim: Qt.darker(foreground, 1.55) readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family readonly property bool showConnections: tailscale.accounts.length > 1 || tailscale.accountsAccessDenied @@ -54,8 +55,8 @@ Panel { readonly property color iconColor: tailscale.active ? foreground : dim readonly property string toggleHint: tailscale.active ? I18n.tr("Turn Tailscale off") : (tailscale.needsLogin ? I18n.tr("Authorize this device") : I18n.tr("Turn Tailscale on")) readonly property color barIconColor: tailscale.active ? barForeground : Qt.darker(barForeground, 1.55) - readonly property color hoverFill: bar ? Style.hoverFillFor(bar.foreground, Color.accent) : "transparent" - readonly property color selectedFill: bar ? Style.selectedFillFor(bar.foreground, Color.accent) : "transparent" + readonly property color hoverFill: bar ? Style.hoverFillFor(bar.foreground, Commons.Color.accent) : "transparent" + readonly property color selectedFill: bar ? Style.selectedFillFor(bar.foreground, Commons.Color.accent) : "transparent" function selectedPeer() { if (tailscale.peers.length === 0) return null @@ -1040,7 +1041,7 @@ Panel { } } background: BorderSurface { - color: Color.background + color: Commons.Color.background borderSpec: Border.flat(root.dim, 1) radius: Style.cornerRadius } diff --git a/shell/plugins/panels/tailscale/TailscaleIcon.qml b/shell/plugins/panels/tailscale/TailscaleIcon.qml index 8de7933c..0dd06a07 100644 --- a/shell/plugins/panels/tailscale/TailscaleIcon.qml +++ b/shell/plugins/panels/tailscale/TailscaleIcon.qml @@ -1,13 +1,14 @@ import QtQuick import qs.Commons +import qs.Commons as Commons import qs.Ui Item { id: root property real iconSize: Style.font.icon - property color color: Color.foreground - property color badgeColor: Color.urgent + property color color: Commons.Color.foreground + property color badgeColor: Commons.Color.urgent property bool crossed: false property bool warning: false @@ -51,12 +52,12 @@ Item { color: root.badgeColor anchors.right: parent.right anchors.bottom: parent.bottom - borderSpec: Border.flat(Color.popups.background, 1) + borderSpec: Border.flat(Commons.Color.popups.background, 1) Text { anchors.centerIn: parent text: "!" - color: Color.background + color: Commons.Color.background font.family: Style.font.family font.pixelSize: Math.max(6, parent.height * 0.72) font.bold: true diff --git a/shell/plugins/panels/weather/Panel.qml b/shell/plugins/panels/weather/Panel.qml index 9bbbc246..6aa56013 100644 --- a/shell/plugins/panels/weather/Panel.qml +++ b/shell/plugins/panels/weather/Panel.qml @@ -2,6 +2,7 @@ import QtQuick import Quickshell import Quickshell.Io import qs.Commons +import qs.Commons as Commons import qs.Ui import "Model.js" as Model @@ -646,7 +647,7 @@ Panel { height: Style.space(18) anchors.verticalCenter: parent.verticalCenter radius: Math.min(4, Style.cornerRadius) - color: !root.savingLocation && clearLocationArea.containsMouse ? Style.hoverFillFor(root.bar.foreground, Color.accent) : "transparent" + color: !root.savingLocation && clearLocationArea.containsMouse ? Style.hoverFillFor(root.bar.foreground, Commons.Color.accent) : "transparent" Text { textFormat: Text.PlainText @@ -755,7 +756,7 @@ Panel { width: parent.width height: suggestionRow.implicitHeight + Style.space(12) radius: Style.cornerRadius - color: index === root.suggestionIndex ? Style.hoverFillFor(root.bar.foreground, Color.accent) : "transparent" + color: index === root.suggestionIndex ? Style.hoverFillFor(root.bar.foreground, Commons.Color.accent) : "transparent" Row { id: suggestionRow @@ -767,7 +768,7 @@ Panel { Text { textFormat: Text.PlainText text: modelData.name - color: index === root.suggestionIndex ? Style.hoverStateColor(root.bar.foreground, Color.accent) : root.bar.foreground + color: index === root.suggestionIndex ? Style.hoverStateColor(root.bar.foreground, Commons.Color.accent) : root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.body } diff --git a/shell/plugins/polkit/PolkitAgent.qml b/shell/plugins/polkit/PolkitAgent.qml index ce64b01b..4dbbe10c 100644 --- a/shell/plugins/polkit/PolkitAgent.qml +++ b/shell/plugins/polkit/PolkitAgent.qml @@ -4,6 +4,7 @@ import Quickshell.Io import Quickshell.Services.Polkit import Quickshell.Wayland import qs.Commons +import qs.Commons as Commons import qs.Ui import "PolkitModel.js" as PolkitModel @@ -11,14 +12,14 @@ Item { id: root property string fontFamily: Style.font.menuFamily - // Bound to the central [polkit] section in shell.toml via Color.qml. - property color accent: Color.polkit.accent - property color background: Color.polkit.background - property color foreground: Color.polkit.text - property color border: Color.polkit.border - property color borderError: Color.polkit.borderError + // Bound to the central [polkit] section in shell.toml via Commons.Color.qml. + property color accent: Commons.Color.polkit.accent + property color background: Commons.Color.polkit.background + property color foreground: Commons.Color.polkit.text + property color border: Commons.Color.polkit.border + property color borderError: Commons.Color.polkit.borderError property var borderSpec: Border.surfaceSpec("polkit", errorFlash ? "border-error" : "border", errorFlash ? borderError : border, Math.max(1, Style.space(2)), "border-alpha") - property color scrim: Color.polkit.scrim + property color scrim: Commons.Color.polkit.scrim readonly property int cornerRadius: Style.cornerRadius property int contentMargin: Style.spacing.panelPadding property int fieldHeight: Math.max(Style.space(42), Style.spacing.controlHeight) @@ -278,7 +279,7 @@ Item { text: "\udb80\ude37" fontFamily: root.fontFamily fontSize: Math.round(root.fieldHeight * 0.7) - color: root.errorFlash ? Color.polkit.textError : root.accent + color: root.errorFlash ? Commons.Color.polkit.textError : root.accent } Row { @@ -293,7 +294,7 @@ Item { Text { text: "\uf023" - color: root.errorFlash ? Color.polkit.textError : root.accent + color: root.errorFlash ? Commons.Color.polkit.textError : root.accent font.family: root.fontFamily font.pixelSize: Style.font.iconLarge width: Style.space(26) @@ -318,7 +319,7 @@ Item { font.pixelSize: Style.font.iconLarge echoMode: root.responseVisible ? TextInput.Normal : TextInput.Password passwordCharacter: "\u2022" - color: root.errorFlash ? Color.polkit.textError : root.foreground + color: root.errorFlash ? Commons.Color.polkit.textError : root.foreground cursorVisible: activeFocus && !root.submitted && !root.errorFlash readOnly: root.submitted || root.errorFlash enabled: root.dialogVisible @@ -337,7 +338,7 @@ Item { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter text: root.errorFlash ? I18n.tr("Wrong") : (root.submitted ? I18n.tr("Checking...") : I18n.tr("Enter password")) - color: root.errorFlash ? Color.polkit.textError : root.foreground + color: root.errorFlash ? Commons.Color.polkit.textError : root.foreground opacity: root.errorFlash ? 1 : 0.36 font.family: root.fontFamily font.pixelSize: Style.font.iconLarge @@ -350,7 +351,7 @@ Item { height: Style.space(24) anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter - color: root.errorFlash ? Color.polkit.textError : root.foreground + color: root.errorFlash ? Commons.Color.polkit.textError : root.foreground visible: passwordInput.visible && passwordInput.activeFocus && passwordInput.text.length === 0 && !root.submitted && !root.errorFlash } diff --git a/shell/plugins/reminders/ReminderFlow.qml b/shell/plugins/reminders/ReminderFlow.qml index d062f8dc..00509cc6 100644 --- a/shell/plugins/reminders/ReminderFlow.qml +++ b/shell/plugins/reminders/ReminderFlow.qml @@ -2,6 +2,7 @@ import Quickshell import Quickshell.Wayland import QtQuick import qs.Commons +import qs.Commons as Commons import qs.Ui import "ReminderFlowModel.js" as ReminderFlowModel @@ -18,11 +19,11 @@ Item { property string filterText: "" property string fontFamily: Style.font.menuFamily - property color background: Color.menu.background - property color foreground: Color.menu.text - property color border: Color.menu.border + property color background: Commons.Color.menu.background + property color foreground: Commons.Color.menu.text + property color border: Commons.Color.menu.border property var borderSpec: Border.surfaceSpec("menu", "border", border, Math.max(1, Style.space(2))) - property color scrim: Color.menu.scrim + property color scrim: Commons.Color.menu.scrim readonly property int cornerRadius: Style.cornerRadius property int contentMargin: Style.spacing.panelPadding property int headerHeight: Math.max(Style.space(34), Style.font.title + Style.spacing.controlPaddingY * 2) diff --git a/shell/plugins/services/media/BarWidget.qml b/shell/plugins/services/media/BarWidget.qml index d8052be3..776256db 100644 --- a/shell/plugins/services/media/BarWidget.qml +++ b/shell/plugins/services/media/BarWidget.qml @@ -2,6 +2,7 @@ import QtQuick import Quickshell import qs.Ui import qs.Commons +import qs.Commons as Commons BarWidget { id: root @@ -125,8 +126,8 @@ BarWidget { width: Style.space(64) height: Style.space(64) radius: Style.spacing.labelGap - color: Style.normalFillFor(root.bar.foreground, Color.accent) - borderSpec: Border.controlSpec("normal", root.bar.foreground, Color.accent) + color: Style.normalFillFor(root.bar.foreground, Commons.Color.accent) + borderSpec: Border.controlSpec("normal", root.bar.foreground, Commons.Color.accent) Image { anchors.fill: parent @@ -249,8 +250,8 @@ BarWidget { width: sourceList.width height: sourceInner.implicitHeight + Style.space(10) radius: Style.spacing.labelGap - color: selected ? Style.selectedFillFor(root.bar.foreground, Color.accent) : "transparent" - borderSpec: selected ? Border.controlSpec("normal", root.bar.foreground, Color.accent) : Border.none() + color: selected ? Style.selectedFillFor(root.bar.foreground, Commons.Color.accent) : "transparent" + borderSpec: selected ? Border.controlSpec("normal", root.bar.foreground, Commons.Color.accent) : Border.none() Row { id: sourceInner diff --git a/shell/services/BackgroundIntro.qml b/shell/services/BackgroundIntro.qml new file mode 100644 index 00000000..a2530b8e --- /dev/null +++ b/shell/services/BackgroundIntro.qml @@ -0,0 +1,330 @@ +import QtQuick +import Quickshell +import Quickshell.Wayland +import Quickshell.Io +import qs.Commons +import qs.Commons as Commons + +// Keep startup and theme covers alive across background service handoffs. +Item { + id: root + + property var host: null + property bool cover: true + readonly property bool sessionConsumed: String(sessionMarker.text() || "").trim() === (Quickshell.env("OMARCHY_SESSION_ID") || Quickshell.env("HYPRLAND_INSTANCE_SIGNATURE")) + property bool startupSettled: sessionConsumed + property bool startupPending: !sessionConsumed + property bool startupMediaReady: false + property real startupOpacity: startupPending ? 1 : 0 + readonly property bool desktopReady: !host || (!host.pluginReloading && (!host.pluginRegistry || !host.pluginRegistry.scanning) && (!("bar" in host) || !!host.bar)) + property bool checked: false + property string themeToken: "" + property string transitionToken: "" + readonly property string startupBackgroundPath: Quickshell.env("OMARCHY_STARTUP_BACKGROUND") + property string themeBackground: sessionConsumed && !Util.isVideoPath(startupBackgroundPath) ? startupBackgroundPath : "" + property var themeNativeSize: null + property string themeColors: "" + property string themeShell: "" + property real themeOpacity: 1 + readonly property var backgroundService: host && host.services ? host.firstPartyServiceFor("omarchy.background") : null + readonly property bool backgroundActive: !!backgroundService && !backgroundService.suspended + readonly property bool backgroundReady: backgroundActive && backgroundService.ready !== false + + function restoreStartupCursor() { + Quickshell.execDetached(["hyprctl", "eval", "if omarchy_startup_cursor_restore then omarchy_startup_cursor_restore() end"]) + } + + function finishStartup() { + if (!cover || !startupSettled) return + var registry = host ? host.pluginRegistry : null + var backgroundId = registry ? registry.resolveEnabledId("omarchy.background") : "" + var disabled = registry && registry.installedPlugins[backgroundId] && !registry.isEnabled(backgroundId) + if (!backgroundReady && !disabled) return + cover = false + if (!themeToken) themeBackground = "" + startupMediaReady = true + revealStartup() + } + + function revealStartup() { + if (!startupPending || startupFade.running || !startupMediaReady || !desktopReady) return + if (!startupCovers.instances.length) return + for (var panel of startupCovers.instances) + if (panel.readyFrames < 2) return + startupFade.start() + } + + onDesktopReadyChanged: revealStartup() + onBackgroundReadyChanged: finishStartup() + Connections { + target: root.host && root.host.pluginRegistry ? root.host.pluginRegistry : null + function onPluginsChanged() { root.finishStartup() } + } + + function prepareTheme(fromPath, token, colors, shell) { + framePoll.stop() + themeFade.stop() + themeToken = token + transitionToken = token + if (themeBackground !== fromPath) { + themeNativeSize = backgroundService && backgroundService.nativeSizes ? backgroundService.nativeSizes[backgroundService.displayedBackground] : null + } + themeBackground = fromPath + themeColors = colors + themeShell = shell + themeOpacity = 1 + themeFallback.restart() + } + + function revealTheme() { + if (!themeToken && !cover) return + framePoll.stop() + themeFallback.stop() + if (themeToken) { + Commons.Color.loadColors(Util.decodeBase64(themeColors)) + Commons.Color.loadShell(Util.decodeBase64(themeShell)) + Style.scheduleRefresh() + } + cover = false + themeToken = "" + themeColors = "" + themeShell = "" + if (startupPending) { + // The login reveal starts on moving video, without showing its final still. + themeBackground = "" + startupMediaReady = true + revealStartup() + } else { + themeFade.restart() + } + } + + function finishTheme(token) { + if (token === themeToken) revealTheme() + } + + function themeStatus(token) { + return token === transitionToken && (themeToken || themeFade.running || startupFade.running) ? "pending" : "ready" + } + + function themeCoverStatus(token) { + if (token !== themeToken) return "superseded" + if (!themeBackground) return "ready" + for (var panel of covers.instances) { + if (panel.coverFailed) return "error" + if (!panel.coverReady) return "loading" + } + return covers.instances.length > 0 ? "ready" : "loading" + } + + function cancelTheme() { + framePoll.stop() + themeFallback.stop() + themeFade.stop() + themeToken = "" + transitionToken = "" + themeBackground = "" + themeColors = "" + themeShell = "" + } + + NumberAnimation { + id: startupFade + target: root + property: "startupOpacity" + to: 0 + duration: Style.duration(420) + easing.type: Easing.OutCubic + onStarted: root.restoreStartupCursor() + onFinished: root.startupPending = false + } + + // A missing wallpaper or failed plugin must not leave applications hidden. + Timer { + interval: 10000 + running: root.startupPending + onTriggered: { + root.cover = false + if (!root.themeToken) root.themeBackground = "" + root.startupMediaReady = true + startupFade.start() + } + } + + NumberAnimation { + id: themeFade + target: root + property: "themeOpacity" + to: 0 + duration: Style.duration(420) + easing.type: Easing.OutCubic + onFinished: root.themeBackground = "" + } + + Timer { + id: themeFallback + interval: 10000 + onTriggered: root.revealTheme() + } + + // A renderer left over from another intro can first fade from its still. + // Keep that preparation hidden until the actual video is fully revealed. + Timer { + id: framePoll + interval: 16 + repeat: true + onTriggered: if (!frameStatus.running) frameStatus.running = true + } + + Process { + id: frameStatus + command: ["owe", "render-status"] + property string token: "" + onStarted: token = root.themeToken + stdout: StdioCollector { id: frameStatusOut } + onExited: function(exitCode) { + if (exitCode !== 0 || token !== root.themeToken || (!token && !root.cover)) return + try { + var status = JSON.parse(frameStatusOut.text) + if (status.kind === "video" && status.ready && !status.has_transition && status.time_pos > 0) + root.revealTheme() + } catch (e) {} + } + } + + // A shell restart shares this compositor session; a new login does not. + FileView { + id: sessionMarker + path: Quickshell.env("HOME") + "/.local/state/omarchy/background-intro.session-id" + blockLoading: true + watchChanges: false + printErrors: false + } + + Process { + id: startupBackground + command: ["readlink", "-f", Quickshell.env("HOME") + "/.local/state/omarchy/current/background"] + stdout: StdioCollector { id: startupBackgroundOut } + onExited: function(exitCode) { + var path = String(startupBackgroundOut.text || "").trim() + if (exitCode === 0 && root.cover && !root.startupPending && !root.themeToken && !Util.isVideoPath(path)) + root.themeBackground = path + } + } + + Component.onCompleted: { + checked = true + if (sessionConsumed) restoreStartupCursor() + if (!startupBackgroundPath && !startupPending) startupBackground.running = true + introProc.running = true + } + + Component.onDestruction: if (startupPending) restoreStartupCursor() + + onBackgroundActiveChanged: { + if (!backgroundActive && checked) { + if (cover || themeToken) { + framePoll.start() + if (!frameStatus.running) frameStatus.running = true + } + } + } + + // Cover the bar as well as the wallpaper on a new login. Shell restarts + // retain the current desktop and never map this startup cover. + Variants { + id: startupCovers + model: Quickshell.screens + + PanelWindow { + required property var modelData + property int readyFrames: 0 + screen: modelData + visible: root.startupPending + color: "transparent" + mask: Region {} + anchors { top: true; bottom: true; left: true; right: true } + exclusionMode: ExclusionMode.Ignore + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + WlrLayershell.namespace: "omarchy-background" + + Rectangle { + anchors.fill: parent + color: "black" + opacity: root.startupOpacity + } + + FrameAnimation { + running: root.startupPending && root.desktopReady && readyFrames < 2 + onTriggered: { + readyFrames += 1 + if (readyFrames === 2) root.revealStartup() + } + } + } + } + + Variants { + id: covers + model: Quickshell.screens + + PanelWindow { + id: panel + required property var modelData + property int coverFrames: 0 + readonly property bool coverReady: outgoingFrame.status === Image.Ready && coverFrames >= 2 + readonly property bool coverFailed: outgoingFrame.status === Image.Error + screen: modelData + visible: (!root.startupPending && root.cover) || root.themeBackground !== "" + // Keep the window transparent throughout the fade. Paint the startup + // color inside it so changing the window format cannot flash black. + color: "transparent" + mask: Region {} + anchors { top: true; bottom: true; left: true; right: true } + exclusionMode: ExclusionMode.Ignore + WlrLayershell.layer: WlrLayer.Bottom + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + WlrLayershell.namespace: "omarchy-background" + + Rectangle { + anchors.fill: parent + visible: root.cover + color: Commons.Color.background + } + + Image { + id: outgoingFrame + anchors.fill: parent + source: root.themeBackground ? Util.fileUrl(root.themeBackground) : "" + sourceSize: { + var w = Math.ceil((parent.width || modelData.width) * modelData.devicePixelRatio) + var h = Math.ceil((parent.height || modelData.height) * modelData.devicePixelRatio) + var native = root.themeNativeSize + return native && (native.width < w || native.height < h) ? Qt.size(native.width, native.height) : Qt.size(w, h) + } + fillMode: Image.PreserveAspectCrop + opacity: root.themeOpacity + // Start decoding while the remaining theme configs render. + asynchronous: true + onStatusChanged: panel.coverFrames = 0 + } + + // Give the ready image a frame to reach the compositor before OWE + // releases the shell's wallpaper underneath this cover. + FrameAnimation { + running: outgoingFrame.status === Image.Ready && panel.coverFrames < 2 + onTriggered: panel.coverFrames += 1 + } + } + } + + Process { + id: introProc + command: ["omarchy-theme-bg-boot-intro"] + onExited: { + root.startupSettled = true + root.finishStartup() + } + } +} diff --git a/shell/shell.qml b/shell/shell.qml index 7aecdcca..ea6e6de8 100644 --- a/shell/shell.qml +++ b/shell/shell.qml @@ -5,6 +5,7 @@ import Quickshell.Hyprland import Quickshell.Io import qs.Commons +import qs.Commons as Commons import "plugins/bar" import "services" @@ -21,6 +22,7 @@ ShellRoot { property BarWidgetRegistry barWidgetRegistry: BarWidgetRegistry { } property AppLibrary appLibrary: AppLibrary { } property BrightnessKeys brightnessKeys: BrightnessKeys { host: shell } + property BackgroundIntro bootIntro: BackgroundIntro { host: shell } property string home: Quickshell.env("HOME") @@ -1704,6 +1706,7 @@ ShellRoot { function ping(): string { return "ok" } + } // ---------------------------------------------------------- shell IPC @@ -1711,17 +1714,40 @@ ShellRoot { ShellIpc { target: "shell" + function prepareThemeIntro(fromPath: string, token: string, colorsB64: string, shellB64: string): void { + shell.bootIntro.prepareTheme(fromPath, token, colorsB64, shellB64) + } + + function finishThemeIntro(token: string): void { + shell.bootIntro.finishTheme(token) + } + + function themeIntroStatus(token: string): string { + return shell.bootIntro.themeStatus(token) + } + + function themeIntroCoverStatus(token: string): string { + return shell.bootIntro.themeCoverStatus(token) + } + function ping(): string { return "ok" } function applyTheme(colorsB64: string, shellB64: string): string { + if (shell.bootIntro) shell.bootIntro.cancelTheme() + var background = shell.firstPartyServiceFor("omarchy.background") + if (background && typeof background.setPendingTheme === "function" && typeof background.applyPendingTheme === "function") { + background.setPendingTheme(colorsB64, shellB64) + background.applyPendingTheme() + return "ok" + } var colorsRaw = "" var shellRaw = "" try { colorsRaw = Qt.atob(String(colorsB64 || "")) } catch (e) { colorsRaw = "" } try { shellRaw = Qt.atob(String(shellB64 || "")) } catch (e2) { shellRaw = "" } - Color.loadColors(colorsRaw) - Color.loadShell(shellRaw) + Commons.Color.loadColors(colorsRaw) + Commons.Color.loadShell(shellRaw) Style.scheduleRefresh() return "ok" } diff --git a/test/acceptance.d/base-test.sh b/test/acceptance.d/base-test.sh index 7bc9a1af..23ca1402 100644 --- a/test/acceptance.d/base-test.sh +++ b/test/acceptance.d/base-test.sh @@ -81,17 +81,11 @@ layer_absent() { ! layer_present "$1" } -# Overlays built on OverlayWindow (the menu, image selector, emoji picker, -# clipboard and the like) stay mapped between opens, parked 1x1 on the bottom -# layer, so they keep their fractional scale. Showing means on the overlay. +# Fullscreen overlays map on the overlay layer and unmap when closed. 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/menu-test.sh b/test/acceptance.d/menu-test.sh index 64bdd0ff..c9fa806f 100644 --- a/test/acceptance.d/menu-test.sh +++ b/test/acceptance.d/menu-test.sh @@ -86,7 +86,7 @@ screenshot "success-menu-05-position-submenu" wtype -k Down -k Down -k Return wait_until "menu bar position changes to left" 20 bar_position_is "left" wait_until "menu bar becomes vertical" 20 bar_is_vertical -wait_until "menu closes after selecting a position" 15 layer_off_overlay "omarchy-menu" +wait_until "menu closes after selecting a position" 15 layer_absent "omarchy-menu" screenshot "success-menu-06-bar-left" if ((config_existed)); then diff --git a/test/acceptance.d/overlay-monitor-test.sh b/test/acceptance.d/overlay-monitor-test.sh new file mode 100644 index 00000000..6a97113f --- /dev/null +++ b/test/acceptance.d/overlay-monitor-test.sh @@ -0,0 +1,107 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +original_monitors=$(hyprctl -j monitors) +remaining_monitor=$(jq -r 'map(select(.focused))[0].name // .[0].name' <<<"$original_monitors") +remaining_height=$(jq -r --arg name "$remaining_monitor" '.[] | select(.name == $name) | (if (.transform // 0) % 2 == 1 then .width else .height end) / .scale | ceil' <<<"$original_monitors") +# Make the source tall enough that a frozen centered menu would land below +# the remaining display, so a mapped but misplaced menu cannot pass recovery. +tall_height=$((remaining_height * 2 + 1600)) +virtual_monitor="" + +cleanup() { + omarchy-shell shell hide omarchy.menu >/dev/null 2>&1 || true + if [[ -n $virtual_monitor ]]; then + hyprctl output remove "$virtual_monitor" >/dev/null 2>&1 || true + fi + hyprctl dispatch "hl.dsp.focus({ monitor = \"$remaining_monitor\" })" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +find_virtual_monitor() { + virtual_monitor=$(hyprctl -j monitors | jq -r --argjson previous "$original_monitors" '[.[].name | select(. as $name | $previous | map(.name) | index($name) == null)][0] // empty') + [[ -n $virtual_monitor ]] +} + +monitor_is_tall() { + hyprctl -j monitors | jq -e --arg name "$virtual_monitor" --argjson height "$tall_height" 'any(.[]; .name == $name and .height == $height and .scale == 1)' +} + +monitor_is_focused() { + hyprctl -j monitors | jq -e --arg name "$1" 'any(.[]; .name == $name and .focused)' +} + +menu_on_monitor() { + hyprctl -j layers | jq -e --arg name "$1" '[.[$name].levels["3"][]? | select(.namespace == "omarchy-menu")] | length == 1' +} + +monitor_contains() { + local monitor="$1" text="$2" + local snapshot="$ARTIFACTS/monitor-text-$$.png" + local status=0 + + if timeout 10 grim -o "$monitor" -s 2 "$snapshot" 2>/dev/null; then + tesseract "$snapshot" stdout --psm 11 2>/dev/null | grep -Fi -- "$text" >/dev/null || status=$? + else + status=1 + fi + rm -f "$snapshot" + return "$status" +} + +create_source_monitor() { + hyprctl output create headless >/dev/null + wait_until "temporary overlay monitor appears" 15 find_virtual_monitor + hyprctl eval "hl.monitor({ output = \"$virtual_monitor\", mode = \"1920x${tall_height}@60\", position = \"auto\", scale = 1 })" >/dev/null + wait_until "temporary overlay monitor has tall geometry" 15 monitor_is_tall + hyprctl dispatch "hl.dsp.focus({ monitor = \"$virtual_monitor\" })" >/dev/null + wait_until "temporary overlay monitor is focused" 15 monitor_is_focused "$virtual_monitor" +} + +remove_source_monitor() { + hyprctl output remove "$virtual_monitor" >/dev/null + virtual_monitor="" + hyprctl dispatch "hl.dsp.focus({ monitor = \"$remaining_monitor\" })" >/dev/null + wait_until "remaining monitor is focused" 15 monitor_is_focused "$remaining_monitor" +} + +omarchy-shell shell hide omarchy.menu >/dev/null +wait_until "menu starts closed" 15 layer_absent "omarchy-menu" + +# A closed overlay must drop its old output and reopen on a connected one. +create_source_monitor +omarchy-shell shell summon omarchy.menu '{"menu":"root"}' >/dev/null +wait_until "menu opens on temporary monitor" 15 menu_on_monitor "$virtual_monitor" +wait_until "menu renders on temporary monitor" 15 monitor_contains "$virtual_monitor" "Apps" +screenshot "success-overlay-monitor-01-closed-source" +omarchy-shell shell hide omarchy.menu >/dev/null +wait_until "menu unmaps before monitor removal" 15 layer_absent "omarchy-menu" +remove_source_monitor +omarchy-shell shell summon omarchy.menu '{"menu":"root"}' >/dev/null +wait_until "closed menu reopens on remaining monitor" 15 menu_on_monitor "$remaining_monitor" +wait_until "reopened menu renders on remaining monitor" 15 monitor_contains "$remaining_monitor" "Apps" +screenshot "success-overlay-monitor-02-closed-recovered" +omarchy-shell shell hide omarchy.menu >/dev/null +wait_until "reopened menu closes" 15 layer_absent "omarchy-menu" + +# Typing freezes the menu's position. Recovery onto a shorter screen must +# unfreeze that position while preserving the search and keyboard handling. +create_source_monitor +omarchy-shell shell summon omarchy.menu '{"menu":"root"}' >/dev/null +wait_until "open recovery menu maps on tall monitor" 15 menu_on_monitor "$virtual_monitor" +wait_until "open recovery menu renders" 15 monitor_contains "$virtual_monitor" "Apps" +screenshot "success-overlay-monitor-03-open-root" +wtype "keybindings" +wait_until "menu search renders on tall monitor" 15 monitor_contains "$virtual_monitor" "Keybindings" +screenshot "success-overlay-monitor-04-open-search" +remove_source_monitor +wait_until "open menu moves to remaining monitor" 15 menu_on_monitor "$remaining_monitor" +wait_until "open menu search renders on shorter monitor" 15 monitor_contains "$remaining_monitor" "Keybindings" +screenshot "success-overlay-monitor-05-open-recovered" +wtype -k Escape +wait_until "recovered menu clears search through keyboard" 15 monitor_contains "$remaining_monitor" "Apps" +wtype -k Escape +wait_until "recovered menu closes through keyboard" 15 layer_absent "omarchy-menu" diff --git a/test/acceptance.d/shell-surfaces-test.sh b/test/acceptance.d/shell-surfaces-test.sh index 7d7630ed..88a0ce79 100644 --- a/test/acceptance.d/shell-surfaces-test.sh +++ b/test/acceptance.d/shell-surfaces-test.sh @@ -29,7 +29,7 @@ wtype "rocket" sleep 1 screenshot "success-emoji-picker-search" wtype -k Return -wait_until "emoji picker selection closes" 15 layer_off_overlay "omarchy-emojis" +wait_until "emoji picker selection closes" 15 layer_absent "omarchy-emojis" # Seed two clipboard entries, search for the older one, and copy it back out. clipboard_token="Omarchy acceptance clipboard $(date +%s)" @@ -44,7 +44,7 @@ wtype "$clipboard_token" wait_until "clipboard search finds test text" 15 screen_contains "Omarchy acceptance clipboard" screenshot "success-clipboard-search" wtype -M shift -k Return -m shift -wait_until "clipboard selection closes" 15 layer_off_overlay "omarchy-clipboard" +wait_until "clipboard selection closes" 15 layer_absent "omarchy-clipboard" wait_until "clipboard selection restores test text" 15 bash -c '[[ $(wl-paste --no-newline) == "$1" ]]' _ "$clipboard_token" # Exercise the system branch without invoking any destructive action. @@ -53,7 +53,7 @@ wait_until "system menu opens" 15 layer_on_overlay "omarchy-menu" wait_until "system menu content is visible" 15 screen_contains "Shutdown" screenshot "success-system-menu" wtype -k Escape -wait_until "system menu closes" 15 layer_off_overlay "omarchy-menu" +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. @@ -62,14 +62,14 @@ wait_until "background selector opens" 30 layer_on_overlay "omarchy-image-select sleep 1 screenshot "success-background-selector" wtype -k Escape -wait_until "background selector closes" 15 layer_off_overlay "omarchy-image-selector" +wait_until "background selector closes" 15 layer_absent "omarchy-image-selector" launch_app "omarchy-theme-switcher" 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_off_overlay "omarchy-image-selector" +wait_until "theme selector closes" 15 layer_absent "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. @@ -83,7 +83,7 @@ wtype -k Return wait_until "reminder message prompt opens" 15 screen_contains "Reminder message" screenshot "success-reminder-03-message-prompt" wtype -k Escape -wait_until "reminder flow closes" 15 layer_off_overlay "omarchy-reminders" +wait_until "reminder flow closes" 15 layer_absent "omarchy-reminders" # Render a real shell notification and clear it through the notification IPC. omarchy-shell notifications dismissAll >/dev/null @@ -111,7 +111,7 @@ screenshot "success-apps-menu-search" wtype -k Return wait_until "apps menu launches the top search hit" 60 window_present "(?i)omawrite" -wait_until "apps menu closes after launching" 15 layer_off_overlay "omarchy-menu" +wait_until "apps menu closes after launching" 15 layer_absent "omarchy-menu" close_windows "(?i)omawrite" wait_until "Omawrite window closes" 30 window_absent "(?i)omawrite" diff --git a/test/shell.d/background-instant-reload-test.sh b/test/shell.d/background-instant-reload-test.sh new file mode 100755 index 00000000..6bc87a4a --- /dev/null +++ b/test/shell.d/background-instant-reload-test.sh @@ -0,0 +1,50 @@ +#!/bin/bash + +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +require_compositor "same-path background fallback" +require_command quickshell +require_command grim +require_command magick + +stage=$(mktemp -d) +qs_pid="" +cleanup() { + [[ -z $qs_pid ]] || { kill "$qs_pid" 2>/dev/null || true; wait "$qs_pid" 2>/dev/null || true; } + rm -rf "$stage" +} +trap cleanup EXIT +mkdir -p "$stage/home/.local/state/omarchy/current" +ln -s "$ROOT/shell/plugins/background" "$stage/background" +ln -s "$ROOT/shell/Commons" "$stage/Commons" +ln -s "$ROOT/shell/Ui" "$stage/Ui" +cp "$SHELL_TEST_DIR/fixtures/background-instant-reload/shell.qml" "$stage/shell.qml" +magick -size 128x128 xc:magenta "$stage/still.png" +ln -s "$stage/still.png" "$stage/home/.local/state/omarchy/current/background" +: >"$stage/command" +HOME="$stage/home" RELOAD_TEST_COMMAND="$stage/command" RELOAD_TEST_RESULT="$stage/result" \ + quickshell -p "$stage" --no-color >"$stage/quickshell.log" 2>&1 & +qs_pid=$! +wait_phase() { + for attempt in {1..100}; do + [[ -f $stage/result && $(<"$stage/result") == "$1" ]] && return 0 + sleep 0.03 + done + fail "same-path background reaches $1" "$(cat "$stage/quickshell.log")" +} +pixel() { + local output + output=$(hyprctl -j monitors | jq -r '.[0].name') + timeout -k 1 3 grim -o "$output" "$stage/pixel.png" + magick "$stage/pixel.png" -format '%[hex:p{32,256}]' info: +} +wait_phase initial +[[ $(pixel) == FF00FF ]] || fail "the initial background is visible" +magick -size 128x128 xc:cyan "$stage/still.png" +printf 'reload\n' >"$stage/command" +wait_phase reloaded +[[ $(pixel) == 00FFFF ]] || fail "the instant fallback reloads new pixels behind an unchanged path" +printf 'done\n' >"$stage/command" +wait "$qs_pid" || fail "the same-path reload fixture exits cleanly" "$(cat "$stage/quickshell.log")" +qs_pid="" +pass "the instant fallback reloads a replaced image at the same path" diff --git a/test/shell.d/background-intro-clone-test.sh b/test/shell.d/background-intro-clone-test.sh new file mode 100644 index 00000000..50c650ae --- /dev/null +++ b/test/shell.d/background-intro-clone-test.sh @@ -0,0 +1,93 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +require_compositor "cloned background intro cover test" +require_command quickshell +require_command grim +require_command magick + +stage=$(mktemp -d) +qs_pid="" +cleanup() { + [[ -z $qs_pid ]] || { kill "$qs_pid" 2>/dev/null || true; wait "$qs_pid" 2>/dev/null || true; } + rm -rf "$stage" +} +trap cleanup EXIT +mkdir -p "$stage/bin" "$stage/home/.local/state/omarchy/current" +cat >"$stage/bin/noop" <<'SH' +#!/bin/bash +exit 0 +SH +chmod +x "$stage/bin/noop" +for command in omarchy-shell omarchy-plugin-enable omarchy-notification-send; do + ln -s noop "$stage/bin/$command" +done +cat >"$stage/bin/omarchy-plugin-list" <<'SH' +#!/bin/bash +printf '[{"id":"intro-test.background"}]\n' +SH +cat >"$stage/bin/omarchy-theme-bg-boot-intro" <<'SH' +#!/bin/bash +for attempt in {1..200}; do + [[ ! -s $INTRO_TEST_RELEASE ]] || exit 0 + sleep 0.05 +done +exit 1 +SH +chmod +x "$stage/bin/omarchy-plugin-list" "$stage/bin/omarchy-theme-bg-boot-intro" +HOME="$stage/home" USER=intro-test OMARCHY_PATH="$ROOT" PATH="$stage/bin:$ROOT/bin:$PATH" \ + "$ROOT/bin/omarchy-plugin-clone" omarchy.background >/dev/null +ln -s "$stage/home/.config/omarchy/plugins/intro-test.background" "$stage/clone" +ln -s "$ROOT/shell/Commons" "$stage/Commons" +ln -s "$ROOT/shell/Ui" "$stage/Ui" +ln -s "$ROOT/shell/services" "$stage/services" +cp "$SHELL_TEST_DIR/fixtures/background-intro-clone/shell.qml" "$stage/shell.qml" +magick -size 128x128 xc:magenta "$stage/still.png" +ln -s "$stage/still.png" "$stage/home/.local/state/omarchy/current/background" + +wait_phase() { + for attempt in {1..100}; do + [[ -s $stage/result.json ]] && [[ $(jq -r .phase "$stage/result.json") == "$1" ]] && return 0 + sleep 0.03 + done + fail "cloned background reaches the $1 phase" "$(cat "$stage/quickshell.log")" +} +capture_pixel() { + hyprctl dispatch 'hl.dsp.dpms({ action = "enable" })' >/dev/null + local output + output=$(hyprctl -j monitors | jq -r '.[0].name') + timeout -k 1 3 grim -o "$output" "$stage/$1.png" + magick "$stage/$1.png" -format '%[hex:p{32,256}]' info: +} +for consumed in false true; do + if [[ $consumed == true ]]; then + printf '%s\n' "$HYPRLAND_INSTANCE_SIGNATURE" >"$stage/home/.local/state/omarchy/background-intro.session-id" + fi + rm -f "$stage/result.json" + : >"$stage/release" + HOME="$stage/home" OMARCHY_PATH="$ROOT" PATH="$stage/bin:$PATH" INTRO_TEST_RESULT="$stage/result.json" INTRO_TEST_RELEASE="$stage/release" \ + quickshell -p "$stage" --no-color >"$stage/quickshell.log" 2>&1 & + qs_pid=$! + wait_phase cover + jq -e --argjson consumed "$consumed" '.scoped and .privateCoordinator and .selected and (.covered == ($consumed | not))' "$stage/result.json" >/dev/null || \ + fail "the clone is scoped and a consumed login never maps the cover" "$(cat "$stage/result.json")" + if [[ $consumed == false ]]; then + pixel=$(capture_pixel cover) + [[ $pixel == "000000" ]] || fail "a fresh login keeps the cloned wallpaper hidden until startup is ready" "$pixel" + [[ $(magick "$stage/cover.png" -format '%[hex:p{32,10}]' info:) == "000000" ]] || fail "the startup cover hides the bar as well as the wallpaper" + else + [[ $(capture_pixel cover) == "FF00FF" ]] || fail "a consumed login does not flash a cover over the cloned background" + fi + printf 'captured\n' >"$stage/release" + wait_phase still + [[ $(capture_pixel still) == "FF00FF" ]] || fail "releasing the shared cover reveals the cloned background" + printf 'done\n' >"$stage/release" + wait "$qs_pid" || fail "the cloned background fixture exits cleanly" "$(cat "$stage/quickshell.log")" + qs_pid="" + if rg -q 'ReferenceError|TypeError|Unable to assign|Binding loop' "$stage/quickshell.log"; then + fail "the cloned background fixture has no QML errors" "$(cat "$stage/quickshell.log")" + fi +done +pass "a real clone is covered only for startup, with its scoped facade and consumed-login behavior intact" diff --git a/test/shell.d/background-intro-lifecycle-test.sh b/test/shell.d/background-intro-lifecycle-test.sh new file mode 100644 index 00000000..b0338a7a --- /dev/null +++ b/test/shell.d/background-intro-lifecycle-test.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +require_compositor "background intro lifecycle test" +require_command quickshell + +stage=$(mktemp -d) +trap 'rm -rf "$stage"' EXIT +mkdir -p "$stage/services" "$stage/bin" "$stage/home" +printf 'P6\n1 1\n255\n\377\377\377' >"$stage/cover.ppm" +cp "$ROOT/shell/services/BackgroundIntro.qml" "$stage/services/" +ln -s "$ROOT/shell/Commons" "$stage/Commons" +cp "$SHELL_TEST_DIR/fixtures/background-intro-lifecycle/shell.qml" "$stage/shell.qml" +cat >"$stage/bin/omarchy-theme-bg-boot-intro" <<'SH' +#!/bin/bash +printf 'intro\n' >>"$INTRO_TEST_LOG" +sleep 2 +SH +chmod +x "$stage/bin/omarchy-theme-bg-boot-intro" +cat >"$stage/bin/owe" <<'SH' +#!/bin/bash +if [[ -f $INTRO_TEST_FRAME_READY ]]; then + sleep 0.25 + printf '{"kind":"video","ready":true,"has_transition":false,"time_pos":0.05}\n' +else + printf '{"kind":"video","ready":true,"has_transition":true,"time_pos":0}\n' +fi +SH +chmod +x "$stage/bin/owe" +output=$(HOME="$stage/home" PATH="$stage/bin:$PATH" INTRO_TEST_LOG="$stage/starts" INTRO_TEST_COVER="$stage/cover.ppm" INTRO_TEST_FRAME_READY="$stage/video-ready" timeout 10 quickshell -p "$stage" --no-color 2>&1) || fail "background intro fixture exits cleanly" "$output" +[[ $output == *"RESULT pass"* ]] || fail "background intro lifecycle assertions pass" "$output" +if rg -q 'RESULT fail|ReferenceError|TypeError|Error:|Unable to assign|Binding loop' <<<"$output"; then + fail "background intro fixture has no QML errors" "$output" +fi +[[ $(wc -l <"$stage/starts") == 1 ]] || fail "recreating the background service does not run another launcher" +pass "the cover stays while waiting and remains off when OWE recreates the background service" diff --git a/test/shell.d/background-intro-test.sh b/test/shell.d/background-intro-test.sh new file mode 100755 index 00000000..d9bcf07e --- /dev/null +++ b/test/shell.d/background-intro-test.sh @@ -0,0 +1,303 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +intro_home="$test_tmp/home" +intro_runtime="$test_tmp/runtime" +intro_state="$intro_home/.local/state/omarchy/current" +theme_intro_dir="$intro_state/theme/backgrounds/intros" +background="$intro_state/theme/backgrounds/road.webp" +mkdir -p "$(dirname "$background")" "$theme_intro_dir" "$intro_runtime" +printf 'matching still\n' >"$background" +printf 'theme video\n' >"$theme_intro_dir/road.mp4" +printf 'tokyo-night\n' >"$intro_state/theme.name" +ln -s "$background" "$intro_state/background" + +resolved=$(HOME="$intro_home" "$ROOT/bin/omarchy-theme-bg-boot-intro" --resolve-only) +[[ $resolved == "$theme_intro_dir/road.mp4" ]] || fail "a sibling intro matches the current image by filename" "$resolved" +rm "$theme_intro_dir/road.mp4" +resolved=$(HOME="$intro_home" "$ROOT/bin/omarchy-theme-bg-boot-intro" --resolve-only) +[[ -z $resolved ]] || fail "a background without a matching video has no intro" "$resolved" +printf 'theme video\n' >"$theme_intro_dir/road.mp4" + +custom_background="$intro_home/.config/omarchy/backgrounds/tokyo-night/road.webp" +mkdir -p "${custom_background%/*}" +printf 'different still with the same name\n' >"$custom_background" +ln -nsf "$custom_background" "$intro_state/background" +resolved=$(HOME="$intro_home" "$ROOT/bin/omarchy-theme-bg-boot-intro" --resolve-only) +[[ -z $resolved ]] || fail "a different image directory does not inherit the theme intro" "$resolved" +ln -nsf "$background" "$intro_state/background" + +toggle="$intro_home/.local/state/omarchy/toggles/background-intros-off" +marker="$intro_home/.local/state/omarchy/background-intro.session-id" +command_bin="$test_tmp/bin" +command_log="$test_tmp/command-log" +mkdir -p "$command_bin" +cat >"$command_bin/owe" <<'SH' +#!/bin/bash +printf 'owe: %s\n' "$*" >>"$COMMAND_LOG" +case ${1:-} in + intro) + [[ -z ${COVER_READY_FILE:-} || -f $COVER_READY_FILE ]] || exit 1 + [[ ${OWE_FAIL:-} != "interrupted" && ${OWE_FAIL:-} != "unavailable" ]] || exit 1 + [[ -z ${OWE_READY_FILE:-} || -f $OWE_READY_FILE ]] || exit 1 + ;; + status) + [[ ${OWE_FAIL:-} != "unavailable" ]] || exit 1 + [[ -z ${OWE_READY_FILE:-} || -f $OWE_READY_FILE ]] || exit 1 + printf '{"status":"ok"}\n' + ;; +esac +SH +chmod +x "$command_bin/owe" +cat >"$command_bin/hyprctl" <<'SH' +#!/bin/bash +printf '{"option":"animations:enabled","int":%s,"bool":%s}\n' \ + "$([[ ${ANIMATIONS:-on} == on ]] && echo 1 || echo 0)" "$([[ ${ANIMATIONS:-on} == on ]] && echo true || echo false)" +SH +chmod +x "$command_bin/hyprctl" +cat >"$command_bin/omarchy-shell" <<'SH' +#!/bin/bash +if [[ $2 == "themeIntroCoverStatus" ]]; then + if [[ -n ${COVER_READY_FILE:-} && ! -f $COVER_READY_FILE ]]; then + echo "loading" + else + echo "${COVER_STATUS:-ready}" + fi +fi +SH +chmod +x "$command_bin/omarchy-shell" + +mkdir -p "$(dirname "$toggle")" +resolved=$(PATH="$command_bin:$PATH" HOME="$intro_home" "$ROOT/bin/omarchy-theme-bg-boot-intro" --resolve-startup) +[[ $resolved == "$theme_intro_dir/road.mp4" ]] || fail "OWE can prepare the selected intro while the shell starts" "$resolved" +[[ ! -e $marker && ! -s $command_log ]] || fail "startup preparation does not consume the login or start playback" +resolved=$(PATH="$command_bin:$PATH" HOME="$intro_home" ANIMATIONS=off "$ROOT/bin/omarchy-theme-bg-boot-intro" --resolve-startup) +[[ -z $resolved ]] || fail "disabled animations skip startup preparation" +touch "$toggle" +resolved=$(PATH="$command_bin:$PATH" HOME="$intro_home" "$ROOT/bin/omarchy-theme-bg-boot-intro" --resolve-startup) +[[ -z $resolved ]] || fail "disabled intros skip startup preparation" +pass "speculative login preparation respects settings and leaves playback to the shell" +resolved=$(PATH="$command_bin:$PATH" HOME="$intro_home" XDG_RUNTIME_DIR="$intro_runtime" COMMAND_LOG="$command_log" OMARCHY_SESSION_ID=disabled-boot "$ROOT/bin/omarchy-theme-bg-boot-intro") +[[ -z $resolved && $(<"$marker") == "disabled-boot" ]] || fail "the global toggle consumes the current login without playing" +rm "$toggle" +resolved=$(PATH="$command_bin:$PATH" HOME="$intro_home" XDG_RUNTIME_DIR="$intro_runtime" COMMAND_LOG="$command_log" OMARCHY_SESSION_ID=disabled-boot "$ROOT/bin/omarchy-theme-bg-boot-intro") +[[ -z $resolved ]] || fail "enabling midway through a login does not start a delayed intro" "$resolved" + +: >"$command_log" +PATH="$command_bin:$PATH" HOME="$intro_home" XDG_RUNTIME_DIR="$intro_runtime" COMMAND_LOG="$command_log" ANIMATIONS=off OMARCHY_SESSION_ID=still-boot "$ROOT/bin/omarchy-theme-bg-boot-intro" +[[ $(<"$marker") == "still-boot" ]] || fail "with animations off the session is consumed" +! grep -q '^owe: intro ' "$command_log" || fail "with animations off no intro plays" + +rm "$marker" +: >"$command_log" +started=$SECONDS +if ! PATH="$command_bin:$PATH" HOME="$intro_home" XDG_RUNTIME_DIR="$intro_runtime" COMMAND_LOG="$command_log" OWE_FAIL=unavailable OMARCHY_SESSION_ID=unavailable-boot "$ROOT/bin/omarchy-theme-bg-boot-intro"; then + fail "an unavailable OWE settles startup rather than requesting another launcher" +fi +(( SECONDS - started >= 4 && SECONDS - started <= 7 )) || fail "startup waits only through the five-second window" "$((SECONDS - started))" +[[ $(<"$marker") == "unavailable-boot" ]] || fail "an unavailable OWE still settles the session" +! grep -q '^owe: intro ' "$command_log" || fail "an unavailable OWE never starts an intro" +PATH="$command_bin:$PATH" HOME="$intro_home" XDG_RUNTIME_DIR="$intro_runtime" COMMAND_LOG="$command_log" OMARCHY_SESSION_ID=unavailable-boot "$ROOT/bin/omarchy-theme-bg-boot-intro" +! grep -q '^owe: intro ' "$command_log" || fail "starting OWE after the deadline does not play a late intro" + +rm "$marker" +: >"$command_log" +ready="$test_tmp/owe-ready" +(sleep 1; touch "$ready") & +ready_pid=$! +PATH="$command_bin:$PATH" HOME="$intro_home" XDG_RUNTIME_DIR="$intro_runtime" COMMAND_LOG="$command_log" OWE_READY_FILE="$ready" OMARCHY_SESSION_ID=delayed-boot "$ROOT/bin/omarchy-theme-bg-boot-intro" +wait "$ready_pid" +[[ $(<"$marker") == "delayed-boot" ]] || fail "waiting for OWE keeps the session consumed" +grep -Fxq "owe: intro --start first-frame $theme_intro_dir/road.mp4" "$command_log" || fail "a ready OWE starts on the video's first frame without revealing the still" + +rm "$marker" "$ready" +: >"$command_log" +PATH="$command_bin:$PATH" HOME="$intro_home" XDG_RUNTIME_DIR="$intro_runtime" COMMAND_LOG="$command_log" OWE_READY_FILE="$ready" OMARCHY_SESSION_ID=restart-while-waiting "$ROOT/bin/omarchy-theme-bg-boot-intro" & +waiting_pid=$! +for attempt in {1..100}; do + [[ -f $marker && $(<"$marker") == "restart-while-waiting" ]] && break + sleep 0.01 +done +if ! PATH="$command_bin:$PATH" HOME="$intro_home" XDG_RUNTIME_DIR="$intro_runtime" COMMAND_LOG="$command_log" OWE_READY_FILE="$ready" OMARCHY_SESSION_ID=restart-while-waiting timeout 1 "$ROOT/bin/omarchy-theme-bg-boot-intro"; then + kill "$waiting_pid" 2>/dev/null || true + wait "$waiting_pid" || true + fail "a restarted shell reads the consumed session without waiting for the old launcher" +fi +touch "$ready" +wait "$waiting_pid" + +rm "$marker" "$ready" +: >"$command_log" +(sleep 0.5; touch "$toggle" "$ready") & +ready_pid=$! +PATH="$command_bin:$PATH" HOME="$intro_home" XDG_RUNTIME_DIR="$intro_runtime" COMMAND_LOG="$command_log" OWE_READY_FILE="$ready" OMARCHY_SESSION_ID=disabled-while-waiting "$ROOT/bin/omarchy-theme-bg-boot-intro" +wait "$ready_pid" +! grep -q '^owe: intro ' "$command_log" || fail "disabling intros while waiting cancels startup playback" +rm "$toggle" "$ready" "$marker" +: >"$command_log" +(sleep 0.5; ln -nsf "$custom_background" "$intro_state/background"; touch "$ready") & +ready_pid=$! +PATH="$command_bin:$PATH" HOME="$intro_home" XDG_RUNTIME_DIR="$intro_runtime" COMMAND_LOG="$command_log" OWE_READY_FILE="$ready" OMARCHY_SESSION_ID=changed-while-waiting "$ROOT/bin/omarchy-theme-bg-boot-intro" +wait "$ready_pid" +! grep -q '^owe: intro ' "$command_log" || fail "changing the background while waiting cancels its old intro" +ln -nsf "$background" "$intro_state/background" + +rm "$marker" +if PATH="$command_bin:$PATH" HOME="$intro_home" XDG_RUNTIME_DIR="$intro_runtime" COMMAND_LOG="$command_log" OWE_FAIL=interrupted OMARCHY_SESSION_ID=interrupted-boot "$ROOT/bin/omarchy-theme-bg-boot-intro"; then + fail "an interrupted intro reports a failure" +else + status=$? + (( status == 1 )) || fail "an interrupted intro does not request a retry" "$status" +fi +[[ $(<"$marker") == "interrupted-boot" ]] || fail "an intro interrupted after OWE accepted it still consumes the session" + +rm "$marker" +: >"$command_log" +for index in {1..32}; do + PATH="$command_bin:$PATH" HOME="$intro_home" XDG_RUNTIME_DIR="$intro_runtime" COMMAND_LOG="$command_log" OMARCHY_SESSION_ID=concurrent-boot "$ROOT/bin/omarchy-theme-bg-boot-intro" >"$test_tmp/output.$index" & + intro_pids[$index]=$! +done +for intro_pid in "${intro_pids[@]}"; do + wait "$intro_pid" +done +intro_count=$(grep -c '^owe: intro ' "$command_log") +(( intro_count == 1 )) || fail "concurrent launchers start exactly one intro" "$intro_count" + +pass "login intros settle the session once, wait briefly for OWE, and start on the first frame" + +: >"$command_log" +for session in first-login first-login second-login; do + PATH="$command_bin:$PATH" HOME="$intro_home" XDG_RUNTIME_DIR="$intro_runtime" COMMAND_LOG="$command_log" \ + OMARCHY_SESSION_ID="" HYPRLAND_INSTANCE_SIGNATURE="$session" "$ROOT/bin/omarchy-theme-bg-boot-intro" +done +intro_count=$(grep -c '^owe: intro ' "$command_log") +(( intro_count == 2 )) || fail "a new login replays the intro while a shell restart in the same session does not" "$intro_count" +[[ $(<"$marker") == "second-login" ]] || fail "the marker identifies the latest compositor session" +pass "logging out and back in replays the intro without requiring a reboot" + +ln -s "$ROOT/bin/omarchy-theme-bg-boot-intro" "$command_bin/omarchy-theme-bg-boot-intro" + +cat >"$command_bin/omarchy-notification-send" <<'SH' +#!/bin/bash +printf 'notification: %s\n' "$*" >>"$COMMAND_LOG" +SH +cat >"$command_bin/omarchy-theme-bg-current" <<'SH' +#!/bin/bash +printf 'Road\n' +SH +chmod +x "$command_bin"/omarchy-* + +PATH="$command_bin:$PATH" HOME="$intro_home" COMMAND_LOG="$command_log" "$ROOT/bin/omarchy-theme-bg-intro" disable +if PATH="$command_bin:$PATH" HOME="$intro_home" "$ROOT/bin/omarchy-theme-bg-intro" status; then + fail "disabled background intros report a disabled status" +fi +PATH="$command_bin:$PATH" HOME="$intro_home" COMMAND_LOG="$command_log" "$ROOT/bin/omarchy-theme-bg-intro" enable +PATH="$command_bin:$PATH" HOME="$intro_home" "$ROOT/bin/omarchy-theme-bg-intro" status || fail "enabled background intros report an enabled status" +pass "background intros have one global on/off control" + +: >"$command_log" +before_marker=$(<"$marker") +PATH="$command_bin:$PATH" HOME="$intro_home" COMMAND_LOG="$command_log" "$ROOT/bin/omarchy-theme-bg-boot-intro" --theme-switch "$background" "$(stat -Lc '%d:%i' "$intro_state/theme")" +grep -Fxq 'owe: refresh' "$command_log" || fail "unprepared theme switching synchronizes OWE with the selected image" +grep -Fxq "owe: intro --start first-frame $theme_intro_dir/road.mp4" "$command_log" || fail "theme switching plays its matching intro even after boot was consumed" +: >"$command_log" +PATH="$command_bin:$PATH" HOME="$intro_home" COMMAND_LOG="$command_log" "$ROOT/bin/omarchy-theme-bg-boot-intro" --theme-switch "$background" "$(stat -Lc '%d:%i' "$intro_state/theme")" true +grep -Fxq "owe: intro --start first-frame --refresh $theme_intro_dir/road.mp4" "$command_log" || fail "prepared theme switching synchronizes and starts in one call" +! grep -Fxq 'owe: refresh' "$command_log" || fail "prepared playback avoids an extra refresh call" +cover_ready="$test_tmp/cover-ready" +(sleep 0.2; touch "$cover_ready") & +cover_pid=$! +PATH="$command_bin:$PATH" HOME="$intro_home" COMMAND_LOG="$command_log" COVER_READY_FILE="$cover_ready" "$ROOT/bin/omarchy-theme-bg-boot-intro" --theme-switch "$background" "$(stat -Lc '%d:%i' "$intro_state/theme")" true || fail "playback waits for the outgoing cover before starting" +wait "$cover_pid" +: >"$command_log" +if PATH="$command_bin:$PATH" HOME="$intro_home" COMMAND_LOG="$command_log" COVER_STATUS=error "$ROOT/bin/omarchy-theme-bg-boot-intro" --theme-switch "$background" "$(stat -Lc '%d:%i' "$intro_state/theme")" true; then + fail "a failed cover requests the still fallback" +fi +! grep -q '^owe: intro ' "$command_log" || fail "a failed outgoing cover cannot expose an intro" +[[ $(<"$marker") == "$before_marker" ]] || fail "theme switching does not reopen the session marker" +: >"$command_log" +PATH="$command_bin:$PATH" HOME="$intro_home" COMMAND_LOG="$command_log" "$ROOT/bin/omarchy-theme-bg-boot-intro" --theme-switch "$custom_background" "$(stat -Lc '%d:%i' "$intro_state/theme")" +! grep -q '^owe: intro ' "$command_log" || fail "a superseded theme switch does not play an old intro" +theme_id=$(stat -Lc '%d:%i' "$intro_state/theme") +: >"$command_log" +if PATH="$command_bin:$PATH" HOME="$intro_home" COMMAND_LOG="$command_log" ANIMATIONS=off "$ROOT/bin/omarchy-theme-bg-boot-intro" --theme-switch "$background" "$theme_id"; then + fail "a current theme launch requests its still fallback when animations were disabled" +fi +! grep -q '^owe: intro ' "$command_log" || fail "a canceled theme intro does not play" +mv "$intro_state/theme" "$intro_state/previous-theme" +cp -r "$intro_state/previous-theme" "$intro_state/theme" +: >"$command_log" +PATH="$command_bin:$PATH" HOME="$intro_home" COMMAND_LOG="$command_log" "$ROOT/bin/omarchy-theme-bg-boot-intro" --theme-switch "$background" "$theme_id" +! grep -q '^owe: intro ' "$command_log" || fail "reused filenames do not let a superseded theme launch play" +pass "theme switches can play their selected intro without resetting login startup" + +migration_home="$test_tmp/migration-home" +migration_bin="$test_tmp/migration-bin" +migration_calls="$test_tmp/migration-calls" +mkdir -p "$migration_home/.local/state/omarchy/current" "$migration_bin" +printf 'catppuccin\n' >"$migration_home/.local/state/omarchy/current/theme.name" +cat >"$migration_bin/omarchy-theme-refresh" <<'SH' +#!/bin/bash +printf 'refresh\n' >>"$MIGRATION_CALLS" +SH +chmod +x "$migration_bin/omarchy-theme-refresh" + +HOME="$migration_home" PATH="$migration_bin:$PATH" MIGRATION_CALLS="$migration_calls" OMARCHY_PATH="$ROOT" OMARCHY_SESSION_ID=migration-boot bash -euo pipefail "$ROOT/migrations/1788281348.sh" >/dev/null +[[ $(<"$migration_calls") == "refresh" ]] || fail "the migration refreshes an active theme with packaged intros" +[[ $(<"$migration_home/.local/state/omarchy/background-intro.session-id") == "migration-boot" ]] || fail "the migration defers a newly installed intro until the next login" + +mkdir -p "$intro_runtime/hypr/older-session" "$intro_runtime/hypr/current-session" +touch -d '2 minutes ago' "$intro_runtime/hypr/older-session" +HOME="$migration_home" PATH="$migration_bin:$PATH" MIGRATION_CALLS="$migration_calls" OMARCHY_PATH="$ROOT" \ + OMARCHY_SESSION_ID="" HYPRLAND_INSTANCE_SIGNATURE="" XDG_RUNTIME_DIR="$intro_runtime" \ + bash -euo pipefail "$ROOT/migrations/1788281348.sh" >/dev/null +[[ $(<"$migration_home/.local/state/omarchy/background-intro.session-id") == "current-session" ]] || fail "an update outside Hyprland consumes the session that shell restart will use" +HOME="$migration_home" PATH="$migration_bin:$PATH" MIGRATION_CALLS="$migration_calls" OMARCHY_PATH="$ROOT" \ + OMARCHY_SESSION_ID="" HYPRLAND_INSTANCE_SIGNATURE="" XDG_RUNTIME_DIR="$test_tmp/no-session" \ + bash -euo pipefail "$ROOT/migrations/1788281348.sh" >/dev/null +[[ $(<"$migration_home/.local/state/omarchy/background-intro.session-id") == "current-session" ]] || fail "a headless update leaves the previous session marker alone" + +pass "the migration does not start a login intro during an update" + +packaged_pairs=0 +for intro in "$ROOT"/themes/*/backgrounds/intros/*.mp4; do + [[ -f $intro ]] || continue + backgrounds=${intro%/intros/*} + stem=${intro##*/} + stem=${stem%.mp4} + matches=0 + for extension in jpg jpeg png bmp webp; do + [[ ! -f $backgrounds/$stem.$extension ]] || ((++matches)) + done + (( matches == 1 )) || fail "$intro has exactly one matching still image" "$matches" + ((++packaged_pairs)) +done +(( packaged_pairs > 0 )) || fail "no packaged theme intros were found" +pass "theme intro filenames match their sibling background images" + +# Picker preparation is speculative and must not consume login startup or play. +prepare_root="$test_tmp/prepare-repo" +prepare_backgrounds="$prepare_root/themes/demo/backgrounds" +mkdir -p "$prepare_backgrounds/intros" "$intro_home/.local/state/omarchy/theme-backgrounds" +printf 'first still\n' >"$prepare_backgrounds/1-first.webp" +printf 'remembered still\n' >"$prepare_backgrounds/2-remembered.webp" +printf 'remembered video\n' >"$prepare_backgrounds/intros/2-remembered.mp4" +printf '%s\n' "$intro_state/theme/backgrounds/2-remembered.webp" >"$intro_home/.local/state/omarchy/theme-backgrounds/demo" +rm -f "$toggle" +: >"$command_log" +marker_before=$(<"$marker") +PATH="$command_bin:$PATH" HOME="$intro_home" OMARCHY_PATH="$prepare_root" COMMAND_LOG="$command_log" "$ROOT/bin/omarchy-theme-bg-boot-intro" --prepare-theme demo +grep -Fxq "owe: intro-prepare $prepare_backgrounds/intros/2-remembered.mp4" "$command_log" || fail "picker preparation follows the theme's remembered background" +! grep -q '^owe: intro ' "$command_log" || fail "preparation does not play an intro" +[[ $(<"$marker") == "$marker_before" ]] || fail "preparation leaves login startup alone" +touch "$toggle" +: >"$command_log" +PATH="$command_bin:$PATH" HOME="$intro_home" OMARCHY_PATH="$prepare_root" COMMAND_LOG="$command_log" "$ROOT/bin/omarchy-theme-bg-boot-intro" --prepare-theme demo +[[ ! -s $command_log ]] || fail "disabled intros do not warm the renderer" +pass "picker preparation respects the remembered background, login startup, and intro toggle" diff --git a/test/shell.d/background-pending-theme-test.sh b/test/shell.d/background-pending-theme-test.sh new file mode 100755 index 00000000..8e82a0c8 --- /dev/null +++ b/test/shell.d/background-pending-theme-test.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +require_compositor "pending background theme cancellation" +require_command quickshell +require_command python3 +require_command magick + +stage=$(mktemp -d) +trap 'rm -rf "$stage"' EXIT +mkdir -p "$stage/home/.local/state/omarchy/current" +ln -s "$ROOT/shell/plugins/background" "$stage/background" +ln -s "$ROOT/shell/Commons" "$stage/Commons" +ln -s "$ROOT/shell/Ui" "$stage/Ui" +magick -size 128x128 xc:magenta "$stage/still.png" +ln -s "$stage/still.png" "$stage/home/.local/state/omarchy/current/background" +python3 - "$ROOT/shell/shell.qml" "$SHELL_TEST_DIR/fixtures/background-pending-theme/shell.qml" "$stage/shell.qml" <<'PY' +import re +import sys +from pathlib import Path +source = Path(sys.argv[1]).read_text() +method = re.search(r'^ function applyTheme\([^\n]+\n.*?^ }', source, re.M | re.S) +assert method, 'real shell applyTheme method not found' +fixture = Path(sys.argv[2]).read_text() +Path(sys.argv[3]).write_text(fixture.replace(' // APPLY_THEME_FUNCTION', method.group())) +PY +output=$(HOME="$stage/home" timeout 10 quickshell -p "$stage" --no-color 2>&1) || fail "pending theme fixture exits cleanly" "$output" +[[ $output == *"RESULT pass"* && $output != *"RESULT fail"* ]] || fail "the authoritative palette survives the previous background fallback timer" "$output" +pass "a pending still transition cannot restore old colors after the current theme is applied" diff --git a/test/shell.d/background-startup-failure-test.sh b/test/shell.d/background-startup-failure-test.sh new file mode 100755 index 00000000..22465857 --- /dev/null +++ b/test/shell.d/background-startup-failure-test.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" +require_compositor "failed background startup" +require_command quickshell + +stage=$(mktemp -d) +trap 'rm -rf "$stage"' EXIT +mkdir -p "$stage/bin" "$stage/home/.local/state/omarchy/current" +ln -s "$stage/deleted.png" "$stage/home/.local/state/omarchy/current/background" +for component in Commons Ui services; do + ln -s "$ROOT/shell/$component" "$stage/$component" +done +ln -s "$ROOT/shell/plugins/background" "$stage/background" +cp "$SHELL_TEST_DIR/fixtures/background-startup-failure/shell.qml" "$stage/shell.qml" +cat >"$stage/bin/omarchy-theme-bg-boot-intro" <<'SH' +#!/bin/bash +exit 0 +SH +chmod +x "$stage/bin/omarchy-theme-bg-boot-intro" + +output=$(HOME="$stage/home" PATH="$stage/bin:$PATH" timeout 13 quickshell -p "$stage" --no-color 2>&1) || fail "failed background startup exits cleanly" "$output" +[[ $output == *"RESULT pass"* ]] || fail "a missing wallpaper reveals the desktop within the startup deadline" "$output" +if rg -q 'RESULT fail|ReferenceError|TypeError|Unable to assign|Binding loop' <<<"$output"; then + fail "failed background startup has no QML errors" "$output" +fi +pass "a missing wallpaper reveals the desktop within the startup deadline" diff --git a/test/shell.d/background-startup-test.sh b/test/shell.d/background-startup-test.sh new file mode 100644 index 00000000..624e80a6 --- /dev/null +++ b/test/shell.d/background-startup-test.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +require_compositor "background startup test" +require_command quickshell + +stage=$(mktemp -d) +trap 'rm -rf "$stage"' EXIT +mkdir -p "$stage/services" "$stage/bin" "$stage/home/.local/state/omarchy/current" +printf 'P6\n1 1\n255\n\377\000\377' >"$stage/still.ppm" +ln -s "$stage/still.ppm" "$stage/home/.local/state/omarchy/current/background" +ln -s "$ROOT/shell/Commons" "$stage/Commons" +cp "$ROOT/shell/services/BackgroundIntro.qml" "$stage/services/" +cp "$SHELL_TEST_DIR/fixtures/background-startup/shell.qml" "$stage/shell.qml" +cat >"$stage/bin/omarchy-theme-bg-boot-intro" <<'SH' +#!/bin/bash +exit 0 +SH +chmod +x "$stage/bin/omarchy-theme-bg-boot-intro" +cat >"$stage/bin/hyprctl" <<'SH' +#!/bin/bash +if [[ $1 == "eval" ]]; then + printf '%s\n' "$*" >>"$STARTUP_CURSOR_LOG" +else + /usr/bin/hyprctl "$@" +fi +SH +chmod +x "$stage/bin/hyprctl" +: >"$stage/cursor.log" +output=$(HOME="$stage/home" PATH="$stage/bin:$PATH" STARTUP_CURSOR_LOG="$stage/cursor.log" timeout 6 quickshell -p "$stage" --no-color 2>&1) || fail "background startup fixture exits cleanly" "$output" +[[ $output == *"RESULT pass"* ]] || fail "the wallpaper restores before background plugin loading" "$output" +if rg -q 'RESULT fail|ReferenceError|TypeError|Error:|Unable to assign|Binding loop' <<<"$output"; then + fail "background startup fixture has no QML errors" "$output" +fi +pass "fresh startup hides the desktop until both the background and bar are ready" +pass "the startup cursor stays hidden until the desktop begins fading in" diff --git a/test/shell.d/bar-test.sh b/test/shell.d/bar-test.sh index 7820fcca..bc2b945f 100644 --- a/test/shell.d/bar-test.sh +++ b/test/shell.d/bar-test.sh @@ -282,7 +282,7 @@ assert( 'bar uses nearest insertion targeting for widget and free-space drops' ) assert( - /component DragGhostPanel:[\s\S]*?readonly property var targetRect: root\.barDragTargetGeometry[\s\S]*?color: Color\.accent/.test(barSource), + /component DragGhostPanel:[\s\S]*?readonly property var targetRect: root\.barDragTargetGeometry[\s\S]*?color: Commons\.Color\.accent/.test(barSource), 'bar draws the insertion marker above the bar in the drag overlay' ) diff --git a/test/shell.d/chromium-overlay-scrollbars-migration-test.sh b/test/shell.d/chromium-overlay-scrollbars-migration-test.sh new file mode 100644 index 00000000..50a4d7e5 --- /dev/null +++ b/test/shell.d/chromium-overlay-scrollbars-migration-test.sh @@ -0,0 +1,46 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +migration="$ROOT/migrations/1791376887.sh" +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +home="$test_tmp/home" +config_dir="$home/.config" +mkdir -p "$config_dir" "$test_tmp/targets" + +printf '%s\n' '--enable-features=TouchpadOverscrollHistoryNavigation' >"$config_dir/chromium-flags.conf" +printf '%s\n' '--enable-features=' >"$config_dir/chrome-flags.conf" +printf '%s' '--ozone-platform=wayland' >"$config_dir/microsoft-edge-stable-flags.conf" +printf '%s\n' '--enable-features=BraveFeature' >"$test_tmp/targets/brave-flags.conf" +ln -s "$test_tmp/targets/brave-flags.conf" "$config_dir/brave-flags.conf" + +run_migration() { + HOME="$home" bash -euo pipefail "$migration" >/dev/null +} + +run_migration +run_migration + +grep -Fxq -- '--enable-features=TouchpadOverscrollHistoryNavigation,OverlayScrollbar' "$config_dir/chromium-flags.conf" || + fail "the migration preserves existing Chromium features" +overlay_count=$(grep -o 'OverlayScrollbar' "$config_dir/chromium-flags.conf" | wc -l) +(( overlay_count == 1 )) || fail "the migration does not duplicate the Chromium feature" +pass "the migration preserves existing features and can run twice" + +[[ $(cat "$config_dir/chrome-flags.conf") == '--enable-features=OverlayScrollbar' ]] || + fail "the migration fills an empty feature list" +[[ $(cat "$config_dir/microsoft-edge-stable-flags.conf") == $'--ozone-platform=wayland\n--enable-features=OverlayScrollbar' ]] || + fail "the migration adds a feature list with a missing trailing newline" +pass "the migration handles empty and missing feature lists" + +[[ -L $config_dir/brave-flags.conf ]] || fail "the migration preserves symlinked browser flags" +grep -Fxq -- '--enable-features=BraveFeature,OverlayScrollbar' "$config_dir/brave-flags.conf" || + fail "the migration updates the target of symlinked browser flags" +pass "the migration follows symlinked browser flags" + +[[ ! -e $config_dir/brave-origin-flags.conf ]] || fail "the migration leaves missing browser flags absent" +pass "the migration skips missing browser flags" diff --git a/test/shell.d/dictation-test.sh b/test/shell.d/dictation-test.sh new file mode 100644 index 00000000..57c67638 --- /dev/null +++ b/test/shell.d/dictation-test.sh @@ -0,0 +1,342 @@ +#!/bin/bash + +set -euo pipefail +source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT +export HOME="$test_tmp/home" XDG_CONFIG_HOME="$test_tmp/config" OMARCHY_PATH="$ROOT" +export DICTATION_LOG="$test_tmp/calls" DICTATION_INSTALLED="voxtype superwhisper" +mkdir -p "$test_tmp/bin" "$XDG_CONFIG_HOME/omarchy/defaults" +export PATH="$test_tmp/bin:$ROOT/bin:$PATH" +config="$XDG_CONFIG_HOME/omarchy/defaults/dictation" + +cat > "$test_tmp/bin/omarchy-cmd-present" <<'SH' +#!/bin/bash +case "$1" in + omarchy-dictation-*|omarchy-install-dictation-*) command -v "$1" >/dev/null ;; + *) [[ " $DICTATION_INSTALLED " == *" $1 "* ]] ;; +esac +SH +cat > "$test_tmp/bin/omarchy-cmd-missing" <<'SH' +#!/bin/bash +! omarchy-cmd-present "$1" +SH +cat > "$test_tmp/bin/omarchy-launch-floating-terminal-with-presentation" <<'SH' +#!/bin/bash +printf '%s\n' "$*" > "$DICTATION_INSTALL_LOG" +SH +export DICTATION_INSTALL_LOG="$test_tmp/install" +for backend in voxtype superwhisper; do + cat > "$test_tmp/bin/$backend" <<'SH' +#!/bin/bash +printf '%s %s\n' "${0##*/}" "$*" >> "$DICTATION_LOG" +if [[ $1 == "status" ]]; then echo "${VOXTYPE_STATUS:-idle}"; exit 0; fi +if [[ -n ${FAIL_SETUP_STEP:-} && $* == "$FAIL_SETUP_STEP" ]]; then exit 7; fi +if [[ ${0##*/} == "superwhisper" && $1 == "shortcuts" && ${DICTATION_EXIT:-0} == 0 ]]; then + if [[ $2 == "show" ]]; then + cat "$SHORTCUT_STATE" + elif [[ $2 == "set" ]]; then + chord=$4 + [[ $chord != "none" ]] || chord="" + updated=$(jq --arg action "$3" --arg chord "$chord" '.shortcuts[$action] = $chord' "$SHORTCUT_STATE") + jq -e '.shortcuts | .hold != "RightAlt" and (.hold != "" or .toggle != "")' <<< "$updated" >/dev/null || exit 1 + printf '%s\n' "$updated" > "$SHORTCUT_STATE" + fi +fi +exit "${DICTATION_EXIT:-0}" +SH +done +for command in omarchy-pkg-add omarchy-notification-send hyprctl omarchy-restart-shell; do + cat > "$test_tmp/bin/$command" <<'SH' +#!/bin/bash +printf '%s %s\n' "${0##*/}" "$*" >> "$DICTATION_LOG" +exit "${SETUP_EXIT:-0}" +SH +done +cat > "$test_tmp/bin/systemctl" <<'SH' +#!/bin/bash +if [[ $* == "--user is-active --quiet superwhisper.service" ]]; then + [[ ${DICTATION_SERVICE_ACTIVE:-1} == 1 ]] +else + printf '%s %s\n' "${0##*/}" "$*" >> "$DICTATION_LOG" + exit "${SERVICE_EXIT:-0}" +fi +SH +cat > "$test_tmp/bin/omarchy-hw-vulkan" <<'SH' +#!/bin/bash +exit 1 +SH +cat > "$test_tmp/bin/gum" <<'SH' +#!/bin/bash +exit "${CONFIRM_EXIT:-0}" +SH +cat > "$test_tmp/bin/bash" <<'SH' +#!/bin/bash +if [[ $1 == "/usr/share/superwhisper/setup-user" ]]; then + printf '%s\n' setup-user >> "$DICTATION_LOG" + exit "${SETUP_EXIT:-0}" +else + exec /bin/bash "$@" +fi +SH +chmod +x "$test_tmp/bin/"* + +if omarchy-dictation start 2> "$test_tmp/error"; then + fail "unset backend must fail even when providers are installed" +fi +[[ ! -e $DICTATION_LOG ]] || fail "recording never configures or installs a backend" +pass "dictation requires explicit configuration" +omarchy-default-dictation superwhisper +[[ $(cat "$DICTATION_INSTALL_LOG") == "omarchy-install-dictation-superwhisper" ]] || fail "Defaults launches backend installer" +[[ ! -e $config ]] || fail "Defaults does not select before setup succeeds" +if omarchy-default-dictation unknown > "$test_tmp/output" 2>&1; then fail "missing installer must fail"; fi +if omarchy-default-dictation '../invalid' > "$test_tmp/output" 2>&1; then fail "invalid backend must fail"; fi +pass "Defaults delegates setup without prematurely selecting a backend" + + +for backend in voxtype superwhisper; do + printf '%s\n' "$backend" > "$config" + [[ $(omarchy-default-dictation) == "$backend" ]] || fail "configured backend is readable" + : > "$DICTATION_LOG" + for action in start stop toggle; do omarchy dictation "$action"; done + if [[ $backend == "voxtype" ]]; then + expected=$'voxtype record start\nvoxtype status\nvoxtype record stop\nvoxtype record toggle' + else + expected=$'superwhisper start\nsuperwhisper stop\nsuperwhisper record' + fi + [[ $(cat "$DICTATION_LOG") == "$expected" ]] || fail "adapter dispatches recording for $backend" +done +pass "built-in adapters implement start stop and toggle" + +cat > "$test_tmp/bin/omarchy-dictation-future-backend" <<'SH' +#!/bin/bash +[[ ! -e /proc/$$/fd/9 ]] || exit 90 +printf '%s\n' "$1" >> "$DICTATION_LOG" +exit "${DICTATION_EXIT:-0}" +SH +chmod +x "$test_tmp/bin/omarchy-dictation-future-backend" +cat > "$test_tmp/bin/omarchy-install-dictation-future-backend" <<'SH' +#!/bin/bash +exit 0 +SH +chmod +x "$test_tmp/bin/omarchy-install-dictation-future-backend" +omarchy-default-dictation future-backend +[[ $(cat "$DICTATION_INSTALL_LOG") == "omarchy-install-dictation-future-backend" ]] || fail "third backend installer requires no selector changes" +printf '%s\n' future-backend > "$config" +: > "$DICTATION_LOG" +for action in start stop toggle; do omarchy-dictation "$action"; done +[[ $(cat "$DICTATION_LOG") == $'start\nstop\ntoggle' ]] || fail "third backend requires no dispatcher changes" +result=0 +DICTATION_EXIT=7 omarchy-dictation start || result=$? +(( result == 7 )) || fail "backend failures propagate" +rm "$test_tmp/bin/omarchy-dictation-future-backend" +if omarchy-dictation start 2> "$test_tmp/error"; then fail "missing adapter must fail"; fi +printf '%s\n' '../invalid' > "$config" +if omarchy-dictation start 2> "$test_tmp/error"; then fail "invalid backend must fail"; fi +pass "external adapters follow the same protocol and errors propagate" + +printf '%s\n' voxtype > "$config" +export SHORTCUT_STATE="$test_tmp/shortcuts.json" +printf '%s\n' '{"shortcuts":{"hold":"RightAlt","toggle":"","cancel":"Escape"}}' > "$SHORTCUT_STATE" +if SETUP_EXIT=1 omarchy-install-dictation-superwhisper > "$test_tmp/output" 2>&1; then fail "failed installation must fail"; fi +[[ $(cat "$config") == "voxtype" ]] || fail "failed setup must preserve selection" +if DICTATION_EXIT=7 omarchy-install-dictation-superwhisper > "$test_tmp/output" 2>&1; then fail "failed shortcut setup must fail"; fi +[[ $(cat "$config") == "voxtype" ]] || fail "failed shortcuts must preserve selection" +omarchy-install-dictation-superwhisper +[[ $(omarchy-default-dictation) == "superwhisper" ]] || fail "installer selects Superwhisper" +jq -e '.shortcuts | .hold == "" and .toggle == "Alt+Space"' "$SHORTCUT_STATE" >/dev/null || fail "installer repairs conflicting hold-only profiles" +printf '%s\n' '{"shortcuts":{"hold":"RightAlt","toggle":"RightSuper","cancel":"Escape"}}' > "$SHORTCUT_STATE" +omarchy-install-dictation-superwhisper +jq -e '.shortcuts | .hold == "" and .toggle == "Alt+Space"' "$SHORTCUT_STATE" >/dev/null || fail "installer repairs conflicting profiles with an existing toggle" +omarchy-install-dictation-superwhisper +pass "Superwhisper installer owns setup and saves selection only after success" + +DICTATION_INSTALLED=superwhisper +CONFIRM_EXIT=1 omarchy-install-dictation-voxtype +[[ $(cat "$config") == "superwhisper" ]] || fail "cancelled installation preserves selection" +omarchy-install-dictation-voxtype +[[ $(omarchy-default-dictation) == "voxtype" ]] || fail "installer selects Voxtype" +DICTATION_INSTALLED="voxtype superwhisper" +: > "$DICTATION_LOG" +omarchy-install-dictation-voxtype +grep -q 'voxtype setup --download --no-post-install' "$DICTATION_LOG" || fail "installed Voxtype retries model setup" +printf '%s\n' 'custom model configuration' > "$XDG_CONFIG_HOME/voxtype/config.toml" +printf '%s\n' superwhisper > "$config" +for step in 'setup --download --no-post-install' 'setup systemd'; do + if FAIL_SETUP_STEP="$step" omarchy-install-dictation-voxtype > "$test_tmp/output" 2>&1; then fail "incomplete setup must fail"; fi + [[ $(cat "$config") == "superwhisper" ]] || fail "incomplete setup preserves selection" +done +if SERVICE_EXIT=1 omarchy-install-dictation-voxtype > "$test_tmp/output" 2>&1; then fail "service failure must fail"; fi +[[ $(cat "$config") == "superwhisper" ]] || fail "service failure preserves selection" +omarchy-install-dictation-voxtype +[[ $(cat "$config") == "voxtype" ]] || fail "retry finishes installed Voxtype setup" +[[ $(cat "$XDG_CONFIG_HOME/voxtype/config.toml") == "custom model configuration" ]] || fail "retry preserves custom configuration" +pass "Voxtype retries incomplete model and service setup without replacing configuration" + +: > "$DICTATION_LOG" +if DICTATION_EXIT=7 omarchy-dictation-use superwhisper > "$test_tmp/output" 2>&1; then fail "failed stop must prevent switching"; fi +[[ $(cat "$config") == "voxtype" ]] || fail "failed stop preserves selection" +omarchy-dictation-use superwhisper +[[ $(cat "$DICTATION_LOG") == $'voxtype status\nvoxtype record stop\nvoxtype status\nvoxtype record stop' ]] || fail "switch stops the previous backend" +[[ $(cat "$config") == "superwhisper" ]] || fail "successful stop permits switching" +: > "$DICTATION_LOG" +omarchy-dictation-use superwhisper +[[ ! -s $DICTATION_LOG ]] || fail "reselecting does not interrupt recording" +printf '%s\n' voxtype > "$config" +VOXTYPE_STATUS=stopped omarchy-dictation-use superwhisper +[[ $(cat "$config") == "superwhisper" ]] || fail "a stopped Voxtype daemon permits switching" +printf '%s\n' superwhisper > "$config" +DICTATION_SERVICE_ACTIVE=0 omarchy-dictation-use voxtype +[[ $(cat "$config") == "voxtype" ]] || fail "a stopped Superwhisper service permits switching" +printf '%s\n' removed-backend > "$config" +omarchy-dictation-use voxtype +[[ $(cat "$config") == "voxtype" ]] || fail "a removed adapter permits recovery" +pass "switching stops recordings, preserves failed stops, and recovers unavailable providers" + +# Readers may run throughout a switch, but must never observe a truncated file. +( + for ((i = 0; i < 200; i++)); do + selection=$(cat "$config") + [[ $selection == "voxtype" || $selection == "superwhisper" ]] || exit 1 + done +) & +reader=$! +for ((i = 0; i < 20; i++)); do + omarchy-dictation-use voxtype + omarchy-dictation-use superwhisper +done +wait "$reader" || fail "concurrent readers always see a complete selection" +printf '%s\n' voxtype > "$config" +pass "selection updates are atomic for concurrent readers" + +lua <<'LUA' +local root = os.getenv("ROOT") +local configured, bindings = false, {} +o = { + shell_succeeds = function(command) + assert(command == "omarchy-default-dictation") + return configured + end, + bind = function(...) table.insert(bindings, {...}) end, +} +dofile(root .. "/default/hypr/bindings/dictation.lua") +assert(#bindings == 0, "unconfigured dictation leaves application keys available") +configured = true +dofile(root .. "/default/hypr/bindings/dictation.lua") +assert(#bindings == 5, "any configured backend receives the same bindings") +LUA +pass "dictation shortcuts require a selection without limiting backend names" + +# Fresh users receive the backend choice through the settings package's skel. +cp "$ROOT/config/omarchy/defaults/dictation" "$config" +[[ $(omarchy-default-dictation) == "superwhisper" ]] || fail "fresh users default to Superwhisper" +printf '%s\n' voxtype > "$config" +pass "fresh users get Superwhisper as their default backend" + +lua <<'LUA' +local root = os.getenv("ROOT") +package.path = root .. "/?.lua;" .. package.path +package.loaded["default.hypr.paths"] = { config_home = "/config" } +local selected, available, loaded = "superwhisper", true, {} +local missing = false +o = { cmd_missing = function() return missing end } +local real_open, real_popen, real_dofile = io.open, io.popen, dofile +io.popen = function(command) + assert(command == "omarchy-default-dictation 2>/dev/null") + return { read = function() return selected end, close = function() end } +end +io.open = function(path) + assert(path == "/config/" .. selected .. "/shortcuts.lua") + if available then return { close = function() end } end +end +dofile = function(path) table.insert(loaded, path) end +local function load_backend() + package.loaded["default.hypr.dictation-backend"] = nil + require("default.hypr.dictation-backend") +end +load_backend() +assert(loaded[1] == "/config/superwhisper/shortcuts.lua") +selected, available = "voxtype", false +load_backend() +assert(#loaded == 1, "a backend with no integration should need no config") +selected, available = "future-backend", true +load_backend() +assert(loaded[2] == "/config/future-backend/shortcuts.lua", "integration must not be limited to named providers") +missing = true +load_backend() +assert(#loaded == 2, "an uninstalled backend must not load a stale bridge") +missing = false +dofile = function() error("broken generated bridge") end +load_backend() +assert(#loaded == 2, "a broken bridge must not abort the desktop configuration") +dofile = function(path) table.insert(loaded, path) end +selected = "../../outside" +load_backend() +assert(#loaded == 2, "a backend must not escape the config directory") +selected = nil +load_backend() +assert(#loaded == 2, "no installed backend must leave configuration usable") +io.open, io.popen, dofile = real_open, real_popen, real_dofile +LUA +pass "desktop integration follows the selected backend without personal Hyprland config" + +cat > "$test_tmp/bin/systemctl" <<'SH' +#!/bin/bash +exit 0 +SH +cat > "$test_tmp/bin/omarchy-pkg-drop" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >> "$DICTATION_LOG" +exit "${REMOVE_EXIT:-0}" +SH +chmod +x "$test_tmp/bin/systemctl" "$test_tmp/bin/omarchy-pkg-drop" +omarchy-remove-dictation-voxtype +[[ ! -e $config ]] || fail "removing Voxtype clears selection" +if omarchy-default-dictation 2> "$test_tmp/error"; then fail "removal must not auto-select another backend"; fi +pass "removal clears selection without automatic fallback" + +cat > "$test_tmp/bin/omarchy-shell" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >> "$DICTATION_LOG" +SH +chmod +x "$test_tmp/bin/omarchy-shell" +export XDG_DATA_HOME="$test_tmp/data" +mkdir -p "$HOME/.local/bin" "$XDG_DATA_HOME/superwhisper/app" "$XDG_DATA_HOME/superwhisper/models" "$XDG_CONFIG_HOME/omarchy/plugins" "$HOME/.agents/skills" "$HOME/.claude/skills" +ln -s /usr/bin/superwhisper "$HOME/.local/bin/superwhisper" +ln -s /opt/superwhisper "$XDG_DATA_HOME/superwhisper/app/current" +ln -s /opt/superwhisper/assets/omarchy-plugin/superwhisper-panel "$XDG_CONFIG_HOME/omarchy/plugins/superwhisper-panel" +ln -s /opt/superwhisper/assets/agent-skill/superwhisper "$HOME/.agents/skills/superwhisper" +ln -s /custom/skill "$HOME/.claude/skills/superwhisper" +printf '%s\n' retained > "$XDG_DATA_HOME/superwhisper/models/model" +printf '%s\n' superwhisper > "$config" +if REMOVE_EXIT=1 omarchy-remove-dictation-superwhisper > "$test_tmp/output" 2>&1; then + fail "failed package removal must fail" +fi +[[ -L $HOME/.local/bin/superwhisper && $(cat "$config") == "superwhisper" ]] || fail "failed removal preserves selection and links" +omarchy-remove-dictation-superwhisper +[[ ! -e $config ]] || fail "Superwhisper removal clears its selection" +for link in "$HOME/.local/bin/superwhisper" "$XDG_DATA_HOME/superwhisper/app/current" "$XDG_CONFIG_HOME/omarchy/plugins/superwhisper-panel" "$HOME/.agents/skills/superwhisper"; do + [[ ! -L $link ]] || fail "Superwhisper removal unlinks packaged integration" +done +[[ $(readlink "$HOME/.claude/skills/superwhisper") == "/custom/skill" ]] || fail "custom skills are preserved" +[[ -f $XDG_DATA_HOME/superwhisper/models/model ]] || fail "downloaded models are retained" +printf '%s\n' voxtype > "$config" +omarchy-remove-dictation-superwhisper +[[ $(cat "$config") == "voxtype" ]] || fail "removing Superwhisper preserves another selected backend" +pass "Superwhisper removal clears its selection and owned links while preserving user data and other backends" + +cat > "$test_tmp/bin/omarchy-pkg-present" <<'SH' +#!/bin/bash +[[ ${VOXTYPE_PACKAGE:-0} == 1 && $1 == "voxtype-bin" ]] +SH +chmod +x "$test_tmp/bin/omarchy-pkg-present" +rm "$config" +VOXTYPE_PACKAGE=0 /bin/bash -euo pipefail "$ROOT/migrations/1791479273.sh" +[[ ! -e $config ]] || fail "migration does not select an uninstalled backend" +VOXTYPE_PACKAGE=1 /bin/bash -euo pipefail "$ROOT/migrations/1791479273.sh" +[[ $(cat "$config") == "voxtype" ]] || fail "existing Voxtype users keep dictation after upgrade" +printf '%s\n' future-backend > "$config" +VOXTYPE_PACKAGE=1 /bin/bash -euo pipefail "$ROOT/migrations/1791479273.sh" +[[ $(cat "$config") == "future-backend" ]] || fail "upgrade preserves explicit backend selections" +pass "one-time upgrade preserves existing Voxtype without runtime autodetection" diff --git a/test/shell.d/fixtures/background-instant-reload/shell.qml b/test/shell.d/fixtures/background-instant-reload/shell.qml new file mode 100644 index 00000000..7c214a05 --- /dev/null +++ b/test/shell.d/fixtures/background-instant-reload/shell.qml @@ -0,0 +1,28 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import "background" as BackgroundPlugin + +ShellRoot { + property bool reloaded: false + BackgroundPlugin.Background { id: background } + FileView { id: command; path: Quickshell.env("RELOAD_TEST_COMMAND"); watchChanges: true; onFileChanged: reload() } + FileView { id: result; path: Quickshell.env("RELOAD_TEST_RESULT"); atomicWrites: true } + + Timer { interval: 600; running: true; onTriggered: result.setText("initial") } + Timer { id: settled; interval: 600; onTriggered: result.setText("reloaded") } + Timer { + interval: 100 + running: true + repeat: true + onTriggered: { + if (command.text().trim() === "done") { + Qt.quit() + } else if (command.text().trim() === "reload" && !reloaded) { + reloaded = true + background.setBackground(background.currentBackground, true) + settled.start() + } + } + } +} diff --git a/test/shell.d/fixtures/background-intro-clone/shell.qml b/test/shell.d/fixtures/background-intro-clone/shell.qml new file mode 100644 index 00000000..f20d5941 --- /dev/null +++ b/test/shell.d/fixtures/background-intro-clone/shell.qml @@ -0,0 +1,43 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import "services" +import "clone" as Clone + +ShellRoot { + id: test + property var services: ({ "intro-test.background": background }) + function firstPartyServiceFor(id) { return id === "omarchy.background" ? background : null } + + PluginShellApi { id: facade; pluginId: "intro-test.background" } + Clone.Background { id: background; shell: facade } + BackgroundIntro { id: intro; host: test } + property bool stillReported: false + FileView { id: release; path: Quickshell.env("INTRO_TEST_RELEASE"); watchChanges: true; onFileChanged: reload() } + FileView { id: result; path: Quickshell.env("INTRO_TEST_RESULT"); atomicWrites: true } + + Timer { + interval: 600 + running: true + onTriggered: result.setText(JSON.stringify({ + phase: "cover", + scoped: background.shell.pluginId === "intro-test.background", + privateCoordinator: background.shell.bootIntro === undefined, + selected: background.displayedBackground.endsWith("still.png"), + covered: intro.cover + })) + } + Timer { + interval: 100 + running: true + repeat: true + onTriggered: { + if (release.text().trim() === "done") { + Qt.quit() + } else if (release.text().trim() === "captured" && !intro.cover && !intro.startupPending && !test.stillReported) { + test.stillReported = true + result.setText(JSON.stringify({ phase: "still", covered: intro.cover })) + } + } + } +} diff --git a/test/shell.d/fixtures/background-intro-lifecycle/shell.qml b/test/shell.d/fixtures/background-intro-lifecycle/shell.qml new file mode 100644 index 00000000..d800aa11 --- /dev/null +++ b/test/shell.d/fixtures/background-intro-lifecycle/shell.qml @@ -0,0 +1,121 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import "services" +import "Commons" as Commons + +ShellRoot { + id: test + property bool failed: false + property var services: ({}) + function firstPartyServiceFor(id) { return services[id] || null } + function check(ok, message) { + if (!ok) { + failed = true + console.log("RESULT fail " + message) + } + } + + QtObject { id: retainedBackground; property bool suspended: false } + BackgroundIntro { id: intro; host: test } + + Process { + id: revealVideo + command: ["touch", Quickshell.env("INTRO_TEST_FRAME_READY")] + } + + Timer { + interval: 300 + running: true + onTriggered: { + test.check(intro.checked, "startup runs even when OWE left the background disabled for a video") + test.services = ({ "omarchy.background": retainedBackground }) + intro.prepareTheme(Quickshell.env("INTRO_TEST_COVER"), "theme-one", Qt.btoa('background = "#123456"'), "") + test.check(intro.themeCoverStatus("theme-one") === "loading", "the cover waits for decoding and presentation") + test.check(intro.themeStatus("theme-one") === "pending", "app retints wait for the intro reveal") + test.check(!Qt.colorEqual(Commons.Color.background, "#123456"), "the palette waits for OWE's first frame") + intro.finishTheme("superseded-theme") + test.check(intro.themeToken === "theme-one", "an older intro cannot release the pending handoff") + } + } + Timer { + interval: 500 + running: true + onTriggered: { + test.check(intro.cover, "the still stays covered while the launcher waits") + test.check(intro.themeCoverStatus("theme-one") === "ready", "the outgoing cover is presented before the shell is hidden") + retainedBackground.suspended = true + } + } + Timer { + interval: 600 + running: true + onTriggered: { + test.check(intro.themeToken === "theme-one", "a leftover renderer still stays covered until video is revealed") + revealVideo.running = true + handoffDeadline = Date.now() + 3000 + handoff.start() + } + } + property double handoffDeadline: 0 + property bool openingFadeObserved: false + Connections { + target: intro + function onThemeTokenChanged() { + if (handoff.running && intro.themeToken === "") { + Qt.callLater(function() { + test.check(intro.themeStatus("theme-one") === "pending", "app retints also wait through the opening crossfade") + test.openingFadeObserved = true + }) + } + } + } + Timer { + id: handoff + interval: 16 + repeat: true + onTriggered: { + if (intro.themeToken || !test.openingFadeObserved) { + if (Date.now() >= test.handoffDeadline) { + test.check(false, "the ready video releases its cover before the deadline") + Qt.quit() + } + return + } + stop() + test.check(!intro.cover && intro.checked, "OWE taking the background releases the cover") + test.check(intro.themeToken === "" && Qt.colorEqual(Commons.Color.background, "#123456"), "first-frame handoff starts the palette and wallpaper fade together") + retainedBackground.suspended = false + Qt.callLater(function() { test.check(!intro.cover, "resuming the retained background cannot cover or retry playback") }) + completionDeadline = Date.now() + 3000 + completion.start() + } + } + property double completionDeadline: 0 + Timer { + id: completion + interval: 16 + repeat: true + onTriggered: { + if (intro.themeStatus("theme-one") !== "ready" || !intro.startupSettled) { + if (Date.now() >= test.completionDeadline) { + test.check(false, "the fade and launcher complete before the deadline") + Qt.quit() + } + return + } + stop() + test.check(intro.themeStatus("theme-one") === "ready", "the completed crossfade releases app retints") + intro.prepareTheme("", "failed-theme", Qt.btoa('background = "#654321"'), "") + intro.finishTheme("failed-theme") + test.check(Qt.colorEqual(Commons.Color.background, "#654321"), "failed playback still releases its pending palette") + intro.prepareTheme("", "cancelled-theme", Qt.btoa('background = "#abcdef"'), "") + intro.cancelTheme() + intro.finishTheme("cancelled-theme") + test.check(Qt.colorEqual(Commons.Color.background, "#654321") && !intro.themeToken, "a superseding theme cannot be overwritten by an older completion") + test.check(!intro.cover, "launcher completion leaves the still uncovered") + if (!test.failed) console.log("RESULT pass") + Qt.quit() + } + } +} diff --git a/test/shell.d/fixtures/background-pending-theme/shell.qml b/test/shell.d/fixtures/background-pending-theme/shell.qml new file mode 100644 index 00000000..55b83f3f --- /dev/null +++ b/test/shell.d/fixtures/background-pending-theme/shell.qml @@ -0,0 +1,36 @@ +import QtQuick +import Quickshell +import qs.Commons +import qs.Commons as Commons +import "background" as BackgroundPlugin + +ShellRoot { + id: shell + function firstPartyServiceFor(id) { return id === "omarchy.background" ? background : null } + BackgroundPlugin.Background { id: background } + + // APPLY_THEME_FUNCTION + + Timer { + interval: 600 + running: true + onTriggered: { + var oldColors = Qt.btoa('accent = "#ff0000"\nbackground = "#000000"\nforeground = "#ffffff"\n') + var newColors = Qt.btoa('accent = "#123456"\nbackground = "#000000"\nforeground = "#ffffff"\n') + background.transitionBackgroundWithTheme("", "/missing-still.png", "/missing-still.png", oldColors, "") + shell.applyTheme(newColors, "") + } + } + Timer { + interval: 1300 + running: true + onTriggered: { + if (Qt.colorEqual(Commons.Color.accent, "#123456")) { + console.log("RESULT pass") + } else { + console.log("RESULT fail previous colors returned: " + Commons.Color.accent) + } + Qt.quit() + } + } +} diff --git a/test/shell.d/fixtures/background-startup-failure/shell.qml b/test/shell.d/fixtures/background-startup-failure/shell.qml new file mode 100644 index 00000000..4d728b74 --- /dev/null +++ b/test/shell.d/fixtures/background-startup-failure/shell.qml @@ -0,0 +1,40 @@ +import QtQuick +import Quickshell +import "services" +import "background" as BackgroundPlugin + +ShellRoot { + id: test + property var services: ({ "omarchy.background": background }) + property var bar: ({}) + property bool failed: false + function firstPartyServiceFor(id) { return services[id] || null } + function check(ok, message) { + if (!ok) { + failed = true + console.log("RESULT fail " + message) + } + } + + BackgroundPlugin.Background { id: background } + BackgroundIntro { id: intro; host: test } + + Timer { + interval: 600 + running: true + onTriggered: { + test.check(background.displayedBackground.endsWith("deleted.png"), "the missing wallpaper is selected") + test.check(!background.ready && intro.startupPending && intro.cover, "the failed image cannot release startup through readiness") + } + } + Timer { + interval: 11000 + running: true + onTriggered: { + test.check(!background.ready, "the missing wallpaper never becomes ready") + test.check(!intro.startupPending && !intro.cover && intro.startupOpacity === 0, "the deadline fades the cover away despite the failed wallpaper") + if (!test.failed) console.log("RESULT pass") + Qt.quit() + } + } +} diff --git a/test/shell.d/fixtures/background-startup/shell.qml b/test/shell.d/fixtures/background-startup/shell.qml new file mode 100644 index 00000000..c5354584 --- /dev/null +++ b/test/shell.d/fixtures/background-startup/shell.qml @@ -0,0 +1,86 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import "services" + +ShellRoot { + id: test + property var services: ({}) + property var pluginRegistry: null + property var bar: null + property bool failed: false + function firstPartyServiceFor(id) { return services[id] || null } + function check(ok, message) { + if (!ok) { + failed = true + console.log("RESULT fail " + message) + } + } + + QtObject { + id: registry + property var installedPlugins: ({ "omarchy.background": {} }) + signal pluginsChanged() + function resolveEnabledId(id) { return id } + function isEnabled(id) { return false } + } + QtObject { + id: background + property bool suspended: false + property bool ready: false + } + BackgroundIntro { id: intro; host: test } + FileView { id: cursorLog; path: Quickshell.env("STARTUP_CURSOR_LOG"); printErrors: false } + + Timer { + interval: 400 + running: true + onTriggered: { + test.check(!intro.backgroundActive, "the plugin has not loaded yet") + test.check(intro.cover && !intro.themeBackground, "fresh startup does not prepare an outgoing still image") + test.check(intro.startupSettled, "the launcher has finished without an intro") + test.check(intro.startupPending && intro.startupOpacity === 1, "a fresh login hides the desktop until its media and bar are ready") + cursorLog.reload() + cursorLog.waitForJob() + test.check(!cursorLog.text().trim(), "startup does not restore the cursor while media is loading") + test.services = ({ "omarchy.background": background }) + } + } + Timer { + interval: 600 + running: true + onTriggered: { + test.check(intro.cover, "a loading plugin does not release the startup wallpaper") + background.ready = true + } + } + Timer { + interval: 800 + running: true + onTriggered: { + test.check(!intro.cover && !intro.themeBackground, "a ready plugin releases the startup cover and its image") + test.check(intro.startupPending && intro.startupOpacity === 1, "a ready background alone cannot reveal an unready bar") + cursorLog.reload() + cursorLog.waitForJob() + test.check(!cursorLog.text().trim(), "startup does not restore the cursor before the bar is ready") + test.bar = ({}) + } + } + Timer { + interval: 1400 + running: true + onTriggered: { + test.check(!intro.startupPending && intro.startupOpacity === 0, "the desktop fades in after both the media and bar are ready") + cursorLog.reload() + cursorLog.waitForJob() + test.check(cursorLog.text().includes("omarchy_startup_cursor_restore()"), "the opening fade restores the cursor") + test.services = ({}) + intro.cover = true + test.pluginRegistry = registry + registry.pluginsChanged() + test.check(!intro.cover, "a disabled background plugin does not leave a startup cover on screen") + if (!test.failed) console.log("RESULT pass") + Qt.quit() + } + } +} diff --git a/test/shell.d/fixtures/indicator-contract/shell.qml b/test/shell.d/fixtures/indicator-contract/shell.qml index 0adc6ade..4b3f04c3 100644 --- a/test/shell.d/fixtures/indicator-contract/shell.qml +++ b/test/shell.d/fixtures/indicator-contract/shell.qml @@ -198,16 +198,6 @@ ShellRoot { root.assertTrue(root.commandCount("omarchy-capture-screenrecording --stop-recording") === 1, "Screen Recording left click stops active recording") } - var dictation = root.createIndicator("Dictation") - if (dictation) { - dictation.moduleName = "Dictation" - root.injectBar(dictation) - dictation.triggerPress(Qt.LeftButton) - dictation.triggerPress(Qt.RightButton) - root.assertTrue(root.commandCount("omarchy-voxtype-config") === 2, "Dictation clicks run config command") - root.assertTrue(root.commandCount("omarchy-voxtype-model") === 0, "Dictation clicks do not run model command") - } - var stayAwake = root.createIndicator("StayAwake") if (stayAwake) { stayAwake.moduleName = "StayAwake" diff --git a/test/shell.d/fixtures/network-captive-portal/shell.qml b/test/shell.d/fixtures/network-captive-portal/shell.qml index b72a4407..a0126898 100644 --- a/test/shell.d/fixtures/network-captive-portal/shell.qml +++ b/test/shell.d/fixtures/network-captive-portal/shell.qml @@ -2,6 +2,7 @@ import QtQuick import Quickshell import Quickshell.Networking import qs.Commons +import qs.Commons as Commons import "mocks" import "network" as Network @@ -21,9 +22,9 @@ ShellRoot { Network.Panel { id: panel bar: QtObject { - property color foreground: Color.foreground - property color barForeground: Color.foreground - property color urgent: Color.urgent + property color foreground: Commons.Color.foreground + property color barForeground: Commons.Color.foreground + property color urgent: Commons.Color.urgent property string fontFamily: Style.font.family property string position: "top" property int barSize: 24 diff --git a/test/shell.d/fixtures/network-owe-transition/shell.qml b/test/shell.d/fixtures/network-owe-transition/shell.qml index 5cbfcdcd..0a6f6796 100644 --- a/test/shell.d/fixtures/network-owe-transition/shell.qml +++ b/test/shell.d/fixtures/network-owe-transition/shell.qml @@ -1,6 +1,7 @@ import QtQuick import Quickshell import qs.Commons +import qs.Commons as Commons import "mocks" import "network" as Network import "network/Model.js" as Model @@ -25,9 +26,9 @@ ShellRoot { Network.Panel { id: panel bar: QtObject { - property color foreground: Color.foreground - property color barForeground: Color.foreground - property color urgent: Color.urgent + property color foreground: Commons.Color.foreground + property color barForeground: Commons.Color.foreground + property color urgent: Commons.Color.urgent property string fontFamily: Style.font.family property string position: "top" property int barSize: 24 diff --git a/test/shell.d/fixtures/startup-cursor/hyprland.lua b/test/shell.d/fixtures/startup-cursor/hyprland.lua new file mode 100644 index 00000000..8b725092 --- /dev/null +++ b/test/shell.d/fixtures/startup-cursor/hyprland.lua @@ -0,0 +1,25 @@ +package.path = os.getenv("OMARCHY_PATH") .. "/?.lua;" .. package.path +o = { + shell_quote = function(value) return "'" .. value:gsub("'", "'\\''") .. "'" end, + launch = function(value) return value end, +} +local exec = hl.exec_cmd +hl.exec_cmd = function(command) + if command == "omarchy-launch-shell" then + local file = assert(io.open(os.getenv("CURSOR_TEST_STAGE") .. "/session", "w")) + file:write(os.getenv("WAYLAND_DISPLAY") .. "\n" .. os.getenv("HYPRLAND_INSTANCE_SIGNATURE")) + file:close() + exec("quickshell -p " .. o.shell_quote(os.getenv("CURSOR_TEST_STAGE"))) + elseif command:match("^hyprctl ") then + exec(command) + end +end +require("default.hypr.autostart") +hl.config({ + misc = { disable_hyprland_logo = true, disable_splash_rendering = true, background_color = "rgb(000000)", disable_watchdog_warning = true }, + xwayland = { enabled = false }, + cursor = { enable_hyprcursor = false }, +}) +hl.monitor({ output = "", mode = "640x480@60", position = "auto", scale = 1 }) +hl.layer_rule({ match = { namespace = "omarchy-background" }, no_anim = true }) +hl.bind("SUPER + Q", hl.dsp.exec_cmd("true")) diff --git a/test/shell.d/fixtures/startup-cursor/shell.qml b/test/shell.d/fixtures/startup-cursor/shell.qml new file mode 100644 index 00000000..ab046a24 --- /dev/null +++ b/test/shell.d/fixtures/startup-cursor/shell.qml @@ -0,0 +1,38 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import "services" + +ShellRoot { + id: test + property var bar: ({}) + property var services: ({ "omarchy.background": background }) + function firstPartyServiceFor(id) { return background } + QtObject { id: background; property bool suspended: false; property bool ready: false } + BackgroundIntro { + id: intro + host: test + onStartupPendingChanged: if (!startupPending) phase.setText("revealed") + } + FileView { + id: release + path: Quickshell.env("CURSOR_TEST_STAGE") + "/release" + watchChanges: true + onLoaded: background.ready = text().trim() === "reveal" + onFileChanged: reload() + } + FileView { + id: phase + path: Quickshell.env("CURSOR_TEST_STAGE") + "/phase" + atomicWrites: true + } + PanelWindow { + anchors { top: true; bottom: true; left: true; right: true } + exclusionMode: ExclusionMode.Ignore + WlrLayershell.layer: WlrLayer.Bottom + mask: Region {} + color: "magenta" + } + Component.onCompleted: phase.setText("holding") +} diff --git a/test/shell.d/fixtures/theme-set-herdr-ssh-reuse.py b/test/shell.d/fixtures/theme-set-herdr-ssh-reuse.py new file mode 100644 index 00000000..0aedb621 --- /dev/null +++ b/test/shell.d/fixtures/theme-set-herdr-ssh-reuse.py @@ -0,0 +1,105 @@ +import os +import pathlib +import pwd +import signal +import shutil +import socket +import subprocess +import tempfile +import time + +root = pathlib.Path(os.environ['ROOT']) +with tempfile.TemporaryDirectory(prefix='theme-ssh-') as directory: + stage = pathlib.Path(directory) + for name in ('host', 'identity'): + subprocess.run(['ssh-keygen', '-q', '-t', 'ed25519', '-N', '', '-f', str(stage / name)], check=True) + try: + with socket.socket() as port_probe: + port_probe.bind(('127.0.0.1', 0)) + port = port_probe.getsockname()[1] + except PermissionError: + print('ok - sandbox blocks loopback sockets; skipping real SSH connection reuse # SKIP') + raise SystemExit(0) + remote = stage / 'remote' + remote.write_text('#!/bin/bash\ncat >>"' + str(stage / 'received') + '"\necho theme-received\n') + remote.chmod(0o700) + server_config = stage / 'sshd.conf' + server_config.write_text(f'''Port {port} +ListenAddress 127.0.0.1 +HostKey {stage / 'host'} +PidFile {stage / 'server.pid'} +AuthorizedKeysFile {stage / 'identity.pub'} +PasswordAuthentication no +KbdInteractiveAuthentication no +UsePAM no +StrictModes no +AllowUsers {pwd.getpwuid(os.getuid()).pw_name} +LogLevel VERBOSE +ForceCommand /bin/bash {remote} +''') + host_key = (stage / 'host.pub').read_text().split() + (stage / 'known_hosts').write_text(f'[127.0.0.1]:{port} {host_key[0]} {host_key[1]}\n') + client_config = stage / 'ssh.conf' + client_config.write_text(f'''Host fixture + HostName 127.0.0.1 + Port {port} + User {pwd.getpwuid(os.getuid()).pw_name} + IdentityFile {stage / 'identity'} + IdentityAgent none + IdentitiesOnly yes + StrictHostKeyChecking yes + UserKnownHostsFile {stage / 'known_hosts'} +''') + home = stage / 'home' + current = home / '.local/state/omarchy/current' + current.mkdir(parents=True) + toggles = home / '.local/state/omarchy/toggles' + toggles.mkdir() + (toggles / 'herdr-theme-sync').touch() + runtime = stage / 'run' + runtime.mkdir(mode=0o700) + stub_bin = stage / 'bin' + stub_bin.mkdir() + scripts = { + 'herdr': "#!/bin/bash\nprintf 'id\\tfixture\\tfixture\\tdefault\\tenabled\\n'\n", + 'ssh': '#!/bin/bash\nexec /usr/bin/ssh -F "' + str(client_config) + '" "$@"\n', + } + for name, content in scripts.items(): + path = stub_bin / name + path.write_text(content) + path.chmod(0o700) + environment = {**os.environ, 'HOME': str(home), 'XDG_RUNTIME_DIR': str(runtime), 'PATH': str(stub_bin) + ':' + str(root / 'bin') + ':' + os.environ['PATH']} + log_path = stage / 'sshd.log' + with log_path.open('w') as log: + server = subprocess.Popen([shutil.which('sshd'), '-D', '-e', '-f', str(server_config)], stdout=log, stderr=log, start_new_session=True) + try: + deadline = time.monotonic() + 5 + while True: + try: + with socket.create_connection(('127.0.0.1', port), timeout=0.1): + break + except OSError: + if server.poll() is not None and 'Missing privilege separation directory' in log_path.read_text(): + print('ok - SSH server runtime directory is unavailable; skipping real SSH connection reuse # SKIP') + raise SystemExit(0) + assert server.poll() is None and time.monotonic() < deadline, log_path.read_text() + time.sleep(0.05) + for theme in ('tokyo-night', 'nord'): + (current / 'theme.name').write_text(theme + '\n') + result = subprocess.run([str(root / 'bin/omarchy-theme-set-herdr-machines')], env=environment, capture_output=True, text=True, timeout=10) + assert result.returncode == 0 and 'theme-received' in result.stdout, (result.stdout, result.stderr, log_path.read_text()) + if theme == 'tokyo-night': + (stage / 'identity').rename(stage / 'identity-unavailable') + assert log_path.read_text().count('Accepted publickey') == 1, log_path.read_text() + received = (stage / 'received').read_text() + assert 'theme=tokyo-night' in received and 'theme=nord' in received + assert (runtime / 'omarchy-theme-sync').stat().st_mode & 0o777 == 0o700 + lock = runtime / 'omarchy-theme-set-herdr-machines.lock' + assert subprocess.run(['flock', '-n', str(lock), 'true']).returncode == 0, 'SSH master retained the theme sync lock' + print('ok - two theme syncs authenticate once, even with the key removed before the second sync') + print('ok - persistent SSH master leaves the sync lock available') + finally: + subprocess.run(['/usr/bin/ssh', '-F', str(client_config), '-o', 'ControlPath=' + str(runtime / 'omarchy-theme-sync/%C'), '-O', 'exit', 'fixture'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5) + if server.poll() is None: + os.killpg(server.pid, signal.SIGTERM) + server.wait(timeout=5) diff --git a/test/shell.d/hyprland-binding-conflicts-test.sh b/test/shell.d/hyprland-binding-conflicts-test.sh index d0eb9e0e..90af5845 100755 --- a/test/shell.d/hyprland-binding-conflicts-test.sh +++ b/test/shell.d/hyprland-binding-conflicts-test.sh @@ -147,8 +147,11 @@ trap 'rm -rf "$tmpdir"' EXIT home="$tmpdir/home" stub_bin="$tmpdir/bin" mkdir -p "$home" "$stub_bin" -touch "$stub_bin/voxtype" -chmod +x "$stub_bin/voxtype" +cat > "$stub_bin/omarchy-default-dictation" <<'SH' +#!/bin/bash +echo voxtype +SH +chmod +x "$stub_bin/omarchy-default-dictation" bindings=$(PATH="$stub_bin:$PATH" list_bindings "$home") [[ -n $bindings ]] || fail "default bindings load for the conflict check" diff --git a/test/shell.d/hyprland-default-config-test.sh b/test/shell.d/hyprland-default-config-test.sh index f50e5761..0e12178a 100644 --- a/test/shell.d/hyprland-default-config-test.sh +++ b/test/shell.d/hyprland-default-config-test.sh @@ -144,36 +144,29 @@ no_bindings_output=$(run_omarchy_bindings "$no_bindings_home" 'omarchy_default_b [[ -z $no_bindings_output ]] || fail "default binding variable disables all Omarchy bindings" "$no_bindings_output" pass "default binding variable disables all Omarchy bindings" -voxtype_home="$tmpdir/voxtype-home" -voxtype_bin="$tmpdir/voxtype-bin" -mkdir -p "$voxtype_home" "$voxtype_bin" -touch "$voxtype_bin/voxtype" -chmod +x "$voxtype_bin/voxtype" -voxtype_output=$(PATH="$voxtype_bin:$PATH" run_omarchy_bindings "$voxtype_home") -grep -Fq $'SUPER + CTRL + X Toggle dictation' <<<"$voxtype_output" || - fail "installed Voxtype enables its toggle binding" -grep -Fq $'F9 Start dictation (push-to-talk)' <<<"$voxtype_output" || - fail "installed Voxtype enables its push-to-talk binding" -grep -Fq $'F9 Stop dictation (push-to-talk)' <<<"$voxtype_output" || - fail "installed Voxtype enables its release binding" -pass "installed Voxtype conditionally enables dictation bindings" - -voxtype_without_execute_output=$(PATH="$voxtype_bin:$PATH" run_omarchy_bindings \ - "$voxtype_home" 'os.execute = function() return nil, "No child processes", 10 end') -grep -Fq $'SUPER + CTRL + X Toggle dictation' <<<"$voxtype_without_execute_output" || - fail "Voxtype detection does not require spawning a subprocess" -pass "installed Voxtype detection works without os.execute" - +dictation_home="$tmpdir/dictation-home" missing_bin="$tmpdir/missing-bin" -mkdir -p "$missing_bin" +mkdir -p "$dictation_home" "$missing_bin" ln -s "$(command -v lua)" "$missing_bin/lua" ln -s "$(command -v lspci)" "$missing_bin/lspci" ln -s "$(command -v sort)" "$missing_bin/sort" -missing_voxtype_output=$(PATH="$missing_bin" run_omarchy_bindings "$voxtype_home") -if grep -Fq $'SUPER + CTRL + X Toggle dictation' <<<"$missing_voxtype_output"; then - fail "missing Voxtype skips its bindings" -fi -pass "missing Voxtype skips dictation bindings" +dictation_output=$(PATH="$missing_bin" run_omarchy_bindings "$dictation_home") +if grep -Fq 'dictation' <<<"$dictation_output"; then fail "unconfigured dictation leaves application shortcuts available"; fi +cat > "$missing_bin/omarchy-default-dictation" <<'SH' +#!/bin/bash +echo future-backend +SH +chmod +x "$missing_bin/omarchy-default-dictation" +dictation_output=$(PATH="$missing_bin" run_omarchy_bindings "$dictation_home") +for binding in \ + $'SUPER + CTRL + X\tToggle dictation' \ + $'F9\tStart dictation (push-to-talk)' \ + $'F9\tStop dictation (push-to-talk)' \ + $'ALT + Alt_R\tStart dictation (push-to-talk)' \ + $'ALT + Alt_R\tStop dictation (push-to-talk)'; do + grep -Fq "$binding" <<<"$dictation_output" || fail "dictation bindings load for any selected backend" "$binding" +done +pass "dictation bindings require a selection without hardcoding backends" # Lazydocker is optional on new installs, while existing installs keep the hotkey. lazydocker_bin="$tmpdir/lazydocker-bin" diff --git a/test/shell.d/hyprland-shutdown-test.sh b/test/shell.d/hyprland-shutdown-test.sh new file mode 100755 index 00000000..016a3f81 --- /dev/null +++ b/test/shell.d/hyprland-shutdown-test.sh @@ -0,0 +1,70 @@ +#!/bin/bash + +source "$(dirname "$0")/base-test.sh" + +if ! systemctl --user show-environment >/dev/null 2>&1; then + skip "no user manager; skipping compositor shutdown" + exit 0 +fi + +require_command python3 +require_command systemd-run + +test_tmp=$(mktemp -d) +unit="omarchy-test-hyprland-shutdown-$$.service" +trap 'systemctl --user stop "$unit" >/dev/null 2>&1; rm -rf "$test_tmp"' EXIT + +cat >"$test_tmp/compositor.py" <<'PY' +import os +import pathlib +import signal +import sys +import time + +state = pathlib.Path(sys.argv[1]) +child = os.fork() +if child == 0: + def stop_shell(signum, frame): + (state / "shell-stopped").touch() + sys.exit(0) + + signal.signal(signal.SIGTERM, stop_shell) + (state / "shell-ready").touch() + while True: + signal.pause() + +def stop_compositor(signum, frame): + # Give an early shell SIGTERM time to remove the wallpaper. + time.sleep(0.2) + if not (state / "shell-stopped").exists(): + (state / "wallpaper-retained").touch() + sys.exit(0) + +signal.signal(signal.SIGTERM, stop_compositor) +while not (state / "shell-ready").exists(): + time.sleep(0.01) +(state / "ready").touch() +while True: + signal.pause() +PY + +dropin="$ROOT/etc/systemd/user/wayland-wm@hyprland.desktop.service.d/10-keep-shell.conf" +kill_mode=$(sed -n 's/^KillMode=//p' "$dropin") +systemd-run --user --collect --quiet --unit="$unit" \ + --property="KillMode=$kill_mode" --property=TimeoutStopSec=2s \ + python3 "$test_tmp/compositor.py" "$test_tmp" || fail "isolated compositor starts" + +for (( attempt = 0; attempt < 100; attempt++ )); do + [[ -f $test_tmp/ready ]] && break + sleep 0.02 +done +[[ -f $test_tmp/ready ]] || fail "isolated compositor and shell are ready" + +systemctl --user stop "$unit" || fail "isolated compositor stops" +[[ -f $test_tmp/wallpaper-retained ]] || fail "wallpaper survives compositor teardown" +pass "wallpaper survives compositor teardown" + +if systemctl --user is-active --quiet "$unit"; then + fail "compositor shutdown leaves no running service" +fi +pass "compositor shutdown leaves no running service" diff --git a/test/shell.d/hyprland-startup-cursor-cold-test.sh b/test/shell.d/hyprland-startup-cursor-cold-test.sh new file mode 100755 index 00000000..dca0a2f1 --- /dev/null +++ b/test/shell.d/hyprland-startup-cursor-cold-test.sh @@ -0,0 +1,86 @@ +#!/bin/bash + +set -euo pipefail +source "$(dirname "$0")/base-test.sh" +require_compositor "cold compositor cursor startup" +require_command Hyprland +require_command quickshell +require_command grim +require_command magick +require_command python3 + +python3 <<'PY' +import os +import pathlib +import signal +import subprocess +import tempfile +import time + +root = pathlib.Path(os.environ["ROOT"]) +with tempfile.TemporaryDirectory() as directory: + stage = pathlib.Path(directory) + home = stage / "home" + home.mkdir() + (stage / "release").write_text("") + (stage / "bin").mkdir() + launcher = stage / "bin/omarchy-theme-bg-boot-intro" + launcher.write_text("#!/bin/bash\nexit 0\n") + launcher.chmod(0o755) + for name in ("Commons", "services"): + (stage / name).symlink_to(root / "shell" / name) + fixture = root / "test/shell.d/fixtures/startup-cursor" + (stage / "shell.qml").write_text((fixture / "shell.qml").read_text()) + env = os.environ.copy() + env.update(HOME=str(home), OMARCHY_PATH=str(root), CURSOR_TEST_STAGE=str(stage), + PATH=str(stage / "bin") + ":" + env["PATH"], AQ_BACKEND="wayland", + HYPRLAND_NO_SD_VARS="1", HYPRLAND_NO_SD_NOTIFY="1", GSETTINGS_BACKEND="memory", + XCURSOR_THEME="Adwaita", XCURSOR_PATH="/usr/share/icons:/usr/share/pixmaps", XCURSOR_SIZE="24", HYPRCURSOR_SIZE="24") + env.pop("HYPRLAND_INSTANCE_SIGNATURE", None) + env.pop("HYPRCURSOR_THEME", None) + log = open(stage / "compositor.log", "w") + compositor = subprocess.Popen(["Hyprland", "--config", str(fixture / "hyprland.lua")], + env=env, stdout=log, stderr=log, start_new_session=True) + try: + deadline = time.monotonic() + 5 + while not (stage / "session").exists(): + assert compositor.poll() is None, "cold compositor exited before startup" + assert time.monotonic() < deadline, "cold compositor never started" + time.sleep(0.005) + display, signature = (stage / "session").read_text().splitlines() + env.update(WAYLAND_DISPLAY=display, HYPRLAND_INSTANCE_SIGNATURE=signature) + # Capture immediately, including before Quickshell loads its cover. The + # fixture cannot reveal while grim or magick is busy taking these frames. + deadline = time.monotonic() + 5 + for sample in range(3): + assert time.monotonic() < deadline, "initial compositor captures timed out" + screenshot = stage / "frame.png" + subprocess.run(["grim", "-c", str(screenshot)], env=env, check=True, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5) + maximum = subprocess.check_output(["magick", str(screenshot), "-format", "%[fx:maxima]", "info:"], text=True, timeout=5) + assert maximum == "0", "the cursor appeared in the first compositor frames" + + def wait_phase(expected): + deadline = time.monotonic() + 5 + while not (stage / "phase").exists() or (stage / "phase").read_text() != expected: + assert compositor.poll() is None and time.monotonic() < deadline, "startup never reached " + expected + time.sleep(0.01) + + wait_phase("holding") + (stage / "release").write_text("reveal") + wait_phase("revealed") + # Cursor visibility is polled by the compositor independently of the fade. + deadline = time.monotonic() + 5 + while True: + subprocess.run(["grim", "-c", str(screenshot)], env=env, check=True, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5) + green = subprocess.check_output(["magick", str(screenshot), "-channel", "G", "-separate", "-threshold", "0", "-format", "%[fx:mean*w*h]", "info:"], text=True, timeout=5) + if float(green) > 20: + break + assert time.monotonic() < deadline, "the normal cursor did not return with the desktop" + finally: + os.killpg(compositor.pid, signal.SIGTERM) + compositor.wait(timeout=10) + log.close() +PY +pass "a fresh compositor never exposes its cursor before the desktop reveal" diff --git a/test/shell.d/hyprland-startup-cursor-test.sh b/test/shell.d/hyprland-startup-cursor-test.sh new file mode 100755 index 00000000..248afd48 --- /dev/null +++ b/test/shell.d/hyprland-startup-cursor-test.sh @@ -0,0 +1,66 @@ +#!/bin/bash + +set -euo pipefail +source "$(dirname "$0")/base-test.sh" +require_command lua + +lua <<'LUA' +package.path = os.getenv("ROOT") .. "/?.lua;" .. package.path +local events, recovery, command = {}, nil, nil +local config = { invisible = false, enable_hyprcursor = true, sync_gsettings_theme = true } +local env = { XCURSOR_THEME = "my-xcursor", HYPRCURSOR_THEME = "my-hyprcursor", XCURSOR_PATH = "/my/icons" } +local getenv = os.getenv +os.getenv = function(name) return env[name] or getenv(name) end +local monitors = {} +hl = { + on = function(event, callback) events[event] = callback end, + get_monitors = function() return monitors end, + get_config = function(key) return config[key:match("%.(.+)")] end, + env = function(name, value) env[name] = value end, + config = function(values) + for name, value in pairs(values.cursor) do config[name] = value end + end, + timer = function(callback, options) + assert(options.timeout == 15000 and options.type == "oneshot") + recovery = callback + end, + exec_cmd = function(value) + if value == "omarchy-launch-shell" then + assert(config.invisible and env.XCURSOR_THEME == "my-xcursor", "hide the compositor cursor while restoring application settings before launch") + end + command = value + end, +} +o = { shell_quote = function(value) return "'" .. value .. "'" end, launch = function(value) return value end } + +require("default.hypr.autostart") +assert(not config.invisible, "loading the module must wait for user configuration") +events["config.reloaded"]() +assert(config.invisible and not config.enable_hyprcursor and not config.sync_gsettings_theme) +assert(env.XCURSOR_THEME == "omarchy-startup" and env.XCURSOR_PATH:match("/default/hypr/cursors:/my/icons$")) +assert(omarchy_startup_cursor_pending, "the first compositor frame must use the blank cursor") +events["hyprland.start"]() +assert(env.XCURSOR_THEME == "my-xcursor" and env.XCURSOR_PATH == "/my/icons", "applications must inherit the user's cursor") +assert(config.invisible, "starting applications must not reveal the cursor") + +local previous_recovery = recovery +events["config.reloaded"]() +assert(recovery ~= previous_recovery and config.invisible, "a config reload must retain startup hiding and rearm recovery") +recovery() +assert(command:match("setcursor 'my%-xcursor'"), "restore the Xcursor fallback before revealing the pointer") +assert(config.invisible, "wait for the normal theme before restoring visibility") +omarchy_startup_cursor_restore(true) +assert(not config.invisible and config.enable_hyprcursor and config.sync_gsettings_theme) +assert(command:match("setcursor 'my%-hyprcursor'"), "restore the user's Hyprcursor theme") +local previous_command = command +previous_recovery() +assert(command == previous_command, "recovery must not change a revealed cursor") +events["config.reloaded"]() +assert(not config.invisible and env.XCURSOR_THEME == "my-xcursor", "ordinary config reloads must not hide the cursor") + +omarchy_startup_cursor_pending = nil +monitors = { {} } +events["config.reloaded"]() +assert(not omarchy_startup_cursor_pending and not config.invisible, "installing the fix in a running compositor must leave its cursor alone") +LUA +pass "the first compositor frame starts blank and the reveal restores user cursor settings" diff --git a/test/shell.d/image-picker-test.sh b/test/shell.d/image-picker-test.sh index c1cfc03e..a18ac870 100644 --- a/test/shell.d/image-picker-test.sh +++ b/test/shell.d/image-picker-test.sh @@ -147,7 +147,7 @@ assert( ) assert( /OverlayWindow \{\s*id: panel\s*shown: root\.opened\s*shownKeyboardFocus: root\.imagesLoaded \? WlrKeyboardFocus\.Exclusive : WlrKeyboardFocus\.None/.test(imagePickerQml), - 'image picker parks on OverlayWindow and takes the keyboard once images load' + 'image picker uses OverlayWindow and takes the keyboard once images load' ) assert( /model: visibleImages/.test(imagePickerQml) && diff --git a/test/shell.d/launch-shell-test.sh b/test/shell.d/launch-shell-test.sh index a5e0cf1c..6d377fc2 100755 --- a/test/shell.d/launch-shell-test.sh +++ b/test/shell.d/launch-shell-test.sh @@ -20,7 +20,11 @@ trap cleanup EXIT fake_bin="$test_tmp/bin" shell_root="$test_tmp/root" -mkdir -p "$fake_bin" "$shell_root/shell" +launch_home="$test_tmp/home" +startup_wallpaper="$test_tmp/Selected wallpaper.webp" +mkdir -p "$fake_bin" "$shell_root/shell" "$launch_home/.local/state/omarchy/current" +touch "$startup_wallpaper" +ln -s "$startup_wallpaper" "$launch_home/.local/state/omarchy/current/background" # Each launch consumes the next status from OMARCHY_TEST_QS_STATUSES; "run" # stands in for a healthy shell that keeps going until stopped. @@ -28,8 +32,8 @@ cat >"$fake_bin/quickshell" <<'SH' #!/bin/bash printf '%s\n' "$*" >>"$OMARCHY_TEST_QS_LOG" -printf 'watcher=%s popup=%s\n' \ - "${QS_DISABLE_FILE_WATCHER:-unset}" "${QS_NO_RELOAD_POPUP:-unset}" >>"$OMARCHY_TEST_QS_ENV_LOG" +printf 'watcher=%s popup=%s background=%s\n' \ + "${QS_DISABLE_FILE_WATCHER:-unset}" "${QS_NO_RELOAD_POPUP:-unset}" "${OMARCHY_STARTUP_BACKGROUND:-unset}" >>"$OMARCHY_TEST_QS_ENV_LOG" launches=$(wc -l <"$OMARCHY_TEST_QS_LOG") status=$(awk -v n="$launches" 'NR == n { print; found = 1 } END { if (!found) print "0" }' <<<"$OMARCHY_TEST_QS_STATUSES") @@ -90,6 +94,7 @@ launch_shell() { : >"$logger_log" PATH="$fake_bin:$PATH" \ + HOME="$launch_home" \ OMARCHY_PATH="$shell_root" \ OMARCHY_TEST_QS_LOG="$qs_log" \ OMARCHY_TEST_QS_ENV_LOG="$qs_env_log" \ @@ -113,9 +118,11 @@ pass "a shell that exits cleanly is left alone" # A misspelled variable would leave Quickshell hot-reloading the tree pacman # rewrites underneath it, which is what crashes the restart that follows. -[[ $(<"$qs_env_log") == "watcher=1 popup=1" ]] || +[[ $(<"$qs_env_log") == "watcher=1 popup=1 "* ]] || fail "the shell launches with Quickshell's own reloading off" "$(<"$qs_env_log")" pass "the shell launches with Quickshell's config watcher and reload popup off" +[[ $(<"$qs_env_log") == *"background=$startup_wallpaper" ]] || fail "the shell receives the resolved wallpaper path before startup" "$(<"$qs_env_log")" +pass "the selected wallpaper can decode as soon as Quickshell starts" # Qt leaves through _exit(), so Quickshell's crash handler never relaunches it. launch_shell $'255\n0' || fail "a shell that died on a Wayland error is relaunched" diff --git a/test/shell.d/menu-guards-test.sh b/test/shell.d/menu-guards-test.sh index 546596ce..75a7c35a 100755 --- a/test/shell.d/menu-guards-test.sh +++ b/test/shell.d/menu-guards-test.sh @@ -71,7 +71,7 @@ const fs = require('fs') const defaultItems = menu.parseMenuJsonc(fs.readFileSync(path.join(root, 'default/omarchy/omarchy-menu.jsonc'), 'utf8')) const guardText = defaultItems.map(item => `${item.when}\n${item.checked}\n${item.disabled}`).join('\n') const repeated = [...new Set( - (guardText.match(/\$\((omarchy-[a-z0-9-]+)\)/g) || []).map(match => match.slice(2, -1)) + (guardText.match(/\$\((omarchy-[a-z0-9-]+(?: [a-z0-9-]+)*)\)/g) || []).map(match => match.slice(2, -1)) )].filter(command => guardText.split(`$(${command})`).length > 2) assertDeepEqual( repeated.filter(command => !menu.guardReaders.includes(command)), diff --git a/test/shell.d/menu-test.sh b/test/shell.d/menu-test.sh index 402089d2..3137830f 100644 --- a/test/shell.d/menu-test.sh +++ b/test/shell.d/menu-test.sh @@ -356,6 +356,7 @@ assertDeepEqual( [ 'remove.package', 'remove.ai', + 'remove.dictation', 'remove.service', 'remove.development', 'remove.theme', @@ -367,7 +368,7 @@ assertDeepEqual( 'remove.preinstalls', 'remove.security' ], - 'menu orders Remove categories like their Install counterparts, followed by Remove-only categories' + 'menu keeps the Remove category order' ) assert( defaultById['setup.security.passwordless-sudo'].action.includes('omarchy-sudo-passwordless'), @@ -390,6 +391,11 @@ assertEqual( 'omarchy-bar transparent toggle', 'menu exposes Menu Bar transparency as a toggle' ) +assert( + !defaultItems.some(item => item.id.startsWith('style.background-intro')) + && defaultById['trigger.toggle.animations'].action === 'omarchy-toggle-animations', + 'menu uses the existing animations toggle without separate background intro controls' +) assertDeepEqual( defaultItems.filter(item => item.parent === 'setup.plugin').map(item => item.label), ['Enable Plugin', 'Disable Plugin', 'Add Plugin', 'Clone Plugin', 'Remove Plugin'], diff --git a/test/shell.d/mise-work-path-test.sh b/test/shell.d/mise-work-path-test.sh index 9c50b91b..f98fe6b9 100644 --- a/test/shell.d/mise-work-path-test.sh +++ b/test/shell.d/mise-work-path-test.sh @@ -100,6 +100,54 @@ env \ [[ $(<"$install_log") == "use -g node@latest" ]] || fail "installer only invokes Mise for the global Node setup" pass "new installs do not add project bin directories to PATH" +# An offline install unpacks the Node tarball the ISO bundles, and the ISO +# bundles the one for its own architecture. Both are on offer here; each +# machine must take its own. uname and the bundle directory are stand-ins. +bundle="$test_dir/bundle" +mkdir -p "$bundle" "$test_dir/node-src" +for platform in linux-x64 linux-arm64; do + mkdir -p "$test_dir/node-src/node-v24.1.0-$platform/bin" + echo "$platform" >"$test_dir/node-src/node-v24.1.0-$platform/bin/node" + tar -czf "$bundle/node-v24.1.0-$platform.tar.gz" -C "$test_dir/node-src" "node-v24.1.0-$platform" +done +cat >"$test_dir/bin/uname" <<'SH' +#!/bin/bash +[[ $* == -m ]] && { echo "$UNAME_TEST_MACHINE"; exit; } +exec /usr/bin/uname "$@" +SH +cat >"$test_dir/bin/find" <<'SH' +#!/bin/bash +[[ $1 == /opt/packages ]] && { shift; exec /usr/bin/find "$NODE_TEST_BUNDLE" "$@"; } +exec /usr/bin/find "$@" +SH +chmod +x "$test_dir/bin/uname" "$test_dir/bin/find" + +for case in "x86_64 linux-x64" "aarch64 linux-arm64"; do + read -r machine platform <<<"$case" + arch_home="$test_dir/$machine-home" + arch_log="$test_dir/$machine-mise.log" + mkdir -p "$arch_home" + env \ + HOME="$arch_home" \ + MISE_TEST_LOG="$arch_log" \ + NODE_TEST_BUNDLE="$bundle" \ + UNAME_TEST_MACHINE="$machine" \ + OMARCHY_SETUP_CONTEXT=iso-chroot \ + PATH="$test_dir/bin:/usr/bin" \ + bash -euo pipefail -c 'source "$1"' bash "$ROOT/install/user/mise-work.sh" + + installed="$arch_home/.local/share/mise/installs/node/24.1.0/bin/node" + [[ -f $installed && $(<"$installed") == "$platform" ]] || fail "$machine unpacks the $platform Node tarball" + [[ $(head -n1 "$arch_log") == "use -g node@24.1.0" ]] || fail "$machine hands Mise the bundled Node version" +done +[[ ! -e $bundle/node-v24.1.0-linux-x64.tar.gz ]] || rm "$bundle/node-v24.1.0-linux-x64.tar.gz" +if env HOME="$test_dir/missing-home" MISE_TEST_LOG="$test_dir/missing.log" NODE_TEST_BUNDLE="$bundle" \ + UNAME_TEST_MACHINE=x86_64 OMARCHY_SETUP_CONTEXT=iso-chroot PATH="$test_dir/bin:/usr/bin" \ + bash -euo pipefail -c 'source "$1"' bash "$ROOT/install/user/mise-work.sh" 2>/dev/null; then + fail "an x86_64 install accepts another architecture's Node tarball" +fi +pass "offline installs unpack the Node tarball for the machine's architecture" + stock_home="$test_dir/stock-home" stock_config="$stock_home/Work/.mise.toml" stock_project="$stock_home/Work/tries/untrusted-repository" diff --git a/test/shell.d/opr-priority-migration-test.sh b/test/shell.d/opr-priority-migration-test.sh new file mode 100644 index 00000000..dd2d086e --- /dev/null +++ b/test/shell.d/opr-priority-migration-test.sh @@ -0,0 +1,76 @@ +#!/bin/bash + +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT +mkdir -p "$test_dir/bin" +export PACMAN_CONFIG="$test_dir/pacman.conf" CALL_LOG="$test_dir/calls" +sed "s|/etc/pacman.conf|$PACMAN_CONFIG|g" "$ROOT/migrations/1791403252.sh" >"$test_dir/migration.sh" + +cat >"$test_dir/bin/sudo" <<'SH' +#!/bin/bash +[[ ${CONFIG_STATUS:-0} == 0 ]] || exit "$CONFIG_STATUS" +if [[ $1 == "install" ]]; then + install -m 644 "${@: -2}" +else + "$@" +fi +SH +cat >"$test_dir/bin/omarchy-update-system-pkgs" <<'SH' +#!/bin/bash +# Snapshot exactly the configuration visible to the package updater. +cat "$PACMAN_CONFIG" >"$CALL_LOG" +exit "${UPDATE_STATUS:-0}" +SH +chmod +x "$test_dir/bin/"* + +run() { + rm -f "$CALL_LOG" + env PATH="$test_dir/bin:$PATH" "$@" bash -euo pipefail "$test_dir/migration.sh" >"$test_dir/output" 2>&1 +} + +for channel in stable rc edge; do + cat >"$PACMAN_CONFIG" < fs.readFileSync(path.join(root, file), 'utf8') const overlay = read('shell/Ui/OverlayWindow.qml') -// Closed, the surface parks 1x1, input-less and without keyboard below -// windows: off the overlay layer, so it never blocks direct scanout. +// Closing must unmap, not leave a surface on an output that may disappear. assert( - overlay.includes('visible: true') && - overlay.includes('anchors { top: true; left: true; bottom: shown; right: shown }') && - overlay.includes('implicitWidth: 1') && - overlay.includes('implicitHeight: 1') && - overlay.includes('mask: shown ? null : emptyRegion') && - overlay.includes('WlrLayershell.layer: shown ? shownLayer : WlrLayer.Bottom') && - overlay.includes('WlrLayershell.keyboardFocus: shown ? shownKeyboardFocus : WlrKeyboardFocus.None'), - 'overlay window parks a 1x1 input-less surface below windows while hidden' + overlay.includes('visible: shown && !!targetScreen && !!targetMonitor') && + overlay.includes('anchors { top: true; left: true; bottom: true; right: true }') && + !overlay.includes('implicitWidth: 1') && !overlay.includes('WlrLayer.Bottom'), + 'closed overlays unmap instead of parking a surface' ) -// Content stays hidden until the surface has grown, so no frame drawn at 1x1 -// is stretched across the screen. +const readyExpression = /readonly property bool contentReady: ([^\n]*(?:\n &&[^\n]*)*)/.exec(overlay)[1] +const ready = new Function('shown', 'backingWindowVisible', 'width', 'height', 'targetScreen', 'targetMonitor', 'devicePixelRatio', `return ${readyExpression}`) +const screen = { name: 'eDP-1', width: 1200, height: 750 } +const monitor = { name: 'eDP-1', scale: 1.6 } +for (const scale of [2, 1, 1.6]) { + assertEqual(ready(true, true, 1200, 750, screen, monitor, scale), scale === 1.6, + `opening frames at ${scale}x wait for the monitor's 1.6x scale`) +} +for (const scale of [1, 1.25, 1.5, 2]) { + assert(ready(true, true, 1200, 750, screen, { scale }, scale), + `overlay reveals at the correct ${scale}x scale`) +} +assert(ready(true, true, 1200, 750, screen, { scale: 1.333333 }, 160 / 120), + 'scale comparison tolerates compositor floating point rounding') +assert(!ready(true, true, 500, 500, screen, monitor, 1.6), + 'initial window geometry never stretches content across the screen') +assert(!ready(true, false, 1200, 750, screen, monitor, 1.6), + 'content waits for a mapped backing window') +assert(!ready(false, true, 1200, 750, screen, monitor, 1.6), + 'closed content stays transparent even at the correct scale') +assert(!ready(true, true, 1200, 750, null, monitor, 1.6) && + !ready(true, true, 1200, 750, screen, null, 1.6), + 'content waits for a connected screen and its monitor information') assert( - /target: window\.contentItem\s*property: "visible"\s*value: window\.shown && window\.width > 1 && window\.height > 1/.test(overlay), - 'overlay window draws nothing until the surface has grown' + /target: window\.contentItem\s*property: "opacity"\s*value: window\.contentRevealed \? 1 : 0/.test(overlay), + 'transparent opening frames preserve immediate keyboard handling' ) -assert( - /onShownChanged: if \(shown\) targetScreen = focusedScreen\(\) \|\| targetScreen/.test(overlay) && - overlay.includes('screen: targetScreen'), - 'overlay window follows the focused monitor each time it is shown' -) +const revealBody = /onContentReadyChanged: \{([\s\S]*?)\n \}/.exec(overlay)[1] +const reveal = new Function('window', 'Qt', `with (window) { ${revealBody} }`) +const reveals = [] +const revealQt = { callLater(callback) { reveals.push(callback) } } +const content = { contentReady: true, contentRevealed: false } +reveal(content, revealQt) +assert(!content.contentRevealed, 'scale and resize notifications finish before content reveals') +reveals.shift()() +assert(content.contentRevealed, 'settled content reveals on the next event-loop turn') +content.contentReady = false +reveal(content, revealQt) +assert(!content.contentRevealed, 'losing readiness immediately hides content') +content.contentReady = true +reveal(content, revealQt) +content.contentReady = false +reveal(content, revealQt) +reveals.shift()() +assert(!content.contentRevealed, 'a close or screen loss cancels a pending reveal') + +const focusedScreenBody = /function focusedScreen\(\) \{([\s\S]*?)\n \}/.exec(overlay)[1] +const focusedScreen = new Function('Hyprland', 'Quickshell', focusedScreenBody) +const external = { name: 'DP-1', width: 1920, height: 1080 } +assertEqual(focusedScreen({ focusedMonitor: { name: 'DP-1' } }, { screens: [screen, external] }), external, + 'overlay selects the focused output') +assertEqual(focusedScreen({ focusedMonitor: { name: 'DP-1' } }, { screens: [screen] }), screen, + 'disconnecting the focused output falls back to a connected screen') +assertEqual(focusedScreen({ focusedMonitor: null }, { screens: [] }), null, + 'no outputs leaves the overlay unmapped') + +const shownHandler = /onShownChanged: \{([\s\S]*?)\n \}/.exec(overlay)[1] +let refreshed = 0 +const Hyprland = { refreshMonitors() { refreshed++ } } +const changeShown = new Function('shown', 'focusedScreen', 'Hyprland', `let targetScreen = null; ${shownHandler}; return targetScreen`) +assertEqual(changeShown(false, () => external, Hyprland), null, 'closing drops the previous screen reference') +assertEqual(refreshed, 0, 'closing does not refresh monitor data') +assertEqual(changeShown(true, () => screen, Hyprland), screen, 'reopening selects a current screen') +assertEqual(refreshed, 1, 'opening refreshes monitor scales after a live change while closed') +const scaleHandler = /onDevicePixelRatioChanged: ([^\n]+)/.exec(overlay)[1] +const changeScale = new Function('shown', 'Hyprland', scaleHandler) +changeScale(false, Hyprland) +assertEqual(refreshed, 1, 'scale changes on hidden windows do not refresh monitor data') +const changedScreen = { name: 'eDP-1', width: 960, height: 600 } +assert(!ready(true, true, 960, 600, changedScreen, monitor, 2), + 'a stale monitor scale keeps content transparent after a live scale change') +Hyprland.refreshMonitors = () => { refreshed++; monitor.scale = 2 } +changeScale(true, Hyprland) +assertEqual(refreshed, 2, 'a showing window refreshes monitor scales when Qt receives a live change') +assert(ready(true, true, 960, 600, changedScreen, monitor, 2), + 'fresh monitor data lets content reveal at the new scale') + +const screensChangedBody = /function onScreensChanged\(\) \{([\s\S]*?)\n \}/.exec(overlay)[1] +const screensChanged = new Function('window', 'Quickshell', 'Qt', screensChangedBody) +const pending = [] +const Qt = { callLater(callback) { pending.push(callback) } } +const window = { shown: true, targetScreen: external, focusedScreen: () => screen } +screensChanged(window, { screens: [screen] }, Qt) +assertEqual(window.targetScreen, null, 'screen removal unmaps before Qt finishes destroying the old window') +pending.shift()() +assertEqual(window.targetScreen, screen, 'a showing overlay moves off a disconnected screen') +window.targetScreen = screen +window.focusedScreen = () => external +screensChanged(window, { screens: [screen, external] }, Qt) +assertEqual(window.targetScreen, screen, 'connecting another screen does not move a showing overlay') +window.shown = false +window.targetScreen = null +screensChanged(window, { screens: [screen, external] }, Qt) +assertEqual(window.targetScreen, null, 'screen changes leave a closed overlay unmapped') +window.shown = true +window.targetScreen = external +screensChanged(window, { screens: [screen] }, Qt) +window.shown = false +pending.shift()() +assertEqual(window.targetScreen, null, 'closing before a deferred remap prevents a stale reopen') +window.shown = true +window.targetScreen = external +screensChanged(window, { screens: [screen] }, Qt) +window.targetScreen = screen +window.focusedScreen = () => external +pending.shift()() +assertEqual(window.targetScreen, screen, 'a deferred remap preserves the screen chosen by a newer open') const overlays = { 'shell/plugins/menu/Menu.qml': 'shown: root.opened && root.rowsLoaded', @@ -48,11 +139,11 @@ for (const [file, shown] of Object.entries(overlays)) { const qml = read(file) assert( qml.includes('OverlayWindow {') && qml.includes(shown) && !/PanelWindow \{\s*(id: panel\s*)?visible: root\.opened/.test(qml), - `${file} keeps its surface through OverlayWindow` + `${file} uses the shared overlay scale and lifecycle handling` ) } -// Window visibility never changes now, so close-time resets key off shown. +// Layout resets on a requested close, independent of mapping readiness. const menuQml = read('shell/plugins/menu/Menu.qml') assert( menuQml.includes('onShownChanged: if (!shown) { cardTop = -1; maxRowsHeight = -1 }') && @@ -60,9 +151,15 @@ assert( !/onVisibleChanged: if \(!visible\) \{ cardTop/.test(menuQml), 'the menu unfreezes its layout when the overlay hides' ) +const screenHandler = /onTargetScreenChanged: \{([^\n]*)\}/.exec(menuQml)[1] +const changeScreen = new Function('panel', `with (panel) { ${screenHandler} }`) +const menuPanel = { shown: true, cardTop: 945, maxRowsHeight: 510 } +changeScreen(menuPanel) +assertEqual(menuPanel.cardTop, -1, 'moving an open menu off a disconnected output unfreezes its old top edge') +assertEqual(menuPanel.maxRowsHeight, -1, 'moving an open menu drops the old output row-height limit') +assert(menuPanel.shown, 'resetting the layout keeps the menu open') -// A kept surface grows from its parked 1x1 when shown, which Hyprland animates -// as a slide in from the corner unless its layer rule turns animation off. +// Fullscreen overlays open without an additional compositor animation. const shellRules = read('default/hypr/apps/omarchy-shell.lua') const noAnim = /namespace = "\^\(([^)]*)\)\$" \}, no_anim = true/.exec(shellRules) assert(noAnim, 'the shell overlays share one no-animation layer rule') diff --git a/test/shell.d/passwordless-sudo-indicator-test.sh b/test/shell.d/passwordless-sudo-indicator-test.sh index 129b3b82..f9ef1226 100644 --- a/test/shell.d/passwordless-sudo-indicator-test.sh +++ b/test/shell.d/passwordless-sudo-indicator-test.sh @@ -9,9 +9,9 @@ const read = name => fs.readFileSync(path.join(root, name), 'utf8') const indicator = read('shell/plugins/bar/indicators/PasswordlessSudo.qml') const widget = read('shell/plugins/bar/widgets/Indicators.qml') const manifest = JSON.parse(read('shell/plugins/bar/widgets/Indicators.manifest.json')) -assert(widget.match(/defaultIndicatorEntries: \[ "PasswordlessSudo", "Dictation"/), 'passwordless sudo is included in the default indicator tray') +assert(widget.match(/defaultIndicatorEntries: \[ "PasswordlessSudo", "ScreenRecording"/), 'passwordless sudo is included in the default indicator tray') assert(manifest.barWidget.schema.find(field => field.key === 'items').options.some(option => option.value === 'PasswordlessSudo'), 'passwordless sudo is configurable alongside Night Light') -assert(indicator.includes('useActiveColor: true') && indicator.includes('activeColor: Color.urgent'), 'active passwordless sudo uses the theme danger color') +assert(indicator.includes('useActiveColor: true') && indicator.includes('activeColor: Commons.Color.urgent'), 'active passwordless sudo uses the theme danger color') assertEqual(indicator.match(/activeText: "([^"]+)"/)[1], indicator.match(/inactiveText: "([^"]+)"/)[1], 'sudo keeps the same icon in both states') assert(!widget.includes('sudoHorizontal') && !widget.includes('sudoVertical'), 'sudo participates in the normal indicator blocks') assert(!indicator.includes('visible:'), 'sudo uses the shared indicator visibility and hover behavior') diff --git a/test/shell.d/theme-intro-switch-test.sh b/test/shell.d/theme-intro-switch-test.sh new file mode 100755 index 00000000..8c172b03 --- /dev/null +++ b/test/shell.d/theme-intro-switch-test.sh @@ -0,0 +1,203 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT +home="$test_tmp/home" +state="$home/.local/state/omarchy/current" +themes="$home/.config/omarchy/themes" +commands="$test_tmp/bin" +log="$test_tmp/commands" +mkdir -p "$state" "$commands" "$home/.local/state/omarchy/theme-backgrounds" +for theme in alpha beta; do + mkdir -p "$themes/$theme/backgrounds/intros" + cp "$ROOT/themes/tokyo-night/colors.toml" "$themes/$theme/colors.toml" + printf 'still\n' >"$themes/$theme/backgrounds/1-sky.webp" + printf 'still\n' >"$themes/$theme/backgrounds/2-road.webp" + printf 'intro\n' >"$themes/$theme/backgrounds/intros/2-road.mp4" +done +cp -r "$themes/alpha" "$state/theme" +printf 'alpha\n' >"$state/theme.name" +ln -s "$state/theme/backgrounds/1-sky.webp" "$state/background" +printf '%s\n' "$state/theme/backgrounds/2-road.webp" >"$home/.local/state/omarchy/theme-backgrounds/beta" +printf 'already-consumed\n' >"$home/.local/state/omarchy/background-intro.session-id" + +cat >"$commands/noop" <<'STUB' +#!/bin/bash +exit 0 +STUB +chmod +x "$commands/noop" +# Post-theme retints run through bash -lc; this fixture only exercises selection and presentation. +for command in omarchy-theme-set-templates omarchy-hook omarchy-theme-set-herdr-machines omarchy-theme-bg-cache; do + ln -s noop "$commands/$command" +done +cat >"$commands/bash" <<'STUB' +#!/bin/bash +if [[ $1 == "-lc" && $2 == "omarchy-restart-hyprctl" ]]; then + omarchy-restart-hyprctl +fi +STUB +cat >"$commands/omarchy-restart-hyprctl" <<'STUB' +#!/bin/bash +echo "hypr-reload" >>"$TEST_LOG" +STUB +cat >"$commands/hyprctl" <<'STUB' +#!/bin/bash +printf '{"bool":%s}\n' "${TEST_ANIMATIONS:-true}" +STUB +cat >"$commands/omarchy-shell" <<'STUB' +#!/bin/bash +printf 'shell: %s\n' "$*" >>"$TEST_LOG" +if [[ -n ${TEST_GATE_DIR:-} ]]; then + if [[ $2 == "themeIntroStatus" && ! -f $TEST_GATE_DIR/fade-ready ]]; then + if [[ ! -f $TEST_GATE_DIR/fade-waited ]]; then + touch "$TEST_GATE_DIR/fade-waited" + echo "pending" + else + touch "$TEST_GATE_DIR/fade-ready" + echo "ready" + fi + elif [[ $2 == "themeIntroCoverStatus" && ! -f $TEST_GATE_DIR/cover-ready ]]; then + if [[ ! -f $TEST_GATE_DIR/fade-ready || $(<"$TEST_STATE/theme.name") != "$TEST_OLD_THEME" ]]; then + touch "$TEST_GATE_DIR/premature-swap" + fi + if [[ ! -f $TEST_GATE_DIR/cover-waited ]]; then + touch "$TEST_GATE_DIR/cover-waited" + echo "loading" + else + touch "$TEST_GATE_DIR/cover-ready" + echo "ready" + fi + else + echo "ready" + fi +fi +STUB +cat >"$commands/owe" <<'STUB' +#!/bin/bash +if [[ $1 == intro && -n ${TEST_RELEASE:-} ]]; then + printf '%s\n' "$$" >"$TEST_INTRO_PID" +fi +printf 'owe: %s\n' "$*" >>"$TEST_LOG" +[[ ${TEST_OWE_FAIL:-false} != true ]] || exit 1 +if [[ $1 == "render" ]]; then + [[ ${TEST_LIVE_FRAME:-false} == "true" ]] || exit 1 + snapshot=$(jq -r '.path' <<<"$2") + printf 'live video frame\n' >"$snapshot" +elif [[ $1 == "intro-prepare" && ${TEST_OWE_NO_PREPARE:-false} == "true" ]]; then + exit 1 +fi +if [[ $1 == intro && -n ${TEST_RELEASE:-} ]]; then + for attempt in {1..100}; do + [[ ! -f $TEST_RELEASE ]] || exit 0 + sleep 0.05 + done + exit 1 +fi +STUB +chmod +x "$commands/bash" "$commands/omarchy-restart-hyprctl" "$commands/hyprctl" "$commands/omarchy-shell" "$commands/owe" + +set_theme() { + : >"$log" + HOME="$home" OMARCHY_PATH="$ROOT" PATH="$commands:$ROOT/bin:$PATH" XDG_RUNTIME_DIR="$test_tmp" TEST_LOG="$log" \ + /bin/bash "$ROOT/bin/omarchy-theme-set" "$1" >"$test_tmp/stdout" 2>"$test_tmp/stderr" || fail "theme selection completes" "$(cat "$test_tmp/stderr")" +} +wait_command() { + for attempt in {1..100}; do + grep -q "$1" "$log" && return 0 + sleep 0.02 + done + fail "theme selection reaches $1" "$(cat "$log")" +} + +set_theme beta +wait_command '^owe: intro ' +grep -Fxq "owe: intro --start first-frame --refresh $state/theme/backgrounds/intros/2-road.mp4" "$log" || fail "a theme switch plays the remembered background's matching prepared intro" +[[ $(readlink "$state/background") == "$state/theme/backgrounds/2-road.webp" ]] || fail "the remembered background is selected" +! grep -qE '^shell: background (prepare|themeTransition)' "$log" || fail "an intro replaces the normal still transition" +[[ $(<"$home/.local/state/omarchy/background-intro.session-id") == already-consumed ]] || fail "a theme switch leaves login consumption unchanged" +pass "switching themes plays the matching intro for the remembered background" + +set_theme beta +[[ $(readlink "$state/background") == "$state/theme/backgrounds/1-sky.webp" ]] || fail "reselecting the current theme cycles its background" +grep -q '^shell: background themeTransition ' "$log" || fail "same-theme cycling uses the normal still transition" +! grep -q '^owe: intro ' "$log" || fail "same-theme cycling does not play an intro" +OMARCHY_THEME_SKIP_BACKGROUND=1 set_theme alpha +! grep -q '^owe: intro ' "$log" || fail "theme refresh does not play an intro" +pass "same-theme cycling and refresh keep the normal background behavior" + +printf '%s\n' "$state/theme/backgrounds/2-road.webp" >"$home/.local/state/omarchy/theme-backgrounds/beta" +mkdir -p "$home/.local/state/omarchy/toggles" +touch "$home/.local/state/omarchy/toggles/background-intros-off" +set_theme beta +grep -q '^shell: background themeTransition ' "$log" || fail "disabled intros use the normal still transition" +! grep -q '^owe: intro ' "$log" || fail "disabled intros do not play on theme selection" +rm "$home/.local/state/omarchy/toggles/background-intros-off" +printf '%s\n' "$state/theme/backgrounds/2-road.webp" >"$home/.local/state/omarchy/theme-backgrounds/alpha" +TEST_ANIMATIONS=false set_theme alpha +grep -q '^shell: background themeTransition ' "$log" || fail "disabled animations use the normal still transition" +! grep -q '^owe: intro ' "$log" || fail "disabled animations do not play an intro" +OMARCHY_THEME_HEADLESS=1 set_theme beta +! grep -q '^owe: intro ' "$log" || fail "headless theme setup does not play an intro" +pass "theme intros respect the global toggle, disabled animations, and headless setup" + +# Alpha's remembered image must have a clip for these presentation checks. +printf '%s\n' "$state/theme/backgrounds/2-road.webp" >"$home/.local/state/omarchy/theme-backgrounds/alpha" +TEST_OWE_FAIL=true set_theme alpha +wait_command '^shell: background setInstant ' +wait_command '^hypr-reload$' +! grep -q '^owe: intro ' "$log" || fail "an unavailable renderer falls back to the still" +pass "unavailable OWE falls back to the selected still image" + +printf '%s\n' "$state/theme/backgrounds/2-road.webp" >"$home/.local/state/omarchy/theme-backgrounds/beta" +release="$test_tmp/release" +TEST_LIVE_FRAME=true TEST_INTRO_PID="$test_tmp/intro.pid" TEST_RELEASE="$release" set_theme beta +wait_command '^owe: intro ' +cover=$(awk '/^shell: shell prepareThemeIntro / { print $4; exit }' "$log") +[[ -f $cover && $(<"$cover") == "live video frame" ]] || fail "an interrupted video supplies the outgoing cover instead of its final still" +kill -0 "$(<"$test_tmp/intro.pid")" || fail "the fake renderer is still playing during the lock check" +timeout 1 flock "$test_tmp/omarchy-theme-set.lock" true || fail "theme playback does not retain the theme selection lock" +touch "$release" +pass "interrupted playback uses its current frame and releases the theme selection lock" + +TEST_OWE_NO_PREPARE=true set_theme alpha +wait_command '^owe: intro ' +grep -Fxq "owe: intro --start first-frame $state/theme/backgrounds/intros/2-road.mp4" "$log" || fail "older OWE uses the ordinary intro path" +pass "older OWE keeps the ordinary intro startup path" + +gates="$test_tmp/gates" +mkdir "$gates" +TEST_GATE_DIR="$gates" TEST_STATE="$state" TEST_OLD_THEME=alpha set_theme beta +wait_command '^owe: intro ' +[[ -f $gates/fade-waited && -f $gates/cover-waited && -f $gates/cover-ready ]] || fail "a rapid selection waits for the opening fade and replacement cover" +[[ ! -f $gates/premature-swap ]] || fail "the outgoing fade and cover settle before replacing the active theme" +pass "rapid switches present the outgoing video cover before replacing its still" + +wait_command '^hypr-reload$' +release="$test_tmp/reload-release" +TEST_INTRO_PID="$test_tmp/intro.pid" TEST_RELEASE="$release" set_theme alpha +wait_command '^owe: intro ' +! grep -q '^hypr-reload$' "$log" || fail "the compositor is not reloaded during intro playback" +touch "$release" +wait_command '^hypr-reload$' +(( $(grep -c '^hypr-reload$' "$log") == 1 )) || fail "the compositor reloads once after playback" +pass "theme intros defer the compositor reload until playback finishes" + +release="$test_tmp/superseded-release" +TEST_INTRO_PID="$test_tmp/intro.pid" TEST_RELEASE="$release" set_theme beta +wait_command '^owe: intro ' +cover=$(awk '/^shell: shell prepareThemeIntro / { print $4; exit }' "$log") +! grep -q '^hypr-reload$' "$log" || fail "the held intro has not reloaded the compositor" +OMARCHY_THEME_SKIP_BACKGROUND=1 set_theme alpha +grep -q '^hypr-reload$' "$log" || fail "a theme refresh reloads the compositor immediately" +touch "$release" +for attempt in {1..100}; do + [[ -f $cover ]] || break + sleep 0.02 +done +[[ ! -f $cover ]] || fail "the superseded intro finishes cleanup" +(( $(grep -c '^hypr-reload$' "$log") == 1 )) || fail "the superseded intro cannot reload during a newer theme" +pass "superseded intros do not reload the compositor" diff --git a/test/shell.d/theme-set-herdr-machines-test.sh b/test/shell.d/theme-set-herdr-machines-test.sh index 1774a10f..f16de673 100644 --- a/test/shell.d/theme-set-herdr-machines-test.sh +++ b/test/shell.d/theme-set-herdr-machines-test.sh @@ -35,6 +35,7 @@ EOF # ssh runs the script it receives against a fake home for that machine, as the remote would. cat >"$stub_bin/ssh" <<'EOF' #!/bin/bash +printf '%s\n' "$@" >>"$SYNC_TEST/ssh-options" while [[ $1 == "-o" ]]; do shift 2 done @@ -61,7 +62,7 @@ EOF chmod +x "$stub_bin"/* "$remote_bin"/* reset_remotes() { - rm -rf "$SYNC_TEST/remotes" "$SYNC_TEST/ssh-calls" + rm -rf "$SYNC_TEST/remotes" "$SYNC_TEST/ssh-calls" "$SYNC_TEST/ssh-options" local machine for machine in alpha beta gamma local-box; do mkdir -p "$SYNC_TEST/remotes/$machine/.local/state/omarchy/current" @@ -111,7 +112,14 @@ pass "leaves this machine alone when it is listed" [[ $output == *"down: ssh: connect to host down"* ]] || fail "logs an unreachable machine" pass "logs an unreachable machine" -# Connects one machine at a time, so an SSH agent asks for approval at most once. +# Keep reusable connections in a directory only this user can access. +grep -qx 'ControlMaster=auto' "$SYNC_TEST/ssh-options" || fail "reuses authenticated SSH connections" +grep -qx 'ControlPersist=600' "$SYNC_TEST/ssh-options" || fail "keeps connections for ten idle minutes" +grep -qxF "ControlPath=$SYNC_TEST/run/omarchy-theme-sync/%C" "$SYNC_TEST/ssh-options" || fail "keeps separate control sockets for each SSH destination" +[[ $(stat -c %a "$SYNC_TEST/run/omarchy-theme-sync") == "700" ]] || fail "control socket directory is private" +pass "reuses SSH connections for ten idle minutes in a private runtime directory" + +# Connects one machine at a time. [[ $(paste -sd ' ' "$SYNC_TEST/ssh-calls") == "down alpha beta gamma local-box" ]] || fail "connects to machines one at a time in order" pass "connects to machines one at a time in order" diff --git a/test/shell.d/theme-set-herdr-ssh-reuse-test.sh b/test/shell.d/theme-set-herdr-ssh-reuse-test.sh new file mode 100644 index 00000000..82c97e98 --- /dev/null +++ b/test/shell.d/theme-set-herdr-ssh-reuse-test.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +if ! command -v sshd >/dev/null; then + skip "no SSH server; skipping real theme sync connection reuse" + exit 0 +fi +require_command python3 +require_command ssh +require_command ssh-keygen + +python3 "$SHELL_TEST_DIR/fixtures/theme-set-herdr-ssh-reuse.py" diff --git a/test/shell.d/video-background-test.sh b/test/shell.d/video-background-test.sh index 1e437e5a..9a5b3846 100755 --- a/test/shell.d/video-background-test.sh +++ b/test/shell.d/video-background-test.sh @@ -330,6 +330,7 @@ video_snapshot=$(snapshot_background_path "$CURRENT_THEME_PATH/backgrounds/old.m CHOSEN_THEME_BACKGROUND="$transition_home/disappeared.mp4" BACKGROUND_TRANSITION_SNAPSHOTS=false +THEME_BACKGROUND_INTRO="" OLD_BACKGROUND_SNAPSHOT="" PREPARED_BACKGROUND="" PREPARED_BACKGROUND_SNAPSHOT="" diff --git a/test/shell.d/voxtype-invitation-test.sh b/test/shell.d/voxtype-invitation-test.sh deleted file mode 100644 index 2c6efd07..00000000 --- a/test/shell.d/voxtype-invitation-test.sh +++ /dev/null @@ -1,57 +0,0 @@ -#!/bin/bash - -source "$(dirname "$0")/base-test.sh" - -test_home=$(mktemp -d) -test_bin=$(mktemp -d) -log_file=$(mktemp) -hook_path="$test_home/.config/omarchy/hooks/post-update.d/install-voxtype.hook" - -cleanup() { - rm -rf "$test_home" "$test_bin" - rm -f "$log_file" -} -trap cleanup EXIT - -mkdir -p "$(dirname "$hook_path")" - -cat >"$test_bin/omarchy-notification-send" <<'EOF' -#!/bin/bash -echo notification >>"$TEST_LOG" -exec_args=() -while (($# > 0)); do - if [[ $1 == "--exec" ]]; then shift; exec_args=("$@"); break; fi - shift -done -((${#exec_args[@]})) && echo "exec:${exec_args[*]}" >>"$TEST_LOG" -EOF -chmod +x "$test_bin/omarchy-notification-send" - -# The shell runs the click command, so the invitation must not need a unit of its -# own to keep a blocked sender alive until the toast is answered. -cat >"$test_bin/systemd-run" <<'EOF' -#!/bin/bash -echo "systemd-run:$*" >>"$TEST_LOG" -EOF -chmod +x "$test_bin/systemd-run" - -run_invitation_hook() { - cp "$ROOT/install/user/first-run/install-voxtype.hook" "$hook_path" - HOME="$test_home" PATH="$test_bin:$ROOT/bin:$PATH" TEST_LOG="$log_file" bash "$hook_path" -} - -run_invitation_hook - -[[ -f $test_home/.local/state/omarchy/done/voxtype-install-invitation ]] || fail "Voxtype invitation records completion" -[[ -f $hook_path ]] || fail "Voxtype invitation keeps its hook installed" -[[ $(grep -c '^notification$' "$log_file") -eq 1 ]] || fail "Voxtype invitation sends one notification" -grep -qx 'exec:omarchy-launch-floating-terminal-with-presentation omarchy-voxtype-install' "$log_file" || - fail "Voxtype invitation attaches the installer to the notification" -grep -q '^systemd-run:' "$log_file" && fail "Voxtype invitation needs no unit to hold an unanswered toast" - -HOME="$test_home" PATH="$test_bin:$ROOT/bin:$PATH" TEST_LOG="$log_file" bash "$hook_path" - -[[ -f $hook_path ]] || fail "completed Voxtype invitation keeps its hook installed" -[[ $(grep -c '^notification$' "$log_file") -eq 1 ]] || fail "completed Voxtype invitation hook does not notify again" - -pass "Voxtype invitation only runs once" diff --git a/themes/catppuccin-latte/backgrounds/intros/1-color-fade.mp4 b/themes/catppuccin-latte/backgrounds/intros/1-color-fade.mp4 new file mode 100644 index 00000000..49361e9e Binary files /dev/null and b/themes/catppuccin-latte/backgrounds/intros/1-color-fade.mp4 differ diff --git a/themes/catppuccin/backgrounds/intros/1-totoro.mp4 b/themes/catppuccin/backgrounds/intros/1-totoro.mp4 new file mode 100644 index 00000000..9df17bb8 Binary files /dev/null and b/themes/catppuccin/backgrounds/intros/1-totoro.mp4 differ diff --git a/themes/catppuccin/backgrounds/intros/2-waves.mp4 b/themes/catppuccin/backgrounds/intros/2-waves.mp4 new file mode 100644 index 00000000..92737f3d Binary files /dev/null and b/themes/catppuccin/backgrounds/intros/2-waves.mp4 differ diff --git a/themes/catppuccin/backgrounds/intros/3-blue-eye.mp4 b/themes/catppuccin/backgrounds/intros/3-blue-eye.mp4 new file mode 100644 index 00000000..e8a2d38b Binary files /dev/null and b/themes/catppuccin/backgrounds/intros/3-blue-eye.mp4 differ diff --git a/themes/ethereal/backgrounds/intros/1-cosmic.mp4 b/themes/ethereal/backgrounds/intros/1-cosmic.mp4 new file mode 100644 index 00000000..ffa4bf69 Binary files /dev/null and b/themes/ethereal/backgrounds/intros/1-cosmic.mp4 differ diff --git a/themes/ethereal/backgrounds/intros/2-meadow.mp4 b/themes/ethereal/backgrounds/intros/2-meadow.mp4 new file mode 100644 index 00000000..bfa01c04 Binary files /dev/null and b/themes/ethereal/backgrounds/intros/2-meadow.mp4 differ diff --git a/themes/everforest/backgrounds/intros/1-tree-tops.mp4 b/themes/everforest/backgrounds/intros/1-tree-tops.mp4 new file mode 100644 index 00000000..edef1d41 Binary files /dev/null and b/themes/everforest/backgrounds/intros/1-tree-tops.mp4 differ diff --git a/themes/gruvbox/backgrounds/intros/1-the-backwater.mp4 b/themes/gruvbox/backgrounds/intros/1-the-backwater.mp4 new file mode 100644 index 00000000..53ea3231 Binary files /dev/null and b/themes/gruvbox/backgrounds/intros/1-the-backwater.mp4 differ diff --git a/themes/gruvbox/backgrounds/intros/2-flower-basket.mp4 b/themes/gruvbox/backgrounds/intros/2-flower-basket.mp4 new file mode 100644 index 00000000..5582b2e7 Binary files /dev/null and b/themes/gruvbox/backgrounds/intros/2-flower-basket.mp4 differ diff --git a/themes/gruvbox/backgrounds/intros/3-village-square.mp4 b/themes/gruvbox/backgrounds/intros/3-village-square.mp4 new file mode 100644 index 00000000..a3cc2650 Binary files /dev/null and b/themes/gruvbox/backgrounds/intros/3-village-square.mp4 differ diff --git a/themes/gruvbox/backgrounds/intros/4-idyllic-procession.mp4 b/themes/gruvbox/backgrounds/intros/4-idyllic-procession.mp4 new file mode 100644 index 00000000..6edb84f5 Binary files /dev/null and b/themes/gruvbox/backgrounds/intros/4-idyllic-procession.mp4 differ diff --git a/themes/gruvbox/backgrounds/intros/5-leaves.mp4 b/themes/gruvbox/backgrounds/intros/5-leaves.mp4 new file mode 100644 index 00000000..7c65cfda Binary files /dev/null and b/themes/gruvbox/backgrounds/intros/5-leaves.mp4 differ diff --git a/themes/hackerman/backgrounds/intros/1-synth-scape.mp4 b/themes/hackerman/backgrounds/intros/1-synth-scape.mp4 new file mode 100644 index 00000000..4a478090 Binary files /dev/null and b/themes/hackerman/backgrounds/intros/1-synth-scape.mp4 differ diff --git a/themes/hackerman/backgrounds/intros/2-geometric.mp4 b/themes/hackerman/backgrounds/intros/2-geometric.mp4 new file mode 100644 index 00000000..9301b770 Binary files /dev/null and b/themes/hackerman/backgrounds/intros/2-geometric.mp4 differ diff --git a/themes/kanagawa/backgrounds/intros/1-kanagawa.mp4 b/themes/kanagawa/backgrounds/intros/1-kanagawa.mp4 new file mode 100644 index 00000000..82298eab Binary files /dev/null and b/themes/kanagawa/backgrounds/intros/1-kanagawa.mp4 differ diff --git a/themes/last-horizon/backgrounds/intros/1-eyes-wide.mp4 b/themes/last-horizon/backgrounds/intros/1-eyes-wide.mp4 new file mode 100644 index 00000000..f9fa3e6f Binary files /dev/null and b/themes/last-horizon/backgrounds/intros/1-eyes-wide.mp4 differ diff --git a/themes/last-horizon/backgrounds/intros/2-blink.mp4 b/themes/last-horizon/backgrounds/intros/2-blink.mp4 new file mode 100644 index 00000000..8660ddfc Binary files /dev/null and b/themes/last-horizon/backgrounds/intros/2-blink.mp4 differ diff --git a/themes/last-horizon/backgrounds/intros/3-bokeh.mp4 b/themes/last-horizon/backgrounds/intros/3-bokeh.mp4 new file mode 100644 index 00000000..26aedab0 Binary files /dev/null and b/themes/last-horizon/backgrounds/intros/3-bokeh.mp4 differ diff --git a/themes/last-horizon/backgrounds/intros/4-new-horizons.mp4 b/themes/last-horizon/backgrounds/intros/4-new-horizons.mp4 new file mode 100644 index 00000000..2981e2ca Binary files /dev/null and b/themes/last-horizon/backgrounds/intros/4-new-horizons.mp4 differ diff --git a/themes/lumon/backgrounds/intros/01-united-in-severance.mp4 b/themes/lumon/backgrounds/intros/01-united-in-severance.mp4 new file mode 100644 index 00000000..613ff5cc Binary files /dev/null and b/themes/lumon/backgrounds/intros/01-united-in-severance.mp4 differ diff --git a/themes/lumon/backgrounds/intros/02-opinions-equally.mp4 b/themes/lumon/backgrounds/intros/02-opinions-equally.mp4 new file mode 100644 index 00000000..9ceff427 Binary files /dev/null and b/themes/lumon/backgrounds/intros/02-opinions-equally.mp4 differ diff --git a/themes/lupine/backgrounds/intros/01-cherry-blossom-bokeh.mp4 b/themes/lupine/backgrounds/intros/01-cherry-blossom-bokeh.mp4 new file mode 100644 index 00000000..3b190c5c Binary files /dev/null and b/themes/lupine/backgrounds/intros/01-cherry-blossom-bokeh.mp4 differ diff --git a/themes/lupine/backgrounds/intros/02-cherry-blossom-white.mp4 b/themes/lupine/backgrounds/intros/02-cherry-blossom-white.mp4 new file mode 100644 index 00000000..1e41da6e Binary files /dev/null and b/themes/lupine/backgrounds/intros/02-cherry-blossom-white.mp4 differ diff --git a/themes/lupine/backgrounds/intros/03-pastel-clouds.mp4 b/themes/lupine/backgrounds/intros/03-pastel-clouds.mp4 new file mode 100644 index 00000000..fde2cc54 Binary files /dev/null and b/themes/lupine/backgrounds/intros/03-pastel-clouds.mp4 differ diff --git a/themes/lupine/backgrounds/intros/04-elegant-blue-wave.mp4 b/themes/lupine/backgrounds/intros/04-elegant-blue-wave.mp4 new file mode 100644 index 00000000..c565b5ea Binary files /dev/null and b/themes/lupine/backgrounds/intros/04-elegant-blue-wave.mp4 differ diff --git a/themes/lupine/backgrounds/intros/05-abstract-wave.mp4 b/themes/lupine/backgrounds/intros/05-abstract-wave.mp4 new file mode 100644 index 00000000..f41f8e70 Binary files /dev/null and b/themes/lupine/backgrounds/intros/05-abstract-wave.mp4 differ diff --git a/themes/matte-black/backgrounds/intros/0-ship-at-sea.mp4 b/themes/matte-black/backgrounds/intros/0-ship-at-sea.mp4 new file mode 100644 index 00000000..4a9c108b Binary files /dev/null and b/themes/matte-black/backgrounds/intros/0-ship-at-sea.mp4 differ diff --git a/themes/matte-black/backgrounds/intros/1-dark-waters.mp4 b/themes/matte-black/backgrounds/intros/1-dark-waters.mp4 new file mode 100644 index 00000000..b87b11a6 Binary files /dev/null and b/themes/matte-black/backgrounds/intros/1-dark-waters.mp4 differ diff --git a/themes/matte-black/backgrounds/intros/2-dot-hands.mp4 b/themes/matte-black/backgrounds/intros/2-dot-hands.mp4 new file mode 100644 index 00000000..db26cea6 Binary files /dev/null and b/themes/matte-black/backgrounds/intros/2-dot-hands.mp4 differ diff --git a/themes/miasma/backgrounds/intros/01-nature-of-fear.mp4 b/themes/miasma/backgrounds/intros/01-nature-of-fear.mp4 new file mode 100644 index 00000000..8bf2f2ff Binary files /dev/null and b/themes/miasma/backgrounds/intros/01-nature-of-fear.mp4 differ diff --git a/themes/miasma/backgrounds/intros/02-crowned.mp4 b/themes/miasma/backgrounds/intros/02-crowned.mp4 new file mode 100644 index 00000000..d59531bb Binary files /dev/null and b/themes/miasma/backgrounds/intros/02-crowned.mp4 differ diff --git a/themes/nord/backgrounds/intros/0-black-moon.mp4 b/themes/nord/backgrounds/intros/0-black-moon.mp4 new file mode 100644 index 00000000..efd8e013 Binary files /dev/null and b/themes/nord/backgrounds/intros/0-black-moon.mp4 differ diff --git a/themes/nord/backgrounds/intros/1-city-view.mp4 b/themes/nord/backgrounds/intros/1-city-view.mp4 new file mode 100644 index 00000000..31b108a6 Binary files /dev/null and b/themes/nord/backgrounds/intros/1-city-view.mp4 differ diff --git a/themes/nord/backgrounds/intros/2-night-hawks.mp4 b/themes/nord/backgrounds/intros/2-night-hawks.mp4 new file mode 100644 index 00000000..d215b545 Binary files /dev/null and b/themes/nord/backgrounds/intros/2-night-hawks.mp4 differ diff --git a/themes/osaka-jade/backgrounds/intros/1-glowing-city.mp4 b/themes/osaka-jade/backgrounds/intros/1-glowing-city.mp4 new file mode 100644 index 00000000..2a492dae Binary files /dev/null and b/themes/osaka-jade/backgrounds/intros/1-glowing-city.mp4 differ diff --git a/themes/osaka-jade/backgrounds/intros/2-shaded-entrance.mp4 b/themes/osaka-jade/backgrounds/intros/2-shaded-entrance.mp4 new file mode 100644 index 00000000..74422179 Binary files /dev/null and b/themes/osaka-jade/backgrounds/intros/2-shaded-entrance.mp4 differ diff --git a/themes/osaka-jade/backgrounds/intros/3-mountain-moon.mp4 b/themes/osaka-jade/backgrounds/intros/3-mountain-moon.mp4 new file mode 100644 index 00000000..ddcff12a Binary files /dev/null and b/themes/osaka-jade/backgrounds/intros/3-mountain-moon.mp4 differ diff --git a/themes/retro-82/backgrounds/intros/1-in-the-groove.mp4 b/themes/retro-82/backgrounds/intros/1-in-the-groove.mp4 new file mode 100644 index 00000000..1a023eb0 Binary files /dev/null and b/themes/retro-82/backgrounds/intros/1-in-the-groove.mp4 differ diff --git a/themes/retro-82/backgrounds/intros/2-dusk-guardian.mp4 b/themes/retro-82/backgrounds/intros/2-dusk-guardian.mp4 new file mode 100644 index 00000000..3b7f0245 Binary files /dev/null and b/themes/retro-82/backgrounds/intros/2-dusk-guardian.mp4 differ diff --git a/themes/retro-82/backgrounds/intros/3-glassy-lines.mp4 b/themes/retro-82/backgrounds/intros/3-glassy-lines.mp4 new file mode 100644 index 00000000..e42885c6 Binary files /dev/null and b/themes/retro-82/backgrounds/intros/3-glassy-lines.mp4 differ diff --git a/themes/retro-82/backgrounds/intros/4-gateway.mp4 b/themes/retro-82/backgrounds/intros/4-gateway.mp4 new file mode 100644 index 00000000..0ed5c017 Binary files /dev/null and b/themes/retro-82/backgrounds/intros/4-gateway.mp4 differ diff --git a/themes/retro-82/backgrounds/intros/5-zen-boat.mp4 b/themes/retro-82/backgrounds/intros/5-zen-boat.mp4 new file mode 100644 index 00000000..4be7ff5e Binary files /dev/null and b/themes/retro-82/backgrounds/intros/5-zen-boat.mp4 differ diff --git a/themes/retro-82/backgrounds/intros/6-abstract-pyramids.mp4 b/themes/retro-82/backgrounds/intros/6-abstract-pyramids.mp4 new file mode 100644 index 00000000..b8adf731 Binary files /dev/null and b/themes/retro-82/backgrounds/intros/6-abstract-pyramids.mp4 differ diff --git a/themes/retro-82/backgrounds/intros/7-the-journey.mp4 b/themes/retro-82/backgrounds/intros/7-the-journey.mp4 new file mode 100644 index 00000000..67f193bb Binary files /dev/null and b/themes/retro-82/backgrounds/intros/7-the-journey.mp4 differ diff --git a/themes/retro-82/backgrounds/intros/8-glitter-glass.mp4 b/themes/retro-82/backgrounds/intros/8-glitter-glass.mp4 new file mode 100644 index 00000000..6cafac25 Binary files /dev/null and b/themes/retro-82/backgrounds/intros/8-glitter-glass.mp4 differ diff --git a/themes/ristretto/backgrounds/intros/0-launch.mp4 b/themes/ristretto/backgrounds/intros/0-launch.mp4 new file mode 100644 index 00000000..bdb59ea6 Binary files /dev/null and b/themes/ristretto/backgrounds/intros/0-launch.mp4 differ diff --git a/themes/ristretto/backgrounds/intros/1-color-curves.mp4 b/themes/ristretto/backgrounds/intros/1-color-curves.mp4 new file mode 100644 index 00000000..a0e56d3a Binary files /dev/null and b/themes/ristretto/backgrounds/intros/1-color-curves.mp4 differ diff --git a/themes/ristretto/backgrounds/intros/2-coffee-beans.mp4 b/themes/ristretto/backgrounds/intros/2-coffee-beans.mp4 new file mode 100644 index 00000000..6a3f157d Binary files /dev/null and b/themes/ristretto/backgrounds/intros/2-coffee-beans.mp4 differ diff --git a/themes/ristretto/backgrounds/intros/3-industrial-moon.mp4 b/themes/ristretto/backgrounds/intros/3-industrial-moon.mp4 new file mode 100644 index 00000000..a6289486 Binary files /dev/null and b/themes/ristretto/backgrounds/intros/3-industrial-moon.mp4 differ diff --git a/themes/rose-pine/backgrounds/intros/1-funky-shapes.mp4 b/themes/rose-pine/backgrounds/intros/1-funky-shapes.mp4 new file mode 100644 index 00000000..3cace442 Binary files /dev/null and b/themes/rose-pine/backgrounds/intros/1-funky-shapes.mp4 differ diff --git a/themes/rose-pine/backgrounds/intros/2-dot-map.mp4 b/themes/rose-pine/backgrounds/intros/2-dot-map.mp4 new file mode 100644 index 00000000..901b9dd4 Binary files /dev/null and b/themes/rose-pine/backgrounds/intros/2-dot-map.mp4 differ diff --git a/themes/rose-pine/backgrounds/intros/3-omarchy-plants.mp4 b/themes/rose-pine/backgrounds/intros/3-omarchy-plants.mp4 new file mode 100644 index 00000000..62680502 Binary files /dev/null and b/themes/rose-pine/backgrounds/intros/3-omarchy-plants.mp4 differ diff --git a/themes/solitude/backgrounds/intros/1-on-pole.mp4 b/themes/solitude/backgrounds/intros/1-on-pole.mp4 new file mode 100644 index 00000000..58e3e899 Binary files /dev/null and b/themes/solitude/backgrounds/intros/1-on-pole.mp4 differ diff --git a/themes/solitude/backgrounds/intros/2-wreakage.mp4 b/themes/solitude/backgrounds/intros/2-wreakage.mp4 new file mode 100644 index 00000000..4183ff0f Binary files /dev/null and b/themes/solitude/backgrounds/intros/2-wreakage.mp4 differ diff --git a/themes/solitude/backgrounds/intros/3-climb.mp4 b/themes/solitude/backgrounds/intros/3-climb.mp4 new file mode 100644 index 00000000..8030f9e7 Binary files /dev/null and b/themes/solitude/backgrounds/intros/3-climb.mp4 differ diff --git a/themes/solitude/backgrounds/intros/4-ether.mp4 b/themes/solitude/backgrounds/intros/4-ether.mp4 new file mode 100644 index 00000000..e2db9356 Binary files /dev/null and b/themes/solitude/backgrounds/intros/4-ether.mp4 differ diff --git a/themes/solitude/backgrounds/intros/5-eyed.mp4 b/themes/solitude/backgrounds/intros/5-eyed.mp4 new file mode 100644 index 00000000..6f003e0f Binary files /dev/null and b/themes/solitude/backgrounds/intros/5-eyed.mp4 differ diff --git a/themes/tokyo-night/backgrounds/intros/0-winding-road.mp4 b/themes/tokyo-night/backgrounds/intros/0-winding-road.mp4 new file mode 100644 index 00000000..43c45272 Binary files /dev/null and b/themes/tokyo-night/backgrounds/intros/0-winding-road.mp4 differ diff --git a/themes/tokyo-night/backgrounds/intros/1-quattro.mp4 b/themes/tokyo-night/backgrounds/intros/1-quattro.mp4 new file mode 100644 index 00000000..950912fc Binary files /dev/null and b/themes/tokyo-night/backgrounds/intros/1-quattro.mp4 differ diff --git a/themes/tokyo-night/backgrounds/intros/2-swirl-buck.mp4 b/themes/tokyo-night/backgrounds/intros/2-swirl-buck.mp4 new file mode 100644 index 00000000..d6c9f583 Binary files /dev/null and b/themes/tokyo-night/backgrounds/intros/2-swirl-buck.mp4 differ diff --git a/themes/tokyo-night/backgrounds/intros/3-sunset-lake.mp4 b/themes/tokyo-night/backgrounds/intros/3-sunset-lake.mp4 new file mode 100644 index 00000000..36d4c16b Binary files /dev/null and b/themes/tokyo-night/backgrounds/intros/3-sunset-lake.mp4 differ diff --git a/themes/tokyo-night/backgrounds/intros/4-omakub.mp4 b/themes/tokyo-night/backgrounds/intros/4-omakub.mp4 new file mode 100644 index 00000000..c5c26a3e Binary files /dev/null and b/themes/tokyo-night/backgrounds/intros/4-omakub.mp4 differ diff --git a/themes/tokyo-night/backgrounds/intros/5-oma-cityscape.mp4 b/themes/tokyo-night/backgrounds/intros/5-oma-cityscape.mp4 new file mode 100644 index 00000000..7c023ccf Binary files /dev/null and b/themes/tokyo-night/backgrounds/intros/5-oma-cityscape.mp4 differ diff --git a/themes/tokyo-night/backgrounds/intros/6-oma.mp4 b/themes/tokyo-night/backgrounds/intros/6-oma.mp4 new file mode 100644 index 00000000..6e8e0135 Binary files /dev/null and b/themes/tokyo-night/backgrounds/intros/6-oma.mp4 differ diff --git a/themes/vantablack/backgrounds/intros/0-dot-hands.mp4 b/themes/vantablack/backgrounds/intros/0-dot-hands.mp4 new file mode 100644 index 00000000..db26cea6 Binary files /dev/null and b/themes/vantablack/backgrounds/intros/0-dot-hands.mp4 differ diff --git a/themes/vantablack/backgrounds/intros/1-twisted-stairs.mp4 b/themes/vantablack/backgrounds/intros/1-twisted-stairs.mp4 new file mode 100644 index 00000000..547f31a6 Binary files /dev/null and b/themes/vantablack/backgrounds/intros/1-twisted-stairs.mp4 differ diff --git a/themes/vantablack/backgrounds/intros/2-layers-deep.mp4 b/themes/vantablack/backgrounds/intros/2-layers-deep.mp4 new file mode 100644 index 00000000..01f6345c Binary files /dev/null and b/themes/vantablack/backgrounds/intros/2-layers-deep.mp4 differ diff --git a/themes/vantablack/backgrounds/intros/3-layers-stacked.mp4 b/themes/vantablack/backgrounds/intros/3-layers-stacked.mp4 new file mode 100644 index 00000000..7e28f6b6 Binary files /dev/null and b/themes/vantablack/backgrounds/intros/3-layers-stacked.mp4 differ diff --git a/themes/white/backgrounds/intros/1-white.mp4 b/themes/white/backgrounds/intros/1-white.mp4 new file mode 100644 index 00000000..b4b845a8 Binary files /dev/null and b/themes/white/backgrounds/intros/1-white.mp4 differ diff --git a/themes/white/backgrounds/intros/2-white.mp4 b/themes/white/backgrounds/intros/2-white.mp4 new file mode 100644 index 00000000..70283cad Binary files /dev/null and b/themes/white/backgrounds/intros/2-white.mp4 differ diff --git a/themes/white/backgrounds/intros/3-white.mp4 b/themes/white/backgrounds/intros/3-white.mp4 new file mode 100644 index 00000000..e77fc9db Binary files /dev/null and b/themes/white/backgrounds/intros/3-white.mp4 differ