diff --git a/docs/modules/Volume.md b/docs/modules/Volume.md index 0750603e..df9defac 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,23 @@ 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. | +| `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. Incompatible with `truncate` enabled. | +| `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.pause_on_hover` | `bool` | `false` | Pause scrolling when the mouse cursor hovers over the application title. | +| `marquee.pause_on_hover_invert` | `bool` | `false` | Pause scrolling by default and enable it when the cursor hovers over the application title. Takes priority over `pause_on_hover`. |
JSON diff --git a/src/clients/volume/sink_input.rs b/src/clients/volume/sink_input.rs index b6e11aab..ba0ba685 100644 --- a/src/clients/volume/sink_input.rs +++ b/src/clients/volume/sink_input.rs @@ -133,17 +133,27 @@ 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.clone())); + + // HACK: send `Remove` and `Add` events to force UI to recreate widget. + // This is needed to re-initialize scrolling animation. + tx.send_expect(Event::RemoveInput(input_info.index)); + tx.send_expect(Event::AddInput(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..09e09160 --- /dev/null +++ b/src/config/marquee.rs @@ -0,0 +1,36 @@ +use serde::Deserialize; + +/// 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, Copy, Default)] +#[cfg_attr(feature = "schema", 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` + #[serde(default)] + pub enable: bool, + + /// The maximum length of text (roughly, in characters) before it gets truncated and starts scrolling. + /// + /// **Default**: `null` + #[serde(default)] + pub max_length: Option, + + /// Whether to pause scrolling on hover. + /// + /// **Default**: `false` + #[serde(default)] + pub pause_on_hover: bool, + + /// Whether to invert the pause on hover behavior. + /// When true, scrolling will only occur on hover. + /// This takes priority over `pause_on_hover`. + /// + /// **Default**: `false` + #[serde(default)] + pub pause_on_hover_invert: bool, +} diff --git a/src/config/mod.rs b/src/config/mod.rs index 7c06bd96..290a3ce0 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,6 +1,7 @@ mod common; mod r#impl; mod layout; +mod marquee; mod truncate; #[cfg(feature = "bindmode")] @@ -52,6 +53,7 @@ use std::collections::HashMap; pub use self::common::{CommonConfig, ModuleJustification, ModuleOrientation, TransitionType}; pub use self::layout::LayoutConfig; +pub use self::marquee::MarqueeMode; pub use self::truncate::{EllipsizeMode, TruncateMode}; #[derive(Debug, Deserialize, Clone)] diff --git a/src/gtk_helpers.rs b/src/gtk_helpers.rs index 9c685410..c0510df0 100644 --- a/src/gtk_helpers.rs +++ b/src/gtk_helpers.rs @@ -1,8 +1,13 @@ -use crate::config::TruncateMode; +use crate::config::{MarqueeMode, TruncateMode}; +use glib::ControlFlow; +use glib::Propagation; use glib::{IsA, markup_escape_text}; use gtk::pango::EllipsizeMode; use gtk::prelude::*; -use gtk::{Label, Orientation, Widget}; +use gtk::{Label, Orientation, ScrolledWindow, TickCallbackId, Widget}; +use std::cell::RefCell; +use std::rc::Rc; +use std::time::{Duration, Instant}; /// Represents a widget's size /// and location relative to the bar's start edge. @@ -116,3 +121,140 @@ impl IronbarLabelExt for Label { } } } + +// Calculate 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 +} + +pub fn create_marquee_widget( + label: &Label, + text: &str, + marquee_mode: MarqueeMode, +) -> ScrolledWindow { + // Constants + let sep = " ".to_string(); + let ease_pause = Duration::from_secs(5); + + let MarqueeMode { + max_length, + pause_on_hover, + pause_on_hover_invert, + .. + } = marquee_mode; + + let scrolled = ScrolledWindow::builder() + .vscrollbar_policy(gtk::PolicyType::Never) + .build(); + + if let Some(hbar) = scrolled.hscrollbar() { + hbar.hide(); + } + + // 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.add(label); + + // Set initial state. + // The size_allocate signal will handle the rest. + label.set_label(text); + + let label = label.clone(); + let text = text.to_string(); + + let tick_id = Rc::new(RefCell::new(None::)); + let is_hovered = Rc::new(RefCell::new(false)); + let pause_started_at = Rc::new(RefCell::new(None::)); + + let tick_id_clone = tick_id.clone(); + let is_hovered_clone = is_hovered.clone(); + scrolled.connect_size_allocate(move |scrolled, _| { + let allocated_width = scrolled.allocation().width(); + let original_text_width = pixel_width(&label, &text); + + let is_scrolling = tick_id_clone.borrow().is_some(); + + // Widgets can get resized, which would throw off the calculations for scrolling, and whether it has to be done at all. + // Account for this by comparing original text's pixel width and new widget's allocated width. + if original_text_width > allocated_width { + // Needs to scroll + if !is_scrolling { + let duplicated_text = format!("{}{}{}", &text, &sep, &text); + label.set_label(&duplicated_text); + + let reset_at = pixel_width(&label, &format!("{}{}", &text, &sep)) as f64; + + let is_hovered_clone_tick = is_hovered_clone.clone(); + let pause_started_at_clone = pause_started_at.clone(); + let id = scrolled.add_tick_callback(move |widget, _| { + let is_paused = if let Some(start_time) = *pause_started_at_clone.borrow() { + start_time.elapsed() <= ease_pause + } else { + false + }; + + if is_paused { + return ControlFlow::Continue; + } + + // check if we need to resume + if pause_started_at_clone.borrow().is_some() { + *pause_started_at_clone.borrow_mut() = None; + } + + let should_scroll = if pause_on_hover_invert { + *is_hovered_clone_tick.borrow() + } else if pause_on_hover { + !*is_hovered_clone_tick.borrow() + } else { + true + }; + + if should_scroll { + let hadjustment = widget.hadjustment(); + let v = hadjustment.value() + 0.5; + if v >= reset_at { + hadjustment.set_value(v - reset_at); + *pause_started_at_clone.borrow_mut() = Some(Instant::now()); + } else { + hadjustment.set_value(v); + } + } + ControlFlow::Continue + }); + + *tick_id_clone.borrow_mut() = Some(id); + } + } else { + // No need to scroll + if is_scrolling { + if let Some(id) = tick_id_clone.borrow_mut().take() { + id.remove(); + } + label.set_label(&text); + } + } + }); + + if pause_on_hover || pause_on_hover_invert { + let is_hovered_enter = is_hovered.clone(); + scrolled.connect_enter_notify_event(move |_, _| { + *is_hovered_enter.borrow_mut() = true; + Propagation::Stop + }); + + scrolled.connect_leave_notify_event(move |_, _| { + *is_hovered.borrow_mut() = false; + Propagation::Stop + }); + } + + scrolled +} diff --git a/src/modules/volume.rs b/src/modules/volume.rs index d8ac23c1..b0e1fcc6 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::{IronbarGtkExt, IronbarLabelExt}; +use crate::config::{CommonConfig, LayoutConfig, MarqueeMode, TruncateMode}; +use crate::gtk_helpers::{self, IronbarGtkExt, IronbarLabelExt}; use crate::modules::{ Module, ModuleInfo, ModuleParts, ModulePopup, ModuleUpdateEvent, PopupButton, WidgetContext, }; @@ -44,6 +44,12 @@ pub struct VolumeModule { /// **Default**: `null` pub(crate) truncate: Option, + /// See [marquee options](module-level-options#marquee-mode). + /// + /// **Default**: `null` + #[serde(default)] + pub(crate) marquee: Option, + /// See [layout options](module-level-options#layout) #[serde(default, flatten)] layout: LayoutConfig, @@ -410,7 +416,18 @@ impl Module