feat(hyprdrive,hyprlua): vertical mixer meters with the live peak column inside the fill
The per-channel volume meters are now vertical faders (Gtk.Scale, inverted so
loud is up) instead of horizontal sliders, and the live peak indicator moved
out of its own bar above the card into the meter itself: a narrow violet
column Cairo-drawn inside the trough, so one glance reads both "what the
volume is set to" (the fill) and "what is actually reaching the mix" (the
column inside it).
- ui/peakbar.py is a DrawingArea rather than a Gtk.LevelBar: it has to grow
bottom-to-top over the same span as the scale's highlight, minus the
half-slider inset, and it sits in a Gtk.Overlay with can_target=False so
the scale underneath still gets every click and drag.
- ui/meter.py draws the value as its own label and drops the 100% mark;
either one would push the trough off-centre under the overlaid column.
The channel label is always appended (empty for mono) so a lone meter and
a split L/R pair keep the same trough height inside the card's stack.
- ui/card_base.py fans one peak monitor out to every meter of the card —
parec reports one mixed level per target, not a per-channel one.
- The trough's empty portion is transparent now (an idle meter and a silent
monitor both read as "nothing here"), and supersonic-booster's fill gets
the suite's emitted-magenta glow. Both stylesheets re-assert the
transparent background at class specificity: the CyberQueer theme's
`* { background-color: #1a1a1a }` was otherwise painting a dark stripe
straight through the fill where the peak column sits.
hyprlua's audio-panel is regenerated via regen-audio-panel.sh; its plain
stylesheet stays flat and glow-free by design, with @accent as the fill.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main
parent
55d443ecdb
commit
f500dd550e
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue