Dotfiles/desktopenvs/hyprdrive/supersonic-booster/ui/meter.py

127 lines
5.3 KiB
Python

"""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)