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/README.md b/README.md index 074c2e5..4070ebf 100644 --- a/README.md +++ b/README.md @@ -6,16 +6,54 @@ 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/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")) + )); + } +}