diff --git a/.gitignore b/.gitignore index ea8c4bf..d787b70 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +/result diff --git a/Cargo.lock b/Cargo.lock index 2dd16f2..6b3700d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,15 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] [[package]] name = "anyhow" @@ -45,6 +54,12 @@ version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38" +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + [[package]] name = "mini-internal" version = "0.1.34" @@ -92,8 +107,38 @@ dependencies = [ "anyhow", "argh", "miniserde", + "regex", ] +[[package]] +name = "regex" +version = "1.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b5288124840bee7b386bc413c487869b360b2b4ec421ea56425128692f2a82c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" + [[package]] name = "ryu" version = "1.0.15" diff --git a/Cargo.toml b/Cargo.toml index 129a589..b79c5f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,3 +10,4 @@ strip = true argh = "0.1" anyhow = "1.0" miniserde = "0.1" +regex = "1" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ea62440 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sergey Miroshnichenko + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 074c2e5..939ae62 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,62 @@ # raise +Fork of [lambdachad/raise](https://github.com/lambdachad/raise) with support +for Hyprland 0.55+ dispatch syntax and tag-based launch rules. + Run or raise implemented for Hyprland. It will raise window if it exists, or cycle to next window if current window matches class to focus. Otherwise it will launch new window. ``` $ raise -Usage: raise -c -e +Usage: raise [-c ] [--tag ] [--xdgtag ] -e [-m ...] Raise window if it exists, otherwise launch new window. Options: - -c, --class class to focus + -c, --class class to focus (shorthand for `--match class=...`) + --tag window tag to match (repeatable; shorthand for `--match tag=...`) + --xdgtag XDG surface tag to match (repeatable; shorthand for `--match xdgtag=...`) -e, --launch command to launch + -m, --match additional matcher in the form field[:method]=pattern --help display usage information ``` +### Matching + +The `--match` flag allows choosing how a window should be selected. Each +matcher uses the format `field[:method]=pattern` and multiple matchers can be +combined; they all have to match for a window to qualify. + +Supported fields: +- `class` — current window class reported by Hyprland +- `initial-class` — class when the window was first created +- `title` — current window title +- `initial-title` — original title assigned on window creation +- `tag` — window tag assigned via dynamic tags +- `xdgtag` — XDG surface tag (`xdgTag` in `hyprctl clients`) + +Aliases: you can also use the short forms `c`, `initialClass`, `initialTitle`, and `xdg-tag`. + +Supported methods (default is `equals`): +- `equals` / `eq` +- `contains` / `substr` +- `prefix` / `starts-with` +- `suffix` / `ends-with` +- `regex` / `re` + +Examples: + +``` +raise --launch firefox --match class=firefox +raise --launch alacritty --match title:contains=notes +raise --launch slack --match class=Slack --match title:regex="(?i)daily" +# Shorthands for tags: +raise --launch floorp --tag web +raise --launch mpv --tag vid +raise --launch obsidian --tag notes +``` + ## Install `raise` There are multiple ways to install this: diff --git a/flake.lock b/flake.lock index 57a483e..e3fe137 100644 --- a/flake.lock +++ b/flake.lock @@ -1,15 +1,37 @@ { "nodes": { + "fenix": { + "inputs": { + "nixpkgs": [ + "naersk-package", + "nixpkgs" + ], + "rust-analyzer-src": "rust-analyzer-src" + }, + "locked": { + "lastModified": 1752475459, + "narHash": "sha256-z6QEu4ZFuHiqdOPbYss4/Q8B0BFhacR8ts6jO/F/aOU=", + "owner": "nix-community", + "repo": "fenix", + "rev": "bf0d6f70f4c9a9cf8845f992105652173f4b617f", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "fenix", + "type": "github" + } + }, "flake-utils": { "inputs": { "systems": "systems" }, "locked": { - "lastModified": 1692799911, - "narHash": "sha256-3eihraek4qL744EvQXsK1Ha6C3CR7nnT8X2qWap4RNk=", + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", "owner": "numtide", "repo": "flake-utils", - "rev": "f9e7cf818399d17d347f847525c5a5a8032e4e44", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", "type": "github" }, "original": { @@ -20,16 +42,17 @@ }, "naersk-package": { "inputs": { + "fenix": "fenix", "nixpkgs": [ "nixpkgs" ] }, "locked": { - "lastModified": 1692351612, - "narHash": "sha256-KTGonidcdaLadRnv9KFgwSMh1ZbXoR/OBmPjeNMhFwU=", + "lastModified": 1752689277, + "narHash": "sha256-uldUBFkZe/E7qbvxa3mH1ItrWZyT6w1dBKJQF/3ZSsc=", "owner": "nix-community", "repo": "naersk", - "rev": "78789c30d64dea2396c9da516bbcc8db3a475207", + "rev": "0e72363d0938b0208d6c646d10649164c43f4d64", "type": "github" }, "original": { @@ -40,11 +63,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1693158576, - "narHash": "sha256-aRTTXkYvhXosGx535iAFUaoFboUrZSYb1Ooih/auGp0=", + "lastModified": 1761672384, + "narHash": "sha256-o9KF3DJL7g7iYMZq9SWgfS1BFlNbsm6xplRjVlOCkXI=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "a999c1cc0c9eb2095729d5aa03e0d8f7ed256780", + "rev": "08dacfca559e1d7da38f3cf05f1f45ee9bfd213c", "type": "github" }, "original": { @@ -61,6 +84,23 @@ "nixpkgs": "nixpkgs" } }, + "rust-analyzer-src": { + "flake": false, + "locked": { + "lastModified": 1752428706, + "narHash": "sha256-EJcdxw3aXfP8Ex1Nm3s0awyH9egQvB2Gu+QEnJn2Sfg=", + "owner": "rust-lang", + "repo": "rust-analyzer", + "rev": "591e3b7624be97e4443ea7b5542c191311aa141d", + "type": "github" + }, + "original": { + "owner": "rust-lang", + "ref": "nightly", + "repo": "rust-analyzer", + "type": "github" + } + }, "systems": { "locked": { "lastModified": 1681028828, diff --git a/scripts/migrate_raise_to_tags.sh b/scripts/migrate_raise_to_tags.sh new file mode 100755 index 0000000..33c4bee --- /dev/null +++ b/scripts/migrate_raise_to_tags.sh @@ -0,0 +1,313 @@ +#!/usr/bin/env bash +set -euo pipefail + +# migrate_raise_to_tags.sh +# +# Migrates a Hyprland config repo to: +# 1) Use your fork of raise (latest, unpinned) in flake.nix +# 2) Add window rules assigning tags to common apps +# 3) Replace raise usages to prefer `--tag` instead of class-based matching +# 4) Create commits with the changes +# +# Usage: +# scripts/migrate_raise_to_tags.sh -r /path/to/hypr-config -u github:/raise [--no-commit] +# +# Example: +# scripts/migrate_raise_to_tags.sh -r "$HOME/src/dotfiles" -u github:neg-serg/raise +# + +REPO="" +FORK_URL_RAW="" +DO_COMMIT=1 + +die() { echo "Error: $*" >&2; exit 1; } + +while [[ $# -gt 0 ]]; do + case "$1" in + -r|--repo) + REPO="$2"; shift 2;; + -u|--fork-url) + FORK_URL_RAW="$2"; shift 2;; + --no-commit) + DO_COMMIT=0; shift;; + -h|--help) + sed -n '1,60p' "$0"; exit 0;; + *) + die "Unknown argument: $1";; + esac +done + +[[ -n "$REPO" ]] || die "--repo is required" +[[ -n "$FORK_URL_RAW" ]] || die "--fork-url is required (e.g. github:neg-serg/raise or https://github.com/neg-serg/raise)" + +[[ -d "$REPO" ]] || die "Repo path not found: $REPO" + +if [[ ! -d "$REPO/.git" ]]; then + die "Target path is not a git repo: $REPO" +fi + +pushd "$REPO" >/dev/null + +# Normalize fork url to flake-friendly form if plain https was provided +FORK_URL="$FORK_URL_RAW" +if [[ "$FORK_URL_RAW" =~ ^https://github.com/([^/]+)/([^/]+?)(\.git)?$ ]]; then + FORK_URL="github:${BASH_REMATCH[1]}/${BASH_REMATCH[2]}" +fi + +# Create branch if not on a feature branch +current_branch=$(git rev-parse --abbrev-ref HEAD) +if [[ "$current_branch" == "HEAD" ]]; then + git checkout -b feat/raise-tags-migration || true +elif [[ "$current_branch" == "master" || "$current_branch" == "main" ]]; then + git checkout -b feat/raise-tags-migration || true +fi + +# 1) Point flake input to user's fork and remove explicit rev pin if present +if [[ -f flake.nix ]]; then + # Replace inputs.raise.url to provided fork URL; add it if missing + if rg -n "inputs\.raise\.url" -S >/dev/null 2>&1; then + # Replace the entire value conservatively without grouping to avoid shell parsing quirks + sed -i -E "s|inputs\.raise\.url\s*=\s*\"[^\"]*\";|inputs.raise.url = \"${FORK_URL}\";|" flake.nix || true + else + # Try to inject under inputs = { ... } block + awk -v url="$FORK_URL" ' + BEGIN{in_inputs=0} + /inputs\s*=\s*\{/ {in_inputs=1} + {print} + in_inputs && /\{/ && !done { print " raise.url = \"" url "\";"; done=1 } + in_inputs && /\}/ {in_inputs=0} + ' flake.nix > flake.nix.tmp && mv flake.nix.tmp flake.nix + fi + + # Remove explicit pins in flake.nix if present (rev/ref) + sed -i -E "/inputs\.raise\.(rev|ref)\s*=\s*\".*\";$/d" flake.nix || true + + # Suggest updating lock file; we only stage flake.nix here. +else + echo "Note: flake.nix not found; skipping fork update." >&2 +fi + +# 2) Ensure a tags rules file exists with sensible defaults (support multiple hypr roots) +# Discover hypr roots +mapfile -t HYPR_ROOTS < <(find . -type d -path "*/modules/user/gui/hypr" 2>/dev/null | sed 's#^\./##') +if [[ ${#HYPR_ROOTS[@]} -eq 0 ]]; then + mapfile -t HYPR_ROOTS < <(find . -type d -path "*/nix/.config/home-manager/modules/user/gui/hypr" 2>/dev/null | sed 's#^\./##') +fi +if [[ ${#HYPR_ROOTS[@]} -eq 0 ]]; then + echo "Warning: Could not locate modules/user/gui/hypr in repo; continuing with flake pin update only." >&2 +fi + +for HYPR_ROOT in "${HYPR_ROOTS[@]}"; do + RULES_DIR="${HYPR_ROOT}/conf/rules" + RULES_FILE="$RULES_DIR/tags.conf" + mkdir -p "$RULES_DIR" + cat > "$RULES_FILE" <<'RULES' +# Auto-assigned tags for common applications +# Edit to taste. Each rule applies a tag based on window class. + +# Browsers +windowrulev2 = tag, web, class:^(firefox|Firefox|floorp|Floorp|Brave.*|Chromium|Google-chrome|Vivaldi.*)$ + +# Terminals +windowrulev2 = tag, term, class:^(Alacritty|alacritty|kitty|Kitty|WezTerm|wezterm|foot)$ +windowrulev2 = tag, term, class:^(term)$ +windowrulev2 = tag, nwim, class:^(nwim)$ + +# Editors / IDEs +windowrulev2 = tag, code, class:^(code|Code|codium|VSCodium|jetbrains-.*|Idea|CLion|PyCharm|GoLand|WebStorm|Rider)$ + +# Chat / IM +windowrulev2 = tag, chat, class:^(Slack|slack|Discord|discord|TelegramDesktop|telegram-desktop|Element.*)$ + +# Files +windowrulev2 = tag, files, class:^(thunar|Thunar|dolphin|Dolphin|nemo|Nemo|nautilus|pcmanfm)$ + +# Media / Video +windowrulev2 = tag, vid, class:^(mpv|vlc|celluloid|Celluloid)$ + +# Image viewers +windowrulev2 = tag, img, class:^(swayimg|imv|feh)$ + +# Music +windowrulev2 = tag, music, class:^(spotify|Spotify|ncspot|org\.nicotine_plus\.Nicotine)$ + +# Notes / Knowledge +windowrulev2 = tag, notes, class:^(obsidian|Obsidian|logseq|Logseq|Zettlr)$ + +# Mail +windowrulev2 = tag, mail, class:^(thunderbird|Thunderbird)$ + +# Graphics +windowrulev2 = tag, design, class:^(gimp|Gimp|inkscape|Inkscape|krita|Krita)$ + +# Audio tools +windowrulev2 = tag, audio, class:^(qpwgraph|Carla2|REAPER)$ + +# Reading / Documents +windowrulev2 = tag, read, class:^(org\.pwmt\.zathura|zathura)$ + +# Gaming +windowrulev2 = tag, games, class:^(steam|lutris)$ + +# OBS Studio +windowrulev2 = tag, obs, class:^(obs)$ + +# Keyboard manager +windowrulev2 = tag, kb, class:^(Bazecor)$ +RULES +done + +# 3) Replace raise usages to prefer --tag where mapping is obvious +# Known mapping pairs for common apps (class -> tag) +map_class_to_tag() { + case "$1" in + firefox|Firefox|floorp|Floorp|Brave*|Chromium|Google-chrome|Vivaldi*) echo web ;; + Alacritty|alacritty|kitty|Kitty|WezTerm|wezterm|foot) echo term ;; + code|Code|codium|VSCodium|jetbrains-*|Idea|CLion|PyCharm|GoLand|WebStorm|Rider) echo code ;; + Slack|slack|Discord|discord|TelegramDesktop|telegram-desktop|Element*) echo chat ;; + thunar|Thunar|dolphin|Dolphin|nemo|Nemo|nautilus|pcmanfm) echo files ;; + mpv|vlc|celluloid|Celluloid) echo vid ;; + swayimg|imv|feh) echo img ;; + spotify|Spotify|ncspot) echo music ;; + org.nicotine_plus.Nicotine) echo music ;; + obsidian|Obsidian|logseq|Logseq|Zettlr) echo notes ;; + thunderbird|Thunderbird) echo mail ;; + gimp|Gimp|inkscape|Inkscape|krita|Krita) echo design ;; + steam|lutris) echo games ;; + qpwgraph|Carla2|REAPER) echo audio ;; + org.pwmt.zathura|zathura) echo read ;; + term) echo term ;; + nwim) echo nwim ;; + Bazecor) echo kb ;; + obs) echo obs ;; + *) return 1 ;; + esac +} + +changed_files=() + +# Update specific known bindings file if present +for HYPR_ROOT in "${HYPR_ROOTS[@]}"; do + file="${HYPR_ROOT}/conf/bindings/apps.conf" + [[ -f "$file" ]] || continue + tmp=$(mktemp) + while IFS= read -r line; do + if [[ "$line" =~ raise[[:space:]].*--class[[:space:]]\"([^\"]+)\" ]]; then + klass=${BASH_REMATCH[1]} + if tag=$(map_class_to_tag "$klass"); then + line=$(echo "$line" | sed -E "s/--class \"[^\"]+\"/--tag ${tag}/g") + fi + fi + # Convert explicit matchers like --match class=Foo to --tag where possible + if [[ "$line" =~ --match[[:space:]]class=([^[:space:]]+) ]]; then + klass=${BASH_REMATCH[1]} + if tag=$(map_class_to_tag "$klass"); then + line=$(echo "$line" | sed -E "s/--match class=[^[:space:]]+/--tag ${tag}/g") + fi + fi + # Convert class:regex matcher to --tag web (common browser binding case) + line=$(echo "$line" | sed -E 's/--match class:regex="[^"]+"/--tag web/g') + echo "$line" >> "$tmp" + done < "$file" + if ! cmp -s "$file" "$tmp"; then + # De-duplicate identical lines (keep first occurrence) + awk '!seen[$0]++' "$tmp" > "$file" + changed_files+=("$file") + else + rm -f "$tmp" + fi +done + +# Broad pass across hypr conf tree: best-effort replacements +for HYPR_ROOT in "${HYPR_ROOTS[@]}"; do + while IFS= read -r -d '' f; do + tmp=$(mktemp) + modified=0 + while IFS= read -r line; do + if [[ "$line" =~ raise[[:space:]].*--class[[:space:]]\"([^\"]+)\" ]]; then + klass=${BASH_REMATCH[1]} + if tag=$(map_class_to_tag "$klass"); then + line=$(echo "$line" | sed -E "s/--class \"[^\"]+\"/--tag ${tag}/g") + modified=1 + fi + fi + if [[ "$line" =~ --match[[:space:]]class=([^[:space:]]+) ]]; then + klass=${BASH_REMATCH[1]} + if tag=$(map_class_to_tag "$klass"); then + line=$(echo "$line" | sed -E "s/--match class=[^[:space:]]+/--tag ${tag}/g") + modified=1 + fi + fi + echo "$line" >> "$tmp" + done < "$f" + if [[ $modified -eq 1 ]]; then + mv "$tmp" "$f" + changed_files+=("$f") + else + rm -f "$tmp" + fi + done < <(find "$HYPR_ROOT" -type f -name '*.conf' -print0 2>/dev/null) +done + +# Add a canonical example: bind for web tag with $browser +for HYPR_ROOT in "${HYPR_ROOTS[@]}"; do + file="${HYPR_ROOT}/conf/bindings/apps.conf" + [[ -f "$file" ]] || continue + if ! rg -n "raise --tag web" "$file" >/dev/null 2>&1; then + echo "bind = \$M4, w, exec, raise --tag web --launch \$browser" >> "$file" + changed_files+=("$file") + fi +done + +# 3b) Update Home Manager prewarm execs to use --tag where possible +if [[ -f nix/.config/home-manager/home.nix ]]; then + sed -i -E \ + -e "s#raise --class 'term'#raise --tag term#g" \ + -e "s#raise --class '\(one\\.ablaze\\.floorp\|floorp\)'#raise --tag web#g" \ + -e "s#raise --class 'org\.nicotine_plus\.Nicotine'#raise --tag music#g" \ + -e "s#raise --class 'Obsidian'#raise --tag notes#g" \ + nix/.config/home-manager/home.nix || true + changed_files+=("nix/.config/home-manager/home.nix") +fi + +# 3c) Stop shadowing system raise: rename local script to raise_class if present +if [[ -f nix/.config/home-manager/modules/user/local-bin/default.nix ]]; then + sed -i -E "s#name = \"raise\";#name = \"raise_class\";#" nix/.config/home-manager/modules/user/local-bin/default.nix || true + changed_files+=("nix/.config/home-manager/modules/user/local-bin/default.nix") +fi + +# Commit staged changes +if [[ $DO_COMMIT -eq 1 ]]; then + git add -A + if ! git diff --cached --quiet; then + git commit -m "hypr: migrate raise usage to tags and add window tag rules + +- Switch raise calls to use --tag where mapping is known +- Add rules to auto-assign tags by class (web, term, code, chat, etc.) +- Point flake input 'raise' to ${FORK_URL} (update lock separately)" + fi +fi + +# Try to update lock for raise input if nix is available +if command -v nix >/dev/null 2>&1 && [[ -f flake.nix ]]; then + nix flake update --update-input raise || true +fi + +echo "Migration finished. Next steps:" >&2 +echo "- If using flakes, run: nix flake update --update-input raise" >&2 +echo "- Reload Hyprland: hyprctl reload" >&2 +echo "- Test: raise --tag web --launch \$browser" >&2 + +# Report any remaining class-based usages for manual follow-up +echo "\nRemaining occurrences of class-based raise (review manually):" >&2 +rg -n "raise.*(--class|--match[[:space:]]class=)" -S || true + +# Hint to ensure rules are sourced +if [[ ${#HYPR_ROOTS[@]} -gt 0 ]]; then + echo "\nEnsure your Hypr config sources the tags rules (if not already):" >&2 + for HYPR_ROOT in "${HYPR_ROOTS[@]}"; do + echo " source = ~/.config/hypr/conf/rules/tags.conf (root: $HYPR_ROOT)" >&2 + done +fi + +popd >/dev/null diff --git a/src/main.rs b/src/main.rs index 1c52cda..3cc7e36 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,30 +1,217 @@ -use anyhow::{Context, Result, bail}; +use anyhow::{bail, Context, Result}; use argh::FromArgs; use miniserde::{json, Deserialize}; +use regex::Regex; use std::process::{Child, Command}; +#[derive(Debug, Clone)] +struct MatchCondition { + field: MatchField, + matcher: Matcher, +} + +impl MatchCondition { + fn new(field: MatchField, matcher: Matcher) -> Self { + Self { field, matcher } + } + + fn matches(&self, client: &Client) -> bool { + match self.field { + MatchField::Tag => { + if let Some(tags) = &client.tags { + if tags.iter().any(|t| self.matcher.matches(t)) { + return true; + } + } + if let Some(tag) = &client.tag { + return self.matcher.matches(tag); + } + false + } + _ => self + .field + .value(client) + .map(|value| self.matcher.matches(value)) + .unwrap_or(false), + } + } +} + +#[derive(Debug, Clone, Copy)] +enum MatchField { + Class, + InitialClass, + Title, + InitialTitle, + Tag, + XdgTag, +} + +impl MatchField { + fn parse(raw: &str) -> Option { + match raw { + "class" | "c" => Some(Self::Class), + "initial-class" | "initialClass" => Some(Self::InitialClass), + "title" => Some(Self::Title), + "initial-title" | "initialTitle" => Some(Self::InitialTitle), + "tag" => Some(Self::Tag), + "xdgtag" | "xdg-tag" | "xdgTag" => Some(Self::XdgTag), + _ => None, + } + } + + fn value<'a>(&self, client: &'a Client) -> Option<&'a str> { + match self { + Self::Class => Some(client.class.as_str()), + Self::InitialClass => client.initial_class.as_deref(), + Self::Title => client.title.as_deref(), + Self::InitialTitle => client.initial_title.as_deref(), + Self::Tag => client.tag.as_deref(), + Self::XdgTag => client.xdg_tag.as_deref(), + } + } +} + +#[derive(Debug, Clone)] +enum Matcher { + Equals(String), + Contains(String), + Prefix(String), + Suffix(String), + Regex(Regex), +} + +impl Matcher { + fn from_tokens(method: Option<&str>, pattern: &str) -> std::result::Result { + let method = method.unwrap_or("equals"); + match method { + "equals" | "eq" => Ok(Self::Equals(pattern.to_owned())), + "contains" | "substr" => Ok(Self::Contains(pattern.to_owned())), + "prefix" | "starts-with" | "startswith" => Ok(Self::Prefix(pattern.to_owned())), + "suffix" | "ends-with" | "endswith" => Ok(Self::Suffix(pattern.to_owned())), + "regex" | "re" => Regex::new(pattern) + .map(Self::Regex) + .map_err(|err| format!("Invalid regex `{pattern}`: {err}")), + _ => Err(format!("Unsupported match method `{method}`")), + } + } + + fn matches(&self, value: &str) -> bool { + match self { + Self::Equals(pattern) => value == pattern, + Self::Contains(pattern) => value.contains(pattern), + Self::Prefix(pattern) => value.starts_with(pattern), + Self::Suffix(pattern) => value.ends_with(pattern), + Self::Regex(regex) => regex.is_match(value), + } + } +} + +fn parse_match_condition(value: &str) -> std::result::Result { + let (selector, pattern) = value + .split_once('=') + .ok_or_else(|| "Expected matcher in the form field[:method]=pattern".to_string())?; + + if pattern.is_empty() { + return Err("Matcher pattern cannot be empty".to_string()); + } + + let (field_token, method_token) = match selector.split_once(':') { + Some((field, method)) => (field, Some(method)), + None => (selector, None), + }; + + let field = MatchField::parse(field_token) + .ok_or_else(|| format!("Unsupported match field `{field_token}`"))?; + + let matcher = Matcher::from_tokens(method_token, pattern)?; + + Ok(MatchCondition::new(field, matcher)) +} + #[derive(FromArgs)] /// Raise window if it exists, otherwise launch new window. struct Args { - /// class to focus + /// class to focus (shorthand for `--match class=...`) #[argh(option, short = 'c')] - class: String, + class: Option, + + /// window tag to match (repeatable; shorthand for `--match tag=...`) + #[argh(option, long = "tag")] + tag: Vec, + + /// XDG surface tag to match (repeatable; shorthand for `--match xdgtag=...`) + #[argh(option, long = "xdgtag")] + xdgtag: Vec, /// command to launch #[argh(option, short = 'e')] launch: String, + + /// additional matchers in the form field[:method]=pattern + #[argh( + option, + short = 'm', + long = "match", + from_str_fn(parse_match_condition) + )] + matches: Vec, +} + +impl Args { + fn build_matchers(&self) -> Result> { + let mut matchers = Vec::new(); + + if let Some(class) = &self.class { + matchers.push(MatchCondition::new( + MatchField::Class, + Matcher::Equals(class.clone()), + )); + } + + for t in &self.tag { + matchers.push(MatchCondition::new( + MatchField::Tag, + Matcher::Equals(t.clone()), + )); + } + + for x in &self.xdgtag { + matchers.push(MatchCondition::new( + MatchField::XdgTag, + Matcher::Equals(x.clone()), + )); + } + + matchers.extend(self.matches.clone()); + + if matchers.is_empty() { + bail!("Provide at least one matcher via --class or --match"); + } + + Ok(matchers) + } } #[derive(Deserialize, Debug)] struct Client { class: String, address: String, + #[serde(rename = "initialClass")] + initial_class: Option, + title: Option, + #[serde(rename = "initialTitle")] + initial_title: Option, + tag: Option, + // modern Hyprland: array of tags + tags: Option>, + #[serde(rename = "xdgTag")] + xdg_tag: Option, } fn launch_command(args: &Args) -> std::io::Result { - Command::new("hyprctl") - .arg("keyword") - .arg("exec") + Command::new("sh") + .arg("-c") .arg(&args.launch) .spawn() } @@ -32,23 +219,22 @@ fn launch_command(args: &Args) -> std::io::Result { fn focus_window(address: &str) -> std::io::Result { Command::new("hyprctl") .arg("dispatch") - .arg("focuswindow") - .arg(format!("address:{address}")) + .arg(format!(r#"hl.dsp.focus({{window = "address:{address}"}})"#)) .spawn() } -fn get_current_matching_window(class: &str) -> Result { +fn get_current_matching_window(matchers: &[MatchCondition]) -> Result { let output = Command::new("hyprctl") .arg("activewindow") .arg("-j") .output()?; let stdout = String::from_utf8(output.stdout) - .context("Reading `hyprctl currentwindow -j` to string failed")?; + .context("Reading `hyprctl activewindow -j` to string failed")?; let client = json::from_str::(&stdout)?; - if class == &client.class { + if matchers.iter().all(|matcher| matcher.matches(&client)) { Ok(client) } else { - bail!("Current window is not of same class") + bail!("Current window does not match provided conditions") } } @@ -56,6 +242,8 @@ fn main() -> Result<()> { // Get arguments let args: Args = argh::from_env(); + let matchers = args.build_matchers()?; + // Launch hyprctl let json = Command::new("hyprctl").arg("clients").arg("-j").output(); match json { @@ -69,13 +257,16 @@ fn main() -> Result<()> { // Filter matching clients let candidates = clients .iter() - .filter(|client| client.class == args.class) + .filter(|client| matchers.iter().all(|matcher| matcher.matches(*client))) .collect::>(); - + // Are we currently focusing a window of this class? - if let Ok(Client { address, .. }) = get_current_matching_window(&args.class) { + if let Ok(current_client) = get_current_matching_window(&matchers) { // Focus next window based on first - if let Some(index) = candidates.iter().position(|client| client.address == address) { + if let Some(index) = candidates + .iter() + .position(|client| client.address == current_client.address) + { if let Some(next_client) = candidates.iter().cycle().skip(index + 1).next() { focus_window(&next_client.address)?; } @@ -97,3 +288,157 @@ fn main() -> Result<()> { // Success Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn build_client( + class: &str, + initial_class: Option<&str>, + title: Option<&str>, + initial_title: Option<&str>, + tag: Option<&str>, + xdg_tag: Option<&str>, + ) -> Client { + Client { + class: class.to_owned(), + address: "0x123".to_owned(), + initial_class: initial_class.map(str::to_owned), + title: title.map(str::to_owned), + initial_title: initial_title.map(str::to_owned), + tag: tag.map(str::to_owned), + tags: tag.map(|t| vec![t.to_owned()]), + xdg_tag: xdg_tag.map(str::to_owned), + } + } + + fn matches(condition: &MatchCondition, client: &Client) -> bool { + condition.matches(client) + } + + #[test] + fn matches_class_field() { + let client = build_client("Firefox", None, None, None, None, None); + let condition = + MatchCondition::new(MatchField::Class, Matcher::Equals("Firefox".to_string())); + assert!(matches(&condition, &client)); + + let failing = + MatchCondition::new(MatchField::Class, Matcher::Equals("Chromium".to_string())); + assert!(!matches(&failing, &client)); + } + + #[test] + fn matches_title_field() { + let client = build_client("Firefox", None, Some("Docs - Firefox"), None, None, None); + let condition = + MatchCondition::new(MatchField::Title, Matcher::Contains("Docs".to_string())); + assert!(matches(&condition, &client)); + + let failing = + MatchCondition::new(MatchField::Title, Matcher::Contains("Other".to_string())); + assert!(!matches(&failing, &client)); + } + + #[test] + fn matches_initial_class_field() { + let client = build_client("Firefox", Some("firefox"), None, None, None, None); + let condition = MatchCondition::new( + MatchField::InitialClass, + Matcher::Equals("firefox".to_string()), + ); + assert!(matches(&condition, &client)); + + let failing = MatchCondition::new( + MatchField::InitialClass, + Matcher::Equals("kitty".to_string()), + ); + assert!(!matches(&failing, &client)); + } + + #[test] + fn matches_initial_title_field() { + let client = build_client( + "Firefox", + None, + Some("Docs - Firefox"), + Some("Welcome"), + None, + None, + ); + let condition = MatchCondition::new( + MatchField::InitialTitle, + Matcher::Equals("Welcome".to_string()), + ); + assert!(matches(&condition, &client)); + + let failing = MatchCondition::new( + MatchField::InitialTitle, + Matcher::Equals("Other".to_string()), + ); + assert!(!matches(&failing, &client)); + } + + #[test] + fn matches_tag_field() { + let client = build_client("Firefox", None, None, None, Some("work"), None); + let condition = MatchCondition::new(MatchField::Tag, Matcher::Equals("work".to_string())); + assert!(matches(&condition, &client)); + + let failing = MatchCondition::new(MatchField::Tag, Matcher::Equals("play".to_string())); + assert!(!matches(&failing, &client)); + } + + #[test] + fn matches_xdgtag_field() { + let client = build_client("Firefox", None, None, None, None, Some("browser")); + let condition = + MatchCondition::new(MatchField::XdgTag, Matcher::Equals("browser".to_string())); + assert!(matches(&condition, &client)); + + let failing = MatchCondition::new(MatchField::XdgTag, Matcher::Equals("video".to_string())); + assert!(!matches(&failing, &client)); + } + + #[test] + fn matcher_variants_behave_as_expected() { + let client = build_client("Firebox", None, Some("Docs - Firebox"), None, None, None); + + let equals = Matcher::from_tokens(Some("equals"), "Firebox").unwrap(); + assert!(equals.matches(&client.class)); + + let contains = Matcher::from_tokens(Some("contains"), "Docs").unwrap(); + assert!(contains.matches(client.title.as_deref().unwrap())); + + let prefix = Matcher::from_tokens(Some("prefix"), "Docs").unwrap(); + assert!(prefix.matches(client.title.as_deref().unwrap())); + + let suffix = Matcher::from_tokens(Some("suffix"), "Firebox").unwrap(); + assert!(suffix.matches(client.title.as_deref().unwrap())); + + let regex = Matcher::from_tokens(Some("regex"), "^Docs.*box$").unwrap(); + assert!(regex.matches(client.title.as_deref().unwrap())); + } + + #[test] + fn parse_match_condition_supports_aliases() { + let initial_class = parse_match_condition("initialClass=kitty").unwrap(); + assert!(matches( + &initial_class, + &build_client("kitty", Some("kitty"), None, None, None, None) + )); + + let initial_title = parse_match_condition("initial-title=Welcome").unwrap(); + assert!(matches( + &initial_title, + &build_client("App", None, Some("App - now"), Some("Welcome"), None, None) + )); + + let xdg_tag = parse_match_condition("xdg-tag=browser").unwrap(); + assert!(matches( + &xdg_tag, + &build_client("App", None, None, None, None, Some("browser")) + )); + } +}