diff --git a/Cargo.lock b/Cargo.lock index 34a548be4..6fd842c97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1049,6 +1049,21 @@ dependencies = [ "zbus", ] +[[package]] +name = "cosmic-applet-status-line" +version = "0.1.0" +dependencies = [ + "delegate", + "libcosmic", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tracing", + "tracing-log", + "tracing-subscriber", +] + [[package]] name = "cosmic-applet-tiling" version = "0.1.0" @@ -1117,6 +1132,7 @@ dependencies = [ "cosmic-applet-notifications", "cosmic-applet-power", "cosmic-applet-status-area", + "cosmic-applet-status-line", "cosmic-applet-tiling", "cosmic-applet-time", "cosmic-applet-workspaces", @@ -1559,6 +1575,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "delegate" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d358e0ec5c59a5e1603b933def447096886121660fc680dc1e64a0753981fe3c" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "deranged" version = "0.3.11" diff --git a/Cargo.toml b/Cargo.toml index d67c9e032..77d06e68b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "cosmic-applet-notifications", "cosmic-applet-power", "cosmic-applet-status-area", + "cosmic-applet-status-line", "cosmic-applet-tiling", "cosmic-applet-time", "cosmic-applet-workspaces", diff --git a/cosmic-applet-status-line/Cargo.toml b/cosmic-applet-status-line/Cargo.toml new file mode 100644 index 000000000..e5ee56e19 --- /dev/null +++ b/cosmic-applet-status-line/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "cosmic-applet-status-line" +version = "0.1.0" +edition = "2021" + +[dependencies] +delegate = "0.9" +libcosmic.workspace = true +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tokio = { version = "1.27", features = ["io-util", "process", "sync"] } +tokio-stream = "0.1" +tracing-log.workspace = true +tracing-subscriber.workspace = true +tracing.workspace = true diff --git a/cosmic-applet-status-line/data/com.system76.CosmicAppletStatusLine.desktop b/cosmic-applet-status-line/data/com.system76.CosmicAppletStatusLine.desktop new file mode 100644 index 000000000..4adeb2b42 --- /dev/null +++ b/cosmic-applet-status-line/data/com.system76.CosmicAppletStatusLine.desktop @@ -0,0 +1,13 @@ +[Desktop Entry] +Name=Cosmic Applet Status Line +Comment=Applet for status lines via i3bar/swaybar protocol +Type=Application +Exec=cosmic-applet-status-line +Terminal=false +Categories=Cosmic;Iced; +Keywords=Cosmic;Iced; +# Translators: Do NOT translate or transliterate this text (this is an icon file name)! +Icon=com.system76.CosmicAppletStatusLine +StartupNotify=true +NoDisplay=true +X-CosmicApplet=true diff --git a/cosmic-applet-status-line/src/bar_widget.rs b/cosmic-applet-status-line/src/bar_widget.rs new file mode 100644 index 000000000..1929f7a27 --- /dev/null +++ b/cosmic-applet-status-line/src/bar_widget.rs @@ -0,0 +1,146 @@ +use cosmic::{ + iced::{self, widget, Length, Rectangle}, + iced_core::{ + clipboard::Clipboard, + event::{self, Event}, + layout::{Layout, Limits, Node}, + mouse, + renderer::Style, + touch, + widget::{ + operation::{Operation, OperationOutputWrapper}, + Tree, Widget, + }, + Shell, + }, +}; + +use crate::protocol::ClickEvent; + +const BTN_LEFT: u32 = 0x110; +const BTN_RIGHT: u32 = 0x111; +const BTN_MIDDLE: u32 = 0x112; + +/// Wraps a `Row` widget, handling mouse input +pub struct BarWidget<'a, Msg> { + pub row: widget::Row<'a, Msg, cosmic::Theme, cosmic::Renderer>, + pub name_instance: Vec<(Option<&'a str>, Option<&'a str>)>, + pub on_press: fn(ClickEvent) -> Msg, +} + +impl<'a, Msg> Widget for BarWidget<'a, Msg> { + delegate::delegate! { + to self.row { + fn children(&self) -> Vec; + fn diff(&mut self, tree: &mut Tree); + fn layout(&self, tree: &mut Tree, renderer: &cosmic::Renderer, limits: &Limits) -> Node; + fn operate( + &self, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &cosmic::Renderer, + operation: &mut dyn Operation>, + ); + fn draw( + &self, + state: &Tree, + renderer: &mut cosmic::Renderer, + theme: &cosmic::Theme, + style: &Style, + layout: Layout, + cursor: iced::mouse::Cursor, + viewport: &Rectangle, + ); + } + } + + fn on_event( + &mut self, + tree: &mut Tree, + event: Event, + layout: Layout<'_>, + cursor: iced::mouse::Cursor, + renderer: &cosmic::Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Msg>, + viewport: &Rectangle, + ) -> event::Status { + if self.update(&event, layout, cursor, shell) == event::Status::Captured { + return event::Status::Captured; + } + self.row.on_event( + tree, event, layout, cursor, renderer, clipboard, shell, viewport, + ) + } + + fn size(&self) -> iced::Size { + Widget::size(&self.row) + } +} + +impl<'a, Msg> From> for cosmic::Element<'a, Msg> +where + Msg: 'a, +{ + fn from(widget: BarWidget<'a, Msg>) -> cosmic::Element<'a, Msg> { + cosmic::Element::new(widget) + } +} + +impl<'a, Msg> BarWidget<'a, Msg> { + fn update( + &mut self, + event: &Event, + layout: Layout<'_>, + cursor: iced::mouse::Cursor, + shell: &mut Shell<'_, Msg>, + ) -> event::Status { + let Some(cursor_position) = cursor.position() else { + return event::Status::Ignored; + }; + + let (button, event_code) = match event { + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => (1, BTN_LEFT), + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)) => (2, BTN_MIDDLE), + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) => (3, BTN_RIGHT), + Event::Touch(touch::Event::FingerPressed { .. }) => (1, BTN_LEFT), + _ => { + return event::Status::Ignored; + } + }; + + let Some((n, bounds)) = layout + .children() + .map(|x| x.bounds()) + .enumerate() + .find(|(_, bounds)| bounds.contains(cursor_position)) + else { + return event::Status::Ignored; + }; + + let (name, instance) = self.name_instance.get(n).cloned().unwrap_or((None, None)); + + // TODO coordinate space? int conversion? + let x = cursor_position.x as u32; + let y = cursor_position.y as u32; + let relative_x = (cursor_position.x - bounds.x) as u32; + let relative_y = (cursor_position.y - bounds.y) as u32; + let width = bounds.width as u32; + let height = bounds.height as u32; + + shell.publish((self.on_press)(ClickEvent { + name: name.map(str::to_owned), + instance: instance.map(str::to_owned), + x, + y, + button, + event: event_code, + relative_x, + relative_y, + width, + height, + })); + + event::Status::Captured + } +} diff --git a/cosmic-applet-status-line/src/lib.rs b/cosmic-applet-status-line/src/lib.rs new file mode 100644 index 000000000..da265de6e --- /dev/null +++ b/cosmic-applet-status-line/src/lib.rs @@ -0,0 +1,103 @@ +// TODO: work vertically + +use cosmic::{app, iced, iced_style::application, Theme}; + +mod bar_widget; +use bar_widget::BarWidget; +mod protocol; + +#[derive(Clone, Debug)] +enum Msg { + Protocol(protocol::StatusLine), + ClickEvent(protocol::ClickEvent), +} + +struct App { + core: app::Core, + status_line: protocol::StatusLine, +} + +impl cosmic::Application for App { + type Message = Msg; + type Executor = cosmic::SingleThreadExecutor; + type Flags = (); + const APP_ID: &'static str = "com.system76.CosmicAppletStatusLine"; + + fn init(core: app::Core, _flags: ()) -> (Self, app::Command) { + ( + App { + core, + status_line: Default::default(), + }, + iced::Command::none(), + ) + } + + fn core(&self) -> &app::Core { + &self.core + } + + fn core_mut(&mut self) -> &mut app::Core { + &mut self.core + } + + fn style(&self) -> Option<::Style> { + Some(cosmic::applet::style()) + } + + fn subscription(&self) -> iced::Subscription { + protocol::subscription().map(Msg::Protocol) + } + + fn update(&mut self, message: Msg) -> app::Command { + match message { + Msg::Protocol(status_line) => { + println!("{:?}", status_line); + self.status_line = status_line; + } + Msg::ClickEvent(click_event) => { + println!("{:?}", click_event); + if self.status_line.click_events { + // TODO: pass click event to backend + } + } + } + iced::Command::none() + } + + fn view(&self) -> cosmic::Element { + let (block_views, name_instance): (Vec<_>, Vec<_>) = self + .status_line + .blocks + .iter() + .map(|block| { + ( + block_view(block), + (block.name.as_deref(), block.instance.as_deref()), + ) + }) + .unzip(); + BarWidget { + row: iced::widget::row(block_views), + name_instance, + on_press: Msg::ClickEvent, + } + .into() + } +} + +// TODO seperator +fn block_view(block: &protocol::Block) -> cosmic::Element { + let theme = block + .color + .map(cosmic::theme::Text::Color) + .unwrap_or(cosmic::theme::Text::Default); + cosmic::widget::text(&block.full_text) + .size(14) + .style(theme) + .into() +} + +pub fn run() -> iced::Result { + cosmic::applet::run::(true, ()) +} diff --git a/cosmic-applet-status-line/src/main.rs b/cosmic-applet-status-line/src/main.rs new file mode 100644 index 000000000..ebb0bc983 --- /dev/null +++ b/cosmic-applet-status-line/src/main.rs @@ -0,0 +1,10 @@ +const VERSION: &str = env!("CARGO_PKG_VERSION"); + +fn main() -> cosmic::iced::Result { + tracing_subscriber::fmt::init(); + let _ = tracing_log::LogTracer::init(); + + tracing::info!("Starting status line applet with version {VERSION}"); + + cosmic_applet_status_line::run() +} diff --git a/cosmic-applet-status-line/src/protocol/mod.rs b/cosmic-applet-status-line/src/protocol/mod.rs new file mode 100644 index 000000000..01de33781 --- /dev/null +++ b/cosmic-applet-status-line/src/protocol/mod.rs @@ -0,0 +1,111 @@ +/// TODO: if we get an error, terminate process with exit code 1. Let cosmic-panel restart us. +/// TODO: configuration for command? Use cosmic config system. +use cosmic::iced::{self, futures::FutureExt}; +use std::{ + fmt, + io::{BufRead, BufReader}, + process::{self, Stdio}, + thread, +}; +use tokio::sync::mpsc; + +mod serialization; +use serialization::Header; +pub use serialization::{Block, ClickEvent}; + +#[derive(Clone, Debug, Default)] +pub struct StatusLine { + pub blocks: Vec, + pub click_events: bool, +} + +pub fn subscription() -> iced::Subscription { + iced::subscription::run_with_id( + "status-cmd", + async { + let (sender, reciever) = mpsc::channel(20); + thread::spawn(move || { + let mut status_cmd = StatusCmd::spawn(); + let mut deserializer = + serde_json::Deserializer::from_reader(&mut status_cmd.stdout); + deserialize_status_lines(&mut deserializer, |blocks| { + sender + .blocking_send(StatusLine { + blocks, + click_events: status_cmd.header.click_events, + }) + .unwrap(); + }) + .unwrap(); + status_cmd.wait(); + }); + tokio_stream::wrappers::ReceiverStream::new(reciever) + } + .flatten_stream(), + ) +} + +pub struct StatusCmd { + header: Header, + stdin: process::ChildStdin, + stdout: BufReader, + child: process::Child, +} + +impl StatusCmd { + fn spawn() -> StatusCmd { + // XXX command + // XXX unwrap + let mut child = process::Command::new("i3status") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + + let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut header = String::new(); + stdout.read_line(&mut header).unwrap(); + + StatusCmd { + header: serde_json::from_str(&header).unwrap(), + stdin: child.stdin.take().unwrap(), + stdout, + child, + } + } + + fn wait(mut self) { + drop(self.stdin); + drop(self.stdout); + self.child.wait(); + } +} + +/// Deserialize a sequence of `Vec`s, executing a callback for each one. +/// Blocks thread until end of status line sequence. +fn deserialize_status_lines<'de, D: serde::Deserializer<'de>, F: FnMut(Vec)>( + deserializer: D, + cb: F, +) -> Result<(), D::Error> { + struct Visitor)> { + cb: F, + } + + impl<'de, F: FnMut(Vec)> serde::de::Visitor<'de> for Visitor { + type Value = (); + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a sequence of status lines") + } + + fn visit_seq>(mut self, mut seq: S) -> Result<(), S::Error> { + while let Some(blocks) = seq.next_element()? { + (self.cb)(blocks); + } + Ok(()) + } + } + + let visitor = Visitor { cb }; + deserializer.deserialize_seq(visitor) +} diff --git a/cosmic-applet-status-line/src/protocol/serialization.rs b/cosmic-applet-status-line/src/protocol/serialization.rs new file mode 100644 index 000000000..4fe18cfc7 --- /dev/null +++ b/cosmic-applet-status-line/src/protocol/serialization.rs @@ -0,0 +1,142 @@ +// This implementation may be stricter in parsing than swaybar or i3bar. If this is an issue, the +// status command should probably be the one that's corrected to conform. + +use cosmic::iced; +use serde::de::{Deserialize, Error}; + +fn sigcont() -> u8 { + 18 +} + +fn sigstop() -> u8 { + 19 +} + +#[derive(Clone, Debug, serde::Deserialize)] +pub struct Header { + pub version: u8, + #[serde(default)] + pub click_events: bool, + #[serde(default = "sigcont")] + pub cont_signal: u8, + #[serde(default = "sigstop")] + pub stop_signal: u8, +} + +fn default_border() -> u32 { + 1 +} + +fn default_seperator_block_width() -> u32 { + 9 +} + +/// Deserialize string with RGB or RGBA color into `iced::Color` +fn deserialize_color<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + let s = String::deserialize(deserializer)?; + + let unexpected_err = || { + D::Error::invalid_value( + serde::de::Unexpected::Str(&s), + &"a color string #RRGGBBAA or #RRGGBB", + ) + }; + + // Must be 8 or 9 character string starting with # + if !s.starts_with("#") || (s.len() != 7 && s.len() != 9) { + return Err(unexpected_err()); + } + + let parse_hex = |component| u8::from_str_radix(component, 16).map_err(|_| unexpected_err()); + let r = parse_hex(&s[1..3])?; + let g = parse_hex(&s[3..5])?; + let b = parse_hex(&s[5..7])?; + let a = if s.len() == 9 { + parse_hex(&s[7..])? as f32 / 1.0 + } else { + 1.0 + }; + Ok(Some(iced::Color::from_rgba8(r, g, b, a))) +} + +#[derive(Clone, Debug, Default, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Align { + #[default] + Left, + Right, + Center, +} + +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(untagged)] +pub enum MinWidth { + Int(u32), + Str(String), +} + +impl Default for MinWidth { + fn default() -> Self { + Self::Int(0) + } +} + +#[derive(Clone, Debug, Default, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Markup { + #[default] + None, + Pango, +} + +#[derive(Clone, Debug, serde::Deserialize)] +pub struct Block { + pub full_text: String, + pub short_text: Option, + #[serde(default)] + #[serde(deserialize_with = "deserialize_color")] + pub color: Option, + #[serde(default)] + #[serde(deserialize_with = "deserialize_color")] + pub background: Option, + #[serde(default)] + #[serde(deserialize_with = "deserialize_color")] + pub border: Option, + #[serde(default = "default_border")] + pub border_top: u32, + #[serde(default = "default_border")] + pub border_bottom: u32, + #[serde(default = "default_border")] + pub border_left: u32, + #[serde(default = "default_border")] + pub border_right: u32, + #[serde(default)] + pub min_width: MinWidth, + #[serde(default)] + pub align: Align, + pub name: Option, + pub instance: Option, + #[serde(default)] + pub urgent: bool, + #[serde(default)] + pub separator: bool, + #[serde(default = "default_seperator_block_width")] + pub separator_block_width: u32, + pub markup: Markup, +} + +#[derive(Clone, Debug, serde::Serialize)] +pub struct ClickEvent { + pub name: Option, + pub instance: Option, + pub x: u32, + pub y: u32, + pub button: u32, + pub event: u32, + pub relative_x: u32, + pub relative_y: u32, + pub width: u32, + pub height: u32, +} diff --git a/cosmic-applets/Cargo.toml b/cosmic-applets/Cargo.toml index 70965bf20..a954f6b86 100644 --- a/cosmic-applets/Cargo.toml +++ b/cosmic-applets/Cargo.toml @@ -13,10 +13,11 @@ cosmic-applet-network = { path = "../cosmic-applet-network" } cosmic-applet-notifications = { path = "../cosmic-applet-notifications" } cosmic-applet-power = { path = "../cosmic-applet-power" } cosmic-applet-status-area = { path = "../cosmic-applet-status-area" } +cosmic-applet-status-line = { path = "../cosmic-applet-status-line" } cosmic-applet-tiling = { path = "../cosmic-applet-tiling" } cosmic-applet-time = { path = "../cosmic-applet-time" } cosmic-applet-workspaces = { path = "../cosmic-applet-workspaces" } libcosmic.workspace = true tracing.workspace = true tracing-subscriber.workspace = true -tracing-log.workspace = true \ No newline at end of file +tracing-log.workspace = true diff --git a/cosmic-applets/src/main.rs b/cosmic-applets/src/main.rs index 389ce2d3a..252bbe3ed 100644 --- a/cosmic-applets/src/main.rs +++ b/cosmic-applets/src/main.rs @@ -23,6 +23,7 @@ fn main() -> cosmic::iced::Result { "cosmic-applet-notifications" => cosmic_applet_notifications::run(), "cosmic-applet-power" => cosmic_applet_power::run(), "cosmic-applet-status-area" => cosmic_applet_status_area::run(), + "cosmic-applet-status-line" => cosmic_applet_status_line::run(), "cosmic-applet-tiling" => cosmic_applet_tiling::run(), "cosmic-applet-time" => cosmic_applet_time::run(), "cosmic-applet-workspaces" => cosmic_applet_workspaces::run(), diff --git a/justfile b/justfile index a7d196fc0..21ede426e 100644 --- a/justfile +++ b/justfile @@ -51,7 +51,7 @@ _install_applet id name: (_install_icons name) \ _install_button id name: (_install_icons name) (_install_desktop name + '/data/' + id + '.desktop') # Installs files into the system -install: (_install_bin 'cosmic-applets') (_install_applet 'com.system76.CosmicAppList' 'cosmic-app-list') (_install_default_schema 'cosmic-app-list') (_install_applet 'com.system76.CosmicAppletAudio' 'cosmic-applet-audio') (_install_applet 'com.system76.CosmicAppletBattery' 'cosmic-applet-battery') (_install_applet 'com.system76.CosmicAppletBluetooth' 'cosmic-applet-bluetooth') (_install_applet 'com.system76.CosmicAppletMinimize' 'cosmic-applet-minimize') (_install_applet 'com.system76.CosmicAppletNetwork' 'cosmic-applet-network') (_install_applet 'com.system76.CosmicAppletNotifications' 'cosmic-applet-notifications') (_install_applet 'com.system76.CosmicAppletPower' 'cosmic-applet-power') (_install_applet 'com.system76.CosmicAppletStatusArea' 'cosmic-applet-status-area') (_install_applet 'com.system76.CosmicAppletTiling' 'cosmic-applet-tiling') (_install_applet 'com.system76.CosmicAppletTime' 'cosmic-applet-time') (_install_applet 'com.system76.CosmicAppletWorkspaces' 'cosmic-applet-workspaces') (_install_bin 'cosmic-panel-button') (_install_button 'com.system76.CosmicPanelAppButton' 'cosmic-panel-app-button') (_install_button 'com.system76.CosmicPanelLauncherButton' 'cosmic-panel-launcher-button') (_install_button 'com.system76.CosmicPanelWorkspacesButton' 'cosmic-panel-workspaces-button') +install: (_install_bin 'cosmic-applets') (_install_applet 'com.system76.CosmicAppList' 'cosmic-app-list') (_install_default_schema 'cosmic-app-list') (_install_applet 'com.system76.CosmicAppletAudio' 'cosmic-applet-audio') (_install_applet 'com.system76.CosmicAppletBattery' 'cosmic-applet-battery') (_install_applet 'com.system76.CosmicAppletBluetooth' 'cosmic-applet-bluetooth') (_install_applet 'com.system76.CosmicAppletMinimize' 'cosmic-applet-minimize') (_install_applet 'com.system76.CosmicAppletNetwork' 'cosmic-applet-network') (_install_applet 'com.system76.CosmicAppletNotifications' 'cosmic-applet-notifications') (_install_applet 'com.system76.CosmicAppletPower' 'cosmic-applet-power') (_install_applet 'com.system76.CosmicAppletStatusArea' 'cosmic-applet-status-area') (_install_applet 'com.system76.CosmicAppletStatusLine' 'cosmic-applet-status-line') (_install_applet 'com.system76.CosmicAppletTiling' 'cosmic-applet-tiling') (_install_applet 'com.system76.CosmicAppletTime' 'cosmic-applet-time') (_install_applet 'com.system76.CosmicAppletWorkspaces' 'cosmic-applet-workspaces') (_install_bin 'cosmic-panel-button') (_install_button 'com.system76.CosmicPanelAppButton' 'cosmic-panel-app-button') (_install_button 'com.system76.CosmicPanelLauncherButton' 'cosmic-panel-launcher-button') (_install_button 'com.system76.CosmicPanelWorkspacesButton' 'cosmic-panel-workspaces-button') # Vendor Cargo dependencies locally vendor: @@ -65,4 +65,4 @@ vendor: [private] vendor-extract: rm -rf vendor - tar pxf vendor.tar \ No newline at end of file + tar pxf vendor.tar