nirius/
cmds.rs

1// Copyright (C) 2025  Tassilo Horn <tsdh@gnu.org>
2//
3// This program is free software: you can redistribute it and/or modify it
4// under the terms of the GNU General Public License as published by the Free
5// Software Foundation, either version 3 of the License, or (at your option)
6// any later version.
7//
8// This program is distributed in the hope that it will be useful, but WITHOUT
9// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
10// FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for
11// more details.
12//
13// You should have received a copy of the GNU General Public License along with
14// this program.  If not, see <https://www.gnu.org/licenses/>.
15
16use std::{cmp::Ordering, sync::Mutex};
17
18use crate::ipc;
19use niri_ipc::{Action, Request, Response, Window};
20use regex::Regex;
21use serde::{Deserialize, Serialize};
22
23#[derive(clap::Parser, PartialEq, Eq, Debug, Clone, Deserialize, Serialize)]
24pub enum NiriusCmd {
25    FocusOrSpawn {
26        #[clap(flatten)]
27        match_opts: MatchOptions,
28        command: Vec<String>,
29    },
30    Nop,
31}
32
33#[derive(clap::Parser, PartialEq, Eq, Debug, Clone, Deserialize, Serialize)]
34pub struct MatchOptions {
35    #[clap(short = 'a', long, help = "Matches window app-ids")]
36    app_id: Option<String>,
37
38    #[clap(short = 't', long, help = "Matches window titles")]
39    title: Option<String>,
40}
41
42static FOCUSED_WIN_IDS: Mutex<Vec<u64>> = Mutex::new(vec![]);
43
44pub fn exec_nirius_cmd(cmd: NiriusCmd) -> Result<String, String> {
45    let mut clear_focused_win_ids = true;
46
47    let result = match &cmd {
48        NiriusCmd::Nop => Ok("Nothing done".to_string()),
49        NiriusCmd::FocusOrSpawn {
50            match_opts,
51            command,
52        } => {
53            clear_focused_win_ids = false;
54            focus_or_spawn(match_opts, command)
55        }
56    };
57
58    if clear_focused_win_ids {
59        FOCUSED_WIN_IDS
60            .lock()
61            .expect("Could not lock mutex")
62            .clear()
63    }
64
65    result
66}
67
68fn focus_or_spawn(
69    match_opts: &MatchOptions,
70    command: &[String],
71) -> Result<String, String> {
72    match ipc::query_niri(Request::Windows)? {
73        Response::Windows(mut wins) => {
74            let mut ids = FOCUSED_WIN_IDS.lock().expect("Could not lock mutex");
75            wins.retain(|w| window_matches(w, match_opts));
76            if wins.iter().all(|w| ids.contains(&w.id)) {
77                ids.clear();
78            }
79            wins.sort_by(|a, b| {
80                if a.is_focused {
81                    return Ordering::Greater;
82                }
83                if b.is_focused {
84                    return Ordering::Less;
85                }
86
87                let a_visited = ids.contains(&a.id);
88                let b_visited = ids.contains(&b.id);
89
90                if a_visited && !b_visited {
91                    return Ordering::Greater;
92                }
93                if !a_visited && b_visited {
94                    return Ordering::Less;
95                }
96
97                a.id.cmp(&b.id)
98            });
99            log::debug!("ids: {:?}", ids);
100            if let Some(win) = wins.first() {
101                if !ids.contains(&win.id) {
102                    ids.push(win.id);
103                }
104                focus_window(win.id)
105            } else {
106                let r = ipc::query_niri(Request::Action(Action::Spawn {
107                    command: command.to_vec(),
108                }))?;
109                match r {
110                    Response::Handled => Ok("Spawned successfully".to_string()),
111                    x => Err(format!("Received unexpected reply {:?}", x)),
112                }
113            }
114        }
115        x => Err(format!("Received unexpected reply {:?}", x)),
116    }
117}
118
119fn focus_window(id: u64) -> Result<String, String> {
120    match ipc::query_niri(Request::Action(Action::FocusWindow { id }))? {
121        Response::Handled => Ok(format!("Focused window with id {}", id)),
122        x => Err(format!("Received unexpected reply {:?}", x)),
123    }
124}
125
126fn window_matches(w: &Window, match_opts: &MatchOptions) -> bool {
127    log::debug!("Matching window {:?}", w);
128    if w.app_id.is_none() && match_opts.app_id.is_some()
129        || match_opts.app_id.as_ref().is_some_and(|rx| {
130            !Regex::new(rx).unwrap().is_match(w.app_id.as_ref().unwrap())
131        })
132    {
133        log::debug!("app-id does not match.");
134        return false;
135    }
136
137    if w.title.is_none() && match_opts.title.is_some()
138        || match_opts.title.as_ref().is_some_and(|rx| {
139            !Regex::new(rx).unwrap().is_match(w.title.as_ref().unwrap())
140        })
141    {
142        log::debug!("title does not match.");
143        return false;
144    }
145
146    true
147}