"""VolumeMeter — a single-channel volume control styled as a segmented meter bar (a Gtk.Scale dressed up via CSS, see .sb-meter in style/style.css) rather than a bare slider, since the user asked for "an audio meter" per channel. Two guards keep it usable against a live, event-driven backend: - commits are debounced (COMMIT_DEBOUNCE_MS) so dragging doesn't spawn a `pactl set-*-volume` per pixel of motion: - `set_value_quiet()` (called when a `pactl subscribe` refresh reports the authoritative value) is a no-op while the user has the handle pressed, so an in-flight drag never gets yanked back by its own not-yet-applied change. """ from __future__ import annotations from typing import Callable import gi gi.require_version("Gtk", "4.0") from gi.repository import GLib, Gtk # noqa: E402 COMMIT_DEBOUNCE_MS = 80 class VolumeMeter(Gtk.Box): def __init__(self, label: str, value: int, on_change: Callable[[int], None], muted: bool = False) -> None: super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) self.add_css_class("sb-meter-row") self._on_change = on_change self._dragging = False self._commit_id: int | None = None self._pending: int | None = None if label: tag = Gtk.Label(label=label) tag.add_css_class("sb-meter-label") self.append(tag) self.scale = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 0, 150, 1) self.scale.set_value(value) self.scale.set_draw_value(True) self.scale.set_value_pos(Gtk.PositionType.RIGHT) self.scale.set_hexpand(True) self.scale.set_size_request(120, -1) self.scale.add_css_class("sb-meter") self.scale.add_mark(100, Gtk.PositionType.BOTTOM, None) self.scale.connect("value-changed", self._on_value_changed) self.set_muted(muted) click = Gtk.GestureClick() click.set_propagation_phase(Gtk.PropagationPhase.CAPTURE) click.connect("pressed", lambda *_a: setattr(self, "_dragging", True)) click.connect("released", lambda *_a: setattr(self, "_dragging", False)) self.scale.add_controller(click) self.append(self.scale) def _on_value_changed(self, _scale) -> None: self._pending = int(self.scale.get_value()) if self._commit_id is not None: GLib.source_remove(self._commit_id) self._commit_id = GLib.timeout_add(COMMIT_DEBOUNCE_MS, self._commit) def _commit(self) -> bool: self._commit_id = None if self._pending is not None: self._on_change(self._pending) return False def set_value_quiet(self, value: int) -> None: """Reflect a backend-reported value without re-triggering on_change.""" if self._dragging or self._commit_id is not None: return # user (or our own not-yet-applied commit) owns the value right now if int(self.scale.get_value()) != value: self.scale.handler_block_by_func(self._on_value_changed) self.scale.set_value(value) self.scale.handler_unblock_by_func(self._on_value_changed) def set_muted(self, muted: bool) -> None: if muted: self.scale.add_css_class("sb-meter-muted") else: self.scale.remove_css_class("sb-meter-muted")