feat: extract a two-tier colour palette
Colour was 35 call sites and ~29 distinct literals spread across four widget files, with no egui Style or Visuals set at all - so the hand-painted widgets were hand-coloured while ParamSlider and the labels sat on stock egui defaults, which is most of why the UI did not read as one thing. The audit turned up more than duplication. Two different panel blacks ((16,16,20) and (20,20,24)) for the same intended surface; three reds, two greens and two blues close enough to look like mistakes; eight greys doing about four jobs. And a genuine collision: blue/green/orange meant LOW/MID/HIGH in the crossover and input/output/gain-reduction in the scope, so orange meant "the high band" in one widget and "gain reduction" in the one beside it. The rule that resolves it, and that the module documents: channel hue is chrome, functional colour is data. A pixel showing a value comes from the functional palette and means the same thing in every channel; a pixel saying which channel you are looking at comes from that channel's hue and never touches the data. The scope turns out not to need arbitration at all - it is tabbed, one channel at a time, so its three traces never compete with a hue. Two tiers. Seeds are a short hand-picked list: four channel hues, the functional colours, six neutrals. Everything else derives from a hue via `channel()`, so recolouring a channel is one constant. Derivation is const fn - including the HSV conversion, written out because ecolor's is not const - so the whole table is built at compile time and costs nothing at runtime. Chosen so the hues describe the colours already in use: the derived crossover strips land within 1-3 units of the literals they replace. The two drag handles shift by ~17 on one channel, being now consistently saturated with the rest. Also lands the one deliberate visual change: the meter's four cells were identical and distinguishable only by the label underneath, which is where channel identity was hardest to read. They now carry a dark channel wash and an accent rule, while the bars stay strictly functional. Visuals are wired from the same seeds in the editor's build closure, so egui's own widgets stop looking foreign. meter.rs's local level_color is gone; the palette owns the headroom ramp rather than there being two places to edit it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LU1NRL7fAaZTtFMTTPn2b1
This commit is contained in:
@@ -11,6 +11,7 @@ use nih_plug_egui::egui::{
|
||||
};
|
||||
|
||||
use crate::params::Bari206Params;
|
||||
use super::palette;
|
||||
|
||||
/// Height of the handle strip.
|
||||
const GRAPH_H: f32 = 54.0;
|
||||
@@ -55,19 +56,19 @@ pub(super) fn draw(ui: &mut egui::Ui, params: &Bari206Params, setter: &ParamSett
|
||||
let xhi = x_for(hi);
|
||||
|
||||
// Three band regions.
|
||||
p.rect_filled(Rect::from_min_max(pos2(left, top), pos2(xlo, bottom)), CornerRadius::ZERO, Color32::from_rgb(28, 38, 52));
|
||||
p.rect_filled(Rect::from_min_max(pos2(xlo, top), pos2(xhi, bottom)), CornerRadius::ZERO, Color32::from_rgb(30, 48, 36));
|
||||
p.rect_filled(Rect::from_min_max(pos2(xhi, top), pos2(right, bottom)), CornerRadius::ZERO, Color32::from_rgb(52, 38, 30));
|
||||
p.rect_filled(Rect::from_min_max(pos2(left, top), pos2(xlo, bottom)), CornerRadius::ZERO, palette::CHANNELS[0].strip);
|
||||
p.rect_filled(Rect::from_min_max(pos2(xlo, top), pos2(xhi, bottom)), CornerRadius::ZERO, palette::CHANNELS[1].strip);
|
||||
p.rect_filled(Rect::from_min_max(pos2(xhi, top), pos2(right, bottom)), CornerRadius::ZERO, palette::CHANNELS[2].strip);
|
||||
|
||||
let band_label = |cx: f32, t: &str| {
|
||||
p.text(pos2(cx, top + 2.0), Align2::CENTER_TOP, t, FontId::proportional(11.0), Color32::from_gray(160));
|
||||
p.text(pos2(cx, top + 2.0), Align2::CENTER_TOP, t, FontId::proportional(11.0), palette::TEXT);
|
||||
};
|
||||
band_label((left + xlo) * 0.5, "LOW");
|
||||
band_label((xlo + xhi) * 0.5, "MID");
|
||||
band_label((xhi + right) * 0.5, "HIGH");
|
||||
|
||||
handle(ui, &p, setter, "xover_lo", xlo, lo, ¶ms.crossover_low_hz, lo_min, lo_upper, left, width, top, bottom, Color32::from_rgb(120, 170, 230));
|
||||
handle(ui, &p, setter, "xover_hi", xhi, hi, ¶ms.crossover_high_hz, hi_lower, hi_max, left, width, top, bottom, Color32::from_rgb(230, 150, 120));
|
||||
handle(ui, &p, setter, "xover_lo", xlo, lo, ¶ms.crossover_low_hz, lo_min, lo_upper, left, width, top, bottom, palette::CHANNELS[0].handle);
|
||||
handle(ui, &p, setter, "xover_hi", xhi, hi, ¶ms.crossover_high_hz, hi_lower, hi_max, left, width, top, bottom, palette::CHANNELS[2].handle);
|
||||
|
||||
// Number boxes (double-click to type). Clamped to the same dynamic limits.
|
||||
ui.horizontal(|ui| {
|
||||
@@ -129,9 +130,9 @@ fn handle(
|
||||
setter.end_set_parameter(param);
|
||||
}
|
||||
|
||||
let col = if resp.dragged() || resp.hovered() { Color32::WHITE } else { color };
|
||||
let col = if resp.dragged() || resp.hovered() { palette::TEXT_HOVER } else { color };
|
||||
p.line_segment([pos2(x, top), pos2(x, bottom)], Stroke::new(2.0, col));
|
||||
p.text(pos2(x, bottom - 1.0), Align2::CENTER_BOTTOM, fmt_hz(hz), FontId::proportional(10.0), Color32::from_gray(220));
|
||||
p.text(pos2(x, bottom - 1.0), Align2::CENTER_BOTTOM, fmt_hz(hz), FontId::proportional(10.0), palette::TEXT_BRIGHT);
|
||||
}
|
||||
|
||||
/// Set a param to `value` clamped to `[min, max]`, wrapped in its own gesture (for the number box).
|
||||
|
||||
+12
-11
@@ -5,12 +5,13 @@
|
||||
//! the channel's current input level — its right edge rides the curve (width = input, height = out).
|
||||
|
||||
use nih_plug::prelude::util;
|
||||
use nih_plug_egui::egui::{self, pos2, vec2, Align2, Color32, CornerRadius, FontId, Sense, Stroke};
|
||||
use nih_plug_egui::egui::{self, pos2, vec2, Align2, CornerRadius, FontId, Sense, Stroke};
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use crate::dsp::compressor::Compressor;
|
||||
use crate::meters::Meters;
|
||||
use crate::params::Bari206Params;
|
||||
use super::palette;
|
||||
|
||||
/// Side length of the square plot.
|
||||
const CURVE_SIZE: f32 = 150.0;
|
||||
@@ -37,7 +38,7 @@ pub(super) fn draw(ui: &mut egui::Ui, params: &Bari206Params, selected: usize, m
|
||||
ui.label(format!("Curve: {}", labels[ch]));
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(CURVE_SIZE, CURVE_SIZE), Sense::hover());
|
||||
let p = ui.painter_at(rect);
|
||||
p.rect_filled(rect, CornerRadius::ZERO, Color32::from_rgb(16, 16, 20));
|
||||
p.rect_filled(rect, CornerRadius::ZERO, palette::SURFACE);
|
||||
|
||||
let inset = 2.0;
|
||||
let (left, right, top, bottom) =
|
||||
@@ -48,17 +49,17 @@ pub(super) fn draw(ui: &mut egui::Ui, params: &Bari206Params, selected: usize, m
|
||||
let y_for = |db: f32| bottom - (db - FLOOR_DB) / -FLOOR_DB * h;
|
||||
|
||||
// -6 dBFS reference lines on both axes.
|
||||
let g6 = Color32::from_gray(38);
|
||||
let g6 = palette::GRID_FAINT;
|
||||
let x6 = x_for(-6.0);
|
||||
let y6 = y_for(-6.0);
|
||||
p.line_segment([pos2(x6, top), pos2(x6, bottom)], Stroke::new(1.0, g6));
|
||||
p.line_segment([pos2(left, y6), pos2(right, y6)], Stroke::new(1.0, g6));
|
||||
|
||||
// Unity reference (out = in), bottom-left to top-right.
|
||||
p.line_segment([pos2(left, bottom), pos2(right, top)], Stroke::new(1.0, Color32::from_gray(45)));
|
||||
p.line_segment([pos2(left, bottom), pos2(right, top)], Stroke::new(1.0, palette::GRID));
|
||||
// Threshold marker on the input axis — shifted left by pre-gain (the comp sees in + pre).
|
||||
let tx = x_for((threshold - pre).clamp(FLOOR_DB, 0.0));
|
||||
p.line_segment([pos2(tx, top), pos2(tx, bottom)], Stroke::new(1.0, Color32::from_rgb(80, 60, 45)));
|
||||
p.line_segment([pos2(tx, top), pos2(tx, bottom)], Stroke::new(1.0, palette::THRESHOLD_LINE));
|
||||
|
||||
// Full wet transfer: drive into the comp, then makeup. (Mix not folded in.)
|
||||
let n = 96;
|
||||
@@ -74,7 +75,7 @@ pub(super) fn draw(ui: &mut egui::Ui, params: &Bari206Params, selected: usize, m
|
||||
let driven_now = util::gain_to_db(meters.input_level[ch].load(Ordering::Relaxed));
|
||||
let ext_in = (driven_now - pre).clamp(FLOOR_DB, 0.0); // external input -> curve x
|
||||
let x_now = x_for(ext_in);
|
||||
let fill_col = Color32::from_rgba_unmultiplied(120, 200, 160, 45);
|
||||
let fill_col = palette::with_alpha(palette::CURVE, 45);
|
||||
for seg in pts.windows(2) {
|
||||
let a = seg[0];
|
||||
let mut b = seg[1];
|
||||
@@ -92,16 +93,16 @@ pub(super) fn draw(ui: &mut egui::Ui, params: &Bari206Params, selected: usize, m
|
||||
));
|
||||
}
|
||||
|
||||
p.add(egui::Shape::line(pts, Stroke::new(1.6, Color32::from_rgb(120, 200, 160))));
|
||||
p.add(egui::Shape::line(pts, Stroke::new(1.6, palette::CURVE)));
|
||||
|
||||
// Operating-point dot, on the curve at the current input.
|
||||
let driven = ext_in + pre;
|
||||
let gr = Compressor::gain_computer(driven, threshold, ratio, knee, low_slope, low_curve);
|
||||
let out_op = (driven + gr + makeup).clamp(FLOOR_DB, 0.0);
|
||||
p.circle_filled(pos2(x_now, y_for(out_op)), 3.0, Color32::from_rgb(235, 240, 235));
|
||||
p.circle_filled(pos2(x_now, y_for(out_op)), 3.0, palette::OPERATING_POINT);
|
||||
|
||||
// Corner dB ticks + the -6 dB reference.
|
||||
p.text(pos2(left + 1.0, top + 1.0), Align2::LEFT_TOP, "0", FontId::proportional(9.0), Color32::from_gray(90));
|
||||
p.text(pos2(left + 1.0, bottom - 1.0), Align2::LEFT_BOTTOM, "-60", FontId::proportional(9.0), Color32::from_gray(90));
|
||||
p.text(pos2(x6 + 2.0, bottom - 1.0), Align2::LEFT_BOTTOM, "-6", FontId::proportional(9.0), Color32::from_gray(80));
|
||||
p.text(pos2(left + 1.0, top + 1.0), Align2::LEFT_TOP, "0", FontId::proportional(9.0), palette::TEXT_DIM);
|
||||
p.text(pos2(left + 1.0, bottom - 1.0), Align2::LEFT_BOTTOM, "-60", FontId::proportional(9.0), palette::TEXT_DIM);
|
||||
p.text(pos2(x6 + 2.0, bottom - 1.0), Align2::LEFT_BOTTOM, "-6", FontId::proportional(9.0), palette::TEXT_DIM);
|
||||
}
|
||||
|
||||
+25
-19
@@ -15,6 +15,7 @@ use std::sync::atomic::Ordering;
|
||||
|
||||
use super::METER_FLOOR_DB;
|
||||
use crate::meters::{Meters, NUM_CHANNELS};
|
||||
use super::palette;
|
||||
|
||||
/// Full-scale of the gain-reduction bar (fills downward from the top).
|
||||
const GR_FULL_DB: f32 = 24.0;
|
||||
@@ -47,7 +48,7 @@ pub(super) fn draw(ui: &mut egui::Ui, meters: &Meters, state: &mut MeterState) {
|
||||
let (rect, _) =
|
||||
ui.allocate_exact_size(vec2(ui.available_width(), METER_PANEL_H), Sense::hover());
|
||||
let p = ui.painter_at(rect);
|
||||
p.rect_filled(rect, CornerRadius::ZERO, Color32::from_rgb(20, 20, 24));
|
||||
p.rect_filled(rect, CornerRadius::ZERO, palette::SURFACE_RAISED);
|
||||
|
||||
let top = rect.top() + 22.0; // leave a row at the top for the lamps
|
||||
let bottom = rect.bottom() - 18.0; // and a row at the bottom for the labels
|
||||
@@ -58,6 +59,22 @@ pub(super) fn draw(ui: &mut egui::Ui, meters: &Meters, state: &mut MeterState) {
|
||||
|
||||
for i in 0..NUM_CHANNELS {
|
||||
let cell_left = rect.left() + i as f32 * cell_w;
|
||||
|
||||
// Channel chrome. The four clusters are otherwise identical and told apart only by the
|
||||
// label underneath, which is the one place channel identity is hardest to read. A dark
|
||||
// wash behind the cell plus an accent rule under the bars fixes that without touching
|
||||
// the bars themselves - see `palette`: hue is chrome, functional colour is data.
|
||||
let cell = Rect::from_min_max(
|
||||
pos2(cell_left + 1.0, rect.top()),
|
||||
pos2(cell_left + cell_w - 1.0, rect.bottom()),
|
||||
);
|
||||
p.rect_filled(cell, CornerRadius::ZERO, palette::CHANNELS[i].chrome);
|
||||
p.rect_filled(
|
||||
Rect::from_min_max(pos2(cell.left(), bottom + 2.0), pos2(cell.right(), bottom + 4.0)),
|
||||
CornerRadius::ZERO,
|
||||
palette::CHANNELS[i].edge,
|
||||
);
|
||||
|
||||
let group_w = bar_w * 3.0 + gap * 2.0;
|
||||
let bx = cell_left + (cell_w - group_w) * 0.5;
|
||||
|
||||
@@ -71,16 +88,16 @@ pub(super) fn draw(ui: &mut egui::Ui, meters: &Meters, state: &mut MeterState) {
|
||||
let gr_db = meters.gain_reduction_db[i].load(Ordering::Relaxed);
|
||||
let gr_frac = (gr_db / GR_FULL_DB).clamp(0.0, 1.0);
|
||||
|
||||
v_bar(&p, bx, bar_w, top, bottom, l_frac, level_color(l_db), false);
|
||||
v_bar(&p, bx + bar_w + gap, bar_w, top, bottom, gr_frac, Color32::from_rgb(240, 150, 60), true);
|
||||
v_bar(&p, bx + 2.0 * (bar_w + gap), bar_w, top, bottom, r_frac, level_color(r_db), false);
|
||||
v_bar(&p, bx, bar_w, top, bottom, l_frac, palette::level_color(l_db), false);
|
||||
v_bar(&p, bx + bar_w + gap, bar_w, top, bottom, gr_frac, palette::SIGNAL_GR, true);
|
||||
v_bar(&p, bx + 2.0 * (bar_w + gap), bar_w, top, bottom, r_frac, palette::level_color(r_db), false);
|
||||
|
||||
p.text(
|
||||
pos2(cell_left + cell_w * 0.5, rect.bottom() - 2.0),
|
||||
Align2::CENTER_BOTTOM,
|
||||
labels[i],
|
||||
FontId::proportional(12.0),
|
||||
Color32::from_gray(200),
|
||||
palette::TEXT_BRIGHT,
|
||||
);
|
||||
|
||||
// Per-channel lamp: latch on output reaching 0 dBFS; the ALL channel also latches when the
|
||||
@@ -109,9 +126,9 @@ pub(super) fn draw(ui: &mut egui::Ui, meters: &Meters, state: &mut MeterState) {
|
||||
}
|
||||
}
|
||||
let lamp = if state.ceiling_trigger[i].is_some() {
|
||||
Color32::from_rgb(255, 40, 40)
|
||||
palette::LAMP_ON
|
||||
} else {
|
||||
Color32::from_rgb(40, 12, 12)
|
||||
palette::LAMP_OFF
|
||||
};
|
||||
p.circle_filled(lamp_center, 5.0, lamp);
|
||||
}
|
||||
@@ -120,7 +137,7 @@ pub(super) fn draw(ui: &mut egui::Ui, meters: &Meters, state: &mut MeterState) {
|
||||
/// Draw a vertical bar within `[top, bottom]`. `frac` is 0..1; `from_top` fills downward from the
|
||||
/// top (gain reduction) instead of upward from the bottom (level).
|
||||
fn v_bar(p: &Painter, x: f32, w: f32, top: f32, bottom: f32, frac: f32, fill: Color32, from_top: bool) {
|
||||
let track = Color32::from_rgb(34, 34, 40);
|
||||
let track = palette::TRACK;
|
||||
p.rect_filled(Rect::from_min_max(pos2(x, top), pos2(x + w, bottom)), CornerRadius::ZERO, track);
|
||||
|
||||
let h = (bottom - top) * frac.clamp(0.0, 1.0);
|
||||
@@ -131,14 +148,3 @@ fn v_bar(p: &Painter, x: f32, w: f32, top: f32, bottom: f32, frac: f32, fill: Co
|
||||
};
|
||||
p.rect_filled(filled, CornerRadius::ZERO, fill);
|
||||
}
|
||||
|
||||
/// Level-bar colour: green below -6 dB, yellow approaching, red near 0 dBFS.
|
||||
fn level_color(db: f32) -> Color32 {
|
||||
if db >= -1.0 {
|
||||
Color32::from_rgb(235, 70, 60)
|
||||
} else if db >= -6.0 {
|
||||
Color32::from_rgb(230, 200, 70)
|
||||
} else {
|
||||
Color32::from_rgb(90, 200, 110)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -23,6 +23,7 @@ use crate::Bari206;
|
||||
mod crossover;
|
||||
mod gain_curve;
|
||||
mod meter;
|
||||
mod palette;
|
||||
mod plot;
|
||||
|
||||
/// Bottom of the dB scale shared by the meters and the plot (top is 0 dBFS).
|
||||
@@ -41,7 +42,7 @@ pub(crate) fn create(params: Arc<Bari206Params>, meters: Arc<Meters>) -> Option<
|
||||
create_egui_editor(
|
||||
params.editor_state.clone(),
|
||||
EditorState::default(),
|
||||
|_, _| {},
|
||||
|egui_ctx, _| palette::apply_visuals(egui_ctx),
|
||||
move |egui_ctx, setter, state| {
|
||||
// Keep frames coming so the meters animate and the lamp can time out while open.
|
||||
egui_ctx.request_repaint();
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//! The plugin's colour system.
|
||||
//!
|
||||
//! # The rule
|
||||
//!
|
||||
//! **Channel hue is chrome; functional colour is data.** Before picking a colour, ask what the
|
||||
//! pixel is for. Is it showing a *value* — a level, a reduction, a clip? Then it comes from the
|
||||
//! functional palette, and it means the same thing in every channel. Is it telling you *which
|
||||
//! channel you are looking at*? Then it comes from that channel's hue, and it never touches the
|
||||
//! data itself.
|
||||
//!
|
||||
//! This is what keeps the two systems from fighting. An earlier version of this UI used blue,
|
||||
//! green and orange for LOW/MID/HIGH in the crossover *and* for input/output/gain-reduction in
|
||||
//! the scope, so orange meant "the high band" in one widget and "gain reduction" in the one next
|
||||
//! to it.
|
||||
//!
|
||||
//! # The two tiers
|
||||
//!
|
||||
//! 1. **Seeds** — the short, hand-picked list in the first half of this file. This is the part
|
||||
//! you edit. Everything else is derived from it.
|
||||
//! 2. **Derived** — [`Channel`] palettes built from a hue by [`channel`]. Change a hue and every
|
||||
//! tint, edge and strip using it moves with it.
|
||||
//!
|
||||
//! All derivation is `const fn`, so the whole table is computed at compile time and costs nothing
|
||||
//! at runtime. That is also why it uses plain HSV rather than `ecolor::Hsva`, whose conversion is
|
||||
//! not `const`.
|
||||
//!
|
||||
//! # Editing this file
|
||||
//!
|
||||
//! * To recolour a channel, change one `HUE_*` constant. Never hand-edit a derived value.
|
||||
//! * To retune the meter's headroom ramp, edit [`LEVEL_OK`] / [`LEVEL_WARN`] / [`LEVEL_HOT`] and
|
||||
//! the thresholds in [`level_color`]. Both are deliberately literal rather than interpolated,
|
||||
//! so you can set each one by eye without fighting a gradient.
|
||||
//! * To change how loudly channel identity reads, edit the `(saturation, value)` pairs in
|
||||
//! [`channel`].
|
||||
//! * Anything new belongs here and must be reachable from a seed — a colour written at a call
|
||||
//! site is the next magic number.
|
||||
|
||||
use nih_plug_egui::egui::Color32;
|
||||
|
||||
// ===========================================================================
|
||||
// TIER 1 — SEEDS. This is the part to edit.
|
||||
// ===========================================================================
|
||||
|
||||
// --- Channel identity ------------------------------------------------------
|
||||
// Hues in degrees: 0 = red, 120 = green, 240 = blue. Only the hue is chosen here; saturation and
|
||||
// brightness are decided per role by `channel()`, so a channel reads as the same colour whether
|
||||
// it is a 12%-brightness background wash or a full-strength drag handle.
|
||||
//
|
||||
// Keep neighbours far enough apart to survive being dimmed — most chrome lives at low
|
||||
// saturation, where close hues merge. Roughly 60–90 degrees apart works; below ~40 does not.
|
||||
pub const HUE_LOW: f32 = 212.0; // blue
|
||||
pub const HUE_MID: f32 = 144.0; // green
|
||||
pub const HUE_HIGH: f32 = 26.0; // orange
|
||||
pub const HUE_ALL: f32 = 280.0; // violet — the aggregate, distinct from its three inputs
|
||||
|
||||
// --- Functional colours ----------------------------------------------------
|
||||
// Outside the hue system on purpose: these mean the same thing in every channel, so they must
|
||||
// NOT be derived from a channel hue. Red is always "you have hit a limit".
|
||||
|
||||
/// Meter bar with comfortable headroom.
|
||||
pub const LEVEL_OK: Color32 = Color32::from_rgb(90, 200, 110);
|
||||
/// Meter bar approaching full scale.
|
||||
pub const LEVEL_WARN: Color32 = Color32::from_rgb(230, 200, 70);
|
||||
/// Meter bar at or near full scale.
|
||||
pub const LEVEL_HOT: Color32 = Color32::from_rgb(235, 70, 60);
|
||||
|
||||
/// Latched over/ceiling lamp, lit.
|
||||
pub const LAMP_ON: Color32 = Color32::from_rgb(255, 40, 40);
|
||||
/// The same lamp unlit — dark red rather than grey, so it reads as "off", not "absent".
|
||||
pub const LAMP_OFF: Color32 = Color32::from_rgb(40, 12, 12);
|
||||
/// Ceiling-hit tick marks along the top edge of the scope.
|
||||
pub const CLIP: Color32 = Color32::from_rgb(235, 45, 45);
|
||||
|
||||
// Scope traces. The scope is tabbed — one channel at a time — so these never compete with a
|
||||
// channel hue: the tab chrome says which channel, these three say what each line is.
|
||||
pub const SIGNAL_IN: Color32 = Color32::from_rgb(90, 170, 235);
|
||||
pub const SIGNAL_OUT: Color32 = Color32::from_rgb(90, 200, 110);
|
||||
pub const SIGNAL_GR: Color32 = Color32::from_rgb(240, 150, 60);
|
||||
|
||||
/// The transfer curve in the gain-curve display, and its translucent fill.
|
||||
pub const CURVE: Color32 = Color32::from_rgb(120, 200, 160);
|
||||
/// The live operating-point dot riding that curve — near-white, so it reads on top of the fill.
|
||||
pub const OPERATING_POINT: Color32 = Color32::from_rgb(235, 240, 235);
|
||||
/// Threshold marker on the gain curve. Dim on purpose: a reference line, not data.
|
||||
pub const THRESHOLD_LINE: Color32 = Color32::from_rgb(80, 60, 45);
|
||||
|
||||
// --- Neutrals --------------------------------------------------------------
|
||||
// Eight ad-hoc greys collapsed to six named steps. If you need another, add it here rather than
|
||||
// reaching for `from_gray` at a call site.
|
||||
|
||||
/// Data panels — the scope and the gain curve.
|
||||
pub const SURFACE: Color32 = Color32::from_rgb(16, 16, 20);
|
||||
/// The meter panel, one step lighter so the cluster reads as its own surface. These were two
|
||||
/// different blacks by accident before; they are two on purpose now. Set this equal to
|
||||
/// [`SURFACE`] if you would rather they sat flush.
|
||||
pub const SURFACE_RAISED: Color32 = Color32::from_rgb(20, 20, 24);
|
||||
/// The empty part of a meter bar.
|
||||
pub const TRACK: Color32 = Color32::from_rgb(34, 34, 40);
|
||||
|
||||
/// Structural lines — the unity diagonal, panel edges.
|
||||
pub const GRID: Color32 = Color32::from_gray(45);
|
||||
/// Reference lines that should recede — dB gridlines, −6 dB marks.
|
||||
pub const GRID_FAINT: Color32 = Color32::from_gray(38);
|
||||
|
||||
/// Axis numbers and other text that must not compete with the data.
|
||||
pub const TEXT_DIM: Color32 = Color32::from_gray(90);
|
||||
/// Body text — band names on the crossover strip.
|
||||
pub const TEXT: Color32 = Color32::from_gray(160);
|
||||
/// Emphasised text — channel labels, readouts.
|
||||
pub const TEXT_BRIGHT: Color32 = Color32::from_gray(210);
|
||||
/// Hover and drag feedback, where the point is to be unmistakable.
|
||||
pub const TEXT_HOVER: Color32 = Color32::WHITE;
|
||||
|
||||
// ===========================================================================
|
||||
// TIER 2 — DERIVED. Generated from the seeds above; do not hand-edit.
|
||||
// ===========================================================================
|
||||
|
||||
/// One channel's colours, all derived from a single hue.
|
||||
pub struct Channel {
|
||||
/// Background wash behind a channel's cell. Very dark — it must associate the bars drawn on
|
||||
/// top of it with a channel without competing with them.
|
||||
pub chrome: Color32,
|
||||
/// Band strip on the crossover widget: between [`Channel::chrome`] and [`Channel::edge`].
|
||||
pub strip: Color32,
|
||||
/// Accent rule under a meter cell, and the scope's tab underline. Bright enough to identify
|
||||
/// at a glance.
|
||||
pub edge: Color32,
|
||||
/// Full strength — drag handles and anything else the user grabs.
|
||||
pub handle: Color32,
|
||||
}
|
||||
|
||||
/// Build a channel's palette from its hue.
|
||||
///
|
||||
/// The `(saturation, value)` pairs below decide how loudly channel identity reads. Raise
|
||||
/// `CHROME`'s value and the cell washes get more obvious; lower it and they recede into the
|
||||
/// panel background. `CHROME` is deliberately near the floor: it sits *behind* meter bars.
|
||||
pub const fn channel(hue: f32) -> Channel {
|
||||
const CHROME: (f32, f32) = (0.45, 0.115);
|
||||
const STRIP: (f32, f32) = (0.42, 0.20);
|
||||
const EDGE: (f32, f32) = (0.60, 0.55);
|
||||
const HANDLE: (f32, f32) = (0.55, 0.90);
|
||||
Channel {
|
||||
chrome: hsv(hue, CHROME.0, CHROME.1),
|
||||
strip: hsv(hue, STRIP.0, STRIP.1),
|
||||
edge: hsv(hue, EDGE.0, EDGE.1),
|
||||
handle: hsv(hue, HANDLE.0, HANDLE.1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Indexed by the `LOW`/`MID`/`HIGH`/`ALL` consts in `lib.rs`, so `CHANNELS[i]` lines up with
|
||||
/// `meters.level_l[i]` and friends without a lookup step.
|
||||
pub const CHANNELS: [Channel; 4] = [
|
||||
channel(HUE_LOW),
|
||||
channel(HUE_MID),
|
||||
channel(HUE_HIGH),
|
||||
channel(HUE_ALL),
|
||||
];
|
||||
|
||||
/// HSV to RGB, `const` so [`CHANNELS`] is built at compile time.
|
||||
///
|
||||
/// `h` in degrees (wrapped), `s` and `v` in `0..=1`. The standard sextant algorithm, written out
|
||||
/// here only because `ecolor::Hsva`'s conversion is not a `const fn`.
|
||||
pub const fn hsv(h: f32, s: f32, v: f32) -> Color32 {
|
||||
// Wrap into 0..360. A loop rather than `%` because float remainder is not available in a
|
||||
// const context on this toolchain; hues are small numbers, so this costs nothing.
|
||||
let mut h = h;
|
||||
while h < 0.0 {
|
||||
h += 360.0;
|
||||
}
|
||||
while h >= 360.0 {
|
||||
h -= 360.0;
|
||||
}
|
||||
let sector = h / 60.0;
|
||||
let i = sector as i32;
|
||||
let f = sector - i as f32;
|
||||
let p = v * (1.0 - s);
|
||||
let q = v * (1.0 - s * f);
|
||||
let t = v * (1.0 - s * (1.0 - f));
|
||||
let (r, g, b) = match i {
|
||||
0 => (v, t, p),
|
||||
1 => (q, v, p),
|
||||
2 => (p, v, t),
|
||||
3 => (p, q, v),
|
||||
4 => (t, p, v),
|
||||
_ => (v, p, q),
|
||||
};
|
||||
Color32::from_rgb(to_u8(r), to_u8(g), to_u8(b))
|
||||
}
|
||||
|
||||
/// Clamping float-to-byte, `const` for the same reason as [`hsv`].
|
||||
const fn to_u8(x: f32) -> u8 {
|
||||
let v = x * 255.0 + 0.5;
|
||||
if v <= 0.0 {
|
||||
0
|
||||
} else if v >= 255.0 {
|
||||
255
|
||||
} else {
|
||||
v as u8
|
||||
}
|
||||
}
|
||||
|
||||
/// Meter bar colour for an output level in dB.
|
||||
///
|
||||
/// Functional, not per-channel: −6 dB means the same thing in every band. Thresholds and colours
|
||||
/// are both literal so either can be tuned by eye.
|
||||
pub fn level_color(db: f32) -> Color32 {
|
||||
if db >= -1.0 {
|
||||
LEVEL_HOT
|
||||
} else if db >= -6.0 {
|
||||
LEVEL_WARN
|
||||
} else {
|
||||
LEVEL_OK
|
||||
}
|
||||
}
|
||||
|
||||
/// The same colour at `a`/255 opacity, for translucent fills under a trace.
|
||||
///
|
||||
/// Not `const`: `Color32::from_rgba_unmultiplied` is not a `const fn`, and the premultiplied
|
||||
/// constructor that is would push the multiply out to every call site.
|
||||
pub fn with_alpha(c: Color32, a: u8) -> Color32 {
|
||||
Color32::from_rgba_unmultiplied(c.r(), c.g(), c.b(), a)
|
||||
}
|
||||
|
||||
/// Apply the palette to egui's own widgets, so `ParamSlider`, labels and separators sit in the
|
||||
/// same world as the hand-painted ones instead of on stock egui defaults.
|
||||
///
|
||||
/// Call once when the editor is created. Widget colours are per interaction state: `weak_bg_fill`
|
||||
/// is an unfilled slider track, `bg_fill` the filled portion. Interactive states borrow the ALL
|
||||
/// channel's hue, since egui widgets are not per-band — change that to a dedicated accent seed if
|
||||
/// you would rather they were neutral.
|
||||
pub fn apply_visuals(ctx: &nih_plug_egui::egui::Context) {
|
||||
use nih_plug_egui::egui::{Stroke, Visuals};
|
||||
|
||||
let accent = &CHANNELS[3];
|
||||
let mut v = Visuals::dark();
|
||||
|
||||
v.panel_fill = SURFACE;
|
||||
v.window_fill = SURFACE;
|
||||
v.extreme_bg_color = SURFACE;
|
||||
v.faint_bg_color = SURFACE_RAISED;
|
||||
|
||||
let w = &mut v.widgets;
|
||||
w.noninteractive.bg_fill = SURFACE_RAISED;
|
||||
w.noninteractive.weak_bg_fill = SURFACE_RAISED;
|
||||
w.noninteractive.bg_stroke = Stroke::new(1.0, GRID_FAINT);
|
||||
w.noninteractive.fg_stroke = Stroke::new(1.0, TEXT);
|
||||
|
||||
w.inactive.bg_fill = TRACK;
|
||||
w.inactive.weak_bg_fill = TRACK;
|
||||
w.inactive.bg_stroke = Stroke::new(1.0, GRID);
|
||||
w.inactive.fg_stroke = Stroke::new(1.0, TEXT_BRIGHT);
|
||||
|
||||
w.hovered.bg_fill = accent.edge;
|
||||
w.hovered.weak_bg_fill = accent.strip;
|
||||
w.hovered.bg_stroke = Stroke::new(1.0, accent.edge);
|
||||
w.hovered.fg_stroke = Stroke::new(1.0, TEXT_HOVER);
|
||||
|
||||
w.active.bg_fill = accent.handle;
|
||||
w.active.weak_bg_fill = accent.edge;
|
||||
w.active.bg_stroke = Stroke::new(1.0, accent.handle);
|
||||
w.active.fg_stroke = Stroke::new(1.0, TEXT_HOVER);
|
||||
|
||||
ctx.set_visuals(v);
|
||||
}
|
||||
+9
-8
@@ -12,15 +12,16 @@ use nih_plug_egui::egui::{self, pos2, vec2, Align2, Color32, CornerRadius, FontI
|
||||
|
||||
use super::METER_FLOOR_DB;
|
||||
use crate::meters::{Meters, BUCKET_HZ, NUM_CHANNELS};
|
||||
use super::palette;
|
||||
|
||||
/// Height of the plot panel.
|
||||
const PLOT_PANEL_H: f32 = 150.0;
|
||||
/// Number of columns held in the history ring.
|
||||
const PLOT_N: usize = 256;
|
||||
|
||||
const COLOR_IN: Color32 = Color32::from_rgb(90, 170, 235);
|
||||
const COLOR_OUT: Color32 = Color32::from_rgb(90, 200, 110);
|
||||
const COLOR_GR: Color32 = Color32::from_rgb(240, 150, 60);
|
||||
const COLOR_IN: Color32 = palette::SIGNAL_IN;
|
||||
const COLOR_OUT: Color32 = palette::SIGNAL_OUT;
|
||||
const COLOR_GR: Color32 = palette::SIGNAL_GR;
|
||||
|
||||
/// Rolling history for all channels: a per-channel ring of (in_db, out_db, gr_db) columns.
|
||||
struct PlotHistory {
|
||||
@@ -156,7 +157,7 @@ pub(super) fn draw(ui: &mut egui::Ui, meters: &Meters, state: &mut PlotState) {
|
||||
let (rect, _) =
|
||||
ui.allocate_exact_size(vec2(ui.available_width(), PLOT_PANEL_H), Sense::hover());
|
||||
let p = ui.painter_at(rect);
|
||||
p.rect_filled(rect, CornerRadius::ZERO, Color32::from_rgb(16, 16, 20));
|
||||
p.rect_filled(rect, CornerRadius::ZERO, palette::SURFACE);
|
||||
|
||||
let (top, bottom, left, right) =
|
||||
(rect.top() + 4.0, rect.bottom() - 4.0, rect.left() + 4.0, rect.right() - 4.0);
|
||||
@@ -169,13 +170,13 @@ pub(super) fn draw(ui: &mut egui::Ui, meters: &Meters, state: &mut PlotState) {
|
||||
// Gridlines (dB).
|
||||
for &g in &[0.0f32, -12.0, -24.0, -48.0] {
|
||||
let y = y_for_db(g);
|
||||
p.line_segment([pos2(left, y), pos2(right, y)], Stroke::new(1.0, Color32::from_gray(40)));
|
||||
p.line_segment([pos2(left, y), pos2(right, y)], Stroke::new(1.0, palette::GRID_FAINT));
|
||||
p.text(
|
||||
pos2(left + 2.0, y),
|
||||
Align2::LEFT_BOTTOM,
|
||||
format!("{g:.0}"),
|
||||
FontId::proportional(9.0),
|
||||
Color32::from_gray(90),
|
||||
palette::TEXT_DIM,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -193,7 +194,7 @@ pub(super) fn draw(ui: &mut egui::Ui, meters: &Meters, state: &mut PlotState) {
|
||||
// Fill as a strip of per-segment convex quads down to the baseline. A single
|
||||
// concave polygon mis-tessellates in egui (it fans from one corner, leaving stray
|
||||
// triangles), so build convex pieces — one box per time unit — instead.
|
||||
let fill_col = Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), 40);
|
||||
let fill_col = palette::with_alpha(color, 40);
|
||||
for seg in pts.windows(2) {
|
||||
let (a, b) = (seg[0], seg[1]);
|
||||
p.add(egui::Shape::convex_polygon(
|
||||
@@ -214,7 +215,7 @@ pub(super) fn draw(ui: &mut egui::Ui, meters: &Meters, state: &mut PlotState) {
|
||||
// hit the ceiling (global — shown on every channel's view). One column wide, so runs of
|
||||
// hits merge into a continuous segment and a lone hit is just a dot. Nothing otherwise.
|
||||
let dx = width / (PLOT_N - 1) as f32;
|
||||
let marker = Color32::from_rgb(235, 45, 45);
|
||||
let marker = palette::CLIP;
|
||||
for k in 0..len {
|
||||
let idx = (write + PLOT_N - len + k) % PLOT_N;
|
||||
if state.history.hit[idx] {
|
||||
|
||||
Reference in New Issue
Block a user