From 4901b5d77d73db59f0c1545719ec6d1404aad979 Mon Sep 17 00:00:00 2001 From: cashmere Date: Sun, 16 Aug 2026 08:21:37 +0200 Subject: Run detached and reload config live --- src/bar.rs | 25 +++++++++++-- src/blocks_cache.rs | 9 +++++ src/config.rs | 98 ++++++++++++++++++++++++++++++++++++++++++-------- src/event_loop.rs | 11 +++++- src/main.rs | 100 ++++++++++++++++++++++++++++++++++++---------------- src/state.rs | 44 ++++++++++++++++++++++- 6 files changed, 238 insertions(+), 49 deletions(-) (limited to 'src') diff --git a/src/bar.rs b/src/bar.rs index 115641d..7530f2e 100644 --- a/src/bar.rs +++ b/src/bar.rs @@ -132,6 +132,21 @@ impl Bar { self.focused_window_computed = None; } + pub fn config_changed(&mut self, conn: &mut Connection, shared_state: &SharedState) { + self.height = shared_state.config.height; + self.tags_computed.clear(); + self.layout_name_computed = None; + self.mode_computed = None; + self.focused_window_computed = None; + self.tray_toggle_computed = None; + self.layer_surface + .set_layer(conn, shared_state.config.layer.into()); + if !self.hidden { + self.hide(conn); + self.show(conn, shared_state); + } + } + pub fn click( &mut self, conn: &mut Connection, @@ -525,7 +540,10 @@ impl Bar { let mut rendered = Vec::new(); if expanded { for item in &visible { - let label = match (cfg.show_name, cfg.show_description && !item.tooltip.is_empty()) { + let label = match ( + cfg.show_name, + cfg.show_description && !item.tooltip.is_empty(), + ) { (true, true) if item.tooltip != item.title => { format!("{} \u{2014} {}", item.title, item.tooltip) } @@ -583,7 +601,10 @@ impl Bar { let icon_w = icon.width() as f64 * scale; let icon_h = icon.height() as f64 * scale; context.save().unwrap(); - context.translate(item_x + (icon_size - icon_w) / 2.0, (full_height - icon_h) / 2.0); + context.translate( + item_x + (icon_size - icon_w) / 2.0, + (full_height - icon_h) / 2.0, + ); context.scale(scale, scale); context.set_source_surface(icon, 0.0, 0.0).unwrap(); context.paint().unwrap(); diff --git a/src/blocks_cache.rs b/src/blocks_cache.rs index 1e607a6..555ac49 100644 --- a/src/blocks_cache.rs +++ b/src/blocks_cache.rs @@ -32,6 +32,15 @@ impl BlocksCache { pub fn get_computed(&self) -> &[ComputedBlock] { &self.computed } + + pub fn config_changed(&mut self, config: &Config) { + let blocks = self + .computed + .drain(..) + .map(|computed| computed.block) + .collect(); + self.process_new_blocks(config, blocks); + } } impl ComputedBlock { diff --git a/src/config.rs b/src/config.rs index 4e41069..d354839 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,11 +1,15 @@ use crate::color::Color; use crate::protocol::{zwlr_layer_shell_v1, zwlr_layer_surface_v1}; use anyhow::{Context, Result}; +use inotify::{Inotify, WatchMask}; use pangocairo::pango::FontDescription; use serde::{Deserialize, de}; use std::collections::HashMap; +use std::ffi::OsString; use std::fs::read_to_string; +use std::io; use std::ops::Deref; +use std::os::fd::{AsRawFd, RawFd}; use std::path::{Path, PathBuf}; use std::{env, fmt}; @@ -114,27 +118,21 @@ impl Default for Config { impl Config { pub fn new(path: Option<&Path>) -> Result { - let buf; - - let path = match path { - Some(path) => Some(path), - None => { - buf = config_path(); - buf.as_deref() - } - }; - - Ok(match path { + match Self::path(path) { Some(config_path) => { let config = read_to_string(config_path).context("Failed to read configuration")?; - toml::from_str(&config).context("Failed to deserialize configuration")? + toml::from_str(&config).context("Failed to deserialize configuration") } None => { eprintln!("Could not find the configuration path"); eprintln!("Using default configuration"); - Self::default() + Ok(Self::default()) } - }) + } + } + + pub fn path(path: Option<&Path>) -> Option { + path.map(Path::to_path_buf).or_else(config_path) } pub fn output_enabled(&self, output: &str) -> bool { @@ -145,6 +143,47 @@ impl Config { } } +pub struct ConfigWatcher { + inotify: Inotify, + buffer: Vec, + filename: OsString, +} + +impl ConfigWatcher { + pub fn new(path: &Path) -> io::Result { + let directory = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let filename = path + .file_name() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "invalid config path"))? + .to_os_string(); + let inotify = Inotify::init()?; + inotify.watches().add( + directory, + WatchMask::CLOSE_WRITE | WatchMask::CREATE | WatchMask::MOVED_TO, + )?; + Ok(Self { + inotify, + buffer: vec![0; 4096], + filename, + }) + } + + pub fn fd(&self) -> RawFd { + self.inotify.as_raw_fd() + } + + pub fn changed(&mut self) -> io::Result { + let mut changed = false; + for event in self.inotify.read_events(&mut self.buffer)? { + changed |= event.name.is_some_and(|name| name == self.filename); + } + Ok(changed) + } +} + fn config_dir() -> Option { env::var_os("XDG_CONFIG_HOME") .map(PathBuf::from) @@ -296,3 +335,34 @@ impl<'de> de::Deserialize<'de> for Font { deserializer.deserialize_str(FontVisitor) } } + +#[cfg(test)] +mod tests { + use super::ConfigWatcher; + use std::fs; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[test] + fn watcher_detects_overwrite_and_atomic_replacement() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let directory = + std::env::temp_dir().join(format!("i3bar-mango-{unique}-{}", std::process::id())); + let config_path = directory.join("config.toml"); + let replacement_path = directory.join("config.toml.new"); + fs::create_dir(&directory).unwrap(); + fs::write(&config_path, "height = 24").unwrap(); + + let mut watcher = ConfigWatcher::new(&config_path).unwrap(); + fs::write(&config_path, "height = 25").unwrap(); + assert!(watcher.changed().unwrap()); + + fs::write(&replacement_path, "height = 26").unwrap(); + fs::rename(&replacement_path, &config_path).unwrap(); + assert!(watcher.changed().unwrap()); + + fs::remove_dir_all(directory).unwrap(); + } +} diff --git a/src/event_loop.rs b/src/event_loop.rs index 89f6f81..28fc0dc 100644 --- a/src/event_loop.rs +++ b/src/event_loop.rs @@ -7,7 +7,7 @@ use wayrs_client::Connection; use crate::state::State; -type Callback = Box Result>; +pub type Callback = Box Result>; pub struct EventLoopCtx<'a> { pub conn: &'a mut Connection, @@ -22,6 +22,7 @@ pub struct EventLoop { pub enum Action { Keep, + KeepAndRegister(RawFd, Callback), Unregister, } @@ -80,6 +81,10 @@ impl EventLoop { Action::Keep => { self.cbs.insert(fd.fd, cb); } + Action::KeepAndRegister(new_fd, new_cb) => { + self.cbs.insert(fd.fd, cb); + self.cbs.insert(new_fd, new_cb); + } Action::Unregister => (), } } @@ -88,6 +93,10 @@ impl EventLoop { for mut cb in self.on_idle.drain(..) { match cb(EventLoopCtx { conn, state })? { Action::Keep => on_idle_scratch.push(cb), + Action::KeepAndRegister(fd, new_cb) => { + on_idle_scratch.push(cb); + self.cbs.insert(fd, new_cb); + } Action::Unregister => (), } } diff --git a/src/main.rs b/src/main.rs index a4751d5..7c475c3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,28 +20,74 @@ mod tray; mod utils; mod wm_info_provider; +use std::env; use std::io::{self, ErrorKind, Read}; -use std::os::fd::AsRawFd; +use std::os::fd::{AsRawFd, RawFd}; use std::path::PathBuf; +use anyhow::Context; use clap::Parser; +use daemonize::Daemonize; use signal_hook::consts::*; use wayrs_client::{Connection, IoMode}; -use event_loop::EventLoop; +use config::{Config, ConfigWatcher}; +use event_loop::{Action, Callback, EventLoop}; use state::State; #[derive(Parser)] #[command(author, version, about, long_about = None)] struct Cli { - /// The path to a config file. - #[arg(short, long, value_name = "FILE")] + #[arg(short, long, value_name = "FILE", help = "The path to a config file")] config: Option, + #[arg(long, help = "Keep the bar attached to the current terminal")] + foreground: bool, +} + +fn status_callback(fd: RawFd) -> Callback { + Box::new(move |ctx| { + if ctx.state.status_cmd_fd() != Some(fd) { + return Ok(Action::Unregister); + } + match ctx + .state + .shared_state + .status_cmd + .as_mut() + .unwrap() + .receive_blocks() + { + Ok(None) => Ok(Action::Keep), + Ok(Some(blocks)) => { + ctx.state.set_blocks(ctx.conn, blocks); + Ok(Action::Keep) + } + Err(e) => { + let _ = ctx + .state + .shared_state + .status_cmd + .take() + .unwrap() + .child + .kill(); + ctx.state.set_error(ctx.conn, "status", e); + Ok(Action::Unregister) + } + } + }) } fn main() -> anyhow::Result<()> { let args = Cli::parse(); - + let config_path = Config::path(args.config.as_deref()); + if !args.foreground { + let working_directory = env::current_dir().context("failed to read working directory")?; + Daemonize::new() + .working_directory(working_directory) + .start() + .context("failed to start in the background")?; + } let (mut sig_read, sig_write) = io::pipe()?; signal_hook::low_level::pipe::register(SIGUSR1, sig_write)?; @@ -49,7 +95,7 @@ fn main() -> anyhow::Result<()> { conn.blocking_roundtrip()?; let mut el = EventLoop::new(); - let mut state = State::new(&mut conn, &mut el, args.config.as_deref()); + let mut state = State::new(&mut conn, &mut el, config_path.as_deref()); conn.flush(IoMode::Blocking)?; el.add_on_idle(|ctx| { @@ -73,31 +119,23 @@ fn main() -> anyhow::Result<()> { }); if let Some(fd) = state.status_cmd_fd() { - el.register_with_fd(fd, |ctx| { - match ctx - .state - .shared_state - .status_cmd - .as_mut() - .unwrap() - .receive_blocks() - { - Ok(None) => Ok(event_loop::Action::Keep), - Ok(Some(blocks)) => { - ctx.state.set_blocks(ctx.conn, blocks); - Ok(event_loop::Action::Keep) - } - Err(e) => { - let _ = ctx - .state - .shared_state - .status_cmd - .take() - .unwrap() - .child - .kill(); - ctx.state.set_error(ctx.conn, "status", e); - Ok(event_loop::Action::Unregister) + el.register_with_fd(fd, status_callback(fd)); + } + + if let Some(config_path) = config_path { + let mut watcher = ConfigWatcher::new(&config_path) + .with_context(|| format!("failed to watch {}", config_path.display()))?; + let watcher_fd = watcher.fd(); + el.register_with_fd(watcher_fd, move |ctx| { + if !watcher.changed()? { + return Ok(Action::Keep); + } + match ctx.state.reload_config(ctx.conn, &config_path) { + Ok(Some(fd)) => Ok(Action::KeepAndRegister(fd, status_callback(fd))), + Ok(None) => Ok(Action::Keep), + Err(error) => { + ctx.state.set_error(ctx.conn, "config", error); + Ok(Action::Keep) } } }); diff --git a/src/state.rs b/src/state.rs index 2853731..dc7ff15 100644 --- a/src/state.rs +++ b/src/state.rs @@ -188,6 +188,44 @@ impl State { .map(|cmd| cmd.output.as_raw_fd()) } + pub fn reload_config( + &mut self, + conn: &mut Connection, + config_path: &Path, + ) -> anyhow::Result> { + let config = Config::new(Some(config_path))?; + let command_changed = config.command != self.shared_state.config.command; + let status_cmd = if command_changed { + match config.command.as_deref() { + Some(command) => Some(StatusCmd::new(command)?), + None => None, + } + } else { + self.shared_state.status_cmd.take() + }; + + if command_changed && let Some(mut old_status_cmd) = self.shared_state.status_cmd.take() { + let _ = old_status_cmd.child.kill(); + } + + self.shared_state.config = config; + self.shared_state.status_cmd = status_cmd; + if command_changed || self.has_error { + self.shared_state.blocks_cache = BlocksCache::default(); + } else { + self.shared_state + .blocks_cache + .config_changed(&self.shared_state.config); + } + self.has_error = false; + for bar in &mut self.bars { + bar.config_changed(conn, &self.shared_state); + } + self.draw_all(conn); + + Ok(command_changed.then(|| self.status_cmd_fd()).flatten()) + } + pub fn register_output(&mut self, conn: &mut Connection, output: Output) { if !self.shared_state.config.output_enabled(&output.name) { return; @@ -264,7 +302,11 @@ impl State { }); } - pub fn focused_window_updated(&mut self, conn: &mut Connection, output: Option) { + pub fn focused_window_updated( + &mut self, + conn: &mut Connection, + output: Option, + ) { self.for_each_bar(output, |bar, ss| { bar.set_focused_window(ss.wm_info_provider.get_focused_window(&bar.output)); bar.frame(conn, ss); -- cgit v1.3.1