From a62594a03e513734ab4416b2584c94442ba0618d Mon Sep 17 00:00:00 2001 From: Ivan Molodetskikh Date: Thu, 20 Jun 2024 09:22:02 +0300 Subject: [PATCH 01/15] ipc: Read only a single line on the client Allow extensibility. --- niri-ipc/src/socket.rs | 12 +++++++----- src/ipc/server.rs | 3 ++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/niri-ipc/src/socket.rs b/niri-ipc/src/socket.rs index 67b9625cb..3964f000a 100644 --- a/niri-ipc/src/socket.rs +++ b/niri-ipc/src/socket.rs @@ -1,7 +1,7 @@ //! Helper for blocking communication over the niri socket. use std::env; -use std::io::{self, Read, Write}; +use std::io::{self, BufRead, BufReader, Write}; use std::net::Shutdown; use std::os::unix::net::UnixStream; use std::path::Path; @@ -50,14 +50,16 @@ impl Socket { pub fn send(self, request: Request) -> io::Result { let Self { mut stream } = self; - let mut buf = serde_json::to_vec(&request).unwrap(); - stream.write_all(&buf)?; + let mut buf = serde_json::to_string(&request).unwrap(); + stream.write_all(buf.as_bytes())?; stream.shutdown(Shutdown::Write)?; + let mut reader = BufReader::new(stream); + buf.clear(); - stream.read_to_end(&mut buf)?; + reader.read_line(&mut buf)?; - let reply = serde_json::from_slice(&buf)?; + let reply = serde_json::from_str(&buf)?; Ok(reply) } } diff --git a/src/ipc/server.rs b/src/ipc/server.rs index 5b614a83a..5d2c8524f 100644 --- a/src/ipc/server.rs +++ b/src/ipc/server.rs @@ -132,7 +132,8 @@ async fn handle_client(ctx: ClientCtx, stream: Async<'_, UnixStream>) -> anyhow: } } - let buf = serde_json::to_vec(&reply).context("error formatting reply")?; + let mut buf = serde_json::to_vec(&reply).context("error formatting reply")?; + buf.push(b'\n'); write.write_all(&buf).await.context("error writing reply")?; Ok(()) From eca6387ac8a5638391651ff14d553d5ded0b0784 Mon Sep 17 00:00:00 2001 From: Ivan Molodetskikh Date: Wed, 28 Aug 2024 09:14:06 +0300 Subject: [PATCH 02/15] Remove unused function --- src/niri.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/niri.rs b/src/niri.rs index 852e93f67..7b70b9de1 100644 --- a/src/niri.rs +++ b/src/niri.rs @@ -16,7 +16,6 @@ use niri_config::{ Config, FloatOrInt, Key, Modifiers, PreviewRender, TrackLayout, WorkspaceReference, DEFAULT_BACKGROUND_COLOR, }; -use niri_ipc::Workspace; use smithay::backend::allocator::Fourcc; use smithay::backend::renderer::damage::OutputDamageTracker; use smithay::backend::renderer::element::memory::MemoryRenderBufferRenderElement; @@ -4659,10 +4658,6 @@ impl Niri { self.queue_redraw_all(); } } - - pub fn ipc_workspaces(&self) -> Vec { - self.layout.ipc_workspaces() - } } pub struct ClientState { From e0c6ae450fde7a9652f64041d9f96f395427b841 Mon Sep 17 00:00:00 2001 From: Ivan Molodetskikh Date: Wed, 28 Aug 2024 10:35:02 +0300 Subject: [PATCH 03/15] layout: Cache monitor output name --- src/layout/mod.rs | 2 +- src/layout/monitor.rs | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/layout/mod.rs b/src/layout/mod.rs index 2b684ddf3..6407c7a16 100644 --- a/src/layout/mod.rs +++ b/src/layout/mod.rs @@ -1854,7 +1854,7 @@ impl Layout { .map(|name| { monitors .iter_mut() - .position(|monitor| monitor.output.name().eq_ignore_ascii_case(name)) + .position(|monitor| monitor.output_name().eq_ignore_ascii_case(name)) .unwrap_or(*primary_idx) }) .unwrap_or(*active_monitor_idx); diff --git a/src/layout/monitor.rs b/src/layout/monitor.rs index 072235218..df63a189d 100644 --- a/src/layout/monitor.rs +++ b/src/layout/monitor.rs @@ -34,6 +34,8 @@ const WORKSPACE_GESTURE_RUBBER_BAND: RubberBand = RubberBand { pub struct Monitor { /// Output for this monitor. pub output: Output, + /// Cached name of the output. + output_name: String, // Must always contain at least one. pub workspaces: Vec>, /// Index of the currently active workspace. @@ -93,6 +95,7 @@ impl WorkspaceSwitch { impl Monitor { pub fn new(output: Output, workspaces: Vec>, options: Rc) -> Self { Self { + output_name: output.name(), output, workspaces, active_workspace_idx: 0, @@ -102,6 +105,10 @@ impl Monitor { } } + pub fn output_name(&self) -> &String { + &self.output_name + } + pub fn active_workspace_ref(&self) -> &Workspace { &self.workspaces[self.active_workspace_idx] } From 555072825fa82f019778518202a58bbbe8b0c38b Mon Sep 17 00:00:00 2001 From: Ivan Molodetskikh Date: Thu, 29 Aug 2024 15:04:20 +0300 Subject: [PATCH 04/15] Animate focus-workspace by idx/back and forth/previous Deleting the test because it only made sense when no-animation was special cased. --- src/layout/mod.rs | 36 ++---------------------------------- src/layout/monitor.rs | 13 ++++--------- 2 files changed, 6 insertions(+), 43 deletions(-) diff --git a/src/layout/mod.rs b/src/layout/mod.rs index 6407c7a16..ac46d5b89 100644 --- a/src/layout/mod.rs +++ b/src/layout/mod.rs @@ -1011,7 +1011,7 @@ impl Layout { Some(WorkspaceSwitch::Gesture(gesture)) if gesture.current_idx.floor() == workspace_idx as f64 || gesture.current_idx.ceil() == workspace_idx as f64 => {} - _ => mon.switch_workspace(workspace_idx, true), + _ => mon.switch_workspace(workspace_idx), } break; @@ -1532,7 +1532,7 @@ impl Layout { let Some(monitor) = self.active_monitor() else { return; }; - monitor.switch_workspace(idx, false); + monitor.switch_workspace(idx); } pub fn switch_workspace_auto_back_and_forth(&mut self, idx: usize) { @@ -3776,38 +3776,6 @@ mod tests { assert!(monitors[0].workspaces[0].has_windows()); } - #[test] - fn focus_workspace_by_idx_does_not_leave_empty_workspaces() { - let ops = [ - Op::AddOutput(1), - Op::AddWindow { - id: 0, - bbox: Rectangle::from_loc_and_size((0, 0), (100, 200)), - min_max_size: Default::default(), - }, - Op::FocusWorkspaceDown, - Op::AddWindow { - id: 1, - bbox: Rectangle::from_loc_and_size((0, 0), (100, 200)), - min_max_size: Default::default(), - }, - Op::FocusWorkspaceUp, - Op::CloseWindow(0), - Op::FocusWorkspace(3), - ]; - - let mut layout = Layout::default(); - for op in ops { - op.apply(&mut layout); - } - - let MonitorSet::Normal { monitors, .. } = layout.monitor_set else { - unreachable!() - }; - - assert!(monitors[0].workspaces[0].has_windows()); - } - #[test] fn empty_workspaces_dont_move_back_to_original_output() { let ops = [ diff --git a/src/layout/monitor.rs b/src/layout/monitor.rs index df63a189d..6635de9bc 100644 --- a/src/layout/monitor.rs +++ b/src/layout/monitor.rs @@ -594,13 +594,8 @@ impl Monitor { self.workspaces.iter().position(|w| w.id() == id) } - pub fn switch_workspace(&mut self, idx: usize, animate: bool) { + pub fn switch_workspace(&mut self, idx: usize) { self.activate_workspace(min(idx, self.workspaces.len() - 1)); - - if !animate { - self.workspace_switch = None; - self.clean_up_workspaces(); - } } pub fn switch_workspace_auto_back_and_forth(&mut self, idx: usize) { @@ -608,16 +603,16 @@ impl Monitor { if idx == self.active_workspace_idx { if let Some(prev_idx) = self.previous_workspace_idx() { - self.switch_workspace(prev_idx, false); + self.switch_workspace(prev_idx); } } else { - self.switch_workspace(idx, false); + self.switch_workspace(idx); } } pub fn switch_workspace_previous(&mut self) { if let Some(idx) = self.previous_workspace_idx() { - self.switch_workspace(idx, false); + self.switch_workspace(idx); } } From b4d22664bbf32ab81bcc679050434b7ddcfb0dc2 Mon Sep 17 00:00:00 2001 From: Ivan Molodetskikh Date: Thu, 20 Jun 2024 12:04:10 +0300 Subject: [PATCH 05/15] Implement the event stream IPC --- niri-ipc/src/lib.rs | 98 +++++++ niri-ipc/src/socket.rs | 18 +- niri-ipc/src/state.rs | 188 ++++++++++++++ src/cli.rs | 2 + src/input/mod.rs | 17 +- src/ipc/client.rs | 64 ++++- src/ipc/server.rs | 416 ++++++++++++++++++++++++++---- src/layout/mod.rs | 64 ++--- src/layout/workspace.rs | 11 +- src/niri.rs | 31 ++- src/protocols/foreign_toplevel.rs | 2 +- wiki/IPC.md | 20 ++ 12 files changed, 827 insertions(+), 104 deletions(-) create mode 100644 niri-ipc/src/state.rs diff --git a/niri-ipc/src/lib.rs b/niri-ipc/src/lib.rs index ecf202f26..e6058ca11 100644 --- a/niri-ipc/src/lib.rs +++ b/niri-ipc/src/lib.rs @@ -9,6 +9,8 @@ use serde::{Deserialize, Serialize}; mod socket; pub use socket::{Socket, SOCKET_PATH_ENV}; +pub mod state; + /// Request from client to niri. #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] @@ -38,6 +40,11 @@ pub enum Request { FocusedOutput, /// Request information about the keyboard layout. KeyboardLayouts, + /// Start continuously receiving events from the compositor. + /// + /// The compositor should reply with `Reply::Ok(Response::Handled)`, then continuously send + /// [`Event`]s, one per line. + EventStream, /// Respond with an error (for testing error handling). ReturnError, } @@ -536,10 +543,18 @@ pub enum Transform { #[derive(Serialize, Deserialize, Debug, Clone)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct Window { + /// Unique id of this window. + pub id: u64, /// Title, if set. pub title: Option, /// Application ID, if set. pub app_id: Option, + /// Id of the workspace this window is on, if any. + pub workspace_id: Option, + /// Whether this window is currently focused. + /// + /// There can be either one focused window or zero (e.g. when a layer-shell surface has focus). + pub is_focused: bool, } /// Output configuration change result. @@ -556,6 +571,10 @@ pub enum OutputConfigChanged { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct Workspace { + /// Unique id of this workspace. + /// + /// This id remains constant regardless of the workspace moving around and across monitors. + pub id: u64, /// Index of the workspace on its monitor. /// /// This is the same index you can use for requests like `niri msg action focus-workspace`. @@ -567,7 +586,15 @@ pub struct Workspace { /// Can be `None` if no outputs are currently connected. pub output: Option, /// Whether the workspace is currently active on its output. + /// + /// Every output has one active workspace, the one that is currently visible on that output. pub is_active: bool, + /// Whether the workspace is currently focused. + /// + /// There's only one focused workspace across all outputs. + pub is_focused: bool, + /// Id of the active window on this workspace, if any. + pub active_window_id: Option, } /// Configured keyboard layouts. @@ -580,6 +607,77 @@ pub struct KeyboardLayouts { pub current_idx: u8, } +/// A compositor event. +#[derive(Serialize, Deserialize, Debug, Clone)] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] +pub enum Event { + /// The workspace configuration has changed. + WorkspacesChanged { + /// The new workspace configuration. + /// + /// This configuration completely replaces the previous configuration. I.e. if any + /// workspaces are missing from here, then they were deleted. + workspaces: Vec, + }, + /// A workspace was activated on an output. + /// + /// This doesn't always mean the workspace became focused, just that it's now the active + /// workspace on its output. All other workspaces on the same output become inactive. + WorkspaceActivated { + /// Id of the newly active workspace. + id: u64, + /// Whether this workspace also became focused. + /// + /// If `true`, this is now the single focused workspace. All other workspaces are no longer + /// focused, but they may remain active on their respective outputs. + focused: bool, + }, + /// An active window changed on a workspace. + WorkspaceActiveWindowChanged { + /// Id of the workspace on which the active window changed. + workspace_id: u64, + /// Id of the new active window, if any. + active_window_id: Option, + }, + /// The window configuration has changed. + WindowsChanged { + /// The new window configuration. + /// + /// This configuration completely replaces the previous configuration. I.e. if any windows + /// are missing from here, then they were closed. + windows: Vec, + }, + /// A new toplevel window was opened, or an existing toplevel window changed. + WindowOpenedOrChanged { + /// The new or updated window. + /// + /// If the window is focused, all other windows are no longer focused. + window: Window, + }, + /// A toplevel window was closed. + WindowClosed { + /// Id of the removed window. + id: u64, + }, + /// Window focus changed. + /// + /// All other windows are no longer focused. + WindowFocusChanged { + /// Id of the newly focused window, or `None` if no window is now focused. + id: Option, + }, + /// The configured keyboard layouts have changed. + KeyboardLayoutsChanged { + /// The new keyboard layout configuration. + keyboard_layouts: KeyboardLayouts, + }, + /// The keyboard layout switched. + KeyboardLayoutSwitched { + /// Index of the newly active layout. + idx: u8, + }, +} + impl FromStr for WorkspaceReferenceArg { type Err = &'static str; diff --git a/niri-ipc/src/socket.rs b/niri-ipc/src/socket.rs index 3964f000a..d629f1a41 100644 --- a/niri-ipc/src/socket.rs +++ b/niri-ipc/src/socket.rs @@ -6,7 +6,7 @@ use std::net::Shutdown; use std::os::unix::net::UnixStream; use std::path::Path; -use crate::{Reply, Request}; +use crate::{Event, Reply, Request}; /// Name of the environment variable containing the niri IPC socket path. pub const SOCKET_PATH_ENV: &str = "NIRI_SOCKET"; @@ -47,7 +47,11 @@ impl Socket { /// * `Ok(Ok(response))`: successful [`Response`](crate::Response) from niri /// * `Ok(Err(message))`: error message from niri /// * `Err(error)`: error communicating with niri - pub fn send(self, request: Request) -> io::Result { + /// + /// This method also returns a blocking function that you can call to keep reading [`Event`]s + /// after requesting an [`EventStream`][Request::EventStream]. This function is not useful + /// otherwise. + pub fn send(self, request: Request) -> io::Result<(Reply, impl FnMut() -> io::Result)> { let Self { mut stream } = self; let mut buf = serde_json::to_string(&request).unwrap(); @@ -60,6 +64,14 @@ impl Socket { reader.read_line(&mut buf)?; let reply = serde_json::from_str(&buf)?; - Ok(reply) + + let events = move || { + buf.clear(); + reader.read_line(&mut buf)?; + let event = serde_json::from_str(&buf)?; + Ok(event) + }; + + Ok((reply, events)) } } diff --git a/niri-ipc/src/state.rs b/niri-ipc/src/state.rs new file mode 100644 index 000000000..8d2d1744e --- /dev/null +++ b/niri-ipc/src/state.rs @@ -0,0 +1,188 @@ +//! Helpers for keeping track of the event stream state. + +use std::collections::hash_map::Entry; +use std::collections::HashMap; + +use crate::{Event, KeyboardLayouts, Window, Workspace}; + +/// Part of the state communicated via the event stream. +pub trait EventStreamStatePart { + /// Returns a sequence of events that replicates this state from default initialization. + fn replicate(&self) -> Vec; + + /// Applies the event to this state. + /// + /// Returns `None` after applying the event, and `Some(event)` if the event is ignored by this + /// part of the state. + fn apply(&mut self, event: Event) -> Option; +} + +/// The full state communicated over the event stream. +/// +/// Different parts of the state are not guaranteed to be consistent across every single event +/// sent by niri. For example, you may receive the first [`Event::WindowOpenedOrChanged`] for a +/// just-opened window *after* an [`Event::WorkspaceActiveWindowChanged`] for that window. Between +/// these two events, the workspace active window id refers to a window that does not yet exist in +/// the windows state part. +#[derive(Debug, Default)] +pub struct EventStreamState { + /// State of workspaces. + pub workspaces: WorkspacesState, + + /// State of workspaces. + pub windows: WindowsState, + + /// State of the keyboard layouts. + pub keyboard_layouts: KeyboardLayoutsState, +} + +/// The workspaces state communicated over the event stream. +#[derive(Debug, Default)] +pub struct WorkspacesState { + /// Map from a workspace id to the workspace. + pub workspaces: HashMap, +} + +/// The windows state communicated over the event stream. +#[derive(Debug, Default)] +pub struct WindowsState { + /// Map from a window id to the window. + pub windows: HashMap, +} + +/// The keyboard layout state communicated over the event stream. +#[derive(Debug, Default)] +pub struct KeyboardLayoutsState { + /// Configured keyboard layouts. + pub keyboard_layouts: Option, +} + +impl EventStreamStatePart for EventStreamState { + fn replicate(&self) -> Vec { + let mut events = Vec::new(); + events.extend(self.workspaces.replicate()); + events.extend(self.windows.replicate()); + events.extend(self.keyboard_layouts.replicate()); + events + } + + fn apply(&mut self, event: Event) -> Option { + let event = self.workspaces.apply(event)?; + let event = self.windows.apply(event)?; + let event = self.keyboard_layouts.apply(event)?; + Some(event) + } +} + +impl EventStreamStatePart for WorkspacesState { + fn replicate(&self) -> Vec { + let workspaces = self.workspaces.values().cloned().collect(); + vec![Event::WorkspacesChanged { workspaces }] + } + + fn apply(&mut self, event: Event) -> Option { + match event { + Event::WorkspacesChanged { workspaces } => { + self.workspaces = workspaces.into_iter().map(|ws| (ws.id, ws)).collect(); + } + Event::WorkspaceActivated { id, focused } => { + let ws = self.workspaces.get(&id); + let ws = ws.expect("activated workspace was missing from the map"); + let output = ws.output.clone(); + + for ws in self.workspaces.values_mut() { + let got_activated = ws.id == id; + if ws.output == output { + ws.is_active = got_activated; + } + + if focused { + ws.is_focused = got_activated; + } + } + } + Event::WorkspaceActiveWindowChanged { + workspace_id, + active_window_id, + } => { + let ws = self.workspaces.get_mut(&workspace_id); + let ws = ws.expect("changed workspace was missing from the map"); + ws.active_window_id = active_window_id; + } + event => return Some(event), + } + None + } +} + +impl EventStreamStatePart for WindowsState { + fn replicate(&self) -> Vec { + let windows = self.windows.values().cloned().collect(); + vec![Event::WindowsChanged { windows }] + } + + fn apply(&mut self, event: Event) -> Option { + match event { + Event::WindowsChanged { windows } => { + self.windows = windows.into_iter().map(|win| (win.id, win)).collect(); + } + Event::WindowOpenedOrChanged { window } => { + let (id, is_focused) = match self.windows.entry(window.id) { + Entry::Occupied(mut entry) => { + let entry = entry.get_mut(); + *entry = window; + (entry.id, entry.is_focused) + } + Entry::Vacant(entry) => { + let entry = entry.insert(window); + (entry.id, entry.is_focused) + } + }; + + if is_focused { + for win in self.windows.values_mut() { + if win.id != id { + win.is_focused = false; + } + } + } + } + Event::WindowClosed { id } => { + let win = self.windows.remove(&id); + win.expect("closed window was missing from the map"); + } + Event::WindowFocusChanged { id } => { + for win in self.windows.values_mut() { + win.is_focused = Some(win.id) == id; + } + } + event => return Some(event), + } + None + } +} + +impl EventStreamStatePart for KeyboardLayoutsState { + fn replicate(&self) -> Vec { + if let Some(keyboard_layouts) = self.keyboard_layouts.clone() { + vec![Event::KeyboardLayoutsChanged { keyboard_layouts }] + } else { + vec![] + } + } + + fn apply(&mut self, event: Event) -> Option { + match event { + Event::KeyboardLayoutsChanged { keyboard_layouts } => { + self.keyboard_layouts = Some(keyboard_layouts); + } + Event::KeyboardLayoutSwitched { idx } => { + let kb = self.keyboard_layouts.as_mut(); + let kb = kb.expect("keyboard layouts must be set before a layout can be switched"); + kb.current_idx = idx; + } + event => return Some(event), + } + None + } +} diff --git a/src/cli.rs b/src/cli.rs index 4d0d35592..99d22e63a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -88,6 +88,8 @@ pub enum Msg { }, /// Get the configured keyboard layouts. KeyboardLayouts, + /// Start continuously receiving events from the compositor. + EventStream, /// Print the version of the running niri instance. Version, /// Request an error from the running niri instance. diff --git a/src/input/mod.rs b/src/input/mod.rs index 6e7f203c9..388274150 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -16,7 +16,7 @@ use smithay::backend::input::{ TabletToolProximityEvent, TabletToolTipEvent, TabletToolTipState, TouchEvent, }; use smithay::backend::libinput::LibinputInputBackend; -use smithay::input::keyboard::{keysyms, FilterResult, Keysym, ModifiersState}; +use smithay::input::keyboard::{keysyms, FilterResult, Keysym, ModifiersState, XkbContextHandler}; use smithay::input::pointer::{ AxisFrame, ButtonEvent, CursorIcon, CursorImageStatus, Focus, GestureHoldBeginEvent, GestureHoldEndEvent, GesturePinchBeginEvent, GesturePinchEndEvent, GesturePinchUpdateEvent, @@ -539,13 +539,18 @@ impl State { } } Action::SwitchLayout(action) => { - self.niri.seat.get_keyboard().unwrap().with_xkb_state( - self, - |mut state| match action { + let keyboard = &self.niri.seat.get_keyboard().unwrap(); + let new_idx = keyboard.with_xkb_state(self, |mut state| { + match action { LayoutSwitchTarget::Next => state.cycle_next_layout(), LayoutSwitchTarget::Prev => state.cycle_prev_layout(), - }, - ); + }; + state.active_layout().0 + }); + + if let Some(server) = &self.niri.ipc_server { + server.keyboard_layout_switched(new_idx as u8); + } } Action::MoveColumnLeft => { self.niri.layout.move_left(); diff --git a/src/ipc/client.rs b/src/ipc/client.rs index e93129332..ea6121dc3 100644 --- a/src/ipc/client.rs +++ b/src/ipc/client.rs @@ -1,7 +1,7 @@ use anyhow::{anyhow, bail, Context}; use niri_ipc::{ - KeyboardLayouts, LogicalOutput, Mode, Output, OutputConfigChanged, Request, Response, Socket, - Transform, + Event, KeyboardLayouts, LogicalOutput, Mode, Output, OutputConfigChanged, Request, Response, + Socket, Transform, }; use serde_json::json; @@ -21,12 +21,13 @@ pub fn handle_msg(msg: Msg, json: bool) -> anyhow::Result<()> { }, Msg::Workspaces => Request::Workspaces, Msg::KeyboardLayouts => Request::KeyboardLayouts, + Msg::EventStream => Request::EventStream, Msg::RequestError => Request::ReturnError, }; let socket = Socket::connect().context("error connecting to the niri socket")?; - let reply = socket + let (reply, mut read_event) = socket .send(request) .context("error communicating with niri")?; @@ -37,6 +38,7 @@ pub fn handle_msg(msg: Msg, json: bool) -> anyhow::Result<()> { Socket::connect() .and_then(|socket| socket.send(Request::Version)) .ok() + .map(|(reply, _read_event)| reply) } _ => None, }; @@ -261,6 +263,62 @@ pub fn handle_msg(msg: Msg, json: bool) -> anyhow::Result<()> { println!("{is_active}{idx} {name}"); } } + Msg::EventStream => { + let Response::Handled = response else { + bail!("unexpected response: expected Handled, got {response:?}"); + }; + + if !json { + println!("Started reading events."); + } + + loop { + let event = read_event().context("error reading event from niri")?; + + if json { + let event = serde_json::to_string(&event).context("error formatting event")?; + println!("{event}"); + continue; + } + + match event { + Event::WorkspacesChanged { workspaces } => { + println!("Workspaces changed: {workspaces:?}"); + } + Event::WorkspaceActivated { id, focused } => { + let word = if focused { "focused" } else { "activated" }; + println!("Workspace {word}: {id}"); + } + Event::WorkspaceActiveWindowChanged { + workspace_id, + active_window_id, + } => { + println!( + "Workspace {workspace_id}: \ + active window changed to {active_window_id:?}" + ); + } + Event::WindowsChanged { windows } => { + println!("Windows changed: {windows:?}"); + } + Event::WindowOpenedOrChanged { window } => { + println!("Window opened or changed: {window:?}"); + } + Event::WindowClosed { id } => { + println!("Window closed: {id}"); + } + Event::WindowFocusChanged { id } => { + println!("Window focus changed: {id:?}"); + } + Event::KeyboardLayoutsChanged { keyboard_layouts } => { + println!("Keyboard layouts changed: {keyboard_layouts:?}"); + } + Event::KeyboardLayoutSwitched { idx } => { + println!("Keyboard layout switched: {idx}"); + } + } + } + } } Ok(()) diff --git a/src/ipc/server.rs b/src/ipc/server.rs index 5d2c8524f..af2c4fa23 100644 --- a/src/ipc/server.rs +++ b/src/ipc/server.rs @@ -1,15 +1,20 @@ +use std::cell::RefCell; +use std::collections::HashSet; use std::os::unix::net::{UnixListener, UnixStream}; use std::path::PathBuf; +use std::rc::Rc; use std::sync::{Arc, Mutex}; use std::{env, io, process}; use anyhow::Context; +use async_channel::{Receiver, Sender, TrySendError}; +use calloop::futures::Scheduler; use calloop::io::Async; use directories::BaseDirs; use futures_util::io::{AsyncReadExt, BufReader}; -use futures_util::{AsyncBufReadExt, AsyncWriteExt}; -use niri_ipc::{KeyboardLayouts, OutputConfigChanged, Reply, Request, Response}; -use smithay::desktop::Window; +use futures_util::{select_biased, AsyncBufReadExt, AsyncWrite, AsyncWriteExt, FutureExt as _}; +use niri_ipc::state::{EventStreamState, EventStreamStatePart as _}; +use niri_ipc::{Event, KeyboardLayouts, OutputConfigChanged, Reply, Request, Response, Workspace}; use smithay::input::keyboard::XkbContextHandler; use smithay::reexports::calloop::generic::Generic; use smithay::reexports::calloop::{Interest, LoopHandle, Mode, PostAction}; @@ -18,17 +23,38 @@ use smithay::wayland::compositor::with_states; use smithay::wayland::shell::xdg::XdgToplevelSurfaceData; use crate::backend::IpcOutputMap; +use crate::layout::workspace::WorkspaceId; use crate::niri::State; use crate::utils::version; +use crate::window::Mapped; + +// If an event stream client fails to read events fast enough that we accumulate more than this +// number in our buffer, we drop that event stream client. +const EVENT_STREAM_BUFFER_SIZE: usize = 64; pub struct IpcServer { pub socket_path: PathBuf, + event_streams: Rc>>, + event_stream_state: Rc>, } struct ClientCtx { event_loop: LoopHandle<'static, State>, + scheduler: Scheduler<()>, ipc_outputs: Arc>, - ipc_focused_window: Arc>>, + event_streams: Rc>>, + event_stream_state: Rc>, +} + +struct EventStreamClient { + events: Receiver, + disconnect: Receiver<()>, + write: Box, +} + +struct EventStreamSender { + events: Sender, + disconnect: Sender<()>, } impl IpcServer { @@ -60,7 +86,43 @@ impl IpcServer { }) .unwrap(); - Ok(Self { socket_path }) + Ok(Self { + socket_path, + event_streams: Rc::new(RefCell::new(Vec::new())), + event_stream_state: Rc::new(RefCell::new(EventStreamState::default())), + }) + } + + fn send_event(&self, event: Event) { + let mut streams = self.event_streams.borrow_mut(); + let mut to_remove = Vec::new(); + for (idx, stream) in streams.iter_mut().enumerate() { + match stream.events.try_send(event.clone()) { + Ok(()) => (), + Err(TrySendError::Closed(_)) => to_remove.push(idx), + Err(TrySendError::Full(_)) => { + warn!( + "disconnecting IPC event stream client \ + because it is reading events too slowly" + ); + to_remove.push(idx); + } + } + } + + for idx in to_remove.into_iter().rev() { + let stream = streams.swap_remove(idx); + let _ = stream.disconnect.send_blocking(()); + } + } + + pub fn keyboard_layout_switched(&self, new_idx: u8) { + let mut state = self.event_stream_state.borrow_mut(); + let state = &mut state.keyboard_layouts; + + let event = Event::KeyboardLayoutSwitched { idx: new_idx }; + state.apply(event.clone()); + self.send_event(event); } } @@ -90,10 +152,14 @@ fn on_new_ipc_client(state: &mut State, stream: UnixStream) { } }; + let ipc_server = state.niri.ipc_server.as_ref().unwrap(); + let ctx = ClientCtx { event_loop: state.niri.event_loop.clone(), + scheduler: state.niri.scheduler.clone(), ipc_outputs: state.backend.ipc_outputs(), - ipc_focused_window: state.niri.ipc_focused_window.clone(), + event_streams: ipc_server.event_streams.clone(), + event_stream_state: ipc_server.event_stream_state.clone(), }; let future = async move { @@ -106,7 +172,7 @@ fn on_new_ipc_client(state: &mut State, stream: UnixStream) { } } -async fn handle_client(ctx: ClientCtx, stream: Async<'_, UnixStream>) -> anyhow::Result<()> { +async fn handle_client(ctx: ClientCtx, stream: Async<'static, UnixStream>) -> anyhow::Result<()> { let (read, mut write) = stream.split(); let mut buf = String::new(); @@ -120,6 +186,7 @@ async fn handle_client(ctx: ClientCtx, stream: Async<'_, UnixStream>) -> anyhow: .context("error parsing request") .map_err(|err| err.to_string()); let requested_error = matches!(request, Ok(Request::ReturnError)); + let requested_event_stream = matches!(request, Ok(Request::EventStream)); let reply = match request { Ok(request) => process(&ctx, request).await, @@ -136,6 +203,46 @@ async fn handle_client(ctx: ClientCtx, stream: Async<'_, UnixStream>) -> anyhow: buf.push(b'\n'); write.write_all(&buf).await.context("error writing reply")?; + if requested_event_stream { + let (events_tx, events_rx) = async_channel::bounded(EVENT_STREAM_BUFFER_SIZE); + let (disconnect_tx, disconnect_rx) = async_channel::bounded(1); + + // Spawn a task for the client. + let client = EventStreamClient { + events: events_rx, + disconnect: disconnect_rx, + write: Box::new(write) as _, + }; + let future = async move { + if let Err(err) = handle_event_stream_client(client).await { + warn!("error handling IPC event stream client: {err:?}"); + } + }; + if let Err(err) = ctx.scheduler.schedule(future) { + warn!("error scheduling IPC event stream future: {err:?}"); + } + + // Send the initial state. + { + let state = ctx.event_stream_state.borrow(); + for event in state.replicate() { + events_tx + .try_send(event) + .expect("initial event burst had more events than buffer size"); + } + } + + // Add it to the list. + { + let mut streams = ctx.event_streams.borrow_mut(); + let sender = EventStreamSender { + events: events_tx, + disconnect: disconnect_tx, + }; + streams.push(sender); + } + } + Ok(()) } @@ -149,23 +256,9 @@ async fn process(ctx: &ClientCtx, request: Request) -> Reply { Response::Outputs(outputs.collect()) } Request::FocusedWindow => { - let window = ctx.ipc_focused_window.lock().unwrap().clone(); - let window = window.map(|window| { - let wl_surface = window.toplevel().expect("no X11 support").wl_surface(); - with_states(wl_surface, |states| { - let role = states - .data_map - .get::() - .unwrap() - .lock() - .unwrap(); - - niri_ipc::Window { - title: role.title.clone(), - app_id: role.app_id.clone(), - } - }) - }); + let state = ctx.event_stream_state.borrow(); + let windows = &state.windows.windows; + let window = windows.values().find(|win| win.is_focused).cloned(); Response::FocusedWindow(window) } Request::Action(action) => { @@ -202,13 +295,8 @@ async fn process(ctx: &ClientCtx, request: Request) -> Reply { Response::OutputConfigChanged(response) } Request::Workspaces => { - let (tx, rx) = async_channel::bounded(1); - ctx.event_loop.insert_idle(move |state| { - let workspaces = state.niri.layout.ipc_workspaces(); - let _ = tx.send_blocking(workspaces); - }); - let result = rx.recv().await; - let workspaces = result.map_err(|_| String::from("error getting workspace info"))?; + let state = ctx.event_stream_state.borrow(); + let workspaces = state.workspaces.workspaces.values().cloned().collect(); Response::Workspaces(workspaces) } Request::FocusedOutput => { @@ -238,23 +326,261 @@ async fn process(ctx: &ClientCtx, request: Request) -> Reply { Response::FocusedOutput(output) } Request::KeyboardLayouts => { - let (tx, rx) = async_channel::bounded(1); - ctx.event_loop.insert_idle(move |state| { - let keyboard = state.niri.seat.get_keyboard().unwrap(); - let layout = keyboard.with_xkb_state(state, |context| { - let layouts = context.keymap().layouts(); - KeyboardLayouts { - names: layouts.map(str::to_owned).collect(), - current_idx: context.active_layout().0 as u8, - } - }); - let _ = tx.send_blocking(layout); - }); - let result = rx.recv().await; - let layout = result.map_err(|_| String::from("error getting layout info"))?; + let state = ctx.event_stream_state.borrow(); + let layout = state.keyboard_layouts.keyboard_layouts.clone(); + let layout = layout.expect("keyboard layouts should be set at startup"); Response::KeyboardLayouts(layout) } + Request::EventStream => Response::Handled, }; Ok(response) } + +async fn handle_event_stream_client(client: EventStreamClient) -> anyhow::Result<()> { + let EventStreamClient { + events, + disconnect, + mut write, + } = client; + + while let Ok(event) = events.recv().await { + let mut buf = serde_json::to_vec(&event).context("error formatting event")?; + buf.push(b'\n'); + + let res = select_biased! { + _ = disconnect.recv().fuse() => return Ok(()), + res = write.write_all(&buf).fuse() => res, + }; + + match res { + Ok(()) => (), + // Normal client disconnection. + Err(err) if err.kind() == io::ErrorKind::BrokenPipe => return Ok(()), + res @ Err(_) => res.context("error writing event")?, + } + } + + Ok(()) +} + +fn make_ipc_window(mapped: &Mapped, workspace_id: Option) -> niri_ipc::Window { + let wl_surface = mapped.toplevel().wl_surface(); + with_states(wl_surface, |states| { + let role = states + .data_map + .get::() + .unwrap() + .lock() + .unwrap(); + + niri_ipc::Window { + id: u64::from(mapped.id().get()), + title: role.title.clone(), + app_id: role.app_id.clone(), + workspace_id: workspace_id.map(|id| u64::from(id.0)), + is_focused: mapped.is_focused(), + } + }) +} + +impl State { + pub fn ipc_keyboard_layouts_changed(&mut self) { + let keyboard = self.niri.seat.get_keyboard().unwrap(); + let keyboard_layouts = keyboard.with_xkb_state(self, |context| { + let layouts = context.keymap().layouts(); + KeyboardLayouts { + names: layouts.map(str::to_owned).collect(), + current_idx: context.active_layout().0 as u8, + } + }); + + let Some(server) = &self.niri.ipc_server else { + return; + }; + + let mut state = server.event_stream_state.borrow_mut(); + let state = &mut state.keyboard_layouts; + + let event = Event::KeyboardLayoutsChanged { keyboard_layouts }; + state.apply(event.clone()); + server.send_event(event); + } + + pub fn ipc_refresh_layout(&mut self) { + self.ipc_refresh_workspaces(); + self.ipc_refresh_windows(); + } + + fn ipc_refresh_workspaces(&mut self) { + let Some(server) = &self.niri.ipc_server else { + return; + }; + + let _span = tracy_client::span!("State::ipc_refresh_workspaces"); + + let mut state = server.event_stream_state.borrow_mut(); + let state = &mut state.workspaces; + + let mut events = Vec::new(); + let layout = &self.niri.layout; + let focused_ws_id = layout.active_workspace().map(|ws| u64::from(ws.id().0)); + + // Check for workspace changes. + let mut seen = HashSet::new(); + let mut need_workspaces_changed = false; + for (mon, ws_idx, ws) in layout.workspaces() { + let id = u64::from(ws.id().0); + seen.insert(id); + + let Some(ipc_ws) = state.workspaces.get(&id) else { + // A new workspace was added. + need_workspaces_changed = true; + break; + }; + + // Check for any changes that we can't signal as individual events. + let output_name = mon.map(|mon| mon.output_name()); + if ipc_ws.idx != u8::try_from(ws_idx + 1).unwrap_or(u8::MAX) + || ipc_ws.name != ws.name + || ipc_ws.output.as_ref() != output_name + { + need_workspaces_changed = true; + break; + } + + let active_window_id = ws.active_window().map(|win| u64::from(win.id().get())); + if ipc_ws.active_window_id != active_window_id { + events.push(Event::WorkspaceActiveWindowChanged { + workspace_id: id, + active_window_id, + }); + } + + // Check if this workspace became focused. + let is_focused = Some(id) == focused_ws_id; + if is_focused && !ipc_ws.is_focused { + events.push(Event::WorkspaceActivated { id, focused: true }); + continue; + } + + // Check if this workspace became active. + let is_active = mon.map_or(false, |mon| mon.active_workspace_idx == ws_idx); + if is_active && !ipc_ws.is_active { + events.push(Event::WorkspaceActivated { id, focused: false }); + } + } + + // Check if any workspaces were removed. + if !need_workspaces_changed && state.workspaces.keys().any(|id| !seen.contains(id)) { + need_workspaces_changed = true; + } + + if need_workspaces_changed { + events.clear(); + + let workspaces = layout + .workspaces() + .map(|(mon, ws_idx, ws)| { + let id = u64::from(ws.id().0); + Workspace { + id, + idx: u8::try_from(ws_idx + 1).unwrap_or(u8::MAX), + name: ws.name.clone(), + output: mon.map(|mon| mon.output_name().clone()), + is_active: mon.map_or(false, |mon| mon.active_workspace_idx == ws_idx), + is_focused: Some(id) == focused_ws_id, + active_window_id: ws.active_window().map(|win| u64::from(win.id().get())), + } + }) + .collect(); + + events.push(Event::WorkspacesChanged { workspaces }); + } + + for event in events { + state.apply(event.clone()); + server.send_event(event); + } + } + + fn ipc_refresh_windows(&mut self) { + let Some(server) = &self.niri.ipc_server else { + return; + }; + + let _span = tracy_client::span!("State::ipc_refresh_windows"); + + let mut state = server.event_stream_state.borrow_mut(); + let state = &mut state.windows; + + let mut events = Vec::new(); + let layout = &self.niri.layout; + + // Check for window changes. + let mut seen = HashSet::new(); + let mut focused_id = None; + layout.with_windows(|mapped, _, ws_id| { + let id = u64::from(mapped.id().get()); + seen.insert(id); + + if mapped.is_focused() { + focused_id = Some(id); + } + + let Some(ipc_win) = state.windows.get(&id) else { + let window = make_ipc_window(mapped, Some(ws_id)); + events.push(Event::WindowOpenedOrChanged { window }); + return; + }; + + let workspace_id = Some(u64::from(ws_id.0)); + let mut changed = ipc_win.workspace_id != workspace_id; + + let wl_surface = mapped.toplevel().wl_surface(); + changed |= with_states(wl_surface, |states| { + let role = states + .data_map + .get::() + .unwrap() + .lock() + .unwrap(); + + ipc_win.title != role.title || ipc_win.app_id != role.app_id + }); + + if changed { + let window = make_ipc_window(mapped, Some(ws_id)); + events.push(Event::WindowOpenedOrChanged { window }); + return; + } + + if mapped.is_focused() && !ipc_win.is_focused { + events.push(Event::WindowFocusChanged { id: Some(id) }); + } + }); + + // Check for closed windows. + let mut ipc_focused_id = None; + for (id, ipc_win) in &state.windows { + if !seen.contains(id) { + events.push(Event::WindowClosed { id: *id }); + } + + if ipc_win.is_focused { + ipc_focused_id = Some(id); + } + } + + // Extra check for focus becoming None, since the checks above only work for focus becoming + // a different window. + if focused_id.is_none() && ipc_focused_id.is_some() { + events.push(Event::WindowFocusChanged { id: None }); + } + + for event in events { + state.apply(event.clone()); + server.send_event(event); + } + } +} diff --git a/src/layout/mod.rs b/src/layout/mod.rs index ac46d5b89..20c63d280 100644 --- a/src/layout/mod.rs +++ b/src/layout/mod.rs @@ -42,6 +42,7 @@ use smithay::backend::renderer::gles::{GlesRenderer, GlesTexture}; use smithay::output::{self, Output}; use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface; use smithay::utils::{Logical, Point, Scale, Serial, Size, Transform}; +use workspace::WorkspaceId; pub use self::monitor::MonitorRenderElement; use self::monitor::{Monitor, WorkspaceSwitch}; @@ -1094,13 +1095,13 @@ impl Layout { mon.workspaces.iter().flat_map(|ws| ws.windows()) } - pub fn with_windows(&self, mut f: impl FnMut(&W, Option<&Output>)) { + pub fn with_windows(&self, mut f: impl FnMut(&W, Option<&Output>, WorkspaceId)) { match &self.monitor_set { MonitorSet::Normal { monitors, .. } => { for mon in monitors { for ws in &mon.workspaces { for win in ws.windows() { - f(win, Some(&mon.output)); + f(win, Some(&mon.output), ws.id()); } } } @@ -1108,7 +1109,7 @@ impl Layout { MonitorSet::NoOutputs { workspaces } => { for ws in workspaces { for win in ws.windows() { - f(win, None); + f(win, None, ws.id()); } } } @@ -2484,39 +2485,38 @@ impl Layout { } } - pub fn ipc_workspaces(&self) -> Vec { + pub fn workspaces( + &self, + ) -> impl Iterator>, usize, &Workspace)> + '_ { + let iter_normal; + let iter_no_outputs; + match &self.monitor_set { - MonitorSet::Normal { - monitors, - primary_idx: _, - active_monitor_idx: _, - } => { - let mut workspaces = Vec::new(); - - for monitor in monitors { - for (idx, workspace) in monitor.workspaces.iter().enumerate() { - workspaces.push(niri_ipc::Workspace { - idx: u8::try_from(idx + 1).unwrap_or(u8::MAX), - name: workspace.name.clone(), - output: Some(monitor.output.name()), - is_active: monitor.active_workspace_idx == idx, - }) - } - } + MonitorSet::Normal { monitors, .. } => { + let it = monitors.iter().flat_map(|mon| { + mon.workspaces + .iter() + .enumerate() + .map(move |(idx, ws)| (Some(mon), idx, ws)) + }); - workspaces + iter_normal = Some(it); + iter_no_outputs = None; + } + MonitorSet::NoOutputs { workspaces } => { + let it = workspaces + .iter() + .enumerate() + .map(|(idx, ws)| (None, idx, ws)); + + iter_normal = None; + iter_no_outputs = Some(it); } - MonitorSet::NoOutputs { workspaces } => workspaces - .iter() - .enumerate() - .map(|(idx, ws)| niri_ipc::Workspace { - idx: u8::try_from(idx + 1).unwrap_or(u8::MAX), - name: ws.name.clone(), - output: None, - is_active: false, - }) - .collect(), } + + let iter_normal = iter_normal.into_iter().flatten(); + let iter_no_outputs = iter_no_outputs.into_iter().flatten(); + iter_normal.chain(iter_no_outputs) } } diff --git a/src/layout/workspace.rs b/src/layout/workspace.rs index c948bdf29..17ce9a33e 100644 --- a/src/layout/workspace.rs +++ b/src/layout/workspace.rs @@ -123,7 +123,7 @@ pub struct OutputId(String); static WORKSPACE_ID_COUNTER: IdCounter = IdCounter::new(); #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct WorkspaceId(u32); +pub struct WorkspaceId(pub u32); impl WorkspaceId { fn next() -> WorkspaceId { @@ -528,6 +528,15 @@ impl Workspace { self.output.as_ref() } + pub fn active_window(&self) -> Option<&W> { + if self.columns.is_empty() { + return None; + } + + let col = &self.columns[self.active_column_idx]; + Some(col.tiles[col.active_tile_idx].window()) + } + pub fn set_output(&mut self, output: Option) { if self.output == output { return; diff --git a/src/niri.rs b/src/niri.rs index 7b70b9de1..1a0669f1c 100644 --- a/src/niri.rs +++ b/src/niri.rs @@ -291,7 +291,6 @@ pub struct Niri { pub ipc_server: Option, pub ipc_outputs_changed: bool, - pub ipc_focused_window: Arc>>, // Casts are dropped before PipeWire to prevent a double-free (yay). pub casts: Vec, @@ -502,7 +501,12 @@ impl State { let mut niri = Niri::new(config.clone(), event_loop, stop_signal, display, &backend); backend.init(&mut niri); - Ok(Self { backend, niri }) + let mut state = Self { backend, niri }; + + // Initialize some IPC server state. + state.ipc_keyboard_layouts_changed(); + + Ok(state) } pub fn refresh_and_flush_clients(&mut self) { @@ -538,6 +542,7 @@ impl State { foreign_toplevel::refresh(self); self.niri.refresh_window_rules(); self.refresh_ipc_outputs(); + self.ipc_refresh_layout(); #[cfg(feature = "xdp-gnome-screencast")] self.niri.refresh_mapped_cast_outputs(); @@ -839,8 +844,6 @@ impl State { focus ); - let mut newly_focused_window = None; - // Tell the windows their new focus state for window rule purposes. if let KeyboardFocus::Layout { surface: Some(surface), @@ -856,12 +859,9 @@ impl State { { if let Some((mapped, _)) = self.niri.layout.find_window_and_output_mut(surface) { mapped.set_is_focused(true); - newly_focused_window = Some(mapped.window.clone()); } } - *self.niri.ipc_focused_window.lock().unwrap() = newly_focused_window; - if let Some(grab) = self.niri.popup_grab.as_mut() { if Some(&grab.root) != focus.surface() { trace!( @@ -911,6 +911,10 @@ impl State { keyboard.with_xkb_state(self, |mut context| { context.set_layout(new_layout); }); + + if let Some(server) = &self.niri.ipc_server { + server.keyboard_layout_switched(new_layout.0 as u8); + } } } @@ -1076,6 +1080,8 @@ impl State { if let Err(err) = keyboard.set_xkb_config(self, xkb.to_xkb_config()) { warn!("error updating xkb config: {err:?}"); } + + self.ipc_keyboard_layouts_changed(); } if libinput_config_changed { @@ -1372,7 +1378,7 @@ impl State { } StreamTargetId::Window { id } => { let mut window = None; - self.niri.layout.with_windows(|mapped, _| { + self.niri.layout.with_windows(|mapped, _, _| { if u64::from(mapped.id().get()) != id { return; } @@ -1489,7 +1495,7 @@ impl State { let mut windows = HashMap::new(); - self.niri.layout.with_windows(|mapped, _| { + self.niri.layout.with_windows(|mapped, _, _| { let wl_surface = mapped .window .toplevel() @@ -1843,7 +1849,6 @@ impl Niri { ipc_server, ipc_outputs_changed: false, - ipc_focused_window: Arc::new(Mutex::new(None)), pipewire, casts: vec![], @@ -2811,7 +2816,7 @@ impl Niri { let mut seen = HashSet::new(); let mut output_changed = vec![]; - self.layout.with_windows(|mapped, output| { + self.layout.with_windows(|mapped, output, _| { seen.insert(mapped.window.clone()); let Some(output) = output else { @@ -3510,7 +3515,7 @@ impl Niri { let frame_callback_time = get_monotonic_time(); - self.layout.with_windows(|mapped, _| { + self.layout.with_windows(|mapped, _, _| { mapped.window.send_frame( output, frame_callback_time, @@ -3753,7 +3758,7 @@ impl Niri { let _span = tracy_client::span!("Niri::render_window_for_screen_cast"); let mut window = None; - self.layout.with_windows(|mapped, _| { + self.layout.with_windows(|mapped, _, _| { if u64::from(mapped.id().get()) != window_id { return; } diff --git a/src/protocols/foreign_toplevel.rs b/src/protocols/foreign_toplevel.rs index af2be7119..296847a51 100644 --- a/src/protocols/foreign_toplevel.rs +++ b/src/protocols/foreign_toplevel.rs @@ -95,7 +95,7 @@ pub fn refresh(state: &mut State) { // Save the focused window for last, this way when the focus changes, we will first deactivate // the previous window and only then activate the newly focused window. let mut focused = None; - state.niri.layout.with_windows(|mapped, output| { + state.niri.layout.with_windows(|mapped, output, _| { let wl_surface = mapped.toplevel().wl_surface(); with_states(wl_surface, |states| { diff --git a/wiki/IPC.md b/wiki/IPC.md index ee4ea82e4..f70873d5f 100644 --- a/wiki/IPC.md +++ b/wiki/IPC.md @@ -11,6 +11,26 @@ The communication over the IPC socket happens in JSON. > If you're getting parsing errors from `niri msg` after upgrading niri, make sure that you've restarted niri itself. > You might be trying to run a newer `niri msg` against an older `niri` compositor. +### Event Stream + +Since: 0.1.9 + +While most niri IPC requests return a single response, the event stream request will make niri continuously stream events into the IPC connection until it is closed. +This is useful for implementing various bars and indicators that update as soon as something happens, without continuous polling. + +The event stream IPC is designed to give you the complete current state up-front, then follow up with updates to that state. +This way, your state can never "desync" from niri, and you don't need to make any other IPC information requests. + +Where reasonable, event stream state updates are atomic, though this is not always the case. +For example, a window may end up with a workspace id for a workspace that had already been removed. +This can happen if the corresponding workspaces-changed event arrives before the corresponding window-changed event. + +To get a taste of the events, run `niri msg event-stream`. +Though, this is more of a debug function than anything. +You can get raw events from `niri msg --json event-stream`, or by connecting to the niri socket and requesting an event stream manually. + +You can find the full list of events along with documentation in the [niri-ipc sub-crate](./niri-ipc/). + ### Backwards Compatibility The JSON output *should* remain stable, as in: From e487eea08339662e6bcf47fd36050f1e58cc7bad Mon Sep 17 00:00:00 2001 From: Ivan Molodetskikh Date: Sat, 31 Aug 2024 10:22:57 +0300 Subject: [PATCH 06/15] Change MappedIt::get() to return u64 --- src/handlers/compositor.rs | 2 +- src/handlers/xdg_shell.rs | 2 +- src/ipc/server.rs | 8 ++++---- src/niri.rs | 14 ++++++-------- src/window/mapped.rs | 4 ++-- 5 files changed, 14 insertions(+), 16 deletions(-) diff --git a/src/handlers/compositor.rs b/src/handlers/compositor.rs index ebc4a9840..2244da19b 100644 --- a/src/handlers/compositor.rs +++ b/src/handlers/compositor.rs @@ -216,7 +216,7 @@ impl CompositorHandler for State { #[cfg(feature = "xdp-gnome-screencast")] self.niri .stop_casts_for_target(crate::pw_utils::CastTarget::Window { - id: u64::from(id.get()), + id: id.get(), }); self.niri.layout.remove_window(&window, transaction.clone()); diff --git a/src/handlers/xdg_shell.rs b/src/handlers/xdg_shell.rs index b7d267dd6..2486dd7a8 100644 --- a/src/handlers/xdg_shell.rs +++ b/src/handlers/xdg_shell.rs @@ -480,7 +480,7 @@ impl XdgShellHandler for State { #[cfg(feature = "xdp-gnome-screencast")] self.niri .stop_casts_for_target(crate::pw_utils::CastTarget::Window { - id: u64::from(mapped.id().get()), + id: mapped.id().get(), }); self.backend.with_primary_renderer(|renderer| { diff --git a/src/ipc/server.rs b/src/ipc/server.rs index af2c4fa23..5475f5d76 100644 --- a/src/ipc/server.rs +++ b/src/ipc/server.rs @@ -375,7 +375,7 @@ fn make_ipc_window(mapped: &Mapped, workspace_id: Option) -> niri_i .unwrap(); niri_ipc::Window { - id: u64::from(mapped.id().get()), + id: mapped.id().get(), title: role.title.clone(), app_id: role.app_id.clone(), workspace_id: workspace_id.map(|id| u64::from(id.0)), @@ -449,7 +449,7 @@ impl State { break; } - let active_window_id = ws.active_window().map(|win| u64::from(win.id().get())); + let active_window_id = ws.active_window().map(|win| win.id().get()); if ipc_ws.active_window_id != active_window_id { events.push(Event::WorkspaceActiveWindowChanged { workspace_id: id, @@ -490,7 +490,7 @@ impl State { output: mon.map(|mon| mon.output_name().clone()), is_active: mon.map_or(false, |mon| mon.active_workspace_idx == ws_idx), is_focused: Some(id) == focused_ws_id, - active_window_id: ws.active_window().map(|win| u64::from(win.id().get())), + active_window_id: ws.active_window().map(|win| win.id().get()), } }) .collect(); @@ -521,7 +521,7 @@ impl State { let mut seen = HashSet::new(); let mut focused_id = None; layout.with_windows(|mapped, _, ws_id| { - let id = u64::from(mapped.id().get()); + let id = mapped.id().get(); seen.insert(id); if mapped.is_focused() { diff --git a/src/niri.rs b/src/niri.rs index 1a0669f1c..ab5049a8c 100644 --- a/src/niri.rs +++ b/src/niri.rs @@ -1379,7 +1379,7 @@ impl State { StreamTargetId::Window { id } => { let mut window = None; self.niri.layout.with_windows(|mapped, _, _| { - if u64::from(mapped.id().get()) != id { + if mapped.id().get() != id { return; } @@ -1502,7 +1502,7 @@ impl State { .expect("no X11 support") .wl_surface(); - let id = u64::from(mapped.id().get()); + let id = mapped.id().get(); let props = with_states(wl_surface, |states| { let role = states .data_map @@ -2841,9 +2841,7 @@ impl Niri { let mut to_stop = vec![]; for (id, out) in output_changed { let refresh = out.current_mode().unwrap().refresh as u32; - let target = CastTarget::Window { - id: u64::from(id.get()), - }; + let target = CastTarget::Window { id: id.get() }; for cast in self.casts.iter_mut().filter(|cast| cast.target == target) { if let Err(err) = cast.set_refresh(refresh) { warn!("error changing cast FPS: {err:?}"); @@ -3712,7 +3710,7 @@ impl Niri { }; let mut windows = self.layout.windows_for_output(output); - let Some(mapped) = windows.find(|win| u64::from(win.id().get()) == id) else { + let Some(mapped) = windows.find(|win| win.id().get() == id) else { continue; }; @@ -3759,7 +3757,7 @@ impl Niri { let mut window = None; self.layout.with_windows(|mapped, _, _| { - if u64::from(mapped.id().get()) != window_id { + if mapped.id().get() != window_id { return; } @@ -3778,7 +3776,7 @@ impl Niri { let mut windows = self.layout.windows_for_output(output); let mapped = windows - .find(|mapped| u64::from(mapped.id().get()) == window_id) + .find(|mapped| mapped.id().get() == window_id) .unwrap(); let scale = Scale::from(output.current_scale().fractional_scale()); diff --git a/src/window/mapped.rs b/src/window/mapped.rs index 54c045b3a..60cf7cd9d 100644 --- a/src/window/mapped.rs +++ b/src/window/mapped.rs @@ -105,8 +105,8 @@ impl MappedId { MappedId(MAPPED_ID_COUNTER.next()) } - pub fn get(self) -> u32 { - self.0 + pub fn get(self) -> u64 { + u64::from(self.0) } } From c35c9c11b769873cc5c4c20e45370e0a6942d9f4 Mon Sep 17 00:00:00 2001 From: Ivan Molodetskikh Date: Sat, 31 Aug 2024 10:23:41 +0300 Subject: [PATCH 07/15] utils/id: Use a Relaxed atomic op --- src/utils/id.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/id.rs b/src/utils/id.rs index 06f48394b..94d7f8ee9 100644 --- a/src/utils/id.rs +++ b/src/utils/id.rs @@ -18,7 +18,7 @@ impl IdCounter { } pub fn next(&self) -> u32 { - self.value.fetch_add(1, Ordering::SeqCst) + self.value.fetch_add(1, Ordering::Relaxed) } } From 11d4372f809e194433c186e29488683a85cc92f5 Mon Sep 17 00:00:00 2001 From: Ivan Molodetskikh Date: Sat, 31 Aug 2024 10:25:56 +0300 Subject: [PATCH 08/15] Make WorkspaceId inner field private --- src/ipc/server.rs | 10 +++++----- src/layout/workspace.rs | 6 +++++- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/ipc/server.rs b/src/ipc/server.rs index 5475f5d76..9c292b34c 100644 --- a/src/ipc/server.rs +++ b/src/ipc/server.rs @@ -378,7 +378,7 @@ fn make_ipc_window(mapped: &Mapped, workspace_id: Option) -> niri_i id: mapped.id().get(), title: role.title.clone(), app_id: role.app_id.clone(), - workspace_id: workspace_id.map(|id| u64::from(id.0)), + workspace_id: workspace_id.map(|id| id.get()), is_focused: mapped.is_focused(), } }) @@ -424,13 +424,13 @@ impl State { let mut events = Vec::new(); let layout = &self.niri.layout; - let focused_ws_id = layout.active_workspace().map(|ws| u64::from(ws.id().0)); + let focused_ws_id = layout.active_workspace().map(|ws| ws.id().get()); // Check for workspace changes. let mut seen = HashSet::new(); let mut need_workspaces_changed = false; for (mon, ws_idx, ws) in layout.workspaces() { - let id = u64::from(ws.id().0); + let id = ws.id().get(); seen.insert(id); let Some(ipc_ws) = state.workspaces.get(&id) else { @@ -482,7 +482,7 @@ impl State { let workspaces = layout .workspaces() .map(|(mon, ws_idx, ws)| { - let id = u64::from(ws.id().0); + let id = ws.id().get(); Workspace { id, idx: u8::try_from(ws_idx + 1).unwrap_or(u8::MAX), @@ -534,7 +534,7 @@ impl State { return; }; - let workspace_id = Some(u64::from(ws_id.0)); + let workspace_id = Some(ws_id.get()); let mut changed = ipc_win.workspace_id != workspace_id; let wl_surface = mapped.toplevel().wl_surface(); diff --git a/src/layout/workspace.rs b/src/layout/workspace.rs index 17ce9a33e..da1be408b 100644 --- a/src/layout/workspace.rs +++ b/src/layout/workspace.rs @@ -123,12 +123,16 @@ pub struct OutputId(String); static WORKSPACE_ID_COUNTER: IdCounter = IdCounter::new(); #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct WorkspaceId(pub u32); +pub struct WorkspaceId(u32); impl WorkspaceId { fn next() -> WorkspaceId { WorkspaceId(WORKSPACE_ID_COUNTER.next()) } + + pub fn get(self) -> u64 { + u64::from(self.0) + } } niri_render_elements! { From 8861309f9f05aef38b05ec2ed13455b70a964826 Mon Sep 17 00:00:00 2001 From: Ivan Molodetskikh Date: Sat, 31 Aug 2024 10:27:05 +0300 Subject: [PATCH 09/15] Change OutputId::get() to return u64 --- src/backend/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 850f47ef6..40edef324 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -44,8 +44,8 @@ impl OutputId { OutputId(OUTPUT_ID_COUNTER.next()) } - pub fn get(self) -> u32 { - self.0 + pub fn get(self) -> u64 { + u64::from(self.0) } } From 155f6a91829f1c1bdc4fef6683859a9a9dcc12a7 Mon Sep 17 00:00:00 2001 From: Ivan Molodetskikh Date: Sat, 31 Aug 2024 10:29:06 +0300 Subject: [PATCH 10/15] Change IdCounter to be backed by an AtomicU64 Let's see if anyone complains. --- src/backend/mod.rs | 4 ++-- src/layout/workspace.rs | 4 ++-- src/utils/id.rs | 11 ++++------- src/window/mapped.rs | 4 ++-- 4 files changed, 10 insertions(+), 13 deletions(-) diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 40edef324..bbacf1cf1 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -37,7 +37,7 @@ pub type IpcOutputMap = HashMap; static OUTPUT_ID_COUNTER: IdCounter = IdCounter::new(); #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct OutputId(u32); +pub struct OutputId(u64); impl OutputId { fn next() -> OutputId { @@ -45,7 +45,7 @@ impl OutputId { } pub fn get(self) -> u64 { - u64::from(self.0) + self.0 } } diff --git a/src/layout/workspace.rs b/src/layout/workspace.rs index da1be408b..26d6a7859 100644 --- a/src/layout/workspace.rs +++ b/src/layout/workspace.rs @@ -123,7 +123,7 @@ pub struct OutputId(String); static WORKSPACE_ID_COUNTER: IdCounter = IdCounter::new(); #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct WorkspaceId(u32); +pub struct WorkspaceId(u64); impl WorkspaceId { fn next() -> WorkspaceId { @@ -131,7 +131,7 @@ impl WorkspaceId { } pub fn get(self) -> u64 { - u64::from(self.0) + self.0 } } diff --git a/src/utils/id.rs b/src/utils/id.rs index 94d7f8ee9..c21511e6d 100644 --- a/src/utils/id.rs +++ b/src/utils/id.rs @@ -1,11 +1,8 @@ -use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::atomic::{AtomicU64, Ordering}; /// Counter that returns unique IDs. -/// -/// Under the hood it uses a `u32` that will eventually wrap around. When incrementing it once a -/// second, it will wrap around after about 136 years. pub struct IdCounter { - value: AtomicU32, + value: AtomicU64, } impl IdCounter { @@ -13,11 +10,11 @@ impl IdCounter { Self { // Start from 1 to reduce the possibility that some other code that uses these IDs will // get confused. - value: AtomicU32::new(1), + value: AtomicU64::new(1), } } - pub fn next(&self) -> u32 { + pub fn next(&self) -> u64 { self.value.fetch_add(1, Ordering::Relaxed) } } diff --git a/src/window/mapped.rs b/src/window/mapped.rs index 60cf7cd9d..a2875d024 100644 --- a/src/window/mapped.rs +++ b/src/window/mapped.rs @@ -98,7 +98,7 @@ niri_render_elements! { static MAPPED_ID_COUNTER: IdCounter = IdCounter::new(); #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct MappedId(u32); +pub struct MappedId(u64); impl MappedId { fn next() -> MappedId { @@ -106,7 +106,7 @@ impl MappedId { } pub fn get(self) -> u64 { - u64::from(self.0) + self.0 } } From c5cc8b33639e9d47d2925842c6551054f313105a Mon Sep 17 00:00:00 2001 From: Ivan Molodetskikh Date: Mon, 2 Sep 2024 08:53:50 +0300 Subject: [PATCH 11/15] Rearrange some CLI and IPC enum values --- niri-ipc/src/lib.rs | 24 ++++++++++++------------ src/cli.rs | 8 ++++---- src/ipc/server.rs | 22 +++++++++++----------- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/niri-ipc/src/lib.rs b/niri-ipc/src/lib.rs index e6058ca11..97f67ba78 100644 --- a/niri-ipc/src/lib.rs +++ b/niri-ipc/src/lib.rs @@ -19,6 +19,12 @@ pub enum Request { Version, /// Request information about connected outputs. Outputs, + /// Request information about workspaces. + Workspaces, + /// Request information about the configured keyboard layouts. + KeyboardLayouts, + /// Request information about the focused output. + FocusedOutput, /// Request information about the focused window. FocusedWindow, /// Perform an action. @@ -34,12 +40,6 @@ pub enum Request { /// Configuration to apply. action: OutputAction, }, - /// Request information about workspaces. - Workspaces, - /// Request information about the focused output. - FocusedOutput, - /// Request information about the keyboard layout. - KeyboardLayouts, /// Start continuously receiving events from the compositor. /// /// The compositor should reply with `Reply::Ok(Response::Handled)`, then continuously send @@ -71,16 +71,16 @@ pub enum Response { /// /// Map from connector name to output info. Outputs(HashMap), - /// Information about the focused window. - FocusedWindow(Option), - /// Output configuration change result. - OutputConfigChanged(OutputConfigChanged), /// Information about workspaces. Workspaces(Vec), - /// Information about the focused output. - FocusedOutput(Option), /// Information about the keyboard layout. KeyboardLayouts(KeyboardLayouts), + /// Information about the focused output. + FocusedOutput(Option), + /// Information about the focused window. + FocusedWindow(Option), + /// Output configuration change result. + OutputConfigChanged(OutputConfigChanged), } /// Actions that niri can perform. diff --git a/src/cli.rs b/src/cli.rs index 99d22e63a..238ffe9de 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -62,10 +62,12 @@ pub enum Msg { Outputs, /// List workspaces. Workspaces, - /// Print information about the focused window. - FocusedWindow, + /// Get the configured keyboard layouts. + KeyboardLayouts, /// Print information about the focused output. FocusedOutput, + /// Print information about the focused window. + FocusedWindow, /// Perform an action. Action { #[command(subcommand)] @@ -86,8 +88,6 @@ pub enum Msg { #[command(subcommand)] action: OutputAction, }, - /// Get the configured keyboard layouts. - KeyboardLayouts, /// Start continuously receiving events from the compositor. EventStream, /// Print the version of the running niri instance. diff --git a/src/ipc/server.rs b/src/ipc/server.rs index 9c292b34c..dcdfc3fbe 100644 --- a/src/ipc/server.rs +++ b/src/ipc/server.rs @@ -255,6 +255,17 @@ async fn process(ctx: &ClientCtx, request: Request) -> Reply { let outputs = ipc_outputs.values().cloned().map(|o| (o.name.clone(), o)); Response::Outputs(outputs.collect()) } + Request::Workspaces => { + let state = ctx.event_stream_state.borrow(); + let workspaces = state.workspaces.workspaces.values().cloned().collect(); + Response::Workspaces(workspaces) + } + Request::KeyboardLayouts => { + let state = ctx.event_stream_state.borrow(); + let layout = state.keyboard_layouts.keyboard_layouts.clone(); + let layout = layout.expect("keyboard layouts should be set at startup"); + Response::KeyboardLayouts(layout) + } Request::FocusedWindow => { let state = ctx.event_stream_state.borrow(); let windows = &state.windows.windows; @@ -294,11 +305,6 @@ async fn process(ctx: &ClientCtx, request: Request) -> Reply { Response::OutputConfigChanged(response) } - Request::Workspaces => { - let state = ctx.event_stream_state.borrow(); - let workspaces = state.workspaces.workspaces.values().cloned().collect(); - Response::Workspaces(workspaces) - } Request::FocusedOutput => { let (tx, rx) = async_channel::bounded(1); ctx.event_loop.insert_idle(move |state| { @@ -325,12 +331,6 @@ async fn process(ctx: &ClientCtx, request: Request) -> Reply { let output = result.map_err(|_| String::from("error getting active output info"))?; Response::FocusedOutput(output) } - Request::KeyboardLayouts => { - let state = ctx.event_stream_state.borrow(); - let layout = state.keyboard_layouts.keyboard_layouts.clone(); - let layout = layout.expect("keyboard layouts should be set at startup"); - Response::KeyboardLayouts(layout) - } Request::EventStream => Response::Handled, }; From 1cecc157232c943e4dbf3f3136d77d2fb8e337ec Mon Sep 17 00:00:00 2001 From: Ivan Molodetskikh Date: Mon, 2 Sep 2024 09:05:18 +0300 Subject: [PATCH 12/15] Add niri msg windows --- niri-ipc/src/lib.rs | 4 ++++ src/cli.rs | 2 ++ src/ipc/client.rs | 40 ++++++++++++++++++++++++++++++++++++++++ src/ipc/server.rs | 5 +++++ 4 files changed, 51 insertions(+) diff --git a/niri-ipc/src/lib.rs b/niri-ipc/src/lib.rs index 97f67ba78..611f6bb85 100644 --- a/niri-ipc/src/lib.rs +++ b/niri-ipc/src/lib.rs @@ -21,6 +21,8 @@ pub enum Request { Outputs, /// Request information about workspaces. Workspaces, + /// Request information about open windows. + Windows, /// Request information about the configured keyboard layouts. KeyboardLayouts, /// Request information about the focused output. @@ -73,6 +75,8 @@ pub enum Response { Outputs(HashMap), /// Information about workspaces. Workspaces(Vec), + /// Information about open windows. + Windows(Vec), /// Information about the keyboard layout. KeyboardLayouts(KeyboardLayouts), /// Information about the focused output. diff --git a/src/cli.rs b/src/cli.rs index 238ffe9de..b260e6c3c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -62,6 +62,8 @@ pub enum Msg { Outputs, /// List workspaces. Workspaces, + /// List open windows. + Windows, /// Get the configured keyboard layouts. KeyboardLayouts, /// Print information about the focused output. diff --git a/src/ipc/client.rs b/src/ipc/client.rs index ea6121dc3..0f4d62282 100644 --- a/src/ipc/client.rs +++ b/src/ipc/client.rs @@ -20,6 +20,7 @@ pub fn handle_msg(msg: Msg, json: bool) -> anyhow::Result<()> { action: action.clone(), }, Msg::Workspaces => Request::Workspaces, + Msg::Windows => Request::Windows, Msg::KeyboardLayouts => Request::KeyboardLayouts, Msg::EventStream => Request::EventStream, Msg::RequestError => Request::ReturnError, @@ -155,6 +156,45 @@ pub fn handle_msg(msg: Msg, json: bool) -> anyhow::Result<()> { println!("No window is focused."); } } + Msg::Windows => { + let Response::Windows(mut windows) = response else { + bail!("unexpected response: expected Windows, got {response:?}"); + }; + + if json { + let windows = + serde_json::to_string(&windows).context("error formatting response")?; + println!("{windows}"); + return Ok(()); + } + + windows.sort_unstable_by(|a, b| a.id.cmp(&b.id)); + + for window in windows { + let focused = if window.is_focused { " (focused)" } else { "" }; + println!("Window ID {}:{focused}", window.id); + + if let Some(title) = window.title { + println!(" Title: \"{title}\""); + } else { + println!(" Title: (unset)"); + } + + if let Some(app_id) = window.app_id { + println!(" App ID: \"{app_id}\""); + } else { + println!(" App ID: (unset)"); + } + + if let Some(workspace_id) = window.workspace_id { + println!(" Workspace ID: {workspace_id}"); + } else { + println!(" Workspace ID: (none)"); + } + + println!(); + } + } Msg::FocusedOutput => { let Response::FocusedOutput(output) = response else { bail!("unexpected response: expected FocusedOutput, got {response:?}"); diff --git a/src/ipc/server.rs b/src/ipc/server.rs index dcdfc3fbe..6990cd418 100644 --- a/src/ipc/server.rs +++ b/src/ipc/server.rs @@ -260,6 +260,11 @@ async fn process(ctx: &ClientCtx, request: Request) -> Reply { let workspaces = state.workspaces.workspaces.values().cloned().collect(); Response::Workspaces(workspaces) } + Request::Windows => { + let state = ctx.event_stream_state.borrow(); + let windows = state.windows.windows.values().cloned().collect(); + Response::Windows(windows) + } Request::KeyboardLayouts => { let state = ctx.event_stream_state.borrow(); let layout = state.keyboard_layouts.keyboard_layouts.clone(); From 40ffa818aea7b907961693c3dd40ce1d05dace53 Mon Sep 17 00:00:00 2001 From: Ivan Molodetskikh Date: Mon, 2 Sep 2024 09:16:42 +0300 Subject: [PATCH 13/15] Fix spelling mistake --- niri-ipc/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/niri-ipc/src/lib.rs b/niri-ipc/src/lib.rs index 611f6bb85..9ee0e5f83 100644 --- a/niri-ipc/src/lib.rs +++ b/niri-ipc/src/lib.rs @@ -690,7 +690,7 @@ impl FromStr for WorkspaceReferenceArg { if let Ok(idx) = u8::try_from(index) { Self::Index(idx) } else { - return Err("workspace indexes must be between 0 and 255"); + return Err("workspace index must be between 0 and 255"); } } else { Self::Name(s.to_string()) From 1844a4142a812aaefb73143acae4ba9ad964de43 Mon Sep 17 00:00:00 2001 From: Ivan Molodetskikh Date: Mon, 2 Sep 2024 09:20:23 +0300 Subject: [PATCH 14/15] Implement by-id workspace action addressing It's not added to clap because there's no convenient mutually-exclusive argument enum derive yet (to have either the current or an --id ). It's not added to config parsing because I don't see how it could be useful there. As such, it's only accessible through raw IPC. --- niri-config/src/lib.rs | 2 ++ niri-ipc/src/lib.rs | 4 +++- src/layout/mod.rs | 26 ++++++++++++++++++++++++++ src/layout/workspace.rs | 4 ++++ src/niri.rs | 12 +++++++----- 5 files changed, 42 insertions(+), 6 deletions(-) diff --git a/niri-config/src/lib.rs b/niri-config/src/lib.rs index ff1fe365a..9092cd216 100644 --- a/niri-config/src/lib.rs +++ b/niri-config/src/lib.rs @@ -1233,6 +1233,7 @@ impl From for Action { #[derive(Debug, PartialEq, Eq, Clone)] pub enum WorkspaceReference { + Id(u64), Index(u8), Name(String), } @@ -1240,6 +1241,7 @@ pub enum WorkspaceReference { impl From for WorkspaceReference { fn from(reference: WorkspaceReferenceArg) -> WorkspaceReference { match reference { + WorkspaceReferenceArg::Id(id) => Self::Id(id), WorkspaceReferenceArg::Index(i) => Self::Index(i), WorkspaceReferenceArg::Name(n) => Self::Name(n), } diff --git a/niri-ipc/src/lib.rs b/niri-ipc/src/lib.rs index 9ee0e5f83..22a1a63b3 100644 --- a/niri-ipc/src/lib.rs +++ b/niri-ipc/src/lib.rs @@ -308,10 +308,12 @@ pub enum SizeChange { AdjustProportion(f64), } -/// Workspace reference (index or name) to operate on. +/// Workspace reference (id, index or name) to operate on. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub enum WorkspaceReferenceArg { + /// Id of the workspace. + Id(u64), /// Index of the workspace. Index(u8), /// Name of the workspace. diff --git a/src/layout/mod.rs b/src/layout/mod.rs index 20c63d280..9a9e084d8 100644 --- a/src/layout/mod.rs +++ b/src/layout/mod.rs @@ -836,6 +836,32 @@ impl Layout { None } + pub fn find_workspace_by_id(&self, id: WorkspaceId) -> Option<(usize, &Workspace)> { + match &self.monitor_set { + MonitorSet::Normal { ref monitors, .. } => { + for mon in monitors { + if let Some((index, workspace)) = mon + .workspaces + .iter() + .enumerate() + .find(|(_, w)| w.id() == id) + { + return Some((index, workspace)); + } + } + } + MonitorSet::NoOutputs { workspaces } => { + if let Some((index, workspace)) = + workspaces.iter().enumerate().find(|(_, w)| w.id() == id) + { + return Some((index, workspace)); + } + } + } + + None + } + pub fn find_workspace_by_name(&self, workspace_name: &str) -> Option<(usize, &Workspace)> { match &self.monitor_set { MonitorSet::Normal { ref monitors, .. } => { diff --git a/src/layout/workspace.rs b/src/layout/workspace.rs index 26d6a7859..f85ec622e 100644 --- a/src/layout/workspace.rs +++ b/src/layout/workspace.rs @@ -133,6 +133,10 @@ impl WorkspaceId { pub fn get(self) -> u64 { self.0 } + + pub fn specific(id: u64) -> Self { + Self(id) + } } niri_render_elements! { diff --git a/src/niri.rs b/src/niri.rs index ab5049a8c..edd56466c 100644 --- a/src/niri.rs +++ b/src/niri.rs @@ -115,6 +115,7 @@ use crate::input::{ apply_libinput_settings, mods_with_finger_scroll_binds, mods_with_wheel_binds, TabletData, }; use crate::ipc::server::IpcServer; +use crate::layout::workspace::WorkspaceId; use crate::layout::{Layout, LayoutElement as _, MonitorRenderElement}; use crate::protocols::foreign_toplevel::{self, ForeignToplevelManagerState}; use crate::protocols::gamma_control::GammaControlManagerState; @@ -2422,16 +2423,17 @@ impl Niri { &self, workspace_reference: WorkspaceReference, ) -> Option<(Option, usize)> { - let workspace_name = match workspace_reference { + let (target_workspace_index, target_workspace) = match workspace_reference { WorkspaceReference::Index(index) => { return Some((None, index.saturating_sub(1) as usize)); } - WorkspaceReference::Name(name) => name, + WorkspaceReference::Name(name) => self.layout.find_workspace_by_name(&name)?, + WorkspaceReference::Id(id) => { + let id = WorkspaceId::specific(id); + self.layout.find_workspace_by_id(id)? + } }; - let (target_workspace_index, target_workspace) = - self.layout.find_workspace_by_name(&workspace_name)?; - // FIXME: when we do fixes for no connected outputs, this will need fixing too. let active_workspace = self.layout.active_workspace()?; From ff7e0e04806c0d4dece3b4b74a64dc67ce6f142e Mon Sep 17 00:00:00 2001 From: Ivan Molodetskikh Date: Mon, 2 Sep 2024 09:40:21 +0300 Subject: [PATCH 15/15] wiki: Document IPC programmatic access --- wiki/IPC.md | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/wiki/IPC.md b/wiki/IPC.md index f70873d5f..c56693848 100644 --- a/wiki/IPC.md +++ b/wiki/IPC.md @@ -4,9 +4,6 @@ Check `niri msg --help` for available commands. The `--json` flag prints the response in JSON, rather than formatted. For example, `niri msg --json outputs`. -For programmatic access, check the [niri-ipc sub-crate](./niri-ipc/) which defines the types. -The communication over the IPC socket happens in JSON. - > [!TIP] > If you're getting parsing errors from `niri msg` after upgrading niri, make sure that you've restarted niri itself. > You might be trying to run a newer `niri msg` against an older `niri` compositor. @@ -31,6 +28,37 @@ You can get raw events from `niri msg --json event-stream`, or by connecting to You can find the full list of events along with documentation in the [niri-ipc sub-crate](./niri-ipc/). +### Programmatic Access + +`niri msg --json` is a thin wrapper over writing and reading to a socket. +When implementing more complex scripts and modules, you're encouraged to access the socket directly. + +Connect to the UNIX domain socket located at `$NIRI_SOCKET` in the filesystem. +Write your request encoded in JSON on a single line, followed by a newline character, or by flushing and shutting down the write end of the connection. +Read the reply as JSON, also on a single line. + +You can use `socat` to test communicating with niri directly: + +```sh +$ socat STDIO /run/user/1000/niri.wayland-1.42516.sock +"FocusedWindow" +{"Ok":{"FocusedWindow":{"id":12,"title":"t socat STDIO /run/u ~","app_id":"Alacritty","workspace_id":6,"is_focused":true}}} +``` + +The reply is an `Ok` or an `Err` wrapping the same JSON object as you get from `niri msg --json`. + +For more complex requests, you can use `socat` to find how `niri msg` formats them: + +```sh +$ socat STDIO UNIX-LISTEN:temp.sock +(then, in a different terminal) +$ env NIRI_SOCKET=./temp.sock niri msg action focus-workspace 2 +(then, look in the socat terminal) +{"Action":{"FocusWorkspace":{"reference":{"Index":2}}}} +``` + +You can find all available requests and response types in the [niri-ipc sub-crate](./niri-ipc/). + ### Backwards Compatibility The JSON output *should* remain stable, as in: