From 2050c345f18078331b764107cf331dba01ceb4cd Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sat, 4 Apr 2026 05:11:04 +0200 Subject: [PATCH] feature/scroll-animation --- Cargo.toml | 2 +- example.config.toml | 4 ++ src/config.rs | 20 ++++++ src/daemon.rs | 22 +++++++ src/modules/mpris.rs | 144 +++++++++++++++++++++++++++++++++++-------- src/signaler.rs | 11 ++++ src/state.rs | 17 +++++ 7 files changed, 195 insertions(+), 25 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 5d65f9a..5fa1538 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fluxo-rs" -version = "0.4.2" +version = "0.4.3" edition = "2024" [features] diff --git a/example.config.toml b/example.config.toml index f3e9e68..e9e2326 100644 --- a/example.config.toml +++ b/example.config.toml @@ -93,6 +93,10 @@ format_inactive = "" [mpris] # enabled = false # set to false to disable this module at runtime +# max_length = 30 # truncate text beyond this character length (adds '...') +# scroll = true # enable marquee scroll animation (requires max_length) +# scroll_speed = 500 # ms between scroll steps (only while playing) +# scroll_separator = " /// " # separator shown between loops when scrolling # tokens: {artist}, {title}, {album}, {status_icon} format = "{status_icon} {artist} - {title}" diff --git a/src/config.rs b/src/config.rs index eb3e33a..ed86b04 100644 --- a/src/config.rs +++ b/src/config.rs @@ -290,6 +290,22 @@ pub struct MprisConfig { #[serde(default = "default_true")] pub enabled: bool, pub format: String, + #[serde(default)] + pub max_length: Option, + #[serde(default)] + pub scroll: bool, + #[serde(default = "default_scroll_speed")] + pub scroll_speed: u64, + #[serde(default = "default_scroll_separator")] + pub scroll_separator: String, +} + +fn default_scroll_speed() -> u64 { + 500 +} + +fn default_scroll_separator() -> String { + " /// ".to_string() } impl Default for MprisConfig { @@ -297,6 +313,10 @@ impl Default for MprisConfig { Self { enabled: true, format: "{status_icon} {artist} - {title}".to_string(), + max_length: None, + scroll: false, + scroll_speed: 500, + scroll_separator: " /// ".to_string(), } } } diff --git a/src/daemon.rs b/src/daemon.rs index 7df1990..0406747 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -78,6 +78,10 @@ pub async fn run_daemon(config_path: Option) -> Result<()> { let (keyboard_tx, keyboard_rx) = watch::channel(Default::default()); #[cfg(feature = "mod-dbus")] let (dnd_tx, dnd_rx) = watch::channel(Default::default()); + #[cfg(feature = "mod-dbus")] + let mpris_scroll = Arc::new(RwLock::new(crate::state::MprisScrollState::default())); + #[cfg(feature = "mod-dbus")] + let (mpris_scroll_tick_tx, mpris_scroll_tick_rx) = watch::channel(0u64); let health = Arc::new(RwLock::new(HashMap::new())); #[cfg(feature = "mod-bt")] let (bt_force_tx, mut bt_force_rx) = mpsc::channel(1); @@ -109,6 +113,10 @@ pub async fn run_daemon(config_path: Option) -> Result<()> { keyboard: keyboard_rx, #[cfg(feature = "mod-dbus")] dnd: dnd_rx, + #[cfg(feature = "mod-dbus")] + mpris_scroll: Arc::clone(&mpris_scroll), + #[cfg(feature = "mod-dbus")] + mpris_scroll_tick: mpris_scroll_tick_rx, health: Arc::clone(&health), #[cfg(feature = "mod-bt")] bt_force_poll: bt_force_tx, @@ -313,6 +321,20 @@ pub async fn run_daemon(config_path: Option) -> Result<()> { if config.read().await.mpris.enabled { let mpris_daemon = MprisDaemon::new(); mpris_daemon.start(mpris_tx); + + // Scroll ticker for MPRIS marquee animation + let scroll_config = Arc::clone(&config); + let scroll_rx = receivers.mpris.clone(); + let scroll_state = Arc::clone(&mpris_scroll); + tokio::spawn(async move { + crate::modules::mpris::mpris_scroll_ticker( + scroll_config, + scroll_rx, + scroll_state, + mpris_scroll_tick_tx, + ) + .await; + }); } // 6. Waybar Signaler Task diff --git a/src/modules/mpris.rs b/src/modules/mpris.rs index 7e88e84..ee75b60 100644 --- a/src/modules/mpris.rs +++ b/src/modules/mpris.rs @@ -2,12 +2,66 @@ use crate::config::Config; use crate::error::Result; use crate::modules::WaybarModule; use crate::output::WaybarOutput; -use crate::state::{AppReceivers, MprisState}; +use crate::state::{AppReceivers, MprisScrollState, MprisState}; use crate::utils::{TokenValue, format_template}; -use tokio::sync::watch; +use std::sync::Arc; +use tokio::sync::{RwLock, watch}; +use tokio::time::Duration; use tracing::{debug, info}; use zbus::{Connection, proxy}; +fn format_mpris_text(format: &str, mpris: &MprisState) -> (String, &'static str) { + let status_icon = if mpris.is_playing { + "󰏤" + } else if mpris.is_paused { + "󰐊" + } else { + "󰓛" + }; + + let class = if mpris.is_playing { + "playing" + } else if mpris.is_paused { + "paused" + } else { + "stopped" + }; + + let text = format_template( + format, + &[ + ("artist", TokenValue::String(mpris.artist.clone())), + ("title", TokenValue::String(mpris.title.clone())), + ("album", TokenValue::String(mpris.album.clone())), + ("status_icon", TokenValue::String(status_icon.to_string())), + ], + ); + + (text, class) +} + +fn apply_scroll_window(full_text: &str, max_len: usize, offset: usize, separator: &str) -> String { + let char_count = full_text.chars().count(); + let total_len = char_count + separator.chars().count(); + let offset = offset % total_len; + full_text + .chars() + .chain(separator.chars()) + .cycle() + .skip(offset) + .take(max_len) + .collect() +} + +fn truncate_with_ellipsis(text: &str, max_len: usize) -> String { + let char_count = text.chars().count(); + if char_count <= max_len { + return text.to_string(); + } + let truncated: String = text.chars().take(max_len.saturating_sub(3)).collect(); + format!("{}...", truncated) +} + pub struct MprisModule; impl WaybarModule for MprisModule { @@ -28,32 +82,26 @@ impl WaybarModule for MprisModule { }); } - let status_icon = if mpris.is_playing { - "󰏤" - } else if mpris.is_paused { - "󰐊" - } else { - "󰓛" - }; + let (full_text, class) = format_mpris_text(&config.mpris.format, &mpris); - let class = if mpris.is_playing { - "playing" - } else if mpris.is_paused { - "paused" + let text = if config.mpris.scroll { + if let Some(max_len) = config.mpris.max_length { + let scroll = state.mpris_scroll.read().await; + apply_scroll_window( + &full_text, + max_len, + scroll.offset, + &config.mpris.scroll_separator, + ) + } else { + full_text.clone() + } + } else if let Some(max_len) = config.mpris.max_length { + truncate_with_ellipsis(&full_text, max_len) } else { - "stopped" + full_text.clone() }; - let text = format_template( - &config.mpris.format, - &[ - ("artist", TokenValue::String(mpris.artist.clone())), - ("title", TokenValue::String(mpris.title.clone())), - ("album", TokenValue::String(mpris.album.clone())), - ("status_icon", TokenValue::String(status_icon.to_string())), - ], - ); - Ok(WaybarOutput { text, tooltip: Some(format!("{} - {}", mpris.artist, mpris.title)), @@ -63,6 +111,54 @@ impl WaybarModule for MprisModule { } } +pub async fn mpris_scroll_ticker( + config: Arc>, + mut mpris_rx: watch::Receiver, + scroll_state: Arc>, + tick_tx: watch::Sender, +) { + let mut generation: u64 = 0; + let mut last_track_key = String::new(); + + loop { + let mpris = mpris_rx.borrow_and_update().clone(); + let cfg = config.read().await; + let scroll_enabled = cfg.mpris.scroll; + let has_max_length = cfg.mpris.max_length.is_some(); + let scroll_speed = cfg.mpris.scroll_speed; + let format_str = cfg.mpris.format.clone(); + drop(cfg); + + let (full_text, _) = format_mpris_text(&format_str, &mpris); + let track_key = format!("{}|{}|{}", mpris.artist, mpris.title, mpris.album); + + if track_key != last_track_key { + let mut state = scroll_state.write().await; + state.offset = 0; + state.full_text = full_text.clone(); + last_track_key = track_key; + generation += 1; + let _ = tick_tx.send(generation); + } + + if scroll_enabled && has_max_length && mpris.is_playing { + tokio::time::sleep(Duration::from_millis(scroll_speed)).await; + let mut state = scroll_state.write().await; + state.offset += 1; + state.full_text = full_text; + drop(state); + generation += 1; + let _ = tick_tx.send(generation); + continue; + } + + // Not scrolling — wait for next state change + if mpris_rx.changed().await.is_err() { + break; + } + } +} + pub struct MprisDaemon; #[proxy( diff --git a/src/signaler.rs b/src/signaler.rs index fcc95e2..010b027 100644 --- a/src/signaler.rs +++ b/src/signaler.rs @@ -175,6 +175,13 @@ impl WaybarSignaler { #[cfg(feature = "mod-dbus")] let mpris_changed = receivers.mpris.changed(); + #[cfg(not(feature = "mod-dbus"))] + let mpris_scroll_tick_changed = std::future::pending::< + std::result::Result<(), tokio::sync::watch::error::RecvError>, + >(); + #[cfg(feature = "mod-dbus")] + let mpris_scroll_tick_changed = receivers.mpris_scroll_tick.changed(); + tokio::select! { res = net_changed, if signals.network.is_some() => { if res.is_ok() { check_and_signal!("net", signals.network); } @@ -215,6 +222,10 @@ impl WaybarSignaler { res = mpris_changed, if signals.mpris.is_some() => { if res.is_ok() { check_and_signal!("mpris", signals.mpris); } } + res = mpris_scroll_tick_changed, if signals.mpris.is_some() => { + if res.is_ok() + && let Some(sig) = signals.mpris { self.send_signal(sig); } + } _ = sleep(Duration::from_secs(5)) => { // loop and refresh config } diff --git a/src/state.rs b/src/state.rs index b99462e..0c67e82 100644 --- a/src/state.rs +++ b/src/state.rs @@ -30,6 +30,10 @@ pub struct AppReceivers { pub keyboard: watch::Receiver, #[cfg(feature = "mod-dbus")] pub dnd: watch::Receiver, + #[cfg(feature = "mod-dbus")] + pub mpris_scroll: Arc>, + #[cfg(feature = "mod-dbus")] + pub mpris_scroll_tick: watch::Receiver, pub health: Arc>>, #[cfg(feature = "mod-bt")] pub bt_force_poll: mpsc::Sender<()>, @@ -169,6 +173,12 @@ pub struct BacklightState { pub percentage: u8, } +#[derive(Default, Clone)] +pub struct MprisScrollState { + pub offset: usize, + pub full_text: String, +} + #[derive(Default, Clone)] pub struct MprisState { pub is_playing: bool, @@ -295,6 +305,13 @@ pub fn mock_state(state: AppState) -> MockState { keyboard: keyboard_rx, #[cfg(feature = "mod-dbus")] dnd: dnd_rx, + #[cfg(feature = "mod-dbus")] + mpris_scroll: Arc::new(RwLock::new(MprisScrollState::default())), + #[cfg(feature = "mod-dbus")] + mpris_scroll_tick: { + let (_, rx) = watch::channel(0u64); + rx + }, health: Arc::new(RwLock::new(state.health)), #[cfg(feature = "mod-bt")] bt_force_poll: bt_force_tx,