"""VolumeMeter — a single-channel volume control styled as a *vertical* segmented meter (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 — a mixer column, read bottom-to-top like every hardware fader. The live peak indicator (ui/peakbar.py) is stacked *inside* the same trough via a Gtk.Overlay: magenta fill = the volume you selected, the narrower purple column inside it = the signal actually reaching the mix right now. The overlay child is `can_target=False`, so the scale underneath still gets every click and drag across its full width. VolumeMeter (Gtk.Box, vertical) ├ value label ("100") ├ Gtk.Overlay ─ main: Gtk.Scale (vertical, inverted) │ └ over: PeakBar drawing area (narrow, centred) └ channel label ("L"/"R", omitted for the single-channel meter) 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 from ui.peakbar import PeakBar COMMIT_DEBOUNCE_MS = 80 METER_HEIGHT = 130 # half the scale slider's height (.sb-meter slider min-height in style.css): the # span the highlight can actually cover is inset by that much at each end, so the # peak column drawn inside it has to be inset to match. SLIDER_INSET = 5 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.VERTICAL, spacing=3) 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 self._value_lbl = Gtk.Label(label=str(int(value))) self._value_lbl.add_css_class("sb-meter-value") self.append(self._value_lbl) self.scale = Gtk.Scale.new_with_range(Gtk.Orientation.VERTICAL, 0, 150, 1) self.scale.set_value(value) self.scale.set_inverted(True) # loud at the top, like a hardware fader # value drawn by _value_lbl above instead of by the scale, and no 100% # mark: either one would push the trough off-centre inside the widget, # and the peak column overlaid on it is centred. self.scale.set_draw_value(False) self.scale.set_vexpand(True) self.scale.set_size_request(-1, METER_HEIGHT) self.scale.add_css_class("sb-meter") self.scale.connect("value-changed", self._on_value_changed) self._peakbar = PeakBar(inset=SLIDER_INSET) trough = Gtk.Overlay() trough.set_child(self.scale) trough.add_overlay(self._peakbar.widget) self.append(trough) # always appended, even unlabelled (single-channel) — an empty label # still claims a line, so a lone meter and a split L/R pair keep the # same trough height inside the card's Gtk.Stack tag = Gtk.Label(label=label) tag.add_css_class("sb-meter-label") self.append(tag) 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) def _on_value_changed(self, _scale) -> None: self._pending = int(self.scale.get_value()) self._value_lbl.set_label(str(self._pending)) 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) self._value_lbl.set_label(str(int(value))) def set_muted(self, muted: bool) -> None: if muted: self.scale.add_css_class("sb-meter-muted") self.add_css_class("sb-meter-muted") else: self.scale.remove_css_class("sb-meter-muted") self.remove_css_class("sb-meter-muted") def set_peak(self, level: float) -> None: """Feed the inner live-signal column (ui/card_base.py fans one peak monitor's level out to every meter of the card).""" self._peakbar.set_level(level)