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:
Mikkeli Matlock
2026-08-31 23:28:26 +09:00
parent 6ab9fb9c3f
commit 1361d7c919
6 changed files with 322 additions and 47 deletions
+9 -8
View File
@@ -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, &params.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, &params.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, &params.crossover_low_hz, lo_min, lo_upper, left, width, top, bottom, palette::CHANNELS[0].handle);
handle(ui, &p, setter, "xover_hi", xhi, hi, &params.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).