From f9557e3a254a50e73d225947dc1bc7141b150ae4 Mon Sep 17 00:00:00 2001 From: Schrottkatze Date: Fri, 10 Oct 2025 22:55:16 +0200 Subject: [PATCH] feat: add niri support --- Cargo.lock | 40 +++++++--- Cargo.toml | 1 + flake.nix | 26 +++++++ src/comms/mod.rs | 5 ++ src/comms/niri.rs | 191 ++++++++++++++++++++++++++++++++++++++++++++++ src/comms/sway.rs | 2 +- 6 files changed, 255 insertions(+), 10 deletions(-) create mode 100644 flake.nix create mode 100644 src/comms/niri.rs diff --git a/Cargo.lock b/Cargo.lock index aa89fa6..69edf4d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "ahash" @@ -264,6 +264,7 @@ dependencies = [ "directories-next", "eyre", "hostname", + "niri-ipc", "serde", "strum", "swayipc", @@ -293,6 +294,16 @@ version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +[[package]] +name = "niri-ipc" +version = "25.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a336854faf3818ec2399152c994796e2e8d723867f9987f1e0cfeaae1dadaed1" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "once_cell" version = "1.20.2" @@ -351,18 +362,28 @@ checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" [[package]] name = "serde" -version = "1.0.210" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.210" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -371,14 +392,15 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.128" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ "itoa", "memchr", "ryu", "serde", + "serde_core", ] [[package]] @@ -461,9 +483,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.79" +version = "2.0.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89132cd0bf050864e1d38dc3bbc07a0eb8e7530af26344d3d2bbbef83499f590" +checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index c365ac6..d8b8ca9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ strum = { version = "0.26.2", features = ["derive"] } swayipc = "3.0.2" thiserror = "1.0.60" toml = "0.8.13" +niri-ipc = "25.8.0" # The profile that 'cargo dist' will build with [profile.dist] diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..3b7e24b --- /dev/null +++ b/flake.nix @@ -0,0 +1,26 @@ +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + fenix.url = "github:nix-community/fenix"; + }; + + nixConfig = { + extra-trusted-public-keys = "devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw="; + extra-substituters = "https://devenv.cachix.org"; + }; + + outputs = { + nixpkgs, + fenix, + ... + }: let + pkgs = nixpkgs.legacyPackages."x86_64-linux"; + rs-toolchain = with fenix.packages."x86_64-linux"; combine [complete.toolchain]; + in { + devShells."x86_64-linux".default = pkgs.mkShell { + buildInputs = [ + rs-toolchain + ]; + }; + }; +} diff --git a/src/comms/mod.rs b/src/comms/mod.rs index 21d373d..72e1ea7 100644 --- a/src/comms/mod.rs +++ b/src/comms/mod.rs @@ -11,6 +11,7 @@ //! if there are signs present that the WM is running //! in the current session +pub mod niri; pub mod sway; use std::{env, fmt}; @@ -25,6 +26,8 @@ pub type Name = String; pub fn establish() -> Result, Error> { let comms = if env::var("SWAYSOCK").is_ok() { sway::establish()? + } else if env::var("NIRI_SOCKET").is_ok() { + niri::establish()? } else { return Err(Error::NoWmRunning); }; @@ -36,6 +39,8 @@ pub fn establish() -> Result, Error> { pub enum Error { #[error("When communicating with sway: {0}")] Sway(#[from] sway::Error), + #[error("When communicating with niri: {0}")] + Niri(#[from] niri::Error), #[error("No known WM is running")] NoWmRunning, } diff --git a/src/comms/niri.rs b/src/comms/niri.rs new file mode 100644 index 0000000..cf8467e --- /dev/null +++ b/src/comms/niri.rs @@ -0,0 +1,191 @@ +use niri_ipc::{ + socket::Socket, LogicalOutput, OutputAction, OutputConfigChanged, Request, Response, +}; + +use crate::{ + absolute::{self, Output}, + geometry::{Interval, Rect, Rotation, Size, Transform}, +}; +use thiserror::Error; + +use super::{sway::ParsePortError, Port, Result}; + +#[derive(Debug, Error)] +pub enum Error { + #[error("An IO Error occured: {0}")] + Io(#[from] std::io::Error), + #[error("Reply: {0}")] + Reply(String), + #[error("Could not parse output name `{raw}` into port: {err}")] + ParsePort { raw: String, err: ParsePortError }, + #[error("Output {output} missing.")] + OutputWasMissing { output: Port }, + #[error("Unexpected Response from window manager: {0:?}")] + UnexpectedResponseFromWm(Box), +} + +pub struct Comms { + sock: Socket, + // so we dont have to send 20-30 (or more!!) niri messages for small changes on complex setups +} + +pub fn establish() -> Result> { + let sock = Socket::connect().map_err(Error::Io)?; + Ok(Box::new(Comms { sock }) as Box) +} + +impl super::Comms for Comms { + fn layout(&mut self) -> super::Result { + let mut sock = Socket::connect().map_err(Error::Io)?; + + let Response::Outputs(outputs) = sock_send(&mut sock, Request::Outputs)? else { + unreachable!() + }; + + let res = outputs + .into_values() + .map(Output::try_from) + .collect::>()?; + + Ok(res) + } + + fn set_layout(&mut self, layout: &absolute::Layout) -> super::Result<()> { + layout + .to_niri_output_actions() + .try_for_each(|(output, action)| { + sock_send( + &mut self.sock, + Request::Output { + output: output.to_string(), + action, + }, + ) + .and_then(|response| match response { + Response::OutputConfigChanged(OutputConfigChanged::Applied) => Ok(()), + Response::OutputConfigChanged(OutputConfigChanged::OutputWasMissing) => { + Err(super::Error::Niri(Error::OutputWasMissing { output })) + } + other => Err(super::Error::Niri(Error::UnexpectedResponseFromWm( + Box::new(other), + ))), + }) + }) + } +} + +fn sock_send(sock: &mut Socket, req: Request) -> super::Result { + sock.send(req) + .map_err(Error::Io) + .and_then(|val| val.map_err(Error::Reply)) + .map_err(super::Error::Niri) +} + +impl absolute::Layout { + pub fn to_niri_output_actions(&self) -> impl Iterator + '_ { + self.outputs().flat_map(|output| { + vec![ + OutputAction::Position { + position: niri_ipc::PositionToSet::Specific(niri_ipc::ConfiguredPosition { + x: output.cfg.bounds.x.start(), + y: output.cfg.bounds.y.start(), + }), + }, + OutputAction::Scale { + scale: niri_ipc::ScaleToSet::Specific(output.cfg.scale), + }, + OutputAction::Transform { + transform: output.cfg.transform.into(), + }, + ] + .into_iter() + .map(|it| (*output.port, it)) + }) + } +} + +impl TryFrom for Output { + type Error = Error; + + fn try_from(output: niri_ipc::Output) -> std::result::Result { + let port = Port::parse_from_sway(&output.name).map_err(|err| Error::ParsePort { + raw: output.name.clone(), + err, + })?; + + Ok(Self { + port, + cfg: if let Some(LogicalOutput { + x, + y, + width, + height, + scale, + transform, + }) = &output.logical + { + #[allow( + clippy::cast_possible_wrap, + reason = "realistically, a display will not be multiple billion pixels big" + )] + absolute::OutputConfig { + bounds: Rect { + x: Interval::new(*x, *x + *width as i32), + y: Interval::new(*y, *y + *height as i32), + }, + resolution: Some(Size { + width: *width as i32, + height: *height as i32, + }), + scale: *scale, + transform: (*transform).into(), + active: output.current_mode.is_some(), + } + } else { + absolute::OutputConfig { + resolution: None, + bounds: Rect::default(), + scale: 1., + transform: Transform::default(), + active: false, + } + }, + }) + } +} + +impl From for Transform { + #[allow( + clippy::enum_glob_use, + clippy::just_underscores_and_digits, + reason = "readability" + )] + fn from(value: niri_ipc::Transform) -> Self { + use niri_ipc::Transform::*; + + let flipped = matches!(value, Flipped | Flipped90 | Flipped180 | Flipped270); + let rotation = match value { + Normal | Flipped => Rotation::None, + _90 | Flipped90 => Rotation::Quarter, + _180 | Flipped180 => Rotation::Half, + _270 | Flipped270 => Rotation::ThreeQuarter, + }; + + Self { flipped, rotation } + } +} + +impl From for niri_ipc::Transform { + fn from(Transform { flipped, rotation }: Transform) -> Self { + match (flipped, rotation) { + (false, Rotation::None) => niri_ipc::Transform::Normal, + (false, Rotation::Quarter) => niri_ipc::Transform::_90, + (false, Rotation::Half) => niri_ipc::Transform::_180, + (false, Rotation::ThreeQuarter) => niri_ipc::Transform::_270, + (true, Rotation::None) => niri_ipc::Transform::Flipped, + (true, Rotation::Quarter) => niri_ipc::Transform::Flipped90, + (true, Rotation::Half) => niri_ipc::Transform::Flipped180, + (true, Rotation::ThreeQuarter) => niri_ipc::Transform::Flipped270, + } + } +} diff --git a/src/comms/sway.rs b/src/comms/sway.rs index 1d008ac..bd6ac77 100644 --- a/src/comms/sway.rs +++ b/src/comms/sway.rs @@ -94,7 +94,7 @@ pub enum ParsePortError { } impl Port { - fn parse_from_sway(name: &str) -> Result { + pub(super) fn parse_from_sway(name: &str) -> Result { let (kind, idx) = name .rsplit_once('-') .ok_or_else(|| ParsePortError::NoDash {