diff --git a/Cargo.lock b/Cargo.lock index 7a211af..6d46911 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -334,6 +334,16 @@ dependencies = [ "log", ] +[[package]] +name = "bstr" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "542f33a8835a0884b006a0c3df3dadd99c0c3f296ed26c2fdc8028e01ad6230c" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "bumpalo" version = "3.12.0" @@ -354,9 +364,9 @@ checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" [[package]] name = "cairo-rs" -version = "0.18.3" +version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f33613627f0dea6a731b0605101fad59ba4f193a52c96c4687728d822605a8a1" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ "bitflags 2.4.0", "cairo-sys-rs", @@ -1584,6 +1594,7 @@ checksum = "12b6ee2129af8d4fb011108c73d99a1b83a85977f23b82460c0ae2e25bb4b57f" name = "ironbar" version = "0.15.0-pre" dependencies = [ + "cairo-rs", "cfg-if", "chrono", "clap", @@ -1597,6 +1608,8 @@ dependencies = [ "hyprland", "indexmap", "libpulse-binding", + "lua-src", + "mlua", "mpd-utils", "mpris", "nix 0.27.1", @@ -1761,6 +1774,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "lua-src" +version = "546.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da0daa7eee611a4c30c8f5ee31af55266e26e573971ba9336d2993e2da129b2" +dependencies = [ + "cc", +] + [[package]] name = "matchers" version = "0.1.0" @@ -1836,6 +1858,30 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "mlua" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "868d02cb5eb97761bbf6bd6922c1c7a88b8ea252bbf43bd8350a0bf8497a1fc0" +dependencies = [ + "bstr", + "mlua-sys", + "num-traits", + "once_cell", + "rustc-hash", +] + +[[package]] +name = "mlua-sys" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2847b42764435201d8cbee1f517edb79c4cca4181877b90047587c89e1b7bce4" +dependencies = [ + "cc", + "cfg-if", + "pkg-config", +] + [[package]] name = "mpd-utils" version = "0.2.1" @@ -2549,6 +2595,12 @@ version = "0.1.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4a36c42d1873f9a77c53bde094f9664d9891bc604a45b4798fd2c389ed12e5b" +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc_version" version = "0.4.0" diff --git a/Cargo.toml b/Cargo.toml index 2722fc0..6d81275 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ keywords = ["gtk", "bar", "wayland", "wlroots", "gtk-layer-shell"] [features] default = [ "cli", + "cairo", "clipboard", "clock", "config+all", @@ -45,6 +46,8 @@ http = ["dep:reqwest"] "config+corn" = ["universal-config/corn"] "config+ron" = ["universal-config/ron"] +cairo = ["lua-src", "mlua", "cairo-rs"] + clipboard = ["nix"] clock = ["chrono"] @@ -114,7 +117,12 @@ clap = { version = "4.5.4", optional = true, features = ["derive"] } serde_json = { version = "1.0.114", optional = true } # http -reqwest = { version = "0.12.3", default_features = false, features = ["default-tls", "http2"], default_features = false, features = ["default-tls", "http2"], optional = true } +reqwest = { version = "0.12.3", default_features = false, features = ["default-tls", "http2"], optional = true } + +# cairo +lua-src = { version = "546.0.2", optional = true } +mlua = { version = "0.9.6", optional = true, features = ["luajit"] } +cairo-rs = { version = "0.18.5", optional = true, features = ["png"] } # clipboard nix = { version = "0.27.1", optional = true, features = ["event"] } diff --git a/docs/Compiling.md b/docs/Compiling.md index f7eeb93..b16b3fb 100644 --- a/docs/Compiling.md +++ b/docs/Compiling.md @@ -49,8 +49,8 @@ dnf install libpulseaudio-devel By default, all features are enabled for convenience. This can result in a significant compile time. If you know you are not going to need all the features, you can compile with only the features you need. -As of `v0.10.0`, compiling with no features is about 33% faster. -On a 3800X, it takes about 60 seconds for no features and 90 seconds for all. +As of `v0.15.0`, compiling with no features is about 50% faster. +On a 3800X, it takes about 45 seconds for no features and 90 seconds for all. This difference is expected to increase as the bar develops. Features containing a `+` can be stacked, for example `config+json` and `config+yaml` could both be enabled. @@ -77,6 +77,7 @@ cargo build --release --no-default-features \ | config+corn | Enables configuration support for [Corn](https://github.com/jakestanger/corn). | | config+ron | Enables configuration support for [Ron](https://github.com/ron-rs/ron). | | **Modules** | | +| cairo | Enables the `cairo` module | | clipboard | Enables the `clipboard` module. | | clock | Enables the `clock` module. | | focused | Enables the `focused` module. | diff --git a/docs/_Sidebar.md b/docs/_Sidebar.md index 766da7e..405ff6d 100644 --- a/docs/_Sidebar.md +++ b/docs/_Sidebar.md @@ -23,6 +23,7 @@ # Modules +- [Cairo](cairo) - [Clipboard](clipboard) - [Clock](clock) - [Custom](custom) diff --git a/docs/modules/Cairo.md b/docs/modules/Cairo.md new file mode 100644 index 0000000..ff296f3 --- /dev/null +++ b/docs/modules/Cairo.md @@ -0,0 +1,215 @@ +Allows you to render custom content using the Lua and the Cairo drawing library. +This is an advanced feature which provides a powerful escape hatch, allowing you to fetch data and render anything +using an embedded scripting environment. + +Scripts are automatically hot-reloaded. + +> [!NOTE] +> The Lua engine uses LuaJIT 5.1, and requires the use of a library called `lgi`. +> Ensure you have the correct lua-lgi package installed. + +![Circle clock](https://f.jstanger.dev/github/ironbar/cairo-clock.png) + +## Configuration + +> Type: `cairo` + +| Name | Type | Default | Description | +|--------------------|-----------|---------|----------------------------------------------------| +| `path` | `string` | `null` | The path to the Lua script to load. | +| `frequency` | `float` | `200` | The number of milliseconds between each draw call. | +| `width` | `integer` | `42` | The canvas width in pixels. | +| `height` | `integer` | `42` | The canvas height in pixels. | + +
+JSON + +```json +{ + "center": [ + { + "type": "cairo", + "path": ".config/ironbar/clock.lua", + "frequency": 100, + "width": 300, + "height": 300 + } + ] +} + +``` + +
+ +
+TOML + +```toml +[[center]] +type = "cairo" +path = ".config/ironbar/clock.lua" +frequency = 100 +width = 300 +height = 300 +``` +
+ +
+YAML + +```yaml +center: +- type: cairo + path: .config/ironbar/clock.lua + frequency: 100 + width: 300 + height: 300 +``` + +
+ +
+Corn + +```corn +let { + $config_dir = ".config/ironbar" + $cairo = { + type = "cairo" + path = "$config_dir/clock.lua" + frequency = 100 + width = 300 + height = 300 + } +} in { + center = [ $cairo ] +} +``` + +
+ +### Script + +Every script must contain a function called `draw`. +This takes a single parameter, which is the Cairo context. + +Outside of this, you can do whatever you like. +The full lua `stdlib` is available, and you can load in additional system packages as desired. + +The most basic example, which draws a red square, can be seen below: + +```lua +function draw(cr) + cr:set_source_rgb(1.0, 0.0, 0.0) + cr:paint() +end +``` + +A longer example, used to create the clock in the image at the top of the page, is shown below: + +
+Circle clock + +```lua +function get_ms() + local ms = tostring(io.popen('date +%s%3N'):read('a')):sub(-4, 9999) + return tonumber(ms) / 1000 +end + +function draw(cr) + local center_x = 150 + local center_y = 150 + local radius = 130 + + local date_table = os.date("*t") + + local hours = date_table["hour"] + local minutes = date_table["min"] + local seconds = date_table["sec"] + local ms = get_ms() + + + local label_seconds = seconds + seconds = seconds + ms + + local hours_str = tostring(hours) + if string.len(hours_str) == 1 then + hours_str = "0" .. hours_str + end + + local minutes_str = tostring(minutes) + if string.len(minutes_str) == 1 then + minutes_str = "0" .. minutes_str + end + + local seconds_str = tostring(label_seconds) + if string.len(seconds_str) == 1 then + seconds_str = "0" .. seconds_str + end + + local font_size = radius / 5.5 + + cr:set_source_rgb(1.0, 1.0, 1.0) + + cr:move_to(center_x - font_size * 2.5 + 10, center_y + font_size / 2.5) + cr:set_font_size(font_size) + cr:show_text(hours_str .. ':' .. minutes_str .. ':' .. seconds_str) + cr:stroke() + + if hours > 12 then + hours = hours - 12 + end + + local line_width = radius / 8 + local start_angle = -math.pi / 2 + + local end_angle = start_angle + ((hours + minutes / 60 + seconds / 3600) / 12) * 2 * math.pi + cr:set_line_width(line_width) + cr:arc(center_x, center_y, radius, start_angle, end_angle) + cr:stroke() + + end_angle = start_angle + ((minutes + seconds / 60) / 60) * 2 * math.pi + cr:set_line_width(line_width) + cr:arc(center_x, center_y, radius * 0.8, start_angle, end_angle) + cr:stroke() + + if seconds == 0 then + seconds = 60 + end + + end_angle = start_angle + (seconds / 60) * 2 * math.pi + cr:set_line_width(line_width) + cr:arc(center_x, center_y, radius * 0.6, start_angle, end_angle) + cr:stroke() + + return 0 +end +``` + +
+ +> [!TIP] +> The C documentation for the Cairo context interface can be found [here](https://www.cairographics.org/manual/cairo-cairo-t.html). +> The Lua interface provides a slightly friendlier API which restructures things slightly. +> The `cairo_` prefix is dropped, and the `cairo_t *cr` parameters are replaced with a namespaced call. +> For example, `cairo_paint (cairo_t *cr)` becomes `cr:paint()` + +> [!TIP] +> Ironbar's Cairo module has similar functionality to the popular Conky program. +> You can often re-use scripts with little work. + +### Initialization + +You can optionally create an `init.lua` file in your config directory. +Any code in here will be executed once, on bar startup. + +As variables and functions are global by default in Lua, +this provides a mechanism for sharing code between multiple modules. + +## Styling + +| Selector | Description | +|----------|-------------------------| +| `.cairo` | Cairo widget container. | + +For more information on styling, please see the [styling guide](styling-guide). \ No newline at end of file diff --git a/lua/draw.lua b/lua/draw.lua new file mode 100644 index 0000000..5bc2e5e --- /dev/null +++ b/lua/draw.lua @@ -0,0 +1,4 @@ +function(id, ptr) + local cr = __lgi_core.record.new(cairo.Context, ptr) + _G['__draw_' .. id](cr) +end \ No newline at end of file diff --git a/lua/init.lua b/lua/init.lua new file mode 100644 index 0000000..a4baa39 --- /dev/null +++ b/lua/init.lua @@ -0,0 +1,4 @@ +local lgi = require('lgi') +cairo = lgi.cairo + +__lgi_core = require('lgi.core') diff --git a/src/clients/lua.rs b/src/clients/lua.rs new file mode 100644 index 0000000..04f8a58 --- /dev/null +++ b/src/clients/lua.rs @@ -0,0 +1,41 @@ +use mlua::Lua; +use std::ops::Deref; +use std::path::Path; +use tracing::{debug, error}; + +/// Wrapper around Lua instance +/// to create a singleton and handle initialization. +#[derive(Debug)] +pub struct LuaEngine { + lua: Lua, +} + +impl LuaEngine { + pub fn new(config_dir: &Path) -> Self { + let lua = unsafe { Lua::unsafe_new() }; + + let user_init = config_dir.join("init.lua"); + if user_init.exists() { + debug!("loading user init script"); + + if let Err(err) = lua.load(user_init).exec() { + error!("{err:?}"); + } + } + + debug!("loading internal init script"); + if let Err(err) = lua.load(include_str!("../../lua/init.lua")).exec() { + error!("{err:?}"); + } + + Self { lua } + } +} + +impl Deref for LuaEngine { + type Target = Lua; + + fn deref(&self) -> &Self::Target { + &self.lua + } +} diff --git a/src/clients/mod.rs b/src/clients/mod.rs index f289c2c..b44f0df 100644 --- a/src/clients/mod.rs +++ b/src/clients/mod.rs @@ -1,11 +1,15 @@ use crate::{await_sync, Ironbar}; use color_eyre::Result; +use std::path::Path; +use std::rc::Rc; use std::sync::Arc; #[cfg(feature = "clipboard")] pub mod clipboard; #[cfg(feature = "workspaces")] pub mod compositor; +#[cfg(feature = "cairo")] +pub mod lua; #[cfg(feature = "music")] pub mod music; #[cfg(feature = "notifications")] @@ -27,6 +31,8 @@ pub struct Clients { workspaces: Option>, #[cfg(feature = "clipboard")] clipboard: Option>, + #[cfg(feature = "cairo")] + lua: Option>, #[cfg(feature = "music")] music: std::collections::HashMap>, #[cfg(feature = "notifications")] @@ -75,6 +81,13 @@ impl Clients { Ok(client) } + #[cfg(feature = "cairo")] + pub fn lua(&mut self, config_dir: &Path) -> Rc { + self.lua + .get_or_insert_with(|| Rc::new(lua::LuaEngine::new(config_dir))) + .clone() + } + #[cfg(feature = "music")] pub fn music(&mut self, client_type: music::ClientType) -> Arc { self.music diff --git a/src/config/mod.rs b/src/config/mod.rs index e57fac8..44ee8db 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -2,6 +2,8 @@ mod common; mod r#impl; mod truncate; +#[cfg(feature = "cairo")] +use crate::modules::cairo::CairoModule; #[cfg(feature = "clipboard")] use crate::modules::clipboard::ClipboardModule; #[cfg(feature = "clock")] @@ -40,6 +42,8 @@ pub use self::truncate::TruncateMode; #[derive(Debug, Deserialize, Clone)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ModuleConfig { + #[cfg(feature = "cairo")] + Cairo(Box), #[cfg(feature = "clipboard")] Clipboard(Box), #[cfg(feature = "clock")] @@ -81,6 +85,8 @@ impl ModuleConfig { } match self { + #[cfg(feature = "cairo")] + Self::Cairo(module) => create!(module), #[cfg(feature = "clipboard")] Self::Clipboard(module) => create!(module), #[cfg(feature = "clock")] diff --git a/src/main.rs b/src/main.rs index 7a9f116..0df08ea 100644 --- a/src/main.rs +++ b/src/main.rs @@ -96,14 +96,18 @@ pub struct Ironbar { bars: Rc>>, clients: Rc>, config: Rc>, + config_dir: PathBuf, } impl Ironbar { fn new() -> Self { + let (config, config_dir) = load_config(); + Self { bars: Rc::new(RefCell::new(vec![])), clients: Rc::new(RefCell::new(Clients::new())), - config: Rc::new(RefCell::new(load_config())), + config: Rc::new(RefCell::new(config)), + config_dir, } } @@ -260,7 +264,7 @@ impl Ironbar { /// Note this does *not* reload bars, which must be performed separately. #[cfg(feature = "ipc")] fn reload_config(&self) { - self.config.replace(load_config()); + self.config.replace(load_config().0); } } @@ -270,20 +274,37 @@ fn start_ironbar() { } /// Loads the config file from disk. -fn load_config() -> Config { - let mut config = env::var("IRONBAR_CONFIG") - .map_or_else( - |_| ConfigLoader::new("ironbar").find_and_load(), - ConfigLoader::load, - ) - .unwrap_or_else(|err| { - error!("Failed to load config: {}", err); - warn!("Falling back to the default config"); - info!("If this is your first time using Ironbar, you should create a config in ~/.config/ironbar/"); - info!("More info here: https://github.com/JakeStanger/ironbar/wiki/configuration-guide"); +fn load_config() -> (Config, PathBuf) { + let config_path = env::var("IRONBAR_CONFIG"); - Config::default() - }); + let (config, directory) = if let Ok(config_path) = config_path { + let path = PathBuf::from(config_path); + ( + ConfigLoader::load(&path), + path.parent() + .map(PathBuf::from) + .ok_or_else(|| Report::msg("Specified path has no parent")), + ) + } else { + let config_loader = ConfigLoader::new("ironbar"); + ( + config_loader.find_and_load(), + config_loader.config_dir().map_err(Report::new), + ) + }; + + let mut config = config.unwrap_or_else(|err| { + error!("Failed to load config: {}", err); + warn!("Falling back to the default config"); + info!("If this is your first time using Ironbar, you should create a config in ~/.config/ironbar/"); + info!("More info here: https://github.com/JakeStanger/ironbar/wiki/configuration-guide"); + + Config::default() + }); + + let directory = directory + .and_then(|dir| dir.canonicalize().map_err(Report::new)) + .unwrap_or_else(|_| env::current_dir().expect("to have current working directory")); debug!("Loaded config file"); @@ -297,7 +318,7 @@ fn load_config() -> Config { } } - config + (config, directory) } /// Gets the GDK `Display` instance. diff --git a/src/modules/cairo.rs b/src/modules/cairo.rs new file mode 100644 index 0000000..9e21aec --- /dev/null +++ b/src/modules/cairo.rs @@ -0,0 +1,198 @@ +use crate::config::CommonConfig; +use crate::modules::{Module, ModuleInfo, ModuleParts, ModuleUpdateEvent, WidgetContext}; +use crate::{glib_recv, module_impl, spawn, try_send}; +use cairo::{Format, ImageSurface}; +use glib::translate::IntoGlibPtr; +use glib::Propagation; +use gtk::prelude::*; +use gtk::DrawingArea; +use mlua::{Error, Function, LightUserData}; +use notify::event::ModifyKind; +use notify::{recommended_watcher, Event, EventKind, RecursiveMode, Watcher}; +use serde::Deserialize; +use std::fs; +use std::path::PathBuf; +use std::time::Duration; +use tokio::sync::mpsc::Receiver; +use tokio::time::sleep; +use tracing::{debug, error}; + +#[derive(Debug, Clone, Deserialize)] +pub struct CairoModule { + path: PathBuf, + + #[serde(default = "default_frequency")] + frequency: u64, + + #[serde(default = "default_size")] + width: u32, + #[serde(default = "default_size")] + height: u32, + + #[serde(flatten)] + pub common: Option, +} + +const fn default_size() -> u32 { + 42 +} + +const fn default_frequency() -> u64 { + 200 +} + +impl Module for CairoModule { + type SendMessage = (); + type ReceiveMessage = (); + + module_impl!("cairo"); + + fn spawn_controller( + &self, + _info: &ModuleInfo, + context: &WidgetContext, + _rx: Receiver, + ) -> color_eyre::Result<()> + where + >::SendMessage: Clone, + { + let path = self.path.to_path_buf(); + + let tx = context.tx.clone(); + spawn(async move { + let parent = path.parent().expect("to have parent path"); + + let mut watcher = recommended_watcher({ + let path = path.clone(); + move |res: notify::Result| match res { + Ok(event) if matches!(event.kind, EventKind::Modify(ModifyKind::Data(_))) => { + debug!("{event:?}"); + + if event.paths.first().is_some_and(|p| p == &path) { + try_send!(tx, ModuleUpdateEvent::Update(())); + } + } + Err(e) => error!("Error occurred when watching stylesheet: {:?}", e), + _ => {} + } + }) + .expect("Failed to create lua file watcher"); + + watcher + .watch(parent, RecursiveMode::NonRecursive) + .expect("Failed to start lua file watcher"); + + // avoid watcher from dropping + loop { + sleep(Duration::from_secs(1)).await; + } + }); + + // Lua needs to run synchronously with the GTK updates, + // so the controller does not handle the script engine. + + Ok(()) + } + + fn into_widget( + self, + context: WidgetContext, + info: &ModuleInfo, + ) -> color_eyre::Result> + where + >::SendMessage: Clone, + { + let id = context.id.to_string(); + + let container = gtk::Box::new(info.bar_position.orientation(), 0); + + let surface = ImageSurface::create(Format::ARgb32, self.width as i32, self.height as i32)?; + + let area = DrawingArea::new(); + + let lua = context + .ironbar + .clients + .borrow_mut() + .lua(&context.ironbar.config_dir); + + // this feels kinda dirty, + // but it keeps draw functions separate in the global scope + let script = fs::read_to_string(&self.path)? + .replace("function draw", format!("function __draw_{id}").as_str()); + lua.load(&script).exec()?; + + { + let lua = lua.clone(); + let id = id.clone(); + + let path = self.path.clone(); + + area.connect_draw(move |_, cr| { + let function: Function = lua + .load(include_str!("../../lua/draw.lua")) + .eval() + .expect("to be valid"); + + if let Err(err) = cr.set_source_surface(&surface, 0.0, 0.0) { + error!("{err}"); + return Propagation::Stop; + } + + let ptr = unsafe { cr.clone().into_glib_ptr().cast() }; + + // mlua needs a valid return type, even if we don't return anything + + if let Err(err) = + function.call::<_, Option>((id.as_str(), LightUserData(ptr))) + { + match err { + Error::RuntimeError(message) => { + let message = message.split_once("]:").expect("to exist").1; + error!("[lua runtime error] {}:{message}", path.display()) + } + _ => error!("{err}"), + } + + return Propagation::Stop; + } + + Propagation::Proceed + }); + } + + area.set_size_request(self.width as i32, self.height as i32); + container.add(&area); + + glib::spawn_future_local(async move { + loop { + area.queue_draw(); + glib::timeout_future(Duration::from_millis(self.frequency)).await; + } + }); + + glib_recv!(context.subscribe(), _ev => { + let res = fs::read_to_string(&self.path) + .map(|s| s.replace("function draw", format!("function __draw_{id}").as_str())); + + match res { + Ok(script) => { + match lua.load(&script).exec() { + Ok(_) => {}, + Err(Error::SyntaxError { message, ..}) => { + let message = message.split_once("]:").expect("to exist").1; + error!("[lua syntax error] {}:{message}", self.path.display()) + }, + Err(err) => error!("lua error: {err:?}") + } + }, + Err(err) => error!("{err:?}") + } + }); + + Ok(ModuleParts { + widget: container, + popup: None, + }) + } +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 0e7474f..ec3bb65 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -16,6 +16,8 @@ use crate::gtk_helpers::{IronbarGtkExt, WidgetGeometry}; use crate::popup::Popup; use crate::{glib_recv_mpsc, send, Ironbar}; +#[cfg(feature = "cairo")] +pub mod cairo; #[cfg(feature = "clipboard")] pub mod clipboard; /// Displays the current date and time.