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,