diff --git a/desktopenvs/hyprdrive/supersonic-booster/style/style.css b/desktopenvs/hyprdrive/supersonic-booster/style/style.css index 7eeeed2..fc17ba8 100644 --- a/desktopenvs/hyprdrive/supersonic-booster/style/style.css +++ b/desktopenvs/hyprdrive/supersonic-booster/style/style.css @@ -68,7 +68,7 @@ drawingarea { } .sb-tab-page { - min-height: 190px; + min-height: 250px; /* tall enough for a card with vertical meters */ } /* ---- horizontal scroll row of cards --------------------------------------- */ @@ -101,49 +101,58 @@ drawingarea { letter-spacing: 0.5px; } -/* ---- live peak bar -------------------------------------------------------- */ -.sb-peakbar { min-height: 6px; } -.sb-peakbar trough { - background-color: alpha(@bg, 0.6); - border-radius: 4px; - min-height: 6px; -} -.sb-peakbar block { - border-radius: 4px; - min-width: 2px; -} -.sb-peakbar block.filled { - background-color: #34E27A; /* signal present — distinct from the violet/accent UI chrome */ -} -.sb-peakbar block.empty { +/* ---- vertical volume meter (Gtk.Scale) ------------------------------------ */ +/* The magenta highlight is the volume you *selected*; the purple column the + * live peak indicator paints inside it (ui/peakbar.py, Cairo — the only styling + * here is the muted dimming) is the signal actually reaching the mix. */ +.sb-meter-row { margin: 2px 0; } +/* class-specificity re-assertion of the transparent-structure rule above: the + * CyberQueer theme's `* { background-color: #1a1a1a }` otherwise paints a slab + * behind the value label and, worse, behind the peak column overlaid on the + * trough — which reads as a dark stripe punched through the magenta fill. */ +.sb-meter-row, +.sb-meter-stack, +.sb-meter-value, +.sb-meter-label, +.sb-meter, +.sb-peakbar { + background-image: none; background-color: transparent; } - -/* ---- volume meter (Gtk.Scale) --------------------------------------------- */ -.sb-meter-row { margin: 2px 0; } .sb-meter-label { color: @text; opacity: 0.75; font-size: 9pt; - min-width: 12px; } +.sb-meter-value { + color: @text; + opacity: 0.85; + font-size: 9pt; + letter-spacing: 0.5px; +} +/* empty portion stays see-through — an idle meter and a silent monitor both + * read as "nothing here", with only the violet outline holding the shape */ .sb-meter trough { - background-color: alpha(@bg, 0.55); - border-radius: 6px; - min-height: 10px; + background-color: transparent; + border: 1px solid alpha(#8A5CFF, 0.45); + border-radius: 10px; + min-width: 20px; } .sb-meter highlight { - background-color: @accent; - border-radius: 6px; + background-color: @text; /* emitted magenta — the selected volume */ + border-radius: 10px; + /* tight core + wide bloom: the fill emits, like the rest of the suite's chrome */ + box-shadow: 0 0 6px 1px alpha(@text, 0.95), 0 0 22px 6px alpha(@text, 0.55); } .sb-meter slider { - background-color: @text; - border-radius: 50%; - min-width: 14px; - min-height: 14px; + background-color: #FFFFFF; /* the fader cap, riding on top of the fill */ + border-radius: 3px; + min-width: 24px; + min-height: 10px; /* half of this is ui/meter.py's SLIDER_INSET */ } .sb-meter.sb-meter-muted highlight { background-color: alpha(@text, 0.25); } -.sb-meter.sb-meter-muted slider { background-color: alpha(@text, 0.4); } +.sb-meter.sb-meter-muted slider { background-color: alpha(#FFFFFF, 0.4); } +.sb-meter-row.sb-meter-muted .sb-peakbar { opacity: 0.35; } /* ---- controls (mute / L-R toggle / default) -------------------------------- */ .sb-card-controls { margin-top: 2px; } diff --git a/desktopenvs/hyprdrive/supersonic-booster/ui/card_base.py b/desktopenvs/hyprdrive/supersonic-booster/ui/card_base.py index 96087c8..39edcd9 100644 --- a/desktopenvs/hyprdrive/supersonic-booster/ui/card_base.py +++ b/desktopenvs/hyprdrive/supersonic-booster/ui/card_base.py @@ -1,6 +1,10 @@ """Shared chrome for the Applications/Output/Input tabs' cards: icon+title, a -live peak bar (backend/peaklevel.py), a volume meter that becomes an -independent left/right pair via a toggle, and a mute button. Subclasses +vertical volume meter that becomes an independent left/right pair via a +toggle — each with the live peak level (backend/peaklevel.py) drawn as a +purple column inside its magenta fill, see ui/meter.py — and a mute button. +One peak monitor per card feeds every meter of that card: `parec` reports a +single mixed level per target, not a per-channel one, so the L and R columns +of a split pair show the same signal at different set volumes. Subclasses (ApplicationCard, DeviceCard) bolt on their own routing controls (output- device dropdown, profile dropdown, default-device button) via `_build_extra()`/`_update_extra()`/`_peak_target()`. @@ -14,7 +18,6 @@ gi.require_version("Gtk", "4.0") from gi.repository import Gtk # noqa: E402 from ui.meter import VolumeMeter -from ui.peakbar import PeakBar def is_stereo(item: dict) -> bool: @@ -54,15 +57,13 @@ class MeterCard(Gtk.Box): head.append(self._name_lbl) self.append(head) - self._peakbar = PeakBar() - self.append(self._peakbar.widget) - self._meter_stack = Gtk.Stack() self._meter_stack.add_css_class("sb-meter-stack") + self._meter_stack.set_halign(Gtk.Align.CENTER) self._single = VolumeMeter("", avg_percent(item), self._on_single_change) self._meter_stack.add_named(self._single, "single") if self._stereo: - lr_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + lr_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=14) self._left = VolumeMeter("L", self._last_left, self._on_left_change) self._right = VolumeMeter("R", self._last_right, self._on_right_change) lr_box.append(self._left) @@ -167,10 +168,16 @@ class MeterCard(Gtk.Box): if target is None: return key = f"{self._kind}:{self._id}" - self._peak_unwatch = self._peaks.watch(key, target, self._peakbar.set_level) + self._peak_unwatch = self._peaks.watch(key, target, self._set_peak_level) def stop_peak(self) -> None: if self._peak_unwatch is not None: self._peak_unwatch() self._peak_unwatch = None - self._peakbar.set_level(0.0) + self._set_peak_level(0.0) + + def _set_peak_level(self, level: float) -> None: + """One monitored level, fanned out to whichever meters this card has.""" + for meter in (self._single, self._left, self._right): + if meter is not None: + meter.set_peak(level) diff --git a/desktopenvs/hyprdrive/supersonic-booster/ui/meter.py b/desktopenvs/hyprdrive/supersonic-booster/ui/meter.py index c6dc26e..56fad83 100644 --- a/desktopenvs/hyprdrive/supersonic-booster/ui/meter.py +++ b/desktopenvs/hyprdrive/supersonic-booster/ui/meter.py @@ -1,6 +1,19 @@ -"""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. +"""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 @@ -19,33 +32,55 @@ 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.HORIZONTAL, spacing=6) + 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 - if label: - tag = Gtk.Label(label=label) - tag.add_css_class("sb-meter-label") - self.append(tag) + 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.HORIZONTAL, 0, 150, 1) + self.scale = Gtk.Scale.new_with_range(Gtk.Orientation.VERTICAL, 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(90, -1) + 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.add_mark(100, Gtk.PositionType.BOTTOM, None) 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() @@ -54,10 +89,9 @@ class VolumeMeter(Gtk.Box): 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()) + 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) @@ -76,9 +110,17 @@ class VolumeMeter(Gtk.Box): 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) diff --git a/desktopenvs/hyprdrive/supersonic-booster/ui/peakbar.py b/desktopenvs/hyprdrive/supersonic-booster/ui/peakbar.py index a8f9eae..7fb99a5 100644 --- a/desktopenvs/hyprdrive/supersonic-booster/ui/peakbar.py +++ b/desktopenvs/hyprdrive/supersonic-booster/ui/peakbar.py @@ -1,25 +1,84 @@ -"""PeakBar — a thin live amplitude indicator (see backend/peaklevel.py). Shows -whether audio is actually reaching the mix, independent of the volume slider -above it, so a silent app/device reads differently from one that's just quiet: -a stuck/crashed stream or a bad route shows a flat bar even at 100% volume.""" +"""PeakBar — the live amplitude indicator (see backend/peaklevel.py), drawn as +a narrow purple column *inside* the magenta volume meter it belongs to rather +than as a separate bar above the card: one glance tells you both what the +volume is set to and how much signal is actually reaching the mix, so a silent +app/device reads differently from one that's just quiet (a stuck or mis-routed +stream shows an empty inner column at 100% volume). + +Cairo-drawn instead of a Gtk.LevelBar because it has to line up with the +Gtk.Scale trough underneath it: the fill has to grow bottom-to-top over the +same span as the scale's highlight, minus the half-slider inset the scale +reserves at each end (`inset` below). It also sidesteps this GTK build's +reluctance to paint CSS backgrounds on non-drawing widgets. Owned by +ui/meter.py, which stacks it over the scale in a Gtk.Overlay. +""" from __future__ import annotations +import math + import gi gi.require_version("Gtk", "4.0") from gi.repository import Gtk # noqa: E402 +# glow-violet, the suite's frame colour — deliberately *inside* the meter's +# emitted-magenta fill, so "set to" and "actually playing" never read alike +PEAK_COLOR = (0x8A / 255, 0x5C / 255, 0xFF / 255) + class PeakBar: - def __init__(self) -> None: - self.widget = Gtk.LevelBar() + WIDTH = 9 # narrower than the meter trough, so magenta frames it + MIN_VISIBLE = 0.01 + + def __init__(self, inset: int = 0) -> None: + self._inset = inset + self._level = 0.0 + self.widget = Gtk.DrawingArea() self.widget.add_css_class("sb-peakbar") - self.widget.set_min_value(0.0) - self.widget.set_max_value(1.0) - self.widget.set_value(0.0) - self.widget.set_size_request(-1, 6) - self.widget.set_hexpand(True) + self.widget.set_size_request(self.WIDTH, -1) + self.widget.set_halign(Gtk.Align.CENTER) + self.widget.set_valign(Gtk.Align.FILL) + self.widget.set_vexpand(True) + # the scale underneath owns every click/drag in this area + self.widget.set_can_target(False) + self.widget.set_draw_func(self._draw) def set_level(self, level: float) -> None: - self.widget.set_value(max(0.0, min(1.0, level))) + level = max(0.0, min(1.0, level)) + if abs(level - self._level) < 0.002: + return + self._level = level + self.widget.queue_draw() + + # -- drawing ------------------------------------------------------------------ + def _draw(self, _area, cr, width: int, height: int) -> None: + if self._level < self.MIN_VISIBLE: + return + top = self._inset + bottom = height - self._inset + span = bottom - top + if span <= 0: + return + + w = min(width, self.WIDTH) + x = (width - w) / 2.0 + h = span * self._level + y = bottom - h + + self._rounded(cr, x, y, w, h, w / 2.0) + cr.set_source_rgba(*PEAK_COLOR, 0.95) + cr.fill_preserve() + cr.set_source_rgba(*PEAK_COLOR, 0.35) + cr.set_line_width(2.0) + cr.stroke() + + @staticmethod + def _rounded(cr, x: float, y: float, w: float, h: float, r: float) -> None: + r = min(r, w / 2.0, h / 2.0) + cr.new_sub_path() + cr.arc(x + w - r, y + r, r, -math.pi / 2, 0) + cr.arc(x + w - r, y + h - r, r, 0, math.pi / 2) + cr.arc(x + r, y + h - r, r, math.pi / 2, math.pi) + cr.arc(x + r, y + r, r, math.pi, 3 * math.pi / 2) + cr.close_path() diff --git a/desktopenvs/hyprlua/audio-panel-theme/style.css b/desktopenvs/hyprlua/audio-panel-theme/style.css index e9d2aa9..aaf8cfa 100644 --- a/desktopenvs/hyprlua/audio-panel-theme/style.css +++ b/desktopenvs/hyprlua/audio-panel-theme/style.css @@ -63,7 +63,7 @@ drawingarea { color: @bg; } -.sb-tab-page { min-height: 190px; } +.sb-tab-page { min-height: 250px; } /* tall enough for a card with vertical meters */ /* ---- horizontal scroll row of cards ---------------------------------------- */ .sb-scroll-row { padding: 4px 2px 10px 2px; } @@ -94,45 +94,56 @@ drawingarea { font-size: 8.5pt; } -/* ---- live peak bar ---------------------------------------------------------- */ -.sb-peakbar { min-height: 6px; } -.sb-peakbar trough { - background-color: @bg; - border-radius: 4px; - min-height: 6px; -} -.sb-peakbar block { - border-radius: 4px; - min-width: 2px; -} -.sb-peakbar block.filled { background-color: #34E27A; } -.sb-peakbar block.empty { background-color: transparent; } - -/* ---- volume meter (Gtk.Scale) ----------------------------------------------- */ +/* ---- vertical volume meter (Gtk.Scale) --------------------------------------- */ +/* The accent fill is the volume you *selected*; the violet column ui/peakbar.py + * paints inside it (Cairo — nothing to style here beyond the muted dimming) is + * the live signal. No box-shadow glow on the fill, unlike hyprdrive's + * supersonic-booster: this variant stays flat, see the header. */ .sb-meter-row { margin: 2px 0; } +/* class-specificity re-assertion of the transparent-structure rule above: the + * CyberQueer theme's `* { background-color: #1a1a1a }` otherwise paints a slab + * behind the value label and behind the peak column overlaid on the trough, + * which reads as a dark stripe punched through the fill. */ +.sb-meter-row, +.sb-meter-stack, +.sb-meter-value, +.sb-meter-label, +.sb-meter, +.sb-peakbar { + background-image: none; + background-color: transparent; +} .sb-meter-label { color: @text; opacity: 0.75; font-size: 9pt; - min-width: 12px; } +.sb-meter-value { + color: @text; + opacity: 0.85; + font-size: 9pt; +} +/* empty portion stays see-through — an idle meter and a silent monitor both + * read as "nothing here", with only the outline holding the shape */ .sb-meter trough { - background-color: @bg; - border-radius: 6px; - min-height: 10px; + background-color: transparent; + border: 1px solid @violet; + border-radius: 10px; + min-width: 20px; } .sb-meter highlight { background-color: @accent; - border-radius: 6px; + border-radius: 10px; } .sb-meter slider { - background-color: @text; - border-radius: 50%; - min-width: 14px; - min-height: 14px; + background-color: @text; /* the fader cap, riding on top of the fill */ + border-radius: 3px; + min-width: 24px; + min-height: 10px; /* half of this is ui/meter.py's SLIDER_INSET */ } .sb-meter.sb-meter-muted highlight { background-color: alpha(@text, 0.3); } .sb-meter.sb-meter-muted slider { background-color: alpha(@text, 0.4); } +.sb-meter-row.sb-meter-muted .sb-peakbar { opacity: 0.35; } /* ---- controls (mute / L-R toggle / default) --------------------------------- */ .sb-card-controls { margin-top: 2px; } diff --git a/desktopenvs/hyprlua/audio-panel/style/style.css b/desktopenvs/hyprlua/audio-panel/style/style.css index 961b4b2..3bbe252 100644 --- a/desktopenvs/hyprlua/audio-panel/style/style.css +++ b/desktopenvs/hyprlua/audio-panel/style/style.css @@ -63,7 +63,7 @@ drawingarea { color: @bg; } -.sb-tab-page { min-height: 190px; } +.sb-tab-page { min-height: 250px; } /* tall enough for a card with vertical meters */ /* ---- horizontal scroll row of cards ---------------------------------------- */ .sb-scroll-row { padding: 4px 2px 10px 2px; } @@ -94,45 +94,56 @@ drawingarea { font-size: 8.5pt; } -/* ---- live peak bar ---------------------------------------------------------- */ -.sb-peakbar { min-height: 6px; } -.sb-peakbar trough { - background-color: @bg; - border-radius: 4px; - min-height: 6px; -} -.sb-peakbar block { - border-radius: 4px; - min-width: 2px; -} -.sb-peakbar block.filled { background-color: #34E27A; } -.sb-peakbar block.empty { background-color: transparent; } - -/* ---- volume meter (Gtk.Scale) ----------------------------------------------- */ +/* ---- vertical volume meter (Gtk.Scale) --------------------------------------- */ +/* The accent fill is the volume you *selected*; the violet column ui/peakbar.py + * paints inside it (Cairo — nothing to style here beyond the muted dimming) is + * the live signal. No box-shadow glow on the fill, unlike hyprlua's + * audio-panel: this variant stays flat, see the header. */ .sb-meter-row { margin: 2px 0; } +/* class-specificity re-assertion of the transparent-structure rule above: the + * CyberQueer theme's `* { background-color: #1a1a1a }` otherwise paints a slab + * behind the value label and behind the peak column overlaid on the trough, + * which reads as a dark stripe punched through the fill. */ +.sb-meter-row, +.sb-meter-stack, +.sb-meter-value, +.sb-meter-label, +.sb-meter, +.sb-peakbar { + background-image: none; + background-color: transparent; +} .sb-meter-label { color: @text; opacity: 0.75; font-size: 9pt; - min-width: 12px; } +.sb-meter-value { + color: @text; + opacity: 0.85; + font-size: 9pt; +} +/* empty portion stays see-through — an idle meter and a silent monitor both + * read as "nothing here", with only the outline holding the shape */ .sb-meter trough { - background-color: @bg; - border-radius: 6px; - min-height: 10px; + background-color: transparent; + border: 1px solid @violet; + border-radius: 10px; + min-width: 20px; } .sb-meter highlight { background-color: @accent; - border-radius: 6px; + border-radius: 10px; } .sb-meter slider { - background-color: @text; - border-radius: 50%; - min-width: 14px; - min-height: 14px; + background-color: @text; /* the fader cap, riding on top of the fill */ + border-radius: 3px; + min-width: 24px; + min-height: 10px; /* half of this is ui/meter.py's SLIDER_INSET */ } .sb-meter.sb-meter-muted highlight { background-color: alpha(@text, 0.3); } .sb-meter.sb-meter-muted slider { background-color: alpha(@text, 0.4); } +.sb-meter-row.sb-meter-muted .sb-peakbar { opacity: 0.35; } /* ---- controls (mute / L-R toggle / default) --------------------------------- */ .sb-card-controls { margin-top: 2px; } diff --git a/desktopenvs/hyprlua/audio-panel/ui/card_base.py b/desktopenvs/hyprlua/audio-panel/ui/card_base.py index 96087c8..39edcd9 100644 --- a/desktopenvs/hyprlua/audio-panel/ui/card_base.py +++ b/desktopenvs/hyprlua/audio-panel/ui/card_base.py @@ -1,6 +1,10 @@ """Shared chrome for the Applications/Output/Input tabs' cards: icon+title, a -live peak bar (backend/peaklevel.py), a volume meter that becomes an -independent left/right pair via a toggle, and a mute button. Subclasses +vertical volume meter that becomes an independent left/right pair via a +toggle — each with the live peak level (backend/peaklevel.py) drawn as a +purple column inside its magenta fill, see ui/meter.py — and a mute button. +One peak monitor per card feeds every meter of that card: `parec` reports a +single mixed level per target, not a per-channel one, so the L and R columns +of a split pair show the same signal at different set volumes. Subclasses (ApplicationCard, DeviceCard) bolt on their own routing controls (output- device dropdown, profile dropdown, default-device button) via `_build_extra()`/`_update_extra()`/`_peak_target()`. @@ -14,7 +18,6 @@ gi.require_version("Gtk", "4.0") from gi.repository import Gtk # noqa: E402 from ui.meter import VolumeMeter -from ui.peakbar import PeakBar def is_stereo(item: dict) -> bool: @@ -54,15 +57,13 @@ class MeterCard(Gtk.Box): head.append(self._name_lbl) self.append(head) - self._peakbar = PeakBar() - self.append(self._peakbar.widget) - self._meter_stack = Gtk.Stack() self._meter_stack.add_css_class("sb-meter-stack") + self._meter_stack.set_halign(Gtk.Align.CENTER) self._single = VolumeMeter("", avg_percent(item), self._on_single_change) self._meter_stack.add_named(self._single, "single") if self._stereo: - lr_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + lr_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=14) self._left = VolumeMeter("L", self._last_left, self._on_left_change) self._right = VolumeMeter("R", self._last_right, self._on_right_change) lr_box.append(self._left) @@ -167,10 +168,16 @@ class MeterCard(Gtk.Box): if target is None: return key = f"{self._kind}:{self._id}" - self._peak_unwatch = self._peaks.watch(key, target, self._peakbar.set_level) + self._peak_unwatch = self._peaks.watch(key, target, self._set_peak_level) def stop_peak(self) -> None: if self._peak_unwatch is not None: self._peak_unwatch() self._peak_unwatch = None - self._peakbar.set_level(0.0) + self._set_peak_level(0.0) + + def _set_peak_level(self, level: float) -> None: + """One monitored level, fanned out to whichever meters this card has.""" + for meter in (self._single, self._left, self._right): + if meter is not None: + meter.set_peak(level) diff --git a/desktopenvs/hyprlua/audio-panel/ui/meter.py b/desktopenvs/hyprlua/audio-panel/ui/meter.py index c6dc26e..56fad83 100644 --- a/desktopenvs/hyprlua/audio-panel/ui/meter.py +++ b/desktopenvs/hyprlua/audio-panel/ui/meter.py @@ -1,6 +1,19 @@ -"""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. +"""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 @@ -19,33 +32,55 @@ 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.HORIZONTAL, spacing=6) + 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 - if label: - tag = Gtk.Label(label=label) - tag.add_css_class("sb-meter-label") - self.append(tag) + 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.HORIZONTAL, 0, 150, 1) + self.scale = Gtk.Scale.new_with_range(Gtk.Orientation.VERTICAL, 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(90, -1) + 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.add_mark(100, Gtk.PositionType.BOTTOM, None) 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() @@ -54,10 +89,9 @@ class VolumeMeter(Gtk.Box): 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()) + 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) @@ -76,9 +110,17 @@ class VolumeMeter(Gtk.Box): 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) diff --git a/desktopenvs/hyprlua/audio-panel/ui/peakbar.py b/desktopenvs/hyprlua/audio-panel/ui/peakbar.py index a8f9eae..7fb99a5 100644 --- a/desktopenvs/hyprlua/audio-panel/ui/peakbar.py +++ b/desktopenvs/hyprlua/audio-panel/ui/peakbar.py @@ -1,25 +1,84 @@ -"""PeakBar — a thin live amplitude indicator (see backend/peaklevel.py). Shows -whether audio is actually reaching the mix, independent of the volume slider -above it, so a silent app/device reads differently from one that's just quiet: -a stuck/crashed stream or a bad route shows a flat bar even at 100% volume.""" +"""PeakBar — the live amplitude indicator (see backend/peaklevel.py), drawn as +a narrow purple column *inside* the magenta volume meter it belongs to rather +than as a separate bar above the card: one glance tells you both what the +volume is set to and how much signal is actually reaching the mix, so a silent +app/device reads differently from one that's just quiet (a stuck or mis-routed +stream shows an empty inner column at 100% volume). + +Cairo-drawn instead of a Gtk.LevelBar because it has to line up with the +Gtk.Scale trough underneath it: the fill has to grow bottom-to-top over the +same span as the scale's highlight, minus the half-slider inset the scale +reserves at each end (`inset` below). It also sidesteps this GTK build's +reluctance to paint CSS backgrounds on non-drawing widgets. Owned by +ui/meter.py, which stacks it over the scale in a Gtk.Overlay. +""" from __future__ import annotations +import math + import gi gi.require_version("Gtk", "4.0") from gi.repository import Gtk # noqa: E402 +# glow-violet, the suite's frame colour — deliberately *inside* the meter's +# emitted-magenta fill, so "set to" and "actually playing" never read alike +PEAK_COLOR = (0x8A / 255, 0x5C / 255, 0xFF / 255) + class PeakBar: - def __init__(self) -> None: - self.widget = Gtk.LevelBar() + WIDTH = 9 # narrower than the meter trough, so magenta frames it + MIN_VISIBLE = 0.01 + + def __init__(self, inset: int = 0) -> None: + self._inset = inset + self._level = 0.0 + self.widget = Gtk.DrawingArea() self.widget.add_css_class("sb-peakbar") - self.widget.set_min_value(0.0) - self.widget.set_max_value(1.0) - self.widget.set_value(0.0) - self.widget.set_size_request(-1, 6) - self.widget.set_hexpand(True) + self.widget.set_size_request(self.WIDTH, -1) + self.widget.set_halign(Gtk.Align.CENTER) + self.widget.set_valign(Gtk.Align.FILL) + self.widget.set_vexpand(True) + # the scale underneath owns every click/drag in this area + self.widget.set_can_target(False) + self.widget.set_draw_func(self._draw) def set_level(self, level: float) -> None: - self.widget.set_value(max(0.0, min(1.0, level))) + level = max(0.0, min(1.0, level)) + if abs(level - self._level) < 0.002: + return + self._level = level + self.widget.queue_draw() + + # -- drawing ------------------------------------------------------------------ + def _draw(self, _area, cr, width: int, height: int) -> None: + if self._level < self.MIN_VISIBLE: + return + top = self._inset + bottom = height - self._inset + span = bottom - top + if span <= 0: + return + + w = min(width, self.WIDTH) + x = (width - w) / 2.0 + h = span * self._level + y = bottom - h + + self._rounded(cr, x, y, w, h, w / 2.0) + cr.set_source_rgba(*PEAK_COLOR, 0.95) + cr.fill_preserve() + cr.set_source_rgba(*PEAK_COLOR, 0.35) + cr.set_line_width(2.0) + cr.stroke() + + @staticmethod + def _rounded(cr, x: float, y: float, w: float, h: float, r: float) -> None: + r = min(r, w / 2.0, h / 2.0) + cr.new_sub_path() + cr.arc(x + w - r, y + r, r, -math.pi / 2, 0) + cr.arc(x + w - r, y + h - r, r, 0, math.pi / 2) + cr.arc(x + r, y + h - r, r, math.pi / 2, math.pi) + cr.arc(x + r, y + r, r, math.pi, 3 * math.pi / 2) + cr.close_path()