From f5bdc5a0272fefca4c91336699ea63913cdf3c08 Mon Sep 17 00:00:00 2001 From: Jake Stanger Date: Thu, 22 Jun 2023 23:06:45 +0100 Subject: [PATCH 1/4] feat: ipc server and cli --- Cargo.lock | 122 ++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 17 +++++- src/bridge_channel.rs | 2 +- src/cli/mod.rs | 19 +++++++ src/ipc/client.rs | 28 ++++++++++ src/ipc/commands.rs | 14 +++++ src/ipc/mod.rs | 33 ++++++++++++ src/ipc/responses.rs | 17 ++++++ src/ipc/server.rs | 119 ++++++++++++++++++++++++++++++++++++++++ src/main.rs | 62 +++++++++++++++++++-- 10 files changed, 427 insertions(+), 6 deletions(-) create mode 100644 src/cli/mod.rs create mode 100644 src/ipc/client.rs create mode 100644 src/ipc/commands.rs create mode 100644 src/ipc/mod.rs create mode 100644 src/ipc/responses.rs create mode 100644 src/ipc/server.rs diff --git a/Cargo.lock b/Cargo.lock index df8804e..9bd618a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,6 +52,55 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ca84f3628370c59db74ee214b3263d58f9aadd9b4fe7e711fd87dc452b7f163" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is-terminal", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41ed9a86bf92ae6580e0a31281f65a1b1d867c0cc68d5346e2ae128dddfa6a7d" + +[[package]] +name = "anstyle-parse" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e765fd216e48e067936442276d1d57399e37bce53c264d6fefbe298080cb57ee" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca11d4be1bab0c8bc8734a9aa7bf4ee8316d462a08c6ac5052f888fef5b494b" +dependencies = [ + "windows-sys 0.48.0", +] + +[[package]] +name = "anstyle-wincon" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180abfa45703aebe0093f79badacc01b8fd4ea2e35118747e5811127f926e188" +dependencies = [ + "anstyle", + "windows-sys 0.48.0", +] + [[package]] name = "anyhow" version = "1.0.71" @@ -395,6 +444,48 @@ dependencies = [ "winapi", ] +[[package]] +name = "clap" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80672091db20273a15cf9fdd4e47ed43b5091ec9841bf4c6145c9dfbbcae09ed" +dependencies = [ + "clap_builder", + "clap_derive", + "once_cell", +] + +[[package]] +name = "clap_builder" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1458a1df40e1e2afebb7ab60ce55c1fa8f431146205aa5f4887e0b111c27636" +dependencies = [ + "anstream", + "anstyle", + "bitflags 1.3.2", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8cd2b2a819ad6eec39e8f1d6b53001af1e5469f8c177579cdaeb313115b825f" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote 1.0.28", + "syn 2.0.18", +] + +[[package]] +name = "clap_lex" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da6da31387c7e4ef160ffab6d5e7f00c42626fe39aea70a7b0f1773f7dd6c1b" + [[package]] name = "color-eyre" version = "0.6.2" @@ -422,6 +513,12 @@ dependencies = [ "tracing-error", ] +[[package]] +name = "colorchoice" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" + [[package]] name = "concurrent-queue" version = "2.2.0" @@ -515,6 +612,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "ctrlc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a011bbe2c35ce9c1f143b7af6f94f29a167beb4cd1d29e6740ce836f723120e" +dependencies = [ + "nix 0.26.2", + "windows-sys 0.48.0", +] + [[package]] name = "darling" version = "0.14.4" @@ -1453,7 +1560,9 @@ dependencies = [ "async_once", "cfg-if", "chrono", + "clap", "color-eyre", + "ctrlc", "dirs", "futures-lite", "futures-util", @@ -1470,6 +1579,7 @@ dependencies = [ "regex", "reqwest", "serde", + "serde_json", "smithay-client-toolkit", "stray", "strip-ansi-escapes", @@ -1489,6 +1599,18 @@ dependencies = [ "zbus", ] +[[package]] +name = "is-terminal" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adcf93614601c8129ddf72e2d5633df827ba6551541c6d8c59520a371475be1f" +dependencies = [ + "hermit-abi 0.3.1", + "io-lifetimes", + "rustix", + "windows-sys 0.48.0", +] + [[package]] name = "itoa" version = "1.0.6" diff --git a/Cargo.toml b/Cargo.toml index d35de71..49486b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,9 +4,12 @@ version = "0.12.1" edition = "2021" license = "MIT" description = "Customisable GTK Layer Shell wlroots/sway bar" +repository = "https://github.com/jakestanger/ironbar" [features] default = [ + "cli", + "ipc", "http", "config+all", "clipboard", @@ -17,8 +20,11 @@ default = [ "upower", "workspaces+all" ] + +cli = ["dep:clap", "ipc"] +ipc = ["dep:serde_json"] + http = ["dep:reqwest"] -upower = ["upower_dbus", "zbus", "futures-lite"] "config+all" = ["config+json", "config+yaml", "config+toml", "config+corn", "config+ron"] "config+json" = ["universal-config/json"] @@ -40,6 +46,8 @@ sys_info = ["sysinfo", "regex"] tray = ["stray"] +upower = ["upower_dbus", "zbus", "futures-lite"] + workspaces = ["futures-util"] "workspaces+all" = ["workspaces", "workspaces+sway", "workspaces+hyprland"] "workspaces+sway" = ["workspaces", "swayipc-async"] @@ -67,11 +75,18 @@ wayland-protocols = { version = "0.30.0", features = ["unstable", "client"] } wayland-protocols-wlr = { version = "0.1.0", features = ["client"] } smithay-client-toolkit = { version = "0.17.0", default-features = false, features = ["calloop"] } universal-config = { version = "0.4.0", default_features = false } +ctrlc = "3.4.0" lazy_static = "1.4.0" async_once = "0.2.6" cfg-if = "1.0.0" +# cli +clap = { version = "4.2.7", optional = true, features = ["derive"] } + +# ipc +serde_json = { version = "1.0.96", optional = true } + # http reqwest = { version = "0.11.18", optional = true } diff --git a/src/bridge_channel.rs b/src/bridge_channel.rs index 2208e0c..101eb10 100644 --- a/src/bridge_channel.rs +++ b/src/bridge_channel.rs @@ -2,7 +2,7 @@ use crate::send; use tokio::spawn; use tokio::sync::mpsc; -/// MPSC async -> sync channel. +/// MPSC async -> GTK sync channel. /// The sender uses `tokio::sync::mpsc` /// while the receiver uses `glib::MainContext::channel`. /// diff --git a/src/cli/mod.rs b/src/cli/mod.rs new file mode 100644 index 0000000..feb2933 --- /dev/null +++ b/src/cli/mod.rs @@ -0,0 +1,19 @@ +use crate::ipc::commands::Command; +use crate::ipc::responses::Response; +use clap::Parser; +use serde::{Deserialize, Serialize}; + +#[derive(Parser, Debug, Serialize, Deserialize)] +#[command(version)] +pub struct Args { + #[command(subcommand)] + pub command: Option, +} + +pub fn handle_response(response: Response) { + match response { + Response::Ok => println!("ok"), + Response::OkValue { value } => println!("ok\n{value}"), + Response::Err { message } => eprintln!("error\n{}", message.unwrap_or_default()), + } +} diff --git a/src/ipc/client.rs b/src/ipc/client.rs new file mode 100644 index 0000000..b7e1da9 --- /dev/null +++ b/src/ipc/client.rs @@ -0,0 +1,28 @@ +use super::Ipc; +use crate::ipc::{Command, Response}; +use color_eyre::Result; +use color_eyre::{Help, Report}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::UnixStream; + +impl Ipc { + /// Sends a command to the IPC server. + /// The server response is returned. + pub async fn send(&self, command: Command) -> Result { + let mut stream = match UnixStream::connect(&self.path).await { + Ok(stream) => Ok(stream), + Err(err) => Err(Report::new(err) + .wrap_err("Failed to connect to Ironbar IPC server") + .suggestion("Is Ironbar running?")), + }?; + + let write_buffer = serde_json::to_vec(&command)?; + stream.write_all(&write_buffer).await?; + + let mut read_buffer = vec![0; 1024]; + let bytes = stream.read(&mut read_buffer).await?; + + let response = serde_json::from_slice(&read_buffer[..bytes])?; + Ok(response) + } +} diff --git a/src/ipc/commands.rs b/src/ipc/commands.rs new file mode 100644 index 0000000..51fc4d3 --- /dev/null +++ b/src/ipc/commands.rs @@ -0,0 +1,14 @@ +use clap::Subcommand; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Subcommand, Debug, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum Command { + /// Return "ok" + Ping, + + /// Open the GTK inspector + Inspect, +} + diff --git a/src/ipc/mod.rs b/src/ipc/mod.rs new file mode 100644 index 0000000..87aab89 --- /dev/null +++ b/src/ipc/mod.rs @@ -0,0 +1,33 @@ +mod client; +pub mod commands; +pub mod responses; +mod server; + +use std::path::PathBuf; +use tracing::warn; + +pub use commands::Command; +pub use responses::Response; + +#[derive(Debug)] +pub struct Ipc { + path: PathBuf, +} + +impl Ipc { + /// Creates a new IPC instance. + /// This can be used as both a server and client. + pub fn new() -> Self { + let ipc_socket_file = std::env::var("XDG_RUNTIME_DIR") + .map_or_else(|_| PathBuf::from("/tmp"), PathBuf::from) + .join("ironbar-ipc.sock"); + + if format!("{}", ipc_socket_file.display()).len() > 100 { + warn!("The IPC socket file's absolute path exceeds 100 bytes, the socket may fail to create."); + } + + Self { + path: ipc_socket_file, + } + } +} diff --git a/src/ipc/responses.rs b/src/ipc/responses.rs new file mode 100644 index 0000000..506732c --- /dev/null +++ b/src/ipc/responses.rs @@ -0,0 +1,17 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum Response { + Ok, + Err { message: Option }, +} + +impl Response { + /// Creates a new `Response::Error`. + pub fn error(message: &str) -> Self { + Self::Err { + message: Some(message.to_string()), + } + } +} diff --git a/src/ipc/server.rs b/src/ipc/server.rs new file mode 100644 index 0000000..d24ed48 --- /dev/null +++ b/src/ipc/server.rs @@ -0,0 +1,119 @@ +use super::Ipc; +use crate::bridge_channel::BridgeChannel; +use crate::ipc::{Command, Response}; +use crate::ironvar::get_variable_manager; +use crate::{read_lock, send_async, try_send, write_lock}; +use color_eyre::{Report, Result}; +use glib::Continue; +use std::fs; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{UnixListener, UnixStream}; +use tokio::spawn; +use tokio::sync::mpsc; +use tokio::sync::mpsc::{Receiver, Sender}; +use tracing::{debug, error, info, warn}; + +impl Ipc { + /// Starts the IPC server on its socket. + /// + /// Once started, the server will begin accepting connections. + pub fn start(&self) { + let bridge = BridgeChannel::::new(); + let cmd_tx = bridge.create_sender(); + let (res_tx, mut res_rx) = mpsc::channel(32); + + let path = self.path.clone(); + + if path.exists() { + warn!("Socket already exists. Did Ironbar exit abruptly?"); + warn!("Attempting IPC shutdown to allow binding to address"); + self.shutdown(); + } + + spawn(async move { + info!("Starting IPC on {}", path.display()); + + let listener = match UnixListener::bind(&path) { + Ok(listener) => listener, + Err(err) => { + error!( + "{:?}", + Report::new(err).wrap_err("Unable to start IPC server") + ); + return; + } + }; + + loop { + match listener.accept().await { + Ok((stream, _addr)) => { + if let Err(err) = + Self::handle_connection(stream, &cmd_tx, &mut res_rx).await + { + error!("{err:?}"); + } + } + Err(err) => { + error!("{err:?}"); + } + } + } + }); + + bridge.recv(move |command| { + let res = Self::handle_command(command); + try_send!(res_tx, res); + Continue(true) + }); + } + + /// Takes an incoming connections, + /// reads the command message, and sends the response. + /// + /// The connection is closed once the response has been written. + async fn handle_connection( + mut stream: UnixStream, + cmd_tx: &Sender, + res_rx: &mut Receiver, + ) -> Result<()> { + let (mut stream_read, mut stream_write) = stream.split(); + + let mut read_buffer = vec![0; 1024]; + let bytes = stream_read.read(&mut read_buffer).await?; + + let command = serde_json::from_slice::(&read_buffer[..bytes])?; + + debug!("Received command: {command:?}"); + + send_async!(cmd_tx, command); + let res = res_rx + .recv() + .await + .unwrap_or(Response::Err { message: None }); + let res = serde_json::to_vec(&res)?; + + stream_write.write_all(&res).await?; + stream_write.shutdown().await?; + + Ok(()) + } + + /// Takes an input command, runs it and returns with the appropriate response. + /// + /// This runs on the main thread, allowing commands to interact with GTK. + fn handle_command(command: Command) -> Response { + match command { + Command::Inspect => { + gtk::Window::set_interactive_debugging(true); + Response::Ok + } + Command::Ping => Response::Ok, + } + } + + /// Shuts down the IPC server, + /// removing the socket file in the process. + pub fn shutdown(&self) { + fs::remove_file(&self.path).ok(); + } +} diff --git a/src/main.rs b/src/main.rs index 8523a52..6ddcfb0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,8 @@ mod bar; mod bridge_channel; +#[cfg(feature = "cli")] +mod cli; mod clients; mod config; mod desktop_file; @@ -9,6 +11,8 @@ mod dynamic_string; mod error; mod gtk_helpers; mod image; +#[cfg(feature = "ipc")] +mod ipc; mod logging; mod macros; mod modules; @@ -20,6 +24,9 @@ mod unique_id; use crate::bar::create_bar; use crate::config::{Config, MonitorConfig}; use crate::style::load_css; +use cfg_if::cfg_if; +#[cfg(feature = "cli")] +use clap::Parser; use color_eyre::eyre::Result; use color_eyre::Report; use dirs::config_dir; @@ -32,8 +39,9 @@ use std::future::Future; use std::path::PathBuf; use std::process::exit; use std::rc::Rc; +use std::sync::mpsc; use tokio::runtime::Handle; -use tokio::task::block_in_place; +use tokio::task::{block_in_place, spawn_blocking}; use crate::error::ExitCode; use clients::wayland::{self, WaylandClient}; @@ -47,6 +55,32 @@ const VERSION: &str = env!("CARGO_PKG_VERSION"); async fn main() { let _guard = logging::install_logging(); + cfg_if! { + if #[cfg(feature = "cli")] { + run_with_args().await + } else { + start_ironbar().await + } + } +} + +#[cfg(feature = "cli")] +async fn run_with_args() { + let args = cli::Args::parse(); + + match args.command { + Some(command) => { + let ipc = ipc::Ipc::new(); + match ipc.send(command).await { + Ok(res) => cli::handle_response(res), + Err(err) => error!("{err:?}"), + }; + } + None => start_ironbar().await, + } +} + +async fn start_ironbar() { info!("Ironbar version {}", VERSION); info!("Starting application"); @@ -64,6 +98,13 @@ async fn main() { running.set(true); + cfg_if! { + if #[cfg(feature = "ipc")] { + let ipc = ipc::Ipc::new(); + ipc.start(); + } + } + let display = Display::default().map_or_else( || { let report = Report::msg("Failed to get default GTK display"); @@ -112,14 +153,27 @@ async fn main() { if style_path.exists() { load_css(style_path); } + + let (tx, rx) = mpsc::channel(); + + spawn_blocking(move || { + rx.recv().expect("to receive from channel"); + + info!("Shutting down"); + + #[cfg(feature = "ipc")] + ipc.shutdown(); + + exit(0); + }); + + ctrlc::set_handler(move || tx.send(()).expect("Could not send signal on channel.")) + .expect("Error setting Ctrl-C handler"); }); // Ignore CLI args // Some are provided by swaybar_config but not currently supported app.run_with_args(&Vec::<&str>::new()); - - info!("Shutting down"); - exit(0); } /// Creates each of the bars across each of the (configured) outputs. From ded50cca6f01f08a8e44257394fdde634d421e8e Mon Sep 17 00:00:00 2001 From: Jake Stanger Date: Thu, 22 Jun 2023 23:07:40 +0100 Subject: [PATCH 2/4] feat: support for 'ironvar' dynamic variables --- src/config/common.rs | 20 +- src/config/mod.rs | 2 + src/dynamic_string.rs | 160 -------------- src/dynamic_value/dynamic_bool.rs | 78 +++++++ src/dynamic_value/dynamic_string.rs | 321 ++++++++++++++++++++++++++++ src/dynamic_value/mod.rs | 7 + src/ipc/commands.rs | 18 +- src/ipc/responses.rs | 1 + src/ipc/server.rs | 16 ++ src/ironvar.rs | 107 ++++++++++ src/macros.rs | 42 +++- src/main.rs | 16 +- src/modules/custom/button.rs | 4 +- src/modules/custom/image.rs | 4 +- src/modules/custom/label.rs | 4 +- src/modules/custom/progress.rs | 4 +- src/modules/label.rs | 4 +- 17 files changed, 613 insertions(+), 195 deletions(-) delete mode 100644 src/dynamic_string.rs create mode 100644 src/dynamic_value/dynamic_bool.rs create mode 100644 src/dynamic_value/dynamic_string.rs create mode 100644 src/dynamic_value/mod.rs create mode 100644 src/ironvar.rs diff --git a/src/config/common.rs b/src/config/common.rs index 3bfe085..b22b38e 100644 --- a/src/config/common.rs +++ b/src/config/common.rs @@ -1,11 +1,9 @@ -use crate::dynamic_string::DynamicString; +use crate::dynamic_value::{dynamic_string, DynamicBool}; use crate::script::{Script, ScriptInput}; -use crate::send; use gtk::gdk::ScrollDirection; use gtk::prelude::*; use gtk::{EventBox, Orientation, Revealer, RevealerTransitionType}; use serde::Deserialize; -use tokio::spawn; use tracing::trace; /// Common configuration options @@ -15,7 +13,7 @@ pub struct CommonConfig { pub class: Option, pub name: Option, - pub show_if: Option, + pub show_if: Option, pub transition_type: Option, pub transition_duration: Option, @@ -114,7 +112,7 @@ impl CommonConfig { if let Some(tooltip) = self.tooltip { let container = container.clone(); - DynamicString::new(&tooltip, move |string| { + dynamic_string(&tooltip, move |string| { container.set_tooltip_text(Some(&string)); Continue(true) }); @@ -127,23 +125,13 @@ impl CommonConfig { container.show_all(); }, |show_if| { - let script = Script::new_polling(show_if); let container = container.clone(); - let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT); - - spawn(async move { - script - .run(None, |_, success| { - send!(tx, success); - }) - .await; - }); { let revealer = revealer.clone(); let container = container.clone(); - rx.attach(None, move |success| { + show_if.subscribe(move |success| { if success { container.show_all(); } diff --git a/src/config/mod.rs b/src/config/mod.rs index 1bc04c6..0ed2652 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -100,6 +100,8 @@ pub struct Config { /// GTK icon theme to use. pub icon_theme: Option, + pub ironvar_defaults: Option, String>>, + pub start: Option>, pub center: Option>, pub end: Option>, diff --git a/src/dynamic_string.rs b/src/dynamic_string.rs deleted file mode 100644 index b27e2d3..0000000 --- a/src/dynamic_string.rs +++ /dev/null @@ -1,160 +0,0 @@ -use crate::script::{OutputStream, Script}; -use crate::{lock, send}; -use gtk::prelude::*; -use std::sync::{Arc, Mutex}; -use tokio::spawn; - -/// A segment of a dynamic string, -/// containing either a static string -/// or a script. -#[derive(Debug)] -enum DynamicStringSegment { - Static(String), - Dynamic(Script), -} - -/// A string with embedded scripts for dynamic content. -pub struct DynamicString; - -impl DynamicString { - /// Creates a new dynamic string, based off the input template. - /// Runs `f` with the compiled string each time one of the scripts updates. - /// - /// # Example - /// - /// ```rs - /// DynamicString::new(&text, move |string| { - /// label.set_markup(&string); - /// Continue(true) - /// }); - /// ``` - pub fn new(input: &str, f: F) -> Self - where - F: FnMut(String) -> Continue + 'static, - { - let segments = Self::parse_input(input); - - let label_parts = Arc::new(Mutex::new(Vec::new())); - let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT); - - for (i, segment) in segments.into_iter().enumerate() { - match segment { - DynamicStringSegment::Static(str) => { - lock!(label_parts).push(str); - } - DynamicStringSegment::Dynamic(script) => { - let tx = tx.clone(); - let label_parts = label_parts.clone(); - - // insert blank value to preserve segment order - lock!(label_parts).push(String::new()); - - spawn(async move { - script - .run(None, |out, _| { - if let OutputStream::Stdout(out) = out { - let mut label_parts = lock!(label_parts); - - let _: String = std::mem::replace(&mut label_parts[i], out); - - let string = label_parts.join(""); - send!(tx, string); - } - }) - .await; - }); - } - } - } - - // initialize - { - let label_parts = lock!(label_parts).join(""); - send!(tx, label_parts); - } - - rx.attach(None, f); - - Self - } - - /// Parses the input string into static and dynamic segments - fn parse_input(input: &str) -> Vec { - if !input.contains("{{") { - return vec![DynamicStringSegment::Static(input.to_string())]; - } - - let mut segments = vec![]; - - let mut chars = input.chars().collect::>(); - while !chars.is_empty() { - let char_pair = if chars.len() > 1 { - Some(&chars[..=1]) - } else { - None - }; - - let (token, skip) = if let Some(['{', '{']) = char_pair { - const SKIP_BRACKETS: usize = 4; // two braces either side - - let str = chars - .windows(2) - .skip(2) - .take_while(|win| win != &['}', '}']) - .map(|w| w[0]) - .collect::(); - - let len = str.len(); - - ( - DynamicStringSegment::Dynamic(Script::from(str.as_str())), - len + SKIP_BRACKETS, - ) - } else { - let mut str = chars - .windows(2) - .take_while(|win| win != &['{', '{']) - .map(|w| w[0]) - .collect::(); - - // if segment is at end of string, last char gets missed above due to uneven window. - if chars.len() == str.len() + 1 { - let remaining_char = *chars.get(str.len()).expect("Failed to find last char"); - str.push(remaining_char); - } - - let len = str.len(); - - (DynamicStringSegment::Static(str), len) - }; - - // quick runtime check to make sure the parser is working as expected - assert_ne!(skip, 0); - - segments.push(token); - chars.drain(..skip); - } - - segments - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn test() { - // TODO: see if we can run gtk tests in ci - if gtk::init().is_ok() { - let label = gtk::Label::new(None); - DynamicString::new( - "Uptime: {{1000:uptime -p | cut -d ' ' -f2-}}", - move |string| { - label.set_label(&string); - Continue(true) - }, - ); - } - } -} diff --git a/src/dynamic_value/dynamic_bool.rs b/src/dynamic_value/dynamic_bool.rs new file mode 100644 index 0000000..094408f --- /dev/null +++ b/src/dynamic_value/dynamic_bool.rs @@ -0,0 +1,78 @@ +#[cfg(feature = "ipc")] +use crate::ironvar::get_variable_manager; +use crate::script::Script; +use crate::send; +use cfg_if::cfg_if; +use glib::Continue; +use serde::Deserialize; +use tokio::spawn; + +#[derive(Debug, Deserialize, Clone)] +#[serde(untagged)] +pub enum DynamicBool { + /// Either a script or variable, to be determined. + Unknown(String), + Script(Script), + #[cfg(feature = "ipc")] + Variable(Box), +} + +impl DynamicBool { + pub fn subscribe(self, f: F) + where + F: FnMut(bool) -> Continue + 'static, + { + let value = match self { + Self::Unknown(input) => { + if input.starts_with('#') { + cfg_if! { + if #[cfg(feature = "ipc")] { + Self::Variable(input.into()) + } else { + Self::Unknown(input) + } + } + } else { + let script = Script::from(input.as_str()); + Self::Script(script) + } + } + _ => self, + }; + + let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT); + + rx.attach(None, f); + + spawn(async move { + match value { + DynamicBool::Script(script) => { + script + .run(None, |_, success| { + send!(tx, success); + }) + .await; + } + #[cfg(feature = "ipc")] + DynamicBool::Variable(variable) => { + let variable_manager = get_variable_manager(); + + let variable_name = variable[1..].into(); // remove hash + let mut rx = crate::write_lock!(variable_manager).subscribe(variable_name); + + while let Ok(value) = rx.recv().await { + let has_value = value.map(|s| is_truthy(&s)).unwrap_or_default(); + send!(tx, has_value); + } + } + DynamicBool::Unknown(_) => unreachable!(), + } + }); + } +} + +/// Check if a string ironvar is 'truthy' +#[cfg(feature = "ipc")] +fn is_truthy(string: &str) -> bool { + !(string.is_empty() || string == "0" || string == "false") +} diff --git a/src/dynamic_value/dynamic_string.rs b/src/dynamic_value/dynamic_string.rs new file mode 100644 index 0000000..d9332f0 --- /dev/null +++ b/src/dynamic_value/dynamic_string.rs @@ -0,0 +1,321 @@ +#[cfg(feature = "ipc")] +use crate::ironvar::get_variable_manager; +use crate::script::{OutputStream, Script}; +use crate::{lock, send}; +use gtk::prelude::*; +use std::sync::{Arc, Mutex}; +use tokio::spawn; + +/// A segment of a dynamic string, +/// containing either a static string +/// or a script. +#[derive(Debug)] +enum DynamicStringSegment { + Static(String), + Script(Script), + #[cfg(feature = "ipc")] + Variable(Box), +} + +/// Creates a new dynamic string, based off the input template. +/// Runs `f` with the compiled string each time one of the scripts or variables updates. +/// +/// # Example +/// +/// ```rs +/// dynamic_string(&text, move |string| { +/// label.set_markup(&string); +/// Continue(true) +/// }); +/// ``` +pub fn dynamic_string(input: &str, f: F) +where + F: FnMut(String) -> Continue + 'static, +{ + let tokens = parse_input(input); + + let label_parts = Arc::new(Mutex::new(Vec::new())); + let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT); + + for (i, segment) in tokens.into_iter().enumerate() { + match segment { + DynamicStringSegment::Static(str) => { + lock!(label_parts).push(str); + } + DynamicStringSegment::Script(script) => { + let tx = tx.clone(); + let label_parts = label_parts.clone(); + + // insert blank value to preserve segment order + lock!(label_parts).push(String::new()); + + spawn(async move { + script + .run(None, |out, _| { + if let OutputStream::Stdout(out) = out { + let mut label_parts = lock!(label_parts); + + let _: String = std::mem::replace(&mut label_parts[i], out); + + let string = label_parts.join(""); + send!(tx, string); + } + }) + .await; + }); + } + #[cfg(feature = "ipc")] + DynamicStringSegment::Variable(name) => { + let tx = tx.clone(); + let label_parts = label_parts.clone(); + + // insert blank value to preserve segment order + lock!(label_parts).push(String::new()); + + spawn(async move { + let variable_manager = get_variable_manager(); + let mut rx = crate::write_lock!(variable_manager).subscribe(name); + + while let Ok(value) = rx.recv().await { + if let Some(value) = value { + let mut label_parts = lock!(label_parts); + + let _: String = std::mem::replace(&mut label_parts[i], value); + + let string = label_parts.join(""); + send!(tx, string); + } + } + }); + } + } + } + + rx.attach(None, f); + + // initialize + { + let label_parts = lock!(label_parts).join(""); + send!(tx, label_parts); + } +} + +/// Parses the input string into static and dynamic segments +fn parse_input(input: &str) -> Vec { + // short-circuit parser if it's all static + if !input.contains("{{") && !input.contains('#') { + return vec![DynamicStringSegment::Static(input.to_string())]; + } + + let mut tokens = vec![]; + + let mut chars = input.chars().collect::>(); + while !chars.is_empty() { + let char_pair = if chars.len() > 1 { + Some(&chars[..=1]) + } else { + None + }; + + let (token, skip) = match char_pair { + Some(['{', '{']) => parse_script(&chars), + Some(['#', '#']) => (DynamicStringSegment::Static("#".to_string()), 2), + #[cfg(feature = "ipc")] + Some(['#', _]) => parse_variable(&chars), + _ => parse_static(&chars), + }; + + // quick runtime check to make sure the parser is working as expected + assert_ne!(skip, 0); + + tokens.push(token); + chars.drain(..skip); + } + + tokens +} + +fn parse_script(chars: &[char]) -> (DynamicStringSegment, usize) { + const SKIP_BRACKETS: usize = 4; // two braces either side + + let str = chars + .windows(2) + .skip(2) + .take_while(|win| win != &['}', '}']) + .map(|w| w[0]) + .collect::(); + + let len = str.len() + SKIP_BRACKETS; + let script = Script::from(str.as_str()); + + (DynamicStringSegment::Script(script), len) +} + +#[cfg(feature = "ipc")] +fn parse_variable(chars: &[char]) -> (DynamicStringSegment, usize) { + const SKIP_HASH: usize = 1; + + let str = chars + .iter() + .skip(1) + .take_while(|&c| !c.is_whitespace()) + .collect::(); + + let len = str.len() + SKIP_HASH; + let value = str.into(); + + (DynamicStringSegment::Variable(value), len) +} + +fn parse_static(chars: &[char]) -> (DynamicStringSegment, usize) { + let mut str = chars + .windows(2) + .take_while(|&win| win != ['{', '{'] && win[0] != '#') + .map(|w| w[0]) + .collect::(); + + // if segment is at end of string, last char gets missed above due to uneven window. + if chars.len() == str.len() + 1 { + let remaining_char = *chars.get(str.len()).expect("Failed to find last char"); + str.push(remaining_char); + } + + let len = str.len(); + + (DynamicStringSegment::Static(str), len) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_static() { + const INPUT: &str = "hello world"; + let tokens = parse_input(INPUT); + + assert_eq!(tokens.len(), 1); + assert!(matches!(&tokens[0], DynamicStringSegment::Static(value) if value == INPUT)) + } + + #[test] + fn test_static_odd_char_count() { + const INPUT: &str = "hello"; + let tokens = parse_input(INPUT); + + assert_eq!(tokens.len(), 1); + assert!(matches!(&tokens[0], DynamicStringSegment::Static(value) if value == INPUT)) + } + + #[test] + fn test_script() { + const INPUT: &str = "{{echo hello}}"; + let tokens = parse_input(INPUT); + + assert_eq!(tokens.len(), 1); + assert!( + matches!(&tokens[0], DynamicStringSegment::Script(script) if script.cmd == "echo hello") + ); + } + + #[test] + fn test_variable() { + const INPUT: &str = "#variable"; + let tokens = parse_input(INPUT); + + assert_eq!(tokens.len(), 1); + assert!( + matches!(&tokens[0], DynamicStringSegment::Variable(name) if name.to_string() == "variable") + ); + } + + #[test] + fn test_static_script() { + const INPUT: &str = "hello {{echo world}}"; + let tokens = parse_input(INPUT); + + assert_eq!(tokens.len(), 2); + assert!(matches!(&tokens[0], DynamicStringSegment::Static(str) if str == "hello ")); + assert!( + matches!(&tokens[1], DynamicStringSegment::Script(script) if script.cmd == "echo world") + ); + } + + #[test] + fn test_static_variable() { + const INPUT: &str = "hello #subject"; + let tokens = parse_input(INPUT); + + assert_eq!(tokens.len(), 2); + assert!(matches!(&tokens[0], DynamicStringSegment::Static(str) if str == "hello ")); + assert!( + matches!(&tokens[1], DynamicStringSegment::Variable(name) if name.to_string() == "subject") + ); + } + + #[test] + fn test_static_script_static() { + const INPUT: &str = "hello {{echo world}} foo"; + let tokens = parse_input(INPUT); + + assert_eq!(tokens.len(), 3); + assert!(matches!(&tokens[0], DynamicStringSegment::Static(str) if str == "hello ")); + assert!( + matches!(&tokens[1], DynamicStringSegment::Script(script) if script.cmd == "echo world") + ); + assert!(matches!(&tokens[2], DynamicStringSegment::Static(str) if str == " foo")); + } + + #[test] + fn test_static_variable_static() { + const INPUT: &str = "hello #subject foo"; + let tokens = parse_input(INPUT); + + assert_eq!(tokens.len(), 3); + assert!(matches!(&tokens[0], DynamicStringSegment::Static(str) if str == "hello ")); + assert!( + matches!(&tokens[1], DynamicStringSegment::Variable(name) if name.to_string() == "subject") + ); + assert!(matches!(&tokens[2], DynamicStringSegment::Static(str) if str == " foo")); + } + + #[test] + fn test_static_script_variable() { + const INPUT: &str = "hello {{echo world}} #foo"; + let tokens = parse_input(INPUT); + + assert_eq!(tokens.len(), 4); + assert!(matches!(&tokens[0], DynamicStringSegment::Static(str) if str == "hello ")); + assert!( + matches!(&tokens[1], DynamicStringSegment::Script(script) if script.cmd == "echo world") + ); + assert!(matches!(&tokens[2], DynamicStringSegment::Static(str) if str == " ")); + assert!( + matches!(&tokens[3], DynamicStringSegment::Variable(name) if name.to_string() == "foo") + ); + } + + #[test] + fn test_escape_hash() { + const INPUT: &str = "number ###num"; + let tokens = parse_input(INPUT); + + assert_eq!(tokens.len(), 3); + assert!(matches!(&tokens[0], DynamicStringSegment::Static(str) if str == "number ")); + assert!(matches!(&tokens[1], DynamicStringSegment::Static(str) if str == "#")); + assert!( + matches!(&tokens[2], DynamicStringSegment::Variable(name) if name.to_string() == "num") + ); + } + + #[test] + fn test_script_with_hash() { + const INPUT: &str = "{{echo #hello}}"; + let tokens = parse_input(INPUT); + + assert_eq!(tokens.len(), 1); + assert!( + matches!(&tokens[0], DynamicStringSegment::Script(script) if script.cmd == "echo #hello") + ); + } +} diff --git a/src/dynamic_value/mod.rs b/src/dynamic_value/mod.rs new file mode 100644 index 0000000..83ad88c --- /dev/null +++ b/src/dynamic_value/mod.rs @@ -0,0 +1,7 @@ +#![doc = include_str!("../../docs/Dynamic values.md")] + +mod dynamic_bool; +mod dynamic_string; + +pub use dynamic_bool::DynamicBool; +pub use dynamic_string::dynamic_string; diff --git a/src/ipc/commands.rs b/src/ipc/commands.rs index 51fc4d3..11e1cfb 100644 --- a/src/ipc/commands.rs +++ b/src/ipc/commands.rs @@ -10,5 +10,21 @@ pub enum Command { /// Open the GTK inspector Inspect, -} + /// Set an `ironvar` value. + /// This creates it if it does not already exist, and updates it if it does. + /// Any references to this variable are automatically and immediately updated. + /// Keys and values can be any valid UTF-8 string. + Set { + /// Variable key. Can be any valid UTF-8 string. + key: Box, + /// Variable value. Can be any valid UTF-8 string. + value: String, + }, + + /// Get the current value of an `ironvar`. + Get { + /// Variable key. + key: Box, + }, +} diff --git a/src/ipc/responses.rs b/src/ipc/responses.rs index 506732c..df5a802 100644 --- a/src/ipc/responses.rs +++ b/src/ipc/responses.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; #[serde(tag = "type")] pub enum Response { Ok, + OkValue { value: String }, Err { message: Option }, } diff --git a/src/ipc/server.rs b/src/ipc/server.rs index d24ed48..816e624 100644 --- a/src/ipc/server.rs +++ b/src/ipc/server.rs @@ -106,6 +106,22 @@ impl Ipc { Command::Inspect => { gtk::Window::set_interactive_debugging(true); Response::Ok + }, + Command::Set { key, value } => { + let variable_manager = get_variable_manager(); + let mut variable_manager = write_lock!(variable_manager); + match variable_manager.set(key, value) { + Ok(_) => Response::Ok, + Err(err) => Response::error(&format!("{err}")), + } + } + Command::Get { key } => { + let variable_manager = get_variable_manager(); + let value = read_lock!(variable_manager).get(&key); + match value { + Some(value) => Response::OkValue { value }, + None => Response::error("Variable not found"), + } } Command::Ping => Response::Ok, } diff --git a/src/ironvar.rs b/src/ironvar.rs new file mode 100644 index 0000000..a83f07f --- /dev/null +++ b/src/ironvar.rs @@ -0,0 +1,107 @@ +#![doc = include_str!("../docs/Ironvars.md")] + +use crate::{arc_rw, send}; +use color_eyre::{Report, Result}; +use lazy_static::lazy_static; +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; +use tokio::sync::broadcast; + +lazy_static! { + static ref VARIABLE_MANAGER: Arc> = arc_rw!(VariableManager::new()); +} + +pub fn get_variable_manager() -> Arc> { + VARIABLE_MANAGER.clone() +} + +/// Global singleton manager for `IronVar` variables. +pub struct VariableManager { + variables: HashMap, IronVar>, +} + +impl VariableManager { + pub fn new() -> Self { + Self { + variables: HashMap::new(), + } + } + + /// Sets the value for a variable, + /// creating it if it does not exist. + pub fn set(&mut self, key: Box, value: String) -> Result<()> { + if Self::key_is_valid(&key) { + if let Some(var) = self.variables.get_mut(&key) { + var.set(Some(value)); + } else { + let var = IronVar::new(Some(value)); + self.variables.insert(key, var); + } + + Ok(()) + } else { + Err(Report::msg("Invalid key")) + } + } + + /// Gets the current value of an `ironvar`. + /// Prefer to use `subscribe` where possible. + pub fn get(&self, key: &str) -> Option { + self.variables.get(key).and_then(IronVar::get) + } + + /// Subscribes to an `ironvar`, creating it if it does not exist. + /// Any time the var is set, its value is sent on the channel. + pub fn subscribe(&mut self, key: Box) -> broadcast::Receiver> { + self.variables + .entry(key) + .or_insert_with(|| IronVar::new(None)) + .subscribe() + } + + fn key_is_valid(key: &str) -> bool { + !key.is_empty() + && key + .chars() + .all(|char| char.is_alphanumeric() || char == '_' || char == '-') + } +} + +/// Ironbar dynamic variable representation. +/// Interact with them through the `VARIABLE_MANAGER` `VariableManager` singleton. +#[derive(Debug)] +struct IronVar { + value: Option, + tx: broadcast::Sender>, + _rx: broadcast::Receiver>, +} + +impl IronVar { + /// Creates a new variable. + fn new(value: Option) -> Self { + let (tx, rx) = broadcast::channel(32); + + Self { value, tx, _rx: rx } + } + + /// Gets the current variable value. + /// Prefer to subscribe to changes where possible. + fn get(&self) -> Option { + self.value.clone() + } + + /// Sets the current variable value. + /// The change is broadcast to all receivers. + fn set(&mut self, value: Option) { + self.value = value.clone(); + send!(self.tx, value); + } + + /// Subscribes to the variable. + /// The latest value is immediately sent to all receivers. + fn subscribe(&self) -> broadcast::Receiver> { + let rx = self.tx.subscribe(); + send!(self.tx, self.value.clone()); + rx + } +} diff --git a/src/macros.rs b/src/macros.rs index 49e91a2..fa2ea30 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -1,7 +1,7 @@ /// Sends a message on an asynchronous `Sender` using `send()` /// Panics if the message cannot be sent. /// -/// Usage: +/// # Usage: /// /// ```rs /// send_async!(tx, "my message"); @@ -16,7 +16,7 @@ macro_rules! send_async { /// Sends a message on an synchronous `Sender` using `send()` /// Panics if the message cannot be sent. /// -/// Usage: +/// # Usage: /// /// ```rs /// send!(tx, "my message"); @@ -31,7 +31,7 @@ macro_rules! send { /// Sends a message on an synchronous `Sender` using `try_send()` /// Panics if the message cannot be sent. /// -/// Usage: +/// # Usage: /// /// ```rs /// try_send!(tx, "my message"); @@ -46,7 +46,7 @@ macro_rules! try_send { /// Locks a `Mutex`. /// Panics if the `Mutex` cannot be locked. /// -/// Usage: +/// # Usage: /// /// ```rs /// let mut val = lock!(my_mutex); @@ -62,7 +62,7 @@ macro_rules! lock { /// Gets a read lock on a `RwLock`. /// Panics if the `RwLock` cannot be locked. /// -/// Usage: +/// # Usage: /// /// ```rs /// let val = read_lock!(my_rwlock); @@ -77,7 +77,7 @@ macro_rules! read_lock { /// Gets a write lock on a `RwLock`. /// Panics if the `RwLock` cannot be locked. /// -/// Usage: +/// # Usage: /// /// ```rs /// let mut val = write_lock!(my_rwlock); @@ -88,3 +88,33 @@ macro_rules! write_lock { $rwlock.write().expect($crate::error::ERR_WRITE_LOCK) }; } + +/// Wraps `val` in a new `Arc>`. +/// +/// # Usage: +/// +/// ```rs +/// let val = arc_mut!(MyService::new()); +/// ``` +/// +#[macro_export] +macro_rules! arc_mut { + ($val:expr) => { + std::sync::Arc::new(std::Sync::Mutex::new($val)) + }; +} + +/// Wraps `val` in a new `Arc>`. +/// +/// # Usage: +/// +/// ```rs +/// let val = arc_rw!(MyService::new()); +/// ``` +/// +#[macro_export] +macro_rules! arc_rw { + ($val:expr) => { + std::sync::Arc::new(std::sync::RwLock::new($val)) + }; +} diff --git a/src/main.rs b/src/main.rs index 6ddcfb0..6cd5022 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,12 +7,14 @@ mod cli; mod clients; mod config; mod desktop_file; -mod dynamic_string; +mod dynamic_value; mod error; mod gtk_helpers; mod image; #[cfg(feature = "ipc")] mod ipc; +#[cfg(feature = "ipc")] +mod ironvar; mod logging; mod macros; mod modules; @@ -119,7 +121,7 @@ async fn start_ironbar() { ConfigLoader::load, ); - let config = match config_res { + let mut config: Config = match config_res { Ok(config) => config, Err(err) => { error!("{:?}", err); @@ -129,6 +131,16 @@ async fn start_ironbar() { debug!("Loaded config file"); + #[cfg(feature = "ipc")] + if let Some(ironvars) = config.ironvar_defaults.take() { + let variable_manager = ironvar::get_variable_manager(); + for (k, v) in ironvars { + if write_lock!(variable_manager).set(k.clone(), v).is_err() { + tracing::warn!("Ignoring invalid ironvar: '{k}'"); + } + } + } + if let Err(err) = create_bars(app, &display, wayland_client, &config) { error!("{:?}", err); exit(ExitCode::CreateBars as i32); diff --git a/src/modules/custom/button.rs b/src/modules/custom/button.rs index bb67a88..f9ea7a2 100644 --- a/src/modules/custom/button.rs +++ b/src/modules/custom/button.rs @@ -1,5 +1,5 @@ use super::{CustomWidget, CustomWidgetContext, ExecEvent}; -use crate::dynamic_string::DynamicString; +use crate::dynamic_value::dynamic_string; use crate::popup::Popup; use crate::{build, try_send}; use gtk::prelude::*; @@ -25,7 +25,7 @@ impl CustomWidget for ButtonWidget { label.set_use_markup(true); button.add(&label); - DynamicString::new(&text, move |string| { + dynamic_string(&text, move |string| { label.set_markup(&string); Continue(true) }); diff --git a/src/modules/custom/image.rs b/src/modules/custom/image.rs index 3ae11fb..edbcc26 100644 --- a/src/modules/custom/image.rs +++ b/src/modules/custom/image.rs @@ -1,6 +1,6 @@ use super::{CustomWidget, CustomWidgetContext}; use crate::build; -use crate::dynamic_string::DynamicString; +use crate::dynamic_value::dynamic_string; use crate::image::ImageProvider; use gtk::prelude::*; use gtk::Image; @@ -29,7 +29,7 @@ impl CustomWidget for ImageWidget { let gtk_image = gtk_image.clone(); let icon_theme = context.icon_theme.clone(); - DynamicString::new(&self.src, move |src| { + dynamic_string(&self.src, move |src| { ImageProvider::parse(&src, &icon_theme, self.size) .map(|image| image.load_into_image(gtk_image.clone())); diff --git a/src/modules/custom/label.rs b/src/modules/custom/label.rs index 4b9d682..8fa3d27 100644 --- a/src/modules/custom/label.rs +++ b/src/modules/custom/label.rs @@ -1,6 +1,6 @@ use super::{CustomWidget, CustomWidgetContext}; use crate::build; -use crate::dynamic_string::DynamicString; +use crate::dynamic_value::dynamic_string; use gtk::prelude::*; use gtk::Label; use serde::Deserialize; @@ -22,7 +22,7 @@ impl CustomWidget for LabelWidget { { let label = label.clone(); - DynamicString::new(&self.label, move |string| { + dynamic_string(&self.label, move |string| { label.set_markup(&string); Continue(true) }); diff --git a/src/modules/custom/progress.rs b/src/modules/custom/progress.rs index d6bd285..c7d1eba 100644 --- a/src/modules/custom/progress.rs +++ b/src/modules/custom/progress.rs @@ -1,5 +1,5 @@ use super::{try_get_orientation, CustomWidget, CustomWidgetContext}; -use crate::dynamic_string::DynamicString; +use crate::dynamic_value::dynamic_string; use crate::modules::custom::set_length; use crate::script::{OutputStream, Script, ScriptInput}; use crate::{build, send}; @@ -69,7 +69,7 @@ impl CustomWidget for ProgressWidget { let progress = progress.clone(); progress.set_show_text(true); - DynamicString::new(&text, move |string| { + dynamic_string(&text, move |string| { progress.set_text(Some(&string)); Continue(true) }); diff --git a/src/modules/label.rs b/src/modules/label.rs index 0ca67c7..a10a362 100644 --- a/src/modules/label.rs +++ b/src/modules/label.rs @@ -1,5 +1,5 @@ use crate::config::CommonConfig; -use crate::dynamic_string::DynamicString; +use crate::dynamic_value::dynamic_string; use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext}; use crate::try_send; use color_eyre::Result; @@ -31,7 +31,7 @@ impl Module