diff --git a/niri-ipc/src/lib.rs b/niri-ipc/src/lib.rs index f8d393654..999a64d84 100644 --- a/niri-ipc/src/lib.rs +++ b/niri-ipc/src/lib.rs @@ -43,6 +43,8 @@ use serde::{Deserialize, Serialize}; pub mod socket; pub mod state; +mod utils; + /// Request from client to niri. #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] @@ -63,8 +65,8 @@ pub enum Request { FocusedOutput, /// Request information about the focused window. FocusedWindow, - /// Perform an action. - Action(Action), + /// Perform actions. + Action(#[serde(with = "utils::one_or_many")] Vec), /// Change output configuration temporarily. /// /// The configuration is changed temporarily and not saved into the config file. If the output diff --git a/niri-ipc/src/utils.rs b/niri-ipc/src/utils.rs new file mode 100644 index 000000000..0ab98754a --- /dev/null +++ b/niri-ipc/src/utils.rs @@ -0,0 +1,116 @@ +pub(crate) mod one_or_many { + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + + pub(crate) fn serialize( + value: &Vec, + serializer: S, + ) -> Result { + if value.len() == 1 { + value[0].serialize(serializer) + } else { + value.serialize(serializer) + } + } + + pub(crate) fn deserialize<'de, T: Deserialize<'de>, D: Deserializer<'de>>( + deserializer: D, + ) -> Result, D::Error> { + #[derive(Deserialize)] + #[serde(untagged)] + enum OneOrMany { + Many(Vec), + One(T), + } + + match OneOrMany::deserialize(deserializer)? { + OneOrMany::Many(v) => Ok(v), + OneOrMany::One(v) => Ok(vec![v]), + } + } + + #[cfg(test)] + mod tests { + use serde_json::de::SliceRead; + use serde_json::{Deserializer, Serializer, Value}; + + use super::*; + + #[test] + fn serialize_one() { + let mut result = Vec::new(); + let mut serializer = Serializer::new(&mut result); + serialize(&vec![Value::Null], &mut serializer).expect("failed to serialize"); + assert_eq!(String::from_utf8_lossy(&result), "null"); + } + + #[test] + fn deserialize_one() { + let mut deserailier = Deserializer::new(SliceRead::new("null".as_bytes())); + let result: Vec = deserialize(&mut deserailier).expect("failed to deserialize"); + assert_eq!(result, vec![Value::Null]); + } + + #[test] + fn serialize_many() { + let mut result = Vec::new(); + let mut serializer = Serializer::new(&mut result); + serialize(&vec![Value::Null, Value::Null], &mut serializer) + .expect("failed to serialize"); + assert_eq!(String::from_utf8_lossy(&result), "[null,null]"); + } + + #[test] + fn deserialize_many() { + let mut deserailier = Deserializer::new(SliceRead::new("[null,null]".as_bytes())); + let result: Vec = deserialize(&mut deserailier).expect("failed to deserialize"); + assert_eq!(result, vec![Value::Null, Value::Null]); + } + + #[test] + fn serialize_none() { + let mut result = Vec::new(); + let mut serializer = Serializer::new(&mut result); + serialize(&Vec::::new(), &mut serializer).expect("failed to serialize"); + assert_eq!(String::from_utf8_lossy(&result), "[]"); + } + + #[test] + fn deserialize_none() { + let mut deserailier = Deserializer::new(SliceRead::new("[]".as_bytes())); + let result: Vec = deserialize(&mut deserailier).expect("failed to deserialize"); + assert_eq!(result, Vec::::new()); + } + + #[test] + fn serialize_derive() { + #[derive(Debug, Serialize, PartialEq)] + enum Request { + Action(#[serde(with = "self")] Vec), + } + let request = serde_json::to_string(&Request::Action(vec!["foo".to_string()])) + .expect("failed to serialize"); + assert_eq!(request, r#"{"Action":"foo"}"#); + let request = + serde_json::to_string(&Request::Action(vec!["foo".to_string(), "bar".to_string()])) + .expect("failed to serialize"); + assert_eq!(request, r#"{"Action":["foo","bar"]}"#); + } + + #[test] + fn deserialize_derive() { + #[derive(Debug, Deserialize, PartialEq)] + enum Request { + Action(#[serde(with = "self")] Vec), + } + let request: Request = + serde_json::from_str(r#"{"Action":"foo"}"#).expect("failed to deserialize"); + assert_eq!(request, Request::Action(vec!["foo".to_string()])); + let request: Request = + serde_json::from_str(r#"{"Action":["foo","bar"]}"#).expect("failed to deserialize"); + assert_eq!( + request, + Request::Action(vec!["foo".to_string(), "bar".to_string()]) + ); + } + } +} diff --git a/src/cli.rs b/src/cli.rs index 041b03439..31de29d4b 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -74,9 +74,13 @@ pub enum Msg { FocusedWindow, /// Perform an action. Action { + // NOTE: This is only optional because of clap_derive limitations and will never be `None`. + // If an action is not provided it reads actions from stdin and turns to [`Msg::Actions`]. #[command(subcommand)] - action: Action, + action: Option, }, + #[clap(skip)] + Actions { actions: Vec }, /// Change output configuration temporarily. /// /// The configuration is changed temporarily and not saved into the config file. If the output diff --git a/src/ipc/client.rs b/src/ipc/client.rs index 8682d8d3f..b9204fe8c 100644 --- a/src/ipc/client.rs +++ b/src/ipc/client.rs @@ -19,7 +19,11 @@ pub fn handle_msg(msg: Msg, json: bool) -> anyhow::Result<()> { Msg::Outputs => Request::Outputs, Msg::FocusedWindow => Request::FocusedWindow, Msg::FocusedOutput => Request::FocusedOutput, - Msg::Action { action } => Request::Action(action.clone()), + Msg::Action { + action: Some(action), + } => Request::Action(vec![action.clone()]), + Msg::Action { action: None } => unreachable!(), + Msg::Actions { actions } => Request::Action(actions.clone()), Msg::Output { output, action } => Request::Output { output: output.clone(), action: action.clone(), @@ -252,7 +256,7 @@ pub fn handle_msg(msg: Msg, json: bool) -> anyhow::Result<()> { println!("No output is focused."); } } - Msg::Action { .. } => { + Msg::Action { .. } | Msg::Actions { .. } => { let Response::Handled = response else { bail!("unexpected response: expected Handled, got {response:?}"); }; diff --git a/src/ipc/server.rs b/src/ipc/server.rs index f8b661e17..f17ce1707 100644 --- a/src/ipc/server.rs +++ b/src/ipc/server.rs @@ -309,15 +309,16 @@ async fn process(ctx: &ClientCtx, request: Request) -> Reply { let window = windows.values().find(|win| win.is_focused).cloned(); Response::FocusedWindow(window) } - Request::Action(action) => { + Request::Action(actions) => { let (tx, rx) = async_channel::bounded(1); - let action = niri_config::Action::from(action); ctx.event_loop.insert_idle(move |state| { // Make sure some logic like workspace clean-up has a chance to run before doing // actions. state.niri.advance_animations(); - state.do_action(action, false); + for action in actions.into_iter().map(niri_config::Action::from) { + state.do_action(action, false); + } let _ = tx.send_blocking(()); }); diff --git a/src/main.rs b/src/main.rs index 51ff0a5e0..fd76a0ca1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,11 +7,11 @@ use std::io::{self, Write}; use std::os::fd::FromRawFd; use std::path::PathBuf; use std::process::Command; -use std::{env, mem}; +use std::{env, iter, mem}; use clap::Parser; use directories::ProjectDirs; -use niri::cli::{Cli, Sub}; +use niri::cli::{Cli, Msg, Sub}; #[cfg(feature = "dbus")] use niri::dbus; use niri::ipc::client::handle_msg; @@ -24,6 +24,7 @@ use niri::utils::watcher::Watcher; use niri::utils::{cause_panic, version, IS_SYSTEMD_SERVICE}; use niri_config::Config; use niri_ipc::socket::SOCKET_PATH_ENV; +use niri_ipc::Action; use portable_atomic::Ordering; use sd_notify::NotifyState; use smithay::reexports::calloop::EventLoop; @@ -104,7 +105,22 @@ fn main() -> Result<(), Box> { info!("config is valid"); return Ok(()); } - Sub::Msg { msg, json } => { + Sub::Msg { mut msg, json } => { + if let Msg::Action { action: None } = msg { + let actions = io::stdin() + .lines() + .map(|line| { + line.map(|line| { + Action::parse_from(iter::once("action").chain(line.split(' '))) + }) + }) + .collect::, _>>()?; + if actions.is_empty() { + warn!("read actions from stdin but no actions were provided"); + return Ok(()); + } + msg = Msg::Actions { actions } + } handle_msg(msg, json)?; return Ok(()); }