diff --git a/Cargo.lock b/Cargo.lock index 06ca0cbc..038845e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -338,6 +338,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "error_reporter" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31ae425815400e5ed474178a7a22e275a9687086a12ca63ec793ff292d8fdae8" + [[package]] name = "futures-core" version = "0.3.30" @@ -487,6 +493,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a7558cc96ddcaf0b4144d7149984ace2899bb29d4ee2999979d429efc305200" +[[package]] +name = "itoa" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" + [[package]] name = "jay" version = "0.1.0" @@ -535,9 +547,12 @@ version = "0.1.0" dependencies = [ "backtrace", "bincode", + "bstr", + "error_reporter", "futures-util", "log", "serde", + "serde_json", "thiserror", "uapi", ] @@ -873,6 +888,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "ryu" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" + [[package]] name = "scopeguard" version = "1.2.0" @@ -899,6 +920,17 @@ dependencies = [ "syn 2.0.48", ] +[[package]] +name = "serde_json" +version = "1.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5f09b1bd632ef549eaa9f60a1f8de742bdbc698e6cee2095fc84dde5f549ae0" +dependencies = [ + "itoa", + "ryu", + "serde", +] + [[package]] name = "shaderc" version = "0.8.3" diff --git a/jay-config/Cargo.toml b/jay-config/Cargo.toml index bead5f1c..e5bd2f87 100644 --- a/jay-config/Cargo.toml +++ b/jay-config/Cargo.toml @@ -13,3 +13,6 @@ futures-util = { version = "0.3.30", features = ["io"] } uapi = "0.2.12" thiserror = "1.0.57" backtrace = "0.3.69" +error_reporter = "1.0.0" +serde_json = "1.0.114" +bstr = { version = "1.9.0", default-features = false, features = ["std"] } diff --git a/jay-config/src/_private/client.rs b/jay-config/src/_private/client.rs index b23a6c44..305690e9 100644 --- a/jay-config/src/_private/client.rs +++ b/jay-config/src/_private/client.rs @@ -11,7 +11,7 @@ use { input::{acceleration::AccelProfile, capability::Capability, InputDevice, Seat}, keyboard::Keymap, logging::LogLevel, - tasks::JoinSlot, + tasks::{JoinHandle, JoinSlot}, theme::{colors::Colorable, sized::Resizable, Color}, timer::Timer, video::{ @@ -84,6 +84,8 @@ pub(crate) struct Client { read_interests: RefCell>, write_interests: RefCell>, tasks: Tasks, + status_task: Cell>>, + i3bar_separator: RefCell>>, } struct Interest { @@ -206,6 +208,8 @@ pub unsafe extern "C" fn init( read_interests: Default::default(), write_interests: Default::default(), tasks: Default::default(), + status_task: Default::default(), + i3bar_separator: Default::default(), }); let init = slice::from_raw_parts(init, size); client.handle_init_msg(init); @@ -506,6 +510,20 @@ impl Client { self.send(&ClientMessage::SetStatus { status }); } + pub fn set_status_tasks(&self, tasks: Vec>) { + for old in self.status_task.replace(tasks) { + old.abort(); + } + } + + pub fn set_i3bar_separator(&self, separator: &str) { + *self.i3bar_separator.borrow_mut() = Some(Rc::new(separator.to_string())); + } + + pub fn get_i3bar_separator(&self) -> Option> { + self.i3bar_separator.borrow().clone() + } + pub fn set_split(&self, seat: Seat, axis: Axis) { self.send(&ClientMessage::SetSplit { seat, axis }); } diff --git a/jay-config/src/lib.rs b/jay-config/src/lib.rs index 533e0d77..5d618f01 100644 --- a/jay-config/src/lib.rs +++ b/jay-config/src/lib.rs @@ -37,7 +37,10 @@ #![allow( clippy::zero_prefixed_literal, clippy::manual_range_contains, - clippy::uninlined_format_args + clippy::uninlined_format_args, + clippy::len_zero, + clippy::single_char_pattern, + clippy::single_char_add_str )] use { diff --git a/jay-config/src/status.rs b/jay-config/src/status.rs index 55a0560c..c92fa16a 100644 --- a/jay-config/src/status.rs +++ b/jay-config/src/status.rs @@ -1,5 +1,15 @@ //! Knobs for changing the status text. +use { + crate::{exec::Command, io::Async, tasks::spawn}, + bstr::ByteSlice, + error_reporter::Report, + futures_util::{io::BufReader, AsyncBufReadExt}, + serde::Deserialize, + std::borrow::BorrowMut, + uapi::{c, OwnedFd}, +}; + /// Sets the status text. /// /// The status text is displayed at the right end of the bar. @@ -10,3 +20,237 @@ pub fn set_status(status: &str) { get!().set_status(status); } + +/// The format of a status command output. +#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] +pub enum MessageFormat { + /// The output is plain text. + /// + /// The command should output one line every time it wants to change the status. + /// The content of the line will be interpreted as plain text. + Plain, + /// The output uses [pango][pango] markup. + /// + /// The command should output one line every time it wants to change the status. + /// The content of the line will be interpreted as pango markup. + /// + /// [pango]: https://docs.gtk.org/Pango/pango_markup.html + Pango, + /// The output uses the [i3bar][i3bar] protocol. + /// + /// The separator between individual components can be set using [`set_i3bar_separator`]. + /// + /// [i3bar]: https://github.com/i3/i3/blob/next/docs/i3bar-protocol + I3Bar, +} + +/// Sets a command whose output will be used as the status text. +/// +/// The [`stdout`](Command::stdout) and [`stderr`](Command::stderr)` of the command will +/// be overwritten by this function. The stdout will be used for the status text and the +/// stderr will be appended to the compositor log. +/// +/// The format of stdout is determined by the `format` parameter. +pub fn set_status_command(format: MessageFormat, mut command: impl BorrowMut) { + macro_rules! pipe { + () => {{ + let (read, write) = match uapi::pipe2(c::O_CLOEXEC) { + Ok(p) => p, + Err(e) => { + log::error!("Could not create a pipe: {}", Report::new(e)); + return; + } + }; + let read = match Async::new(read) { + Ok(r) => BufReader::new(r), + Err(e) => { + log::error!("Could not create an Async object: {}", Report::new(e)); + return; + } + }; + (read, write) + }}; + } + let (mut read, write) = pipe!(); + let (mut stderr_read, stderr_write) = pipe!(); + let command = command.borrow_mut(); + command.stdout(write).stderr(stderr_write).spawn(); + let name = command.prog.clone(); + let name2 = command.prog.clone(); + let stderr_handle = spawn(async move { + let mut line = vec![]; + loop { + line.clear(); + if let Err(e) = stderr_read.read_until(b'\n', &mut line).await { + log::warn!("Could not read from {name2} stderr: {}", Report::new(e)); + return; + } + if line.len() == 0 { + return; + } + log::warn!( + "{name2} emitted a message on stderr: {}", + line.trim_with(|c| c == '\n').as_bstr() + ); + } + }); + let handle = spawn(async move { + if format == MessageFormat::I3Bar { + handle_i3bar(name, read).await; + return; + } + let mut line = String::new(); + let mut cleaned = String::new(); + loop { + line.clear(); + if let Err(e) = read.read_line(&mut line).await { + log::error!("Could not read from `{name}`: {}", Report::new(e)); + return; + } + if line.is_empty() { + log::info!("{name} closed stdout"); + return; + } + let line = line.strip_suffix("\n").unwrap_or(&line); + cleaned.clear(); + if format != MessageFormat::Pango && escape_pango(line, &mut cleaned) { + set_status(&cleaned); + } else { + set_status(line); + } + } + }); + get!().set_status_tasks(vec![handle, stderr_handle]); +} + +/// Unsets the previously set status command. +pub fn unset_status_command() { + get!().set_status_tasks(vec![]); +} + +/// Sets the separator for i3bar status commands. +/// +/// The separator should be specified in [pango][pango] markup language. +/// +/// [pango]: https://docs.gtk.org/Pango/pango_markup.html +pub fn set_i3bar_separator(separator: &str) { + get!().set_i3bar_separator(separator); +} + +async fn handle_i3bar(name: String, mut read: BufReader>) { + use std::fmt::Write; + + #[derive(Deserialize)] + struct Version { + version: i32, + } + #[derive(Deserialize)] + struct Component { + markup: Option, + full_text: String, + color: Option, + background: Option, + } + let mut line = String::new(); + macro_rules! read_line { + () => {{ + line.clear(); + if let Err(e) = read.read_line(&mut line).await { + log::error!("Could not read from `{name}`: {}", Report::new(e)); + return; + } + if line.is_empty() { + log::info!("{name} closed stdout"); + return; + } + }}; + } + read_line!(); + match serde_json::from_str::(&line) { + Ok(v) if v.version == 1 => {} + Ok(v) => log::warn!("Unexpected i3bar format version: {}", v.version), + Err(e) => { + log::warn!( + "Could not deserialize i3bar version message: {}", + Report::new(e) + ); + return; + } + } + read_line!(); + let mut status = String::new(); + loop { + read_line!(); + let mut line = line.as_str(); + if let Some(l) = line.strip_prefix(",") { + line = l; + } + let components = match serde_json::from_str::>(line) { + Ok(c) => c, + Err(e) => { + log::warn!( + "Could not deserialize i3bar status message: {}", + Report::new(e) + ); + continue; + } + }; + let separator = get!().get_i3bar_separator(); + let separator = match &separator { + Some(s) => s.as_str(), + _ => r##" | "##, + }; + status.clear(); + let mut first = true; + for component in &components { + if component.full_text.is_empty() { + continue; + } + if !first { + status.push_str(separator); + } + first = false; + let have_span = component.color.is_some() || component.background.is_some(); + if have_span { + status.push_str(""); + } + if component.markup.as_deref() == Some("pango") + || !escape_pango(&component.full_text, &mut status) + { + status.push_str(&component.full_text); + } + if have_span { + status.push_str(""); + } + } + set_status(&status); + } +} + +fn escape_pango(src: &str, dst: &mut String) -> bool { + if src + .bytes() + .any(|b| matches!(b, b'&' | b'<' | b'>' | b'\'' | b'"')) + { + for c in src.chars() { + match c { + '&' => dst.push_str("&"), + '<' => dst.push_str("<"), + '>' => dst.push_str(">"), + '\'' => dst.push_str("'"), + '"' => dst.push_str("""), + _ => dst.push(c), + } + } + true + } else { + false + } +} diff --git a/src/config/handler.rs b/src/config/handler.rs index 030fc498..b898162d 100644 --- a/src/config/handler.rs +++ b/src/config/handler.rs @@ -453,7 +453,7 @@ impl ConfigProxyHandler { fn get_seat(&self, seat: Seat) -> Result, CphError> { let seats = self.state.globals.seats.lock(); for seat_global in seats.values() { - if seat_global.id().raw() == seat.0 as _ { + if seat_global.id().raw() == seat.0 as u32 { return Ok(seat_global.clone()); } }