Skip to content
Closed
31 changes: 18 additions & 13 deletions docs/modules/Volume.md
Original file line number Diff line number Diff line change
@@ -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).

Expand All @@ -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`. |

<details>
<summary>JSON</summary>
Expand Down
20 changes: 15 additions & 5 deletions src/clients/volume/sink_input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<SinkInput>, tx: &broadcast::Sender<Event>) {
Expand Down
36 changes: 36 additions & 0 deletions src/config/marquee.rs
Original file line number Diff line number Diff line change
@@ -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<i32>,

/// 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,
}
2 changes: 2 additions & 0 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
mod common;
mod r#impl;
mod layout;
mod marquee;
mod truncate;

#[cfg(feature = "bindmode")]
Expand Down Expand Up @@ -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)]
Expand Down
146 changes: 144 additions & 2 deletions src/gtk_helpers.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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: &gtk::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::<String>();
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::<TickCallbackId>));
let is_hovered = Rc::new(RefCell::new(false));
let pause_started_at = Rc::new(RefCell::new(None::<Instant>));

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
}
24 changes: 20 additions & 4 deletions src/modules/volume.rs
Original file line number Diff line number Diff line change
@@ -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,
};
Expand Down Expand Up @@ -44,6 +44,12 @@ pub struct VolumeModule {
/// **Default**: `null`
pub(crate) truncate: Option<TruncateMode>,

/// See [marquee options](module-level-options#marquee-mode).
///
/// **Default**: `null`
#[serde(default)]
pub(crate) marquee: Option<MarqueeMode>,

/// See [layout options](module-level-options#layout)
#[serde(default, flatten)]
layout: LayoutConfig,
Expand Down Expand Up @@ -410,7 +416,18 @@ impl Module<Button> for VolumeModule {

if let Some(truncate) = self.truncate {
label.truncate(truncate);
};
item_container.add(&label);
} else if let Some(marquee) = self.marquee {
if marquee.enable {
let scrolled =
gtk_helpers::create_marquee_widget(&label, &info.name, marquee);
item_container.add(&scrolled);
} else {
item_container.add(&label);
}
} else {
item_container.add(&label);
}

let slider = Scale::builder().sensitive(info.can_set_volume).build();
slider.set_range(0.0, self.max_volume);
Expand Down Expand Up @@ -446,7 +463,6 @@ impl Module<Button> for VolumeModule {
});
}

item_container.add(&label);
item_container.add(&slider);
item_container.add(&btn_mute);
item_container.show_all();
Expand Down