diff --git a/docs/modules/Volume.md b/docs/modules/Volume.md index cb8b0a17..9ee50224 100644 --- a/docs/modules/Volume.md +++ b/docs/modules/Volume.md @@ -1,7 +1,7 @@ Displays the current volume level. Clicking on the widget opens a volume mixer, which allows you to change the device output level, the default playback device, and control application volume levels individually. -Use `truncate` option to control the display of application titles in the volume mixer. +Use `truncate` or `marquee` options to control the display of application titles in the volume mixer. This requires PulseAudio to function (`pipewire-pulse` is supported). @@ -11,18 +11,25 @@ This requires PulseAudio to function (`pipewire-pulse` is supported). > Type: `volume` -| Name | Type | Default | Description | -|-----------------------|------------------------------------------------------|------------------------|----------------------------------------------------------------------------------------------------------------| -| `format` | `string` | `{icon} {percentage}%` | Format string to use for the widget button label. | -| `max_volume` | `float` | `100` | Maximum value to allow volume sliders to reach. Pulse supports values > 100 but this may result in distortion. | -| `icons.volume_high` | `string` | `󰕾` | Icon to show for high volume levels. | -| `icons.volume_medium` | `string` | `󰖀` | Icon to show for medium volume levels. | -| `icons.volume_low` | `string` | `󰕿` | Icon to show for low volume levels. | -| `icons.muted` | `string` | `󰝟` | Icon to show for muted outputs. | -| `truncate` | `'start'` or `'middle'` or `'end'` or `off` or `Map` | `off` | The location of the ellipses and where to truncate text from. Leave null to avoid truncating. Use the long-hand `Map` version if specifying a length. | -| `truncate.mode` | `'start'` or `'middle'` or `'end'` or `off` | `off` | The location of the ellipses and where to truncate text from. Leave null to avoid truncating. | -| `truncate.length` | `integer` | `null` | The fixed width (in chars) of the widget. Leave blank to let GTK automatically handle. | -| `truncate.max_length` | `integer` | `null` | The maximum number of characters before truncating. Leave blank to let GTK automatically handle. | +| Name | Type | Default | Description | +|---------------------------------|------------------------------------------------------|------------------------|-----------------------------------------------------------------------------------------------------------------------------------| +| `format` | `string` | `{icon} {percentage}%` | Format string to use for the widget button label. | +| `max_volume` | `float` | `100` | Maximum value to allow volume sliders to reach. Pulse supports values > 100 but this may result in distortion. | +| `icons.volume_high` | `string` | `󰕾` | Icon to show for high volume levels. | +| `icons.volume_medium` | `string` | `󰖀` | Icon to show for medium volume levels. | +| `icons.volume_low` | `string` | `󰕿` | Icon to show for low volume levels. | +| `icons.muted` | `string` | `󰝟` | Icon to show for muted outputs. | +| `truncate` | `'start'` or `'middle'` or `'end'` or `off` or `Map` | `off` | The location of the ellipses and where to truncate text from. Leave null to avoid truncating. Use the long-hand `Map` version if specifying a length. Takes precedence over `marquee` if both are configured. | +| `truncate.mode` | `'start'` or `'middle'` or `'end'` or `off` | `off` | The location of the ellipses and where to truncate text from. Leave null to avoid truncating. | +| `truncate.length` | `integer` | `null` | The fixed width (in chars) of the widget. Leave blank to let GTK automatically handle. | +| `truncate.max_length` | `integer` | `null` | The maximum number of characters before truncating. Leave blank to let GTK automatically handle. | +| `marquee` | `Map` | `false` | Options for enabling and configuring a marquee (scrolling) effect for long text. Ignored if `truncate` is configured. | +| `marquee.enable` | `bool` | `false` | Whether to enable a marquee effect. | +| `marquee.max_length` | `integer` | `null` | The maximum length of text (roughly, in characters) before it gets truncated and starts scrolling. | +| `marquee.scroll_speed` | `float` | `0.5` | Scroll speed in pixels per frame. Higher values scroll faster. | +| `marquee.pause_duration` | `integer` | `5000` | Duration in milliseconds to pause at each loop point. | +| `marquee.separator` | `string` | `" "` | String displayed between the end and beginning of text as it loops. | +| `marquee.on_hover` | `'none'` or `'pause'` or `'play'` | `'none'` | Controls marquee behavior on hover: `'none'` (always scroll), `'pause'` (pause on hover), or `'play'` (only scroll on hover). |
JSON diff --git a/src/clients/volume/sink_input.rs b/src/clients/volume/sink_input.rs index 26f837be..09618885 100644 --- a/src/clients/volume/sink_input.rs +++ b/src/clients/volume/sink_input.rs @@ -131,17 +131,22 @@ fn update( trace!("updating {info:?}"); + let input_info: SinkInput = info.into(); + { let mut inputs = lock!(inputs); - let Some(pos) = inputs.iter().position(|input| input.index == info.index) else { + if let Some(pos) = inputs + .iter() + .position(|input| input.index == input_info.index) + { + inputs[pos] = input_info.clone(); + } else { error!("received update to untracked sink input"); return; - }; - - inputs[pos] = info.into(); + } } - tx.send_expect(Event::UpdateInput(info.into())); + tx.send_expect(Event::UpdateInput(input_info)); } fn remove(index: u32, inputs: &ArcMutVec, tx: &broadcast::Sender) { diff --git a/src/config/marquee.rs b/src/config/marquee.rs new file mode 100644 index 00000000..c411debf --- /dev/null +++ b/src/config/marquee.rs @@ -0,0 +1,74 @@ +use serde::Deserialize; + +/// Defines the behavior of marquee scrolling on hover. +#[derive(Debug, Deserialize, Clone, Copy, Default, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +#[cfg_attr(feature = "extras", derive(schemars::JsonSchema))] +pub enum MarqueeOnHover { + /// Scrolling is always active, hover has no effect. + #[default] + None, + /// Scrolling pauses when the widget is hovered. + Pause, + /// Scrolling only occurs when the widget is hovered. + Play, +} + +/// Some modules provide options for scrolling text (marquee effect). +/// This is controlled using a common `MarqueeMode` type, +/// which is defined below. +/// +#[derive(Debug, Deserialize, Clone)] +#[serde(default)] +#[cfg_attr(feature = "extras", derive(schemars::JsonSchema))] +pub struct MarqueeMode { + /// Whether to enable scrolling on long lines of text. + /// This may not be supported by all modules. + /// + /// **Default**: `false` + pub enable: bool, + + /// The maximum length of text (roughly, in characters) before it gets truncated and starts scrolling. + /// + /// **Default**: `null` + pub max_length: Option, + + /// Scroll speed in pixels per frame. + /// Higher values scroll faster. + /// + /// **Default**: `0.5` + pub scroll_speed: f64, + + /// Duration in milliseconds to pause at each loop point. + /// + /// **Default**: `5000` (5 seconds) + pub pause_duration: u64, + + /// String displayed between the end and beginning of text as it loops. + /// + /// **Default**: `" "` (4 spaces) + pub separator: String, + + /// Controls marquee behavior on hover. + /// + /// **Options**: + /// - `"none"`: Always scroll (default) + /// - `"pause"`: Pause scrolling on hover + /// - `"play"`: Only scroll on hover + /// + /// **Default**: `"none"` + pub on_hover: MarqueeOnHover, +} + +impl Default for MarqueeMode { + fn default() -> Self { + Self { + enable: false, + max_length: None, + scroll_speed: 0.5, + pause_duration: 5000, + separator: " ".to_string(), + on_hover: MarqueeOnHover::default(), + } + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs index 10daf546..c9c45afe 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -2,6 +2,7 @@ mod common; pub mod default; mod r#impl; mod layout; +mod marquee; mod truncate; #[cfg(feature = "battery")] @@ -47,6 +48,7 @@ use crate::modules::workspaces::WorkspacesModule; pub use self::common::{CommonConfig, ModuleJustification, ModuleOrientation, TransitionType}; pub use self::layout::LayoutConfig; +pub use self::marquee::{MarqueeMode, MarqueeOnHover}; pub use self::truncate::{EllipsizeMode, TruncateMode}; use crate::Ironbar; use crate::modules::{AnyModuleFactory, ModuleFactory, ModuleInfo, ModuleRef}; diff --git a/src/gtk_helpers.rs b/src/gtk_helpers.rs index 5099a5a6..0f47abdd 100644 --- a/src/gtk_helpers.rs +++ b/src/gtk_helpers.rs @@ -1,9 +1,16 @@ -use crate::config::TruncateMode; +use crate::config::{MarqueeMode, MarqueeOnHover, TruncateMode}; +use glib::ControlFlow; use glib::{SignalHandlerId, markup_escape_text}; use gtk::gdk::{BUTTON_MIDDLE, BUTTON_PRIMARY, BUTTON_SECONDARY, Paintable}; use gtk::pango::EllipsizeMode; use gtk::prelude::*; -use gtk::{EventSequenceState, GestureClick, Label, Snapshot, Widget}; +use gtk::{ + EventControllerMotion, EventSequenceState, GestureClick, Label, ScrolledWindow, Snapshot, + Widget, +}; +use std::cell::Cell; +use std::rc::Rc; +use std::time::{Duration, Instant}; #[derive(Debug, Clone, Copy, Eq, PartialEq)] #[repr(u32)] @@ -145,3 +152,162 @@ where snapshot.to_paintable(None) } } + +/// Calculates the pixel width of a string given the label it's displayed in. +fn pixel_width(label: >k::Label, string: &str) -> i32 { + let layout = label.create_pango_layout(Some(string)); + let (w, _) = layout.size(); // in Pango units (1/1024 px) + w / gtk::pango::SCALE // back to integer pixels +} + +/// Creates a scrolling marquee widget for long text. +/// +/// Wraps the provided label in a scrolled window that automatically scrolls +/// when the text is longer than the allocated width. Supports configurable +/// scroll speed, pause duration, separator, and hover behavior. +pub fn create_marquee_widget( + label: &Label, + text: &str, + marquee_mode: MarqueeMode, +) -> ScrolledWindow { + let MarqueeMode { + max_length, + scroll_speed, + pause_duration, + separator, + on_hover, + .. + } = marquee_mode; + + let ease_pause = Duration::from_millis(pause_duration); + + let scrolled = ScrolledWindow::builder() + .vscrollbar_policy(gtk::PolicyType::Never) + .build(); + + scrolled.hscrollbar().set_visible(false); + + // Set `min-width` to the pixel width of the text, but not wider than `max_length` (as calculated) + if let Some(max_length) = max_length { + let sample_string = text.chars().take(max_length as usize).collect::(); + let width = pixel_width(label, &sample_string); + scrolled.set_min_content_width(width); + } + + scrolled.set_child(Some(label)); + + // Set initial state + label.set_label(text); + + let label = label.clone(); + let text = text.to_string(); + + // Cache the original text width (calculated once upfront) + let original_text_width = pixel_width(&label, &text); + + let is_hovered = Rc::new(Cell::new(false)); + let pause_started_at = Rc::new(Cell::new(None::)); + let is_scrolling = Rc::new(Cell::new(false)); + let reset_at_cached = Rc::new(Cell::new(None::)); + + // Start a tick callback that checks size and scrolls if needed + scrolled.add_tick_callback({ + let is_hovered = is_hovered.clone(); + let pause_started_at = pause_started_at.clone(); + let is_scrolling = is_scrolling.clone(); + let reset_at_cached = reset_at_cached.clone(); + + move |widget, _| { + let allocated_width = widget.width(); + + // Check if we need to scroll based on text width vs allocated width + let needs_scroll = original_text_width > allocated_width; + + if needs_scroll { + // Setup scrolling if not already set up + if !is_scrolling.get() { + let duplicated_text = format!("{}{}{}", &text, &separator, &text); + label.set_label(&duplicated_text); + + // Calculate and cache reset position (where to loop back to) + let reset_at = pixel_width(&label, &format!("{}{}", &text, &separator)) as f64; + reset_at_cached.set(Some(reset_at)); + + // Start with initial pause + pause_started_at.set(Some(Instant::now())); + is_scrolling.set(true); + } + + let reset_at = reset_at_cached + .get() + .expect("reset_at is always set before is_scrolling becomes true"); + + // Check if paused + let is_paused = if let Some(start_time) = pause_started_at.get() { + start_time.elapsed() <= ease_pause + } else { + false + }; + + if is_paused { + return ControlFlow::Continue; + } + + // Check if we need to resume + if pause_started_at.get().is_some() { + pause_started_at.set(None); + } + + // Determine if we should scroll based on hover state + let should_scroll = match on_hover { + MarqueeOnHover::Play => is_hovered.get(), + MarqueeOnHover::Pause => !is_hovered.get(), + MarqueeOnHover::None => true, + }; + + if should_scroll { + let hadjustment = widget.hadjustment(); + let v = hadjustment.value() + scroll_speed; + if v >= reset_at { + hadjustment.set_value(v - reset_at); + pause_started_at.set(Some(Instant::now())); + } else { + hadjustment.set_value(v); + } + } + } else { + // No need to scroll - reset if currently scrolling + if is_scrolling.get() { + label.set_label(&text); + widget.hadjustment().set_value(0.0); + is_scrolling.set(false); + reset_at_cached.set(None); + } + } + + ControlFlow::Continue + } + }); + + if on_hover != MarqueeOnHover::None { + let motion_controller = EventControllerMotion::new(); + + motion_controller.connect_enter({ + let is_hovered = is_hovered.clone(); + move |_, _, _| { + is_hovered.set(true); + } + }); + + motion_controller.connect_leave({ + let is_hovered = is_hovered.clone(); + move |_| { + is_hovered.set(false); + } + }); + + scrolled.add_controller(motion_controller); + } + + scrolled +} diff --git a/src/modules/volume.rs b/src/modules/volume.rs index 1800b959..ec557c5e 100644 --- a/src/modules/volume.rs +++ b/src/modules/volume.rs @@ -1,7 +1,7 @@ use crate::channels::{AsyncSenderExt, BroadcastReceiverExt}; use crate::clients::volume::{self, Event}; -use crate::config::{CommonConfig, LayoutConfig, TruncateMode}; -use crate::gtk_helpers::IronbarLabelExt; +use crate::config::{CommonConfig, LayoutConfig, MarqueeMode, TruncateMode}; +use crate::gtk_helpers::{self, IronbarLabelExt}; use crate::modules::{ Module, ModuleInfo, ModuleParts, ModulePopup, ModuleUpdateEvent, PopupButton, WidgetContext, }; @@ -46,6 +46,10 @@ pub struct VolumeModule { /// **Default**: `null` pub(crate) truncate: Option, + /// See [marquee options](module-level-options#marquee-mode). + #[serde(default)] + pub(crate) marquee: MarqueeMode, + /// See [layout options](module-level-options#layout) #[serde(default, flatten)] layout: LayoutConfig, @@ -62,6 +66,7 @@ impl Default for VolumeModule { max_volume: 100.0, icons: Icons::default(), truncate: None, + marquee: MarqueeMode::default(), layout: LayoutConfig::default(), common: Some(CommonConfig::default()), } @@ -449,6 +454,16 @@ impl Module