use crate::{
ipc,
state::{STATE, State},
};
use niri_ipc::{
Action, Request, Response, Window, Workspace, WorkspaceReferenceArg,
};
use regex::Regex;
use serde::{Deserialize, Serialize};
static NO_MATCHING_WINDOW: &str = "No matching window.";
#[derive(clap::Parser, PartialEq, Eq, Debug, Clone, Deserialize, Serialize)]
pub enum NiriusCmd {
Focus {
#[clap(flatten)]
match_opts: MatchOptions,
},
FocusOrSpawn {
#[clap(flatten)]
match_opts: MatchOptions,
command: Vec<String>,
},
MoveToCurrentWorkspace {
#[clap(flatten)]
match_opts: MatchOptions,
#[clap(
short = 'f',
long,
help = "Focus the window after moving it to the current workspace."
)]
focus: bool,
#[clap(long, help = "Don't exclude windows of the current workspace.")]
include_current_workspace: bool,
},
MoveToCurrentWorkspaceOrSpawn {
#[clap(flatten)]
match_opts: MatchOptions,
#[clap(
short = 'f',
long,
help = "Focus the window after moving it to the current workspace."
)]
focus: bool,
#[clap(long, help = "Don't exclude windows of the current workspace.")]
include_current_workspace: bool,
command: Vec<String>,
},
ToggleFollowMode,
ToggleMark { mark: Option<String> },
FocusMarked { mark: Option<String> },
ListMarked {
mark: Option<String>,
#[clap(short = 'a', long, help = "List all marks with their windows")]
all: bool,
},
ScratchpadToggle {
#[clap(flatten)]
match_opts: MatchOptions,
#[clap(
long,
help = "Toggle scratchpad state without moving the window"
)]
no_move: bool,
},
ScratchpadShow {
#[clap(flatten)]
match_opts: MatchOptions,
#[clap(long)]
id: Option<u64>,
},
ScratchpadShowAll,
ListScratchpad,
}
#[derive(clap::Parser, PartialEq, Eq, Debug, Clone, Deserialize, Serialize)]
pub struct MatchOptions {
#[clap(
short = 'a',
long,
help = "Selects windows whose app-id matches this regex"
)]
app_id: Option<String>,
#[clap(
short = 't',
long,
help = "Selects windows whose title matches this regex"
)]
title: Option<String>,
#[clap(
short = 'p',
long,
help = "Selects windows belonging to the process with this PID"
)]
pid: Option<i32>,
#[clap(long, help = "Selects windows on the currently focused workspace")]
focused_workspace: bool,
#[clap(
long,
help = "Selects windows on a currently active workspace (one per output)"
)]
active_workspace: bool,
#[clap(long, help = "Selects windows shown on the workspace with this ID")]
workspace_id: Option<u64>,
#[clap(
long,
help = "Selects windows shown on the workspace with this index"
)]
workspace_index: Option<u8>,
#[clap(
long,
help = "Selects windows shown on a workspace whose name matches this regex"
)]
workspace_name: Option<String>,
#[clap(long, help = "Selects only windows marked urgent")]
urgent: bool,
#[clap(long, help = "Selects only floating windows (opposite of --tiled)")]
floating: bool,
#[clap(long, help = "Selects only tiled windows (opposite of --floating)")]
tiled: bool,
}
static DEFAULT_MARK: &str = "__default__";
fn mark_or_default(mark: &Option<String>) -> String {
mark.clone().unwrap_or(DEFAULT_MARK.to_owned())
}
pub fn exec_nirius_cmd(cmd: NiriusCmd) -> Result<String, String> {
match &cmd {
NiriusCmd::Focus { match_opts } => focus(match_opts),
NiriusCmd::FocusOrSpawn {
match_opts,
command,
} => focus_or_spawn(match_opts, command),
NiriusCmd::MoveToCurrentWorkspace {
match_opts,
include_current_workspace,
focus,
} => move_to_current_workspace(
match_opts,
*include_current_workspace,
*focus,
),
NiriusCmd::MoveToCurrentWorkspaceOrSpawn {
match_opts,
include_current_workspace,
focus,
command,
} => move_to_current_workspace_or_spawn(
match_opts,
*include_current_workspace,
*focus,
command,
),
NiriusCmd::ToggleFollowMode => toggle_follow_mode(),
NiriusCmd::ToggleMark { mark } => toggle_mark(mark_or_default(mark)),
NiriusCmd::FocusMarked { mark } => focus_marked(mark_or_default(mark)),
NiriusCmd::ListMarked { mark, all } => {
if *all {
list_all_marked()
} else {
list_marked(mark_or_default(mark))
}
}
NiriusCmd::ScratchpadToggle {
match_opts,
no_move,
} => scratchpad_toggle(match_opts, *no_move),
NiriusCmd::ScratchpadShow { match_opts, id } => {
scratchpad_show(match_opts, *id)
}
NiriusCmd::ScratchpadShowAll => scratchpad_show_all(),
NiriusCmd::ListScratchpad => list_scratchpad(),
}
}
fn toggle_vec_membership(id: u64, v: &mut Vec<u64>) -> bool {
if let Some(index) = v.iter().position(|x| *x == id) {
v.remove(index);
false
} else {
v.push(id);
true
}
}
fn toggle_follow_mode() -> Result<String, String> {
let mut w_state = STATE.write().expect("Could not write() STATE.");
if let Some(focused_win_id) = w_state.get_focused_win_id() {
if toggle_vec_membership(
focused_win_id,
&mut w_state.follow_mode_win_ids,
) {
Ok(format!("Enabled follow mode for window {focused_win_id}"))
} else {
Ok(format!("Disabled follow mode for window {focused_win_id}"))
}
} else {
Err("No focused window".to_owned())
}
}
fn exec_niri_action(
action: Action,
ok_msg: impl Into<String>,
) -> Result<String, String> {
match ipc::query_niri(Request::Action(action))? {
Response::Handled => Ok(ok_msg.into()),
x => Err(format!("Received unexpected reply {x:?}")),
}
}
fn spawn(command: &[String]) -> Result<String, String> {
exec_niri_action(
Action::Spawn {
command: command.to_vec(),
},
"Spawned successfully",
)
}
fn or_spawn(
result: Result<String, String>,
command: &[String],
) -> Result<String, String> {
match result {
Err(str) if NO_MATCHING_WINDOW == str => spawn(command),
x => x,
}
}
fn focus_or_spawn(
match_opts: &MatchOptions,
command: &[String],
) -> Result<String, String> {
or_spawn(focus(match_opts), command)
}
fn focus(match_opts: &MatchOptions) -> Result<String, String> {
let state = STATE.read().expect("Could not read() STATE.");
let matcher = WindowMatcher::new(match_opts)?;
let currently_focused = state.get_focused_win_id();
let focused_matches = currently_focused.is_some_and(|id| {
state
.all_windows
.iter()
.find(|w| w.id == id)
.is_some_and(|w| matcher.matches(w, &state.all_workspaces))
});
let window_id = if focused_matches {
state
.all_windows
.iter()
.find(|w| matcher.matches(w, &state.all_workspaces))
.map(|w| w.id)
} else {
state.get_last_focused_matching(|w| {
matcher.matches(w, &state.all_workspaces)
})
};
match window_id {
Some(id) => focus_window(id, &state),
None => Err(NO_MATCHING_WINDOW.to_owned()),
}
}
fn focus_window(id: u64, state: &State) -> Result<String, String> {
if state.get_focused_win_id() == Some(id) {
return Ok(format!("Window {id} is already focused."));
}
if state.scratchpad_win_ids.contains(&id) {
let focused_ws_id = state.focused_workspace_id_or_err()?;
move_window_to_workspace_and_focus(
id,
WorkspaceReferenceArg::Id(focused_ws_id),
)
} else {
focus_window_by_id(id)
}
}
fn focus_window_by_id(id: u64) -> Result<String, String> {
exec_niri_action(
Action::FocusWindow { id },
format!("Focused window with id {id}"),
)
}
struct WindowMatcher<'a> {
opts: &'a MatchOptions,
app_id_rx: Option<Regex>,
title_rx: Option<Regex>,
workspace_name_rx: Option<Regex>,
}
impl<'a> WindowMatcher<'a> {
fn new(opts: &'a MatchOptions) -> Result<Self, String> {
Ok(Self {
opts,
app_id_rx: Self::compile_opt_regex(&opts.app_id)?,
title_rx: Self::compile_opt_regex(&opts.title)?,
workspace_name_rx: Self::compile_opt_regex(&opts.workspace_name)?,
})
}
fn compile_opt_regex(
pattern: &Option<String>,
) -> Result<Option<Regex>, String> {
match pattern {
None => Ok(None),
Some(rx) => Regex::new(rx).map(Some).map_err(|e| {
let msg = format!("Invalid regex {rx:?}: {e}");
log::error!("{msg}");
msg
}),
}
}
fn regex_matches(rx: &Option<Regex>, value: Option<&str>) -> bool {
match rx {
None => true,
Some(r) => value.is_some_and(|v| r.is_match(v)),
}
}
fn matches(&self, w: &Window, workspaces: &[Workspace]) -> bool {
let opts = self.opts;
log::debug!("Matching window {w:?}");
if opts.urgent && !w.is_urgent {
log::debug!("window is not urgent.");
return false;
}
if opts.floating && !w.is_floating {
log::debug!("window is not floating.");
return false;
}
if opts.tiled && w.is_floating {
log::debug!("window is not tiled.");
return false;
}
if !Self::regex_matches(&self.app_id_rx, w.app_id.as_deref()) {
log::debug!("app-id does not match.");
return false;
}
if !Self::regex_matches(&self.title_rx, w.title.as_deref()) {
log::debug!("title does not match.");
return false;
}
if w.pid.is_none() && opts.pid.is_some()
|| opts.pid.is_some_and(|pid| w.pid.unwrap() != pid)
{
log::debug!("pid does not match.");
return false;
}
if w.workspace_id.is_none() && opts.workspace_id.is_some()
|| opts
.workspace_id
.is_some_and(|wid| w.workspace_id.unwrap() != wid)
{
log::debug!("workspace-id does not match.");
return false;
}
if w.workspace_id.is_none()
&& (opts.workspace_index.is_some()
|| opts.workspace_name.is_some()
|| opts.focused_workspace
|| opts.active_workspace)
{
log::debug!("workspace does not match (window has none).");
return false;
} else if let Some(ws) = workspaces
.iter()
.find(|ws| ws.id == w.workspace_id.unwrap())
{
if opts.workspace_index.is_some_and(|idx| ws.idx != idx) {
log::debug!("workspace-index does not match.");
return false;
}
if !Self::regex_matches(&self.workspace_name_rx, ws.name.as_deref())
{
log::debug!("workspace-name does not match.");
return false;
}
if opts.focused_workspace && !ws.is_focused {
log::debug!("workspace is not focused.");
return false;
}
if opts.active_workspace && !ws.is_active {
log::debug!("workspace is not active.");
return false;
}
} else {
log::warn!(
"No workspace with workspace id {} stated in window {}.
This looks like a bug.",
w.workspace_id.unwrap(),
w.id
);
if opts.workspace_index.is_some() || opts.workspace_name.is_some() {
return false;
}
}
true
}
}
fn move_to_current_workspace(
match_opts: &MatchOptions,
include_current_workspace: bool,
focus: bool,
) -> Result<String, String> {
let state = STATE.read().expect("Could not read() STATE");
let matcher = WindowMatcher::new(match_opts)?;
let focused_ws_id = state.focused_workspace_id_or_err()?;
if let Some(win) = state.all_windows.iter().find(|w| {
w.workspace_id.is_none_or(|ws_id| {
include_current_workspace || ws_id != focused_ws_id
}) && matcher.matches(w, &state.all_workspaces)
}) {
let move_result = move_window_to_workspace(
win.id,
niri_ipc::WorkspaceReferenceArg::Id(focused_ws_id),
focus,
);
if focus {
focus_window_by_id(win.id)?;
}
move_result
} else {
Err(NO_MATCHING_WINDOW.to_owned())
}
}
fn move_to_current_workspace_or_spawn(
match_opts: &MatchOptions,
include_current_workspace: bool,
focus: bool,
command: &[String],
) -> Result<String, String> {
or_spawn(
move_to_current_workspace(match_opts, include_current_workspace, focus),
command,
)
}
pub fn move_window_to_workspace(
window_id: u64,
workspace_ref: niri_ipc::WorkspaceReferenceArg,
focus: bool,
) -> Result<String, String> {
exec_niri_action(
Action::MoveWindowToWorkspace {
window_id: Some(window_id),
reference: workspace_ref,
focus,
},
"Moved successfully",
)
}
fn move_window_to_workspace_and_focus(
window_id: u64,
workspace_ref: niri_ipc::WorkspaceReferenceArg,
) -> Result<String, String> {
move_window_to_workspace(window_id, workspace_ref, true)?;
focus_window_by_id(window_id)
}
pub(crate) fn try_for_each_and_count<I, T, F>(
items: I,
mut f: F,
) -> Result<usize, String>
where
I: IntoIterator<Item = T>,
F: FnMut(T) -> Result<String, String>,
{
let mut count = 0;
for item in items {
f(item)?;
count += 1;
}
Ok(count)
}
fn toggle_mark(mark: String) -> Result<String, String> {
let mut state = STATE.write().expect("Could not write() STATE.");
if let Some(focused_win_id) = state.get_focused_win_id() {
let ids = state.mark_to_win_ids.entry(mark).or_default();
if toggle_vec_membership(focused_win_id, ids) {
Ok(format!("Set mark for window {focused_win_id:?}"))
} else {
Ok(format!("Unset mark for window {focused_win_id:?}"))
}
} else {
Err("No focused window.".to_owned())
}
}
fn focus_marked(mark: String) -> Result<String, String> {
let state = STATE.read().expect("Could not read() STATE.");
if let Some(marked_windows) = state.mark_to_win_ids.get(&mark) {
if let Some(win) = state
.all_windows
.iter()
.find(|w| marked_windows.contains(&w.id))
{
focus_window(win.id, &state)
} else {
Err("No marked window.".to_owned())
}
} else {
Err("No such mark.".to_owned())
}
}
fn list_marked(mark: String) -> Result<String, String> {
let state = STATE.read().expect("Could not read() STATE.");
if let Some(marked_windows) = state.mark_to_win_ids.get(&mark) {
let wins: Vec<&Window> = state
.all_windows
.iter()
.filter(|w| marked_windows.contains(&w.id))
.collect();
Ok(list_windows(wins))
} else {
Err("No such mark.".to_owned())
}
}
fn list_windows(wins: Vec<&Window>) -> String {
let mut str = String::new();
for win in wins {
let line = format!(
"id: {}, app-id: {:?}, title: {:?}, on workspace: {:?}",
win.id, win.app_id, win.title, win.workspace_id
);
str.push_str(line.as_str());
str.push('\n');
}
str
}
fn list_all_marked() -> Result<String, String> {
let keys: Vec<String> = STATE
.read()
.expect("Could not read() STATE.")
.mark_to_win_ids
.keys()
.cloned()
.collect();
let mut s = String::new();
for mark in keys {
s.push_str(format!("-> {mark}:\n").as_str());
match list_marked(mark.to_string()) {
Ok(marks) => s.push_str(marks.as_str()),
err @ Err(_) => return err,
}
}
Ok(s)
}
fn scratchpad_toggle(
match_opts: &MatchOptions,
no_move: bool,
) -> Result<String, String> {
let mut state = STATE.write().expect("Could not write() STATE.");
let matcher = WindowMatcher::new(match_opts)?;
if let Some(window_id) =
if match_opts.app_id.is_some() || match_opts.title.is_some() {
state
.all_windows
.iter()
.find(|w| matcher.matches(w, &state.all_workspaces))
.map(|w| w.id)
} else {
state.get_focused_win_id()
}
{
if toggle_vec_membership(window_id, &mut state.scratchpad_win_ids) {
drop(state);
if no_move {
Ok(format!("Added window {window_id} to scratchpad (no move)."))
} else {
scratchpad_move()
}
} else {
Ok(format!("Removed window {window_id} from scratchpad."))
}
} else {
Err(NO_MATCHING_WINDOW.to_owned())
}
}
pub(crate) fn scratchpad_move() -> Result<String, String> {
let state = STATE.read().expect("Could not read() STATE.");
if state.scratchpad_win_ids.is_empty() {
return Ok("No scratchpad windows to move.".to_owned());
}
let output = state
.get_focused_workspace()
.and_then(|ws| ws.output.as_ref())
.ok_or(String::from("No focused output."))?;
if let Some((ws_id, _)) =
state.get_bottom_workspace_id_and_idx_of_output(output)
{
let i = try_for_each_and_count(
state
.all_windows
.iter()
.filter(|w| state.scratchpad_win_ids.contains(&w.id)),
|w| {
if !w.is_floating {
exec_niri_action(
Action::ToggleWindowFloating { id: Some(w.id) },
"Toggled floating.",
)?;
}
move_window_to_workspace(
w.id,
niri_ipc::WorkspaceReferenceArg::Id(ws_id),
false,
)
},
)?;
Ok(format!(
"Moved {i} scratchpad windows to workspace with id {ws_id}."
))
} else {
Err("Can't move scratchpad windows. No focused workspace.".to_owned())
}
}
fn scratchpad_show(
match_opts: &MatchOptions,
id: Option<u64>,
) -> Result<String, String> {
let state = STATE.read().expect("Could not read STATE.");
let matcher = WindowMatcher::new(match_opts)?;
if let Some(window_id) = id {
if !state.scratchpad_win_ids.contains(&window_id) {
return Err("Not a scratchpad window.".to_string());
}
if state.get_focused_win_id() == Some(window_id) {
return Ok(format!("Window {window_id} already has focus."));
}
let focused_ws_id = state.focused_workspace_id_or_err()?;
scratchpad_move()?;
return move_window_to_workspace_and_focus(
window_id,
WorkspaceReferenceArg::Id(focused_ws_id),
);
}
if state.focused_win_is_scratchpad_window() {
scratchpad_move()
} else {
let focused_ws_id = state.focused_workspace_id_or_err()?;
if let Some(window_id) = state
.all_windows
.iter()
.find(|w| {
state.scratchpad_win_ids.contains(&w.id)
&& matcher.matches(w, &state.all_workspaces)
})
.map(|w| w.id)
{
move_window_to_workspace_and_focus(
window_id,
WorkspaceReferenceArg::Id(focused_ws_id),
)
} else {
Err("No matching scratchpad window.".to_string())
}
}
}
fn scratchpad_show_all() -> Result<String, String> {
let state = STATE.read().expect("Could not read STATE.");
if state.focused_win_is_scratchpad_window() {
scratchpad_move()
} else {
let focused_ws_id = state.focused_workspace_id_or_err()?;
let i = try_for_each_and_count(
state
.all_windows
.iter()
.filter(|w| state.scratchpad_win_ids.contains(&w.id)),
|w| {
move_window_to_workspace_and_focus(
w.id,
WorkspaceReferenceArg::Id(focused_ws_id),
)
},
)?;
Ok(format!(
"Moved {i} scratchpad windows to workspace with id {focused_ws_id}."
))
}
}
fn list_scratchpad() -> Result<String, String> {
let state = STATE.read().expect("Could not read() STATE.");
let scratch_wins = state
.all_windows
.iter()
.filter(|w| state.scratchpad_win_ids.contains(&w.id))
.collect();
let str = list_windows(scratch_wins);
Ok(str)
}