Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 20 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,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). |

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

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

#[cfg(feature = "battery")]
Expand Down Expand Up @@ -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};
Expand Down
170 changes: 168 additions & 2 deletions src/gtk_helpers.rs
Original file line number Diff line number Diff line change
@@ -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)]
Expand Down Expand Up @@ -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: &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
}

/// 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::<String>();
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::<Instant>));
let is_scrolling = Rc::new(Cell::new(false));
let reset_at_cached = Rc::new(Cell::new(None::<f64>));

// 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
}
Loading
Loading