feature/scroll-animation
Release / Build and Release (push) Successful in 2m50s

This commit is contained in:
2026-04-04 05:11:04 +02:00
parent 75601305e2
commit 2050c345f1
7 changed files with 195 additions and 25 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "fluxo-rs" name = "fluxo-rs"
version = "0.4.2" version = "0.4.3"
edition = "2024" edition = "2024"
[features] [features]
+4
View File
@@ -93,6 +93,10 @@ format_inactive = "<span size='large'></span>"
[mpris] [mpris]
# enabled = false # set to false to disable this module at runtime # 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} # tokens: {artist}, {title}, {album}, {status_icon}
format = "{status_icon} {artist} - {title}" format = "{status_icon} {artist} - {title}"
+20
View File
@@ -290,6 +290,22 @@ pub struct MprisConfig {
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub enabled: bool, pub enabled: bool,
pub format: String, pub format: String,
#[serde(default)]
pub max_length: Option<usize>,
#[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 { impl Default for MprisConfig {
@@ -297,6 +313,10 @@ impl Default for MprisConfig {
Self { Self {
enabled: true, enabled: true,
format: "{status_icon} {artist} - {title}".to_string(), format: "{status_icon} {artist} - {title}".to_string(),
max_length: None,
scroll: false,
scroll_speed: 500,
scroll_separator: " /// ".to_string(),
} }
} }
} }
+22
View File
@@ -78,6 +78,10 @@ pub async fn run_daemon(config_path: Option<PathBuf>) -> Result<()> {
let (keyboard_tx, keyboard_rx) = watch::channel(Default::default()); let (keyboard_tx, keyboard_rx) = watch::channel(Default::default());
#[cfg(feature = "mod-dbus")] #[cfg(feature = "mod-dbus")]
let (dnd_tx, dnd_rx) = watch::channel(Default::default()); 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())); let health = Arc::new(RwLock::new(HashMap::new()));
#[cfg(feature = "mod-bt")] #[cfg(feature = "mod-bt")]
let (bt_force_tx, mut bt_force_rx) = mpsc::channel(1); let (bt_force_tx, mut bt_force_rx) = mpsc::channel(1);
@@ -109,6 +113,10 @@ pub async fn run_daemon(config_path: Option<PathBuf>) -> Result<()> {
keyboard: keyboard_rx, keyboard: keyboard_rx,
#[cfg(feature = "mod-dbus")] #[cfg(feature = "mod-dbus")]
dnd: dnd_rx, 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), health: Arc::clone(&health),
#[cfg(feature = "mod-bt")] #[cfg(feature = "mod-bt")]
bt_force_poll: bt_force_tx, bt_force_poll: bt_force_tx,
@@ -313,6 +321,20 @@ pub async fn run_daemon(config_path: Option<PathBuf>) -> Result<()> {
if config.read().await.mpris.enabled { if config.read().await.mpris.enabled {
let mpris_daemon = MprisDaemon::new(); let mpris_daemon = MprisDaemon::new();
mpris_daemon.start(mpris_tx); 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 // 6. Waybar Signaler Task
+120 -24
View File
@@ -2,12 +2,66 @@ use crate::config::Config;
use crate::error::Result; use crate::error::Result;
use crate::modules::WaybarModule; use crate::modules::WaybarModule;
use crate::output::WaybarOutput; use crate::output::WaybarOutput;
use crate::state::{AppReceivers, MprisState}; use crate::state::{AppReceivers, MprisScrollState, MprisState};
use crate::utils::{TokenValue, format_template}; 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 tracing::{debug, info};
use zbus::{Connection, proxy}; 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; pub struct MprisModule;
impl WaybarModule for MprisModule { impl WaybarModule for MprisModule {
@@ -28,32 +82,26 @@ impl WaybarModule for MprisModule {
}); });
} }
let status_icon = if mpris.is_playing { let (full_text, class) = format_mpris_text(&config.mpris.format, &mpris);
"󰏤"
} else if mpris.is_paused {
"󰐊"
} else {
"󰓛"
};
let class = if mpris.is_playing { let text = if config.mpris.scroll {
"playing" if let Some(max_len) = config.mpris.max_length {
} else if mpris.is_paused { let scroll = state.mpris_scroll.read().await;
"paused" 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 { } 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 { Ok(WaybarOutput {
text, text,
tooltip: Some(format!("{} - {}", mpris.artist, mpris.title)), tooltip: Some(format!("{} - {}", mpris.artist, mpris.title)),
@@ -63,6 +111,54 @@ impl WaybarModule for MprisModule {
} }
} }
pub async fn mpris_scroll_ticker(
config: Arc<RwLock<Config>>,
mut mpris_rx: watch::Receiver<MprisState>,
scroll_state: Arc<RwLock<MprisScrollState>>,
tick_tx: watch::Sender<u64>,
) {
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; pub struct MprisDaemon;
#[proxy( #[proxy(
+11
View File
@@ -175,6 +175,13 @@ impl WaybarSignaler {
#[cfg(feature = "mod-dbus")] #[cfg(feature = "mod-dbus")]
let mpris_changed = receivers.mpris.changed(); 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! { tokio::select! {
res = net_changed, if signals.network.is_some() => { res = net_changed, if signals.network.is_some() => {
if res.is_ok() { check_and_signal!("net", signals.network); } if res.is_ok() { check_and_signal!("net", signals.network); }
@@ -215,6 +222,10 @@ impl WaybarSignaler {
res = mpris_changed, if signals.mpris.is_some() => { res = mpris_changed, if signals.mpris.is_some() => {
if res.is_ok() { check_and_signal!("mpris", signals.mpris); } 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)) => { _ = sleep(Duration::from_secs(5)) => {
// loop and refresh config // loop and refresh config
} }
+17
View File
@@ -30,6 +30,10 @@ pub struct AppReceivers {
pub keyboard: watch::Receiver<KeyboardState>, pub keyboard: watch::Receiver<KeyboardState>,
#[cfg(feature = "mod-dbus")] #[cfg(feature = "mod-dbus")]
pub dnd: watch::Receiver<DndState>, pub dnd: watch::Receiver<DndState>,
#[cfg(feature = "mod-dbus")]
pub mpris_scroll: Arc<RwLock<MprisScrollState>>,
#[cfg(feature = "mod-dbus")]
pub mpris_scroll_tick: watch::Receiver<u64>,
pub health: Arc<RwLock<HashMap<String, ModuleHealth>>>, pub health: Arc<RwLock<HashMap<String, ModuleHealth>>>,
#[cfg(feature = "mod-bt")] #[cfg(feature = "mod-bt")]
pub bt_force_poll: mpsc::Sender<()>, pub bt_force_poll: mpsc::Sender<()>,
@@ -169,6 +173,12 @@ pub struct BacklightState {
pub percentage: u8, pub percentage: u8,
} }
#[derive(Default, Clone)]
pub struct MprisScrollState {
pub offset: usize,
pub full_text: String,
}
#[derive(Default, Clone)] #[derive(Default, Clone)]
pub struct MprisState { pub struct MprisState {
pub is_playing: bool, pub is_playing: bool,
@@ -295,6 +305,13 @@ pub fn mock_state(state: AppState) -> MockState {
keyboard: keyboard_rx, keyboard: keyboard_rx,
#[cfg(feature = "mod-dbus")] #[cfg(feature = "mod-dbus")]
dnd: dnd_rx, 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)), health: Arc::new(RwLock::new(state.health)),
#[cfg(feature = "mod-bt")] #[cfg(feature = "mod-bt")]
bt_force_poll: bt_force_tx, bt_force_poll: bt_force_tx,