From 7c2caaa0d44126e91bd2b32d04e47d61980ec0fc Mon Sep 17 00:00:00 2001 From: Sergey Miroshnichenko Date: Wed, 8 Oct 2025 08:45:22 +0300 Subject: [PATCH 01/20] Add flexible matching options --- Cargo.lock | 47 ++++++++- Cargo.toml | 1 + src/main.rs | 287 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 324 insertions(+), 11 deletions(-) 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/src/main.rs b/src/main.rs index 1c52cda..d9d2a40 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,24 +1,174 @@ -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 { + 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, /// 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()), + )); + } + + 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, + #[serde(rename = "xdgTag")] + xdg_tag: Option, } fn launch_command(args: &Args) -> std::io::Result { @@ -37,7 +187,7 @@ fn focus_window(address: &str) -> std::io::Result { .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") @@ -45,10 +195,10 @@ fn get_current_matching_window(class: &str) -> Result { let stdout = String::from_utf8(output.stdout) .context("Reading `hyprctl currentwindow -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 +206,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 +221,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 +252,115 @@ 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), + 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)); + } +} From 22a513bb1359f79f8a7bab21d4abb55a06e1642d Mon Sep 17 00:00:00 2001 From: Sergey Miroshnichenko Date: Wed, 8 Oct 2025 08:45:27 +0300 Subject: [PATCH 02/20] Update README.md --- README.md | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 074c2e5..80e7934 100644 --- a/README.md +++ b/README.md @@ -6,16 +6,48 @@ it will launch new window. ``` $ raise -Usage: raise -c -e +Usage: raise [-c ] -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=...`) -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" +``` + ## Install `raise` There are multiple ways to install this: From 6e9c20593b17056ac1304849e85ad0fc2b2e5dc8 Mon Sep 17 00:00:00 2001 From: Sergey Miroshnichenko Date: Wed, 8 Oct 2025 08:46:57 +0300 Subject: [PATCH 03/20] Add matcher unit tests --- src/main.rs | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/main.rs b/src/main.rs index d9d2a40..0a7b258 100644 --- a/src/main.rs +++ b/src/main.rs @@ -363,4 +363,45 @@ mod tests { 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")) + )); + } } From cceae610c43f30967ad33fe130a1c70b609b1c44 Mon Sep 17 00:00:00 2001 From: Sergey Miroshnichenko Date: Fri, 31 Oct 2025 08:55:35 +0300 Subject: [PATCH 04/20] feat: add tag/xdgtag shorthands and Hyprland tags array support - Add --tag and --xdgtag args that expand to matchers - Match Tag against legacy tag and modern tags array - Update usage and examples in README --- README.md | 8 +++++++- src/main.rs | 49 ++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 80e7934..4070ebf 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,14 @@ it will launch new window. ``` $ raise -Usage: raise [-c ] -e [-m ...] +Usage: raise [-c ] [--tag ] [--xdgtag ] -e [-m ...] Raise window if it exists, otherwise launch new window. Options: -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 @@ -46,6 +48,10 @@ 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` diff --git a/src/main.rs b/src/main.rs index 0a7b258..d769936 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,10 +16,24 @@ impl MatchCondition { } fn matches(&self, client: &Client) -> bool { - self.field - .value(client) - .map(|value| self.matcher.matches(value)) - .unwrap_or(false) + 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), + } } } @@ -122,6 +136,14 @@ struct Args { #[argh(option, short = 'c')] 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, @@ -147,6 +169,20 @@ impl Args { )); } + 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() { @@ -167,6 +203,8 @@ struct Client { #[serde(rename = "initialTitle")] initial_title: Option, tag: Option, + // modern Hyprland: array of tags + tags: Option>, #[serde(rename = "xdgTag")] xdg_tag: Option, } @@ -193,7 +231,7 @@ fn get_current_matching_window(matchers: &[MatchCondition]) -> Result { .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 matchers.iter().all(|matcher| matcher.matches(&client)) { Ok(client) @@ -272,6 +310,7 @@ mod tests { 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), } } From 562de21b6dda28753582bdcbc50dfc3d8581c722 Mon Sep 17 00:00:00 2001 From: Sergey Miroshnichenko Date: Fri, 31 Oct 2025 09:02:59 +0300 Subject: [PATCH 05/20] nix: update flake inputs (nixpkgs/naersk) to support Cargo.lock v4 and build successfully --- flake.lock | 58 +++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 49 insertions(+), 9 deletions(-) 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, From 8178b1b0755f9c1561b09ebba36721ebd02dd513 Mon Sep 17 00:00:00 2001 From: Sergey Miroshnichenko Date: Fri, 31 Oct 2025 09:07:55 +0300 Subject: [PATCH 06/20] scripts: add Hyprland migration tool to switch raise usage to tags and point flake to user fork --- scripts/migrate_raise_to_tags.sh | 222 +++++++++++++++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100755 scripts/migrate_raise_to_tags.sh diff --git a/scripts/migrate_raise_to_tags.sh b/scripts/migrate_raise_to_tags.sh new file mode 100755 index 0000000..3070012 --- /dev/null +++ b/scripts/migrate_raise_to_tags.sh @@ -0,0 +1,222 @@ +#!/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="" +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="$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" ]] || die "--fork-url is required (e.g. github: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 + +# 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 + sed -i -E "s#(inputs\.raise\.url\s*=\s*").*(";)#\1${FORK_URL}\2#" 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 + + # 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 +RULES_DIR="modules/user/gui/hypr/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)$ + +# 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)$ + +# Music +windowrulev2 = tag, music, class:^(spotify|Spotify|ncspot)$ + +# 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)$ +RULES + +# 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 ;; + spotify|Spotify|ncspot) echo music ;; + obsidian|Obsidian|logseq|Logseq|Zettlr) echo notes ;; + thunderbird|Thunderbird) echo mail ;; + gimp|Gimp|inkscape|Inkscape|krita|Krita) echo design ;; + *) return 1 ;; + esac +} + +changed_files=() + +# Update specific known bindings file if present +if [[ -f modules/user/gui/hypr/conf/bindings/apps.conf ]]; then + file=modules/user/gui/hypr/conf/bindings/apps.conf + 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 + echo "$line" >> "$tmp" + done < "$file" + if ! cmp -s "$file" "$tmp"; then + mv "$tmp" "$file" + changed_files+=("$file") + else + rm -f "$tmp" + fi +fi + +# Broad pass across hypr conf tree: best-effort replacements +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 modules/user/gui/hypr -type f -name '*.conf' -print0 2>/dev/null) + +# Add a canonical example: bind for web tag with $browser +if [[ -f modules/user/gui/hypr/conf/bindings/apps.conf ]]; then + if ! rg -n "raise --tag web" modules/user/gui/hypr/conf/bindings/apps.conf >/dev/null 2>&1; then + echo "bind = \$M4, w, exec, raise --tag web --launch \$browser" >> modules/user/gui/hypr/conf/bindings/apps.conf + changed_files+=("modules/user/gui/hypr/conf/bindings/apps.conf") + fi +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 + +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 + +popd >/dev/null + From 947e18bb5d79ad5e8a7860e66a256e4e5452c434 Mon Sep 17 00:00:00 2001 From: Sergey Miroshnichenko Date: Fri, 31 Oct 2025 09:08:14 +0300 Subject: [PATCH 07/20] scripts: report remaining class-based raise usages and hint to source tags rules --- scripts/migrate_raise_to_tags.sh | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/migrate_raise_to_tags.sh b/scripts/migrate_raise_to_tags.sh index 3070012..a2bfd70 100755 --- a/scripts/migrate_raise_to_tags.sh +++ b/scripts/migrate_raise_to_tags.sh @@ -218,5 +218,12 @@ 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 -popd >/dev/null +# 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 +echo "\nEnsure your Hypr config sources the tags rules (if not already):" >&2 +echo " source = $RULES_FILE" >&2 +popd >/dev/null From a88049ef7af363768e30e6d3bc22787a8223b1ec Mon Sep 17 00:00:00 2001 From: Sergey Miroshnichenko Date: Fri, 31 Oct 2025 09:10:03 +0300 Subject: [PATCH 08/20] scripts: normalize https GitHub URL to flake 'github:owner/repo'; strip raise.rev/ref pins; optionally run nix flake update --- scripts/migrate_raise_to_tags.sh | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/scripts/migrate_raise_to_tags.sh b/scripts/migrate_raise_to_tags.sh index a2bfd70..b8c9142 100755 --- a/scripts/migrate_raise_to_tags.sh +++ b/scripts/migrate_raise_to_tags.sh @@ -17,7 +17,7 @@ set -euo pipefail # REPO="" -FORK_URL="" +FORK_URL_RAW="" DO_COMMIT=1 die() { echo "Error: $*" >&2; exit 1; } @@ -27,7 +27,7 @@ while [[ $# -gt 0 ]]; do -r|--repo) REPO="$2"; shift 2;; -u|--fork-url) - FORK_URL="$2"; shift 2;; + FORK_URL_RAW="$2"; shift 2;; --no-commit) DO_COMMIT=0; shift;; -h|--help) @@ -38,7 +38,7 @@ while [[ $# -gt 0 ]]; do done [[ -n "$REPO" ]] || die "--repo is required" -[[ -n "$FORK_URL" ]] || die "--fork-url is required (e.g. github:neg-serg/raise)" +[[ -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" @@ -48,6 +48,12 @@ 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 @@ -72,6 +78,9 @@ if [[ -f flake.nix ]]; then ' 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 @@ -213,6 +222,11 @@ if [[ $DO_COMMIT -eq 1 ]]; then 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 From ace73b8c1e2e28b00851e169f1abd0522200ba19 Mon Sep 17 00:00:00 2001 From: Sergey Miroshnichenko Date: Fri, 31 Oct 2025 09:14:50 +0300 Subject: [PATCH 09/20] scripts: support Home Manager hypr path; convert home.nix prewarm execs to --tag; stop shadowing raise by renaming local script to raise_class --- scripts/migrate_raise_to_tags.sh | 113 ++++++++++++++++++++----------- 1 file changed, 75 insertions(+), 38 deletions(-) diff --git a/scripts/migrate_raise_to_tags.sh b/scripts/migrate_raise_to_tags.sh index b8c9142..81e0758 100755 --- a/scripts/migrate_raise_to_tags.sh +++ b/scripts/migrate_raise_to_tags.sh @@ -86,11 +86,21 @@ else echo "Note: flake.nix not found; skipping fork update." >&2 fi -# 2) Ensure a tags rules file exists with sensible defaults -RULES_DIR="modules/user/gui/hypr/conf/rules" -RULES_FILE="$RULES_DIR/tags.conf" -mkdir -p "$RULES_DIR" -cat > "$RULES_FILE" <<'RULES' +# 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. @@ -124,6 +134,7 @@ windowrulev2 = tag, mail, class:^(thunderbird|Thunderbird)$ # Graphics windowrulev2 = tag, design, class:^(gimp|Gimp|inkscape|Inkscape|krita|Krita)$ RULES +done # 3) Replace raise usages to prefer --tag where mapping is obvious # Known mapping pairs for common apps (class -> tag) @@ -146,8 +157,9 @@ map_class_to_tag() { changed_files=() # Update specific known bindings file if present -if [[ -f modules/user/gui/hypr/conf/bindings/apps.conf ]]; then - file=modules/user/gui/hypr/conf/bindings/apps.conf +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 @@ -171,43 +183,64 @@ if [[ -f modules/user/gui/hypr/conf/bindings/apps.conf ]]; then else rm -f "$tmp" fi -fi +done # Broad pass across hypr conf tree: best-effort replacements -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 +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 - 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 + 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 - echo "$line" >> "$tmp" - done < "$f" - if [[ $modified -eq 1 ]]; then - mv "$tmp" "$f" - changed_files+=("$f") - else - rm -f "$tmp" - fi -done < <(find modules/user/gui/hypr -type f -name '*.conf' -print0 2>/dev/null) + done < <(find "$HYPR_ROOT" -type f -name '*.conf' -print0 2>/dev/null) +done # Add a canonical example: bind for web tag with $browser -if [[ -f modules/user/gui/hypr/conf/bindings/apps.conf ]]; then - if ! rg -n "raise --tag web" modules/user/gui/hypr/conf/bindings/apps.conf >/dev/null 2>&1; then - echo "bind = \$M4, w, exec, raise --tag web --launch \$browser" >> modules/user/gui/hypr/conf/bindings/apps.conf - changed_files+=("modules/user/gui/hypr/conf/bindings/apps.conf") +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 @@ -237,7 +270,11 @@ 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 -echo "\nEnsure your Hypr config sources the tags rules (if not already):" >&2 -echo " source = $RULES_FILE" >&2 +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 From 0a71af38b4c1b932d93ae718c3914f0a60c84f7a Mon Sep 17 00:00:00 2001 From: Sergey Miroshnichenko Date: Fri, 31 Oct 2025 09:15:10 +0300 Subject: [PATCH 10/20] scripts: fix sed replacement to avoid ERE groups causing shell parse issues --- scripts/migrate_raise_to_tags.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/migrate_raise_to_tags.sh b/scripts/migrate_raise_to_tags.sh index 81e0758..e33c49d 100755 --- a/scripts/migrate_raise_to_tags.sh +++ b/scripts/migrate_raise_to_tags.sh @@ -66,7 +66,8 @@ fi 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 - sed -i -E "s#(inputs\.raise\.url\s*=\s*").*(";)#\1${FORK_URL}\2#" flake.nix || true + # 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" ' From 7d90f067772958338a64c7f42ba17c350e5c6297 Mon Sep 17 00:00:00 2001 From: Sergey Miroshnichenko Date: Fri, 31 Oct 2025 09:15:41 +0300 Subject: [PATCH 11/20] =?UTF-8?q?scripts:=20extend=20tag=20rules=20and=20c?= =?UTF-8?q?lass=E2=86=92tag=20mapping=20(img,=20audio,=20read,=20games,=20?= =?UTF-8?q?term/nwim)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/migrate_raise_to_tags.sh | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/scripts/migrate_raise_to_tags.sh b/scripts/migrate_raise_to_tags.sh index e33c49d..2a99551 100755 --- a/scripts/migrate_raise_to_tags.sh +++ b/scripts/migrate_raise_to_tags.sh @@ -123,6 +123,9 @@ windowrulev2 = tag, files, class:^(thunar|Thunar|dolphin|Dolphin|nemo|Nemo|nauti # 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)$ @@ -134,6 +137,15 @@ 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)$ RULES done @@ -147,10 +159,16 @@ map_class_to_tag() { 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 ;; 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 ;; *) return 1 ;; esac } From 979de764628c4ed36d094b7ba82529a9b2b7d150 Mon Sep 17 00:00:00 2001 From: Sergey Miroshnichenko Date: Fri, 31 Oct 2025 09:15:59 +0300 Subject: [PATCH 12/20] =?UTF-8?q?scripts:=20add=20mappings=20for=20Bazecor?= =?UTF-8?q?=E2=86=92kb,=20obs=E2=86=92obs,=20nicotine=E2=86=92music;=20ext?= =?UTF-8?q?end=20music=20rule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/migrate_raise_to_tags.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/migrate_raise_to_tags.sh b/scripts/migrate_raise_to_tags.sh index 2a99551..d685e94 100755 --- a/scripts/migrate_raise_to_tags.sh +++ b/scripts/migrate_raise_to_tags.sh @@ -127,7 +127,7 @@ windowrulev2 = tag, vid, class:^(mpv|vlc|celluloid|Celluloid)$ windowrulev2 = tag, img, class:^(swayimg|imv|feh)$ # Music -windowrulev2 = tag, music, class:^(spotify|Spotify|ncspot)$ +windowrulev2 = tag, music, class:^(spotify|Spotify|ncspot|org\.nicotine_plus\.Nicotine)$ # Notes / Knowledge windowrulev2 = tag, notes, class:^(obsidian|Obsidian|logseq|Logseq|Zettlr)$ @@ -161,6 +161,7 @@ map_class_to_tag() { 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 ;; @@ -169,6 +170,8 @@ map_class_to_tag() { org.pwmt.zathura|zathura) echo read ;; term) echo term ;; nwim) echo nwim ;; + Bazecor) echo kb ;; + obs) echo obs ;; *) return 1 ;; esac } From 3d21629721e8acd0fceaa2a30bc9bdedabee2005 Mon Sep 17 00:00:00 2001 From: Sergey Miroshnichenko Date: Fri, 31 Oct 2025 09:16:17 +0300 Subject: [PATCH 13/20] scripts: add explicit tag rules for term/nwim, obs, Bazecor (kb) --- scripts/migrate_raise_to_tags.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/migrate_raise_to_tags.sh b/scripts/migrate_raise_to_tags.sh index d685e94..39c1249 100755 --- a/scripts/migrate_raise_to_tags.sh +++ b/scripts/migrate_raise_to_tags.sh @@ -110,6 +110,8 @@ windowrulev2 = tag, web, class:^(firefox|Firefox|floorp|Floorp|Brave.*|Chromium| # 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)$ @@ -146,6 +148,12 @@ 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 From cb547495ea8aa43da200be241fcdcd85e8607306 Mon Sep 17 00:00:00 2001 From: Sergey Miroshnichenko Date: Fri, 31 Oct 2025 09:17:16 +0300 Subject: [PATCH 14/20] scripts: convert --match class:regex for browsers to --tag web; de-duplicate bindings in apps.conf --- scripts/migrate_raise_to_tags.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/migrate_raise_to_tags.sh b/scripts/migrate_raise_to_tags.sh index 39c1249..0490029 100755 --- a/scripts/migrate_raise_to_tags.sh +++ b/scripts/migrate_raise_to_tags.sh @@ -205,10 +205,15 @@ for HYPR_ROOT in "${HYPR_ROOTS[@]}"; do line=$(echo "$line" | sed -E "s/--match class=[^[:space:]]+/--tag ${tag}/g") fi fi + # Convert browser regex matcher to --tag web when launching $browser + if [[ "$line" =~ --match[[:space:]]class:regex="[^"]+" ]] && [[ "$line" =~ --launch[[:space:]]\$browser ]]; then + line=$(echo "$line" | sed -E 's/--match class:regex="[^"]+"/--tag web/g') + fi echo "$line" >> "$tmp" done < "$file" if ! cmp -s "$file" "$tmp"; then - mv "$tmp" "$file" + # De-duplicate identical lines (keep first occurrence) + awk '!seen[$0]++' "$tmp" > "$file" changed_files+=("$file") else rm -f "$tmp" From ca875f7d1177c88c70ac909c437dad845180227f Mon Sep 17 00:00:00 2001 From: Sergey Miroshnichenko Date: Fri, 31 Oct 2025 09:17:36 +0300 Subject: [PATCH 15/20] scripts: simplify browser regex conversion (always map class:regex to --tag web) to avoid bash [[...]] regex pitfalls --- scripts/migrate_raise_to_tags.sh | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/scripts/migrate_raise_to_tags.sh b/scripts/migrate_raise_to_tags.sh index 0490029..33c4bee 100755 --- a/scripts/migrate_raise_to_tags.sh +++ b/scripts/migrate_raise_to_tags.sh @@ -205,10 +205,8 @@ for HYPR_ROOT in "${HYPR_ROOTS[@]}"; do line=$(echo "$line" | sed -E "s/--match class=[^[:space:]]+/--tag ${tag}/g") fi fi - # Convert browser regex matcher to --tag web when launching $browser - if [[ "$line" =~ --match[[:space:]]class:regex="[^"]+" ]] && [[ "$line" =~ --launch[[:space:]]\$browser ]]; then - line=$(echo "$line" | sed -E 's/--match class:regex="[^"]+"/--tag web/g') - 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 From 14c4df80d1b204eb7abc2448faa9e6d041426bb9 Mon Sep 17 00:00:00 2001 From: Sergey Miroshnichenko Date: Mon, 29 Jun 2026 01:56:02 +0300 Subject: [PATCH 16/20] fix: use sh -c instead of hyprctl keyword exec for launching hyprctl keyword exec is broken under Hyprland Lua config (v0.55+). Use direct shell execution instead, matching how hl.dsp.exec_cmd works. --- src/main.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main.rs b/src/main.rs index d769936..6da23f9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -210,9 +210,8 @@ struct Client { } fn launch_command(args: &Args) -> std::io::Result { - Command::new("hyprctl") - .arg("keyword") - .arg("exec") + Command::new("sh") + .arg("-c") .arg(&args.launch) .spawn() } From 8b5bf7aafb48a9714c546ebd5ac222edb000ed99 Mon Sep 17 00:00:00 2001 From: Sergey Miroshnichenko Date: Mon, 29 Jun 2026 06:11:31 +0300 Subject: [PATCH 17/20] [fix] Update dispatch API for Hyprland 0.55 (hl.dsp.focus/exec_cmd) --- src/main.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/main.rs b/src/main.rs index d769936..08d3ae3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -211,17 +211,15 @@ struct Client { fn launch_command(args: &Args) -> std::io::Result { Command::new("hyprctl") - .arg("keyword") - .arg("exec") - .arg(&args.launch) + .arg("dispatch") + .arg(format!("hl.dsp.exec_cmd(\"{}\")", args.launch)) .spawn() } fn focus_window(address: &str) -> std::io::Result { Command::new("hyprctl") .arg("dispatch") - .arg("focuswindow") - .arg(format!("address:{address}")) + .arg(format!("hl.dsp.focus({{window=\"address:{address}\"}})")) .spawn() } From 5768c9dd9f1f125aa51ab6d244e37b1ae9de7973 Mon Sep 17 00:00:00 2001 From: Sergey Miroshnichenko Date: Tue, 30 Jun 2026 18:16:47 +0300 Subject: [PATCH 18/20] Use Lua-compatible dispatch syntax for Hyprland 0.55.4 hyprctl dispatch focuswindow address:... is broken in Hyprland 0.55.4 with Lua config manager. Use hl.dsp.focus({window = ...}) instead. --- src/main.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index 6da23f9..3cc7e36 100644 --- a/src/main.rs +++ b/src/main.rs @@ -219,8 +219,7 @@ 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() } From e8c9425ea1ae1d9eb4611eca4123b9d249e3bd30 Mon Sep 17 00:00:00 2001 From: Sergey Miroshnichenko Date: Tue, 18 Aug 2026 23:31:33 +0300 Subject: [PATCH 19/20] chore: ignore nix build result symlink --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index ea8c4bf..d787b70 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +/result From b65e674eb2e7a3d2216592fd818eedd3c56d5b65 Mon Sep 17 00:00:00 2001 From: Sergey Miroshnichenko Date: Tue, 18 Aug 2026 23:36:06 +0300 Subject: [PATCH 20/20] chore: add MIT license and upstream attribution --- LICENSE | 21 +++++++++++++++++++++ README.md | 3 +++ 2 files changed, 24 insertions(+) create mode 100644 LICENSE 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 4070ebf..939ae62 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # 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.