diff --git a/Cargo.lock b/Cargo.lock index de7e0e26..d5a2a7c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -958,6 +958,24 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "cosmic-applet-i3status" +version = "0.1.1" +dependencies = [ + "anyhow", + "i18n-embed", + "i18n-embed-fl", + "libcosmic", + "once_cell", + "rust-embed", + "serde_json", + "swaybar-types", + "tokio", + "tracing", + "tracing-log", + "tracing-subscriber", +] + [[package]] name = "cosmic-applet-minimize" version = "0.1.1" @@ -1112,6 +1130,7 @@ dependencies = [ "cosmic-applet-audio", "cosmic-applet-battery", "cosmic-applet-bluetooth", + "cosmic-applet-i3status", "cosmic-applet-minimize", "cosmic-applet-network", "cosmic-applet-notifications", @@ -4941,6 +4960,16 @@ dependencies = [ "zeno", ] +[[package]] +name = "swaybar-types" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c0b435952b89d872f882cf7ae0756303ef68d310bfa44b9c8012fda88ae143" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "switcheroo-control" version = "0.1.0" @@ -5843,7 +5872,7 @@ dependencies = [ "js-sys", "log", "naga", - "parking_lot 0.11.2", + "parking_lot 0.12.1", "profiling", "raw-window-handle 0.6.0", "smallvec", @@ -5870,7 +5899,7 @@ dependencies = [ "log", "naga", "once_cell", - "parking_lot 0.11.2", + "parking_lot 0.12.1", "profiling", "raw-window-handle 0.6.0", "rustc-hash", @@ -5910,7 +5939,7 @@ dependencies = [ "naga", "objc", "once_cell", - "parking_lot 0.11.2", + "parking_lot 0.12.1", "profiling", "range-alloc", "raw-window-handle 0.6.0", diff --git a/Cargo.toml b/Cargo.toml index 348ab45b..0b941b70 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "cosmic-applet-audio", "cosmic-applet-battery", "cosmic-applet-bluetooth", + "cosmic-applet-i3status", "cosmic-applet-minimize", "cosmic-applet-network", "cosmic-applet-notifications", diff --git a/cosmic-applet-i3status/Cargo.toml b/cosmic-applet-i3status/Cargo.toml new file mode 100644 index 00000000..bcecec84 --- /dev/null +++ b/cosmic-applet-i3status/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "cosmic-applet-i3status" +version = "0.1.1" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +anyhow.workspace = true +i18n-embed-fl.workspace = true +i18n-embed.workspace = true +libcosmic.workspace = true +once_cell = "1" +rust-embed.workspace = true +serde_json = "1" +swaybar-types = "3.0.0" +tokio = { version = "1.36.0", features = ["time", "process"] } +tracing-log.workspace = true +tracing-subscriber.workspace = true +tracing.workspace = true diff --git a/cosmic-applet-i3status/data/com.system76.CosmicAppletI3status.desktop b/cosmic-applet-i3status/data/com.system76.CosmicAppletI3status.desktop new file mode 100644 index 00000000..dd84291b --- /dev/null +++ b/cosmic-applet-i3status/data/com.system76.CosmicAppletI3status.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Name=Cosmic Applet i3status +Comment=Applet for Cosmic Panel +Type=Application +Exec=cosmic-applet-i3status +Terminal=false +Categories=Cosmic;Iced; +Keywords=Cosmic;Iced; +StartupNotify=true +NoDisplay=true +X-CosmicApplet=true +X-HostWaylandDisplay=true diff --git a/cosmic-applet-i3status/i18n.toml b/cosmic-applet-i3status/i18n.toml new file mode 100644 index 00000000..05c50ba2 --- /dev/null +++ b/cosmic-applet-i3status/i18n.toml @@ -0,0 +1,4 @@ +fallback_language = "en" + +[fluent] +assets_dir = "i18n" \ No newline at end of file diff --git a/cosmic-applet-i3status/i18n/en/cosmic_applet_i3status.ftl b/cosmic-applet-i3status/i18n/en/cosmic_applet_i3status.ftl new file mode 100644 index 00000000..e69de29b diff --git a/cosmic-applet-i3status/src/lib.rs b/cosmic-applet-i3status/src/lib.rs new file mode 100644 index 00000000..e123f16f --- /dev/null +++ b/cosmic-applet-i3status/src/lib.rs @@ -0,0 +1,116 @@ +mod localize; +mod subprocess; + +use crate::localize::localize; +use cosmic::app::Command; +use cosmic::applet::cosmic_panel_config::PanelAnchor; +use cosmic::iced::Length; +use cosmic::iced_futures::Subscription; +use cosmic::iced_style::application; +use cosmic::iced_widget::{Column, Row}; +use cosmic::{Element, Theme}; +use subprocess::Output; +use swaybar_types::Block; +use tracing::{span, Level}; + +pub fn run() -> cosmic::iced::Result { + localize(); + cosmic::applet::run::(true, ()) +} + +#[derive(Default)] +struct I3status { + blocks: Vec, + core: cosmic::app::Core, + text: String, +} + +impl cosmic::Application for I3status { + type Message = Output; + type Executor = cosmic::SingleThreadExecutor; + type Flags = (); + const APP_ID: &'static str = "com.system76.CosmicAppletI3status"; + + fn init(core: cosmic::app::Core, _flags: ()) -> (Self, Command) { + ( + Self { + blocks: vec![], + core, + text: String::new(), + }, + Command::none(), + ) + } + + fn core(&self) -> &cosmic::app::Core { + &self.core + } + + fn core_mut(&mut self) -> &mut cosmic::app::Core { + &mut self.core + } + + fn style(&self) -> Option<::Style> { + Some(cosmic::applet::style()) + } + + fn update(&mut self, message: Output) -> Command { + let span = span!(Level::TRACE, "I3status::update()"); + let _ = span.enter(); + match message { + Output::Blocks(blocks) => { + self.blocks = blocks; + self.text = String::new(); + } + Output::Raw(output) => { + self.blocks = vec![]; + self.text = output; + } + Output::None => {} + } + Command::none() + } + + fn subscription(&self) -> Subscription { + let span = span!(Level::TRACE, "I3status::subscription()"); + let _ = span.enter(); + subprocess::child_process() + } + + fn view(&self) -> Element { + let theme = self.core.system_theme().cosmic(); + let space_xxs = theme.space_xxs(); + + let children = if !self.blocks.is_empty() { + self.blocks + .iter() + .map(|block| cosmic::iced_widget::text(&block.full_text).into()) + .collect::>>() + } else if !self.text.is_empty() { + vec![cosmic::iced_widget::text(&self.text).into()] + } else { + vec![cosmic::iced_widget::text("no output").into()] + }; + + if matches!( + self.core.applet.anchor, + PanelAnchor::Top | PanelAnchor::Bottom + ) { + Row::with_children(children) + .align_items(cosmic::iced_core::Alignment::Center) + .height(Length::Shrink) + .width(Length::Shrink) + .spacing(space_xxs) + .padding([0, space_xxs]) + .into() + } else { + Column::with_children(children) + .align_items(cosmic::iced_core::Alignment::Center) + .height(Length::Shrink) + .width(Length::Shrink) + .spacing(space_xxs) + .padding([space_xxs, 0]) + .into() + } + } +} diff --git a/cosmic-applet-i3status/src/localize.rs b/cosmic-applet-i3status/src/localize.rs new file mode 100644 index 00000000..caf9d66f --- /dev/null +++ b/cosmic-applet-i3status/src/localize.rs @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MPL-2.0-only + +use i18n_embed::{ + fluent::{fluent_language_loader, FluentLanguageLoader}, + DefaultLocalizer, LanguageLoader, Localizer, +}; +use once_cell::sync::Lazy; +use rust_embed::RustEmbed; + +#[derive(RustEmbed)] +#[folder = "i18n/"] +struct Localizations; + +pub static LANGUAGE_LOADER: Lazy = Lazy::new(|| { + let loader: FluentLanguageLoader = fluent_language_loader!(); + + loader + .load_fallback_language(&Localizations) + .expect("Error while loading fallback language"); + + loader +}); + +#[macro_export] +macro_rules! fl { + ($message_id:literal) => {{ + i18n_embed_fl::fl!($crate::localize::LANGUAGE_LOADER, $message_id) + }}; + + ($message_id:literal, $($args:expr),*) => {{ + i18n_embed_fl::fl!($crate::localize::LANGUAGE_LOADER, $message_id, $($args), *) + }}; +} + +// Get the `Localizer` to be used for localizing this library. +pub fn localizer() -> Box { + Box::from(DefaultLocalizer::new(&*LANGUAGE_LOADER, &Localizations)) +} + +pub fn localize() { + let localizer = localizer(); + let requested_languages = i18n_embed::DesktopLanguageRequester::requested_languages(); + + if let Err(error) = localizer.select(&requested_languages) { + eprintln!("Error while loading language for i3status {}", error); + } +} diff --git a/cosmic-applet-i3status/src/main.rs b/cosmic-applet-i3status/src/main.rs new file mode 100644 index 00000000..7dd2c681 --- /dev/null +++ b/cosmic-applet-i3status/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 minimize applet with version {VERSION}"); + + cosmic_applet_i3status::run() +} diff --git a/cosmic-applet-i3status/src/subprocess.rs b/cosmic-applet-i3status/src/subprocess.rs new file mode 100644 index 00000000..7534d610 --- /dev/null +++ b/cosmic-applet-i3status/src/subprocess.rs @@ -0,0 +1,126 @@ +use std::{any::TypeId, io, process::Stdio, time::Duration}; + +use cosmic::iced_futures::futures::future; +use swaybar_types::Block; +use tokio::{ + io::{AsyncBufReadExt, BufReader, Lines}, + process::{Child, ChildStdout, Command}, + time::sleep, +}; + +// TODO: support auto-detection of `i3status` and `i3status-rs` executables +// TODO: support basename-only commands by searching PATH (using "which" crate?) +// TODO: provide GUI for user to add their own preferred command +const COMMAND: &str = "/usr/bin/i3status-rs"; + +fn spawn() -> io::Result { + Command::new(COMMAND) + // `.stdin(Stdio::null())` is faster, but `i3status-rs` requires a working stdio + // (and the i3bar protocol doesn't specify either way) + // TODO: support click events on blocks + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() +} + +async fn read_blocks(state: State) -> (Output, State) { + match state { + State::Ready => match spawn() { + Ok(mut child) => { + let stdout = child + .stdout + .take() + .expect("should capture stdout from i3status"); + let reader = BufReader::new(stdout); + let stdout_lines = reader.lines(); + ( + Output::Raw(String::from("i3status started")), + State::Running { + child, + stdout_lines, + }, + ) + } + Err(_) => ( + Output::Raw(String::from("cannot spawn i3status")), + State::Finished, + ), + }, + State::Running { + child, + mut stdout_lines, + } => { + match stdout_lines.next_line().await { + Ok(Some(line)) => { + // for more information about the protocol, + // see: https://i3wm.org/docs/i3bar-protocol.html + + // the "endless array" output means we have a dangling comma to remove + let line = line.trim_end_matches(','); + + if let Ok(blocks) = serde_json::from_str::>(line) { + ( + Output::Blocks(blocks), + State::Running { + child, + stdout_lines, + }, + ) + } else { + ( + Output::Raw(String::from(line)), + State::Running { + child, + stdout_lines, + }, + ) + } + } + Ok(None) => { + sleep(Duration::from_secs(3)).await; + ( + Output::None, + State::Running { + child, + stdout_lines, + }, + ) + } + Err(_) => ( + Output::Raw(String::from("cannot read i3status stdout")), + State::Finished, + ), + } + } + State::Finished => { + // We do not let the stream die, as it would start a + // new download repeatedly if the user is not careful + // in case of errors. + future::pending().await + } + } +} + +pub fn child_process() -> cosmic::iced::Subscription { + struct SomeWorker; + cosmic::iced::subscription::unfold(TypeId::of::(), State::Ready, |state| { + read_blocks(state) + }) +} + +#[derive(Clone, Debug)] +pub enum Output { + Blocks(Vec), + Raw(String), + None, +} + +#[derive(Debug)] +pub enum State { + Ready, + Running { + child: Child, + stdout_lines: Lines>, + }, + Finished, +} diff --git a/cosmic-applets/Cargo.toml b/cosmic-applets/Cargo.toml index 70965bf2..3a7ba9aa 100644 --- a/cosmic-applets/Cargo.toml +++ b/cosmic-applets/Cargo.toml @@ -8,6 +8,7 @@ cosmic-app-list = { path = "../cosmic-app-list" } cosmic-applet-audio = { path = "../cosmic-applet-audio" } cosmic-applet-battery = { path = "../cosmic-applet-battery" } cosmic-applet-bluetooth = { path = "../cosmic-applet-bluetooth" } +cosmic-applet-i3status = { path = "../cosmic-applet-i3status" } cosmic-applet-minimize = { path = "../cosmic-applet-minimize" } cosmic-applet-network = { path = "../cosmic-applet-network" } cosmic-applet-notifications = { path = "../cosmic-applet-notifications" } @@ -19,4 +20,4 @@ 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 389ce2d3..693d08a1 100644 --- a/cosmic-applets/src/main.rs +++ b/cosmic-applets/src/main.rs @@ -18,6 +18,7 @@ fn main() -> cosmic::iced::Result { "cosmic-applet-audio" => cosmic_applet_audio::run(), "cosmic-applet-battery" => cosmic_applet_battery::run(), "cosmic-applet-bluetooth" => cosmic_applet_bluetooth::run(), + "cosmic-applet-i3status" => cosmic_applet_i3status::run(), "cosmic-applet-minimize" => cosmic_applet_minimize::run(), "cosmic-applet-network" => cosmic_applet_network::run(), "cosmic-applet-notifications" => cosmic_applet_notifications::run(), diff --git a/justfile b/justfile index a7d196fc..e91bf42a 100644 --- a/justfile +++ b/justfile @@ -48,10 +48,13 @@ _install_applet id name: (_install_icons name) \ (_install_desktop name + '/data/' + id + '.desktop') \ (_link_applet name) +_install_applet_noicon id name: (_install_desktop name + '/data/' + id + '.desktop') \ + (_link_applet 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.CosmicAppletI3status' 'cosmic-applet-i3status') (_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') # Vendor Cargo dependencies locally vendor: @@ -65,4 +68,4 @@ vendor: [private] vendor-extract: rm -rf vendor - tar pxf vendor.tar \ No newline at end of file + tar pxf vendor.tar