diff --git a/Cargo.lock b/Cargo.lock index 1fd9c035..54481ae1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -680,6 +680,17 @@ dependencies = [ "zbus", ] +[[package]] +name = "cosmic-applet-status-line" +version = "0.1.0" +dependencies = [ + "libcosmic", + "serde", + "serde_json", + "tokio", + "tokio-stream", +] + [[package]] name = "cosmic-applet-time" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 5dbf38a8..ffa6ec13 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "cosmic-applet-network", "cosmic-applet-notifications", "cosmic-applet-power", + "cosmic-applet-status-line", "cosmic-applet-time", "cosmic-applet-workspaces", "cosmic-panel-button", diff --git a/cosmic-applet-status-line/Cargo.toml b/cosmic-applet-status-line/Cargo.toml new file mode 100644 index 00000000..eeb2a3eb --- /dev/null +++ b/cosmic-applet-status-line/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "cosmic-applet-status-line" +version = "0.1.0" +edition = "2021" + +[dependencies] +libcosmic = { git = "https://github.com/pop-os/libcosmic/", branch = "master", default-features = false, features = ["tokio", "wayland", "applet"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tokio = { version = "1.27", features = ["io-util", "process", "sync"] } +tokio-stream = "0.1" diff --git a/cosmic-applet-status-line/src/main.rs b/cosmic-applet-status-line/src/main.rs new file mode 100644 index 00000000..c595ddfe --- /dev/null +++ b/cosmic-applet-status-line/src/main.rs @@ -0,0 +1,91 @@ +// TODO: work vertically + +use cosmic::{ + applet::CosmicAppletHelper, + iced::{self, wayland::InitialSurface, Application}, + iced_native::window, + iced_sctk::layout::Limits, + iced_style::application, +}; + +mod protocol; + +#[derive(Debug)] +enum Msg { + Protocol(protocol::StatusLine), + CloseRequest, +} + +#[derive(Default)] +struct App { + status_line: protocol::StatusLine, +} + +impl iced::Application for App { + type Message = Msg; + type Theme = cosmic::Theme; + type Executor = cosmic::SingleThreadExecutor; + type Flags = (); + + fn new(_flags: ()) -> (Self, iced::Command) { + (App::default(), iced::Command::none()) + } + + fn title(&self) -> String { + String::from("Status Line") + } + + fn close_requested(&self, _id: window::Id) -> Msg { + Msg::CloseRequest + } + + fn style(&self) -> ::Style { + ::Style::Custom(|theme| application::Appearance { + background_color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0), + text_color: theme.cosmic().on_bg_color().into(), + }) + } + + fn subscription(&self) -> iced::Subscription { + protocol::subscription().map(Msg::Protocol) + } + + fn update(&mut self, message: Msg) -> iced::Command { + match message { + Msg::Protocol(status_line) => { + println!("{:?}", status_line); + self.status_line = status_line; + } + Msg::CloseRequest => {} + } + iced::Command::none() + } + + fn view(&self, _id: window::Id) -> cosmic::Element { + iced::widget::row(self.status_line.blocks.iter().map(block_view).collect()).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).style(theme).into() +} + +fn main() -> iced::Result { + let helper = CosmicAppletHelper::default(); + let mut settings = helper.window_settings(); + match &mut settings.initial_surface { + InitialSurface::XdgWindow(s) => { + s.iced_settings.min_size = Some((1, 1)); + s.iced_settings.max_size = None; + s.autosize = true; + s.size_limits = Limits::NONE.min_height(1).min_width(1); + } + _ => unreachable!(), + }; + App::run(settings) +} 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 00000000..e829ea0c --- /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; +pub use serialization::Block; +use serialization::Header; + +#[derive(Debug, Default)] +pub struct StatusLine { + pub blocks: Vec, + pub click_events: bool, +} + +pub fn subscription() -> iced::Subscription { + iced::subscription::run( + "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 00000000..892c239d --- /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)] +struct ClickEvent { + name: Option, + instance: Option, + x: u32, + y: u32, + button: u32, + event: u32, + relative_x: u32, + relative_y: u32, + width: u32, + height: u32, +} diff --git a/justfile b/justfile index 1ef63322..16791991 100644 --- a/justfile +++ b/justfile @@ -38,6 +38,7 @@ _install_notifications: (_install 'com.system76.CosmicAppletNotifications' 'cosm _install_power: (_install 'com.system76.CosmicAppletPower' 'cosmic-applet-power') _install_workspace: (_install 'com.system76.CosmicAppletWorkspaces' 'cosmic-applet-workspaces') _install_time: (_install 'com.system76.CosmicAppletTime' 'cosmic-applet-time') +_install_status_line: (_install 'com.system76.CosmicAppletStatusLine' 'cosmic-applet-status-line') # TODO: Turn this into one configurable applet? _install_panel_button: (_install_bin 'cosmic-panel-button') @@ -46,7 +47,7 @@ _install_app_button: (_install_button 'com.system76.CosmicPanelAppButton' 'cosmi _install_workspaces_button: (_install_button 'com.system76.CosmicPanelWorkspacesButton' 'cosmic-panel-workspaces-button') # Installs files into the system -install: _install_app_list _install_audio _install_battery _install_bluetooth _install_graphics _install_network _install_notifications _install_power _install_workspace _install_time _install_panel_button _install_app_button _install_workspaces_button +install: _install_app_list _install_audio _install_battery _install_bluetooth _install_graphics _install_network _install_notifications _install_power _install_workspace _install_time _install_panel_button _install_app_button _install_workspaces_button _install_status_line # Extracts vendored dependencies if vendor=1 _extract_vendor: