#!/bin/bash # omarchy:summary=Cycle through the windows of an app, or launch it if none are open # omarchy:args= set -uo pipefail APP="${1:-}" if [[ -z $APP ]]; then echo "Usage: omarchy-cycle " exit 1 fi # Per-class launch commands for kitty-* (class -> command run via `kitty -e`). # Unmapped classes just open a plain kitty with that class. declare -A KITTY_CMDS=( # [kitty-lazygit]="lazygit" # [kitty-btop]="btop" ) # --- Hyprland helpers -------------------------------------------------------- # Print the addresses of all windows whose class or initialClass matches the # regex in $1. An optional regex in $2 excludes matches again — used to keep # scratch windows out of a cycle. Windows are sorted by workspace, then by # address, so the cycle order stays the same between calls. addresses_matching() { hyprctl clients -j | jq -r --arg re "$1" --arg not "${2:-}" ' [ .[] | select(((.class // "") | test($re; "i")) or ((.initialClass // "") | test($re; "i"))) | select($not == "" or ( (((.class // "") | test($not; "i")) or ((.initialClass // "") | test($not; "i"))) | not )) ] | sort_by(.workspace.id, .address) | .[].address' } # Same, but drops site-specific app (SSA) windows, so real browsers stay apart # from web apps that share a class prefix. addresses_matching_no_ssa() { hyprctl clients -j | jq -r --arg re "$1" ' [ .[] | select(((.class // "") | test($re; "i")) or ((.initialClass // "") | test($re; "i"))) | select(((.class // "") | test("^(chrome-|webapp-)"; "i")) | not) ] | sort_by(.workspace.id, .address) | .[].address' } focused_address() { hyprctl activewindow -j 2>/dev/null | jq -r '.address // empty' } focus_window() { hyprctl dispatch "hl.dsp.focus({ window = \"address:$1\" })" >/dev/null 2>&1 || hyprctl dispatch focuswindow "address:$1" >/dev/null } spawn() { exec setsid uwsm-app -- $1 } # --- Find the windows -------------------------------------------------------- declare -a INSTANCES if [[ $APP == "webapp" ]]; then # Cycle all web apps: Firefox SSAs (WebApp-*) and Chromium/Brave SSAs (chrome-*). # Real browsers are excluded, because their class has no SSA prefix. INSTANCES=($(addresses_matching '^(webapp-|chrome-)')) FALLBACK_APP="webapp-youtube" elif [[ $APP == webapp-* ]]; then # Extract the web app name (e.g. "webapp-claude" -> "claude"). WEBAPP_NAME="${APP#webapp-}" # Firefox SSAs use the class WebApp-. Chromium SSAs use # chrome-__-Default, so the name is matched anywhere in the class. INSTANCES=($(addresses_matching "webapp-${WEBAPP_NAME}|^chrome-.*${WEBAPP_NAME}")) if ((${#INSTANCES[@]} == 0)); then # No window is open. Launch from the desktop entry. DESKTOP_FILE=$(find ~/.local/share/applications -iname "WebApp-${WEBAPP_NAME}*.desktop" -print -quit 2>/dev/null) [[ -n $DESKTOP_FILE ]] || DESKTOP_FILE=$(find ~/.local/share/applications -iname "*${WEBAPP_NAME}*.desktop" -print -quit 2>/dev/null) if [[ -n $DESKTOP_FILE ]]; then exec setsid gtk-launch "$(basename "$DESKTOP_FILE")" else echo "No desktop file found for web app: ${WEBAPP_NAME}" exit 1 fi fi elif [[ $APP == "kitty" ]]; then # Cycle all kitty windows, whatever the class (kitty, kitty-nvim, ...). INSTANCES=($(addresses_matching '^kitty')) DEFAULT_SPAWN="kitty" elif [[ $APP == kitty-* ]]; then # One kitty class, e.g. kitty-nvim -> class "kitty-nvim". INSTANCES=($(addresses_matching "^${APP}$")) if ((${#INSTANCES[@]} == 0)); then cmd="${KITTY_CMDS[$APP]:-}" if [[ -n $cmd ]]; then spawn "kitty --class $APP -e $cmd" else spawn "kitty --class $APP" fi fi elif [[ $APP == "ghostty" ]]; then # Cycle Ghostty terminals (class com.mitchellh.ghostty). INSTANCES=($(addresses_matching 'ghostty')) DEFAULT_SPAWN="ghostty" elif [[ $APP == "browser" ]]; then # Cycle all browsers. Web apps are left out. INSTANCES=($(addresses_matching_no_ssa '^(zen|brave|chromium|google-chrome|firefox|librewolf|vivaldi|microsoft-edge|helium)')) DEFAULT_LAUNCHER="omarchy-launch-browser" elif [[ $APP == "brave" ]]; then # Cycle the Brave Origin browser and all of its web apps. Both the # real browser (class brave-origin) and the SSAs (class brave-) # share the brave- prefix, so one regex catches them all. INSTANCES=($(addresses_matching '^brave')) DEFAULT_SPAWN="brave-origin" elif [[ $APP == "terminal" ]]; then # Cycle all terminals. kitty-herdr is the SUPER + A scratch terminal # (omarchy-toggle-scratch owns it), so it stays out of the rotation. INSTANCES=($(addresses_matching '(kitty|alacritty|ghostty|foot|wezterm)' '^kitty-herdr$')) DEFAULT_LAUNCHER="omarchy-launch-terminal" elif [[ $APP == "ai" ]]; then # Cycle the AI desktop apps (Claude/Hermes). INSTANCES=($(addresses_matching '(claude|hermes)')) DEFAULT_SPAWN="hermes desktop" else # Any other app, matched on the full class. INSTANCES=($(addresses_matching "^${APP}$")) fi # --- Launch if nothing is open ----------------------------------------------- if ((${#INSTANCES[@]} == 0)); then if [[ -n ${DEFAULT_LAUNCHER:-} ]]; then exec setsid "$DEFAULT_LAUNCHER" fi if [[ -n ${DEFAULT_SPAWN:-} ]]; then spawn "$DEFAULT_SPAWN" fi if [[ -n ${FALLBACK_APP:-} ]]; then exec "$0" "$FALLBACK_APP" fi if [[ $APP == "webapp" ]]; then echo "No ${APP} windows running" exit 0 fi spawn "$APP" fi # --- Focus, or cycle to the next window -------------------------------------- if ((${#INSTANCES[@]} == 1)); then focus_window "${INSTANCES[0]}" exit 0 fi FOCUSED=$(focused_address) # If the focused window is one of ours, go to the next one. If it is not, # go to the first one. next_index=0 for i in "${!INSTANCES[@]}"; do if [[ -n $FOCUSED && ${INSTANCES[$i]} == "$FOCUSED" ]]; then next_index=$(((i + 1) % ${#INSTANCES[@]})) break fi done focus_window "${INSTANCES[$next_index]}"