| 143 |
143 |
|
LazyLock::new(|| makeover::embedded_themes().collect());
|
| 144 |
144 |
|
|
| 145 |
145 |
|
/// The resolved theme colors (intent vocabulary) for egui.
|
| 146 |
|
- |
#[derive(Debug, Clone)]
|
|
146 |
+ |
#[derive(Debug, Clone, PartialEq)]
|
| 147 |
147 |
|
pub struct ThemeColors {
|
| 148 |
148 |
|
// Background
|
| 149 |
149 |
|
pub surface_page: Color32,
|
| 163 |
163 |
|
pub category_six: Color32,
|
| 164 |
164 |
|
// Border
|
| 165 |
165 |
|
pub border: Color32,
|
|
166 |
+ |
// Bevel. Derived inside `makeover::resolve` from `surface-raised`, so no
|
|
167 |
+ |
// theme author writes them and every shipped theme has a light model.
|
|
168 |
+ |
// Read here like any other intent.
|
|
169 |
+ |
pub bevel_light: Color32,
|
|
170 |
+ |
pub bevel_dark: Color32,
|
| 166 |
171 |
|
// Corner radius. Not spacing: the geometry crate is spacing-only today,
|
| 167 |
172 |
|
// and a theme setting its own radius is a standing audiofiles quirk that
|
| 168 |
173 |
|
// this change does not decide either way.
|
| 169 |
|
- |
pub rounding: f32,
|
|
174 |
+ |
//
|
|
175 |
+ |
// Two radii rather than one because Platinum wants square containers and
|
|
176 |
+ |
// push buttons that keep a touch of a corner. See `docs/design-system.md`.
|
|
177 |
+ |
pub radius_control: f32,
|
|
178 |
+ |
pub radius_container: f32,
|
| 170 |
179 |
|
}
|
| 171 |
180 |
|
|
| 172 |
181 |
|
impl Default for ThemeColors {
|
| 174 |
183 |
|
// audiofiles default: Mac OS 8 Platinum (keep in sync with
|
| 175 |
184 |
|
// themes/audiofiles.toml, this is the pre-load Rust fallback).
|
| 176 |
185 |
|
Self {
|
| 177 |
|
- |
surface_page: Color32::from_rgb(0xDD, 0xDD, 0xDD),
|
| 178 |
|
- |
surface_overlay: Color32::from_rgb(0xEE, 0xEE, 0xEE),
|
| 179 |
|
- |
surface_sunken: Color32::from_rgb(0xB0, 0xB0, 0xB0),
|
| 180 |
|
- |
surface_raised: Color32::from_rgb(0xFF, 0xFF, 0xFF),
|
|
186 |
+ |
surface_page: Color32::from_rgb(0xC8, 0xC8, 0xC8),
|
|
187 |
+ |
surface_overlay: Color32::from_rgb(0xD4, 0xD4, 0xD4),
|
|
188 |
+ |
surface_sunken: Color32::from_rgb(0xA8, 0xA8, 0xA8),
|
|
189 |
+ |
surface_raised: Color32::from_rgb(0xDD, 0xDD, 0xDD),
|
| 181 |
190 |
|
content: Color32::from_rgb(0x00, 0x00, 0x00),
|
| 182 |
191 |
|
content_secondary: Color32::from_rgb(0x33, 0x33, 0x33),
|
| 183 |
192 |
|
content_muted: Color32::from_rgb(0x80, 0x80, 0x80),
|
| 188 |
197 |
|
category_five: Color32::from_rgb(0x00, 0x9C, 0xDF),
|
| 189 |
198 |
|
category_six: Color32::from_rgb(0x97, 0x39, 0x99),
|
| 190 |
199 |
|
border: Color32::from_rgb(0x80, 0x80, 0x80),
|
| 191 |
|
- |
rounding: 4.0,
|
|
200 |
+ |
// What `makeover::resolve` derives from the raised surface above:
|
|
201 |
+ |
// the lightening clamps at the top of the ramp, which is the white
|
|
202 |
+ |
// highlight the Platinum tribute wanted.
|
|
203 |
+ |
bevel_light: Color32::from_rgb(0xFF, 0xFF, 0xFF),
|
|
204 |
+ |
bevel_dark: Color32::from_rgb(0xA4, 0xA4, 0xA4),
|
|
205 |
+ |
radius_control: 4.0,
|
|
206 |
+ |
radius_container: 0.0,
|
| 192 |
207 |
|
}
|
| 193 |
208 |
|
}
|
| 194 |
209 |
|
}
|
| 332 |
347 |
|
THEME.read().border
|
| 333 |
348 |
|
}
|
| 334 |
349 |
|
|
|
350 |
+ |
/// The lit edge of a bevel: top and left on a raised surface.
|
|
351 |
+ |
pub fn bevel_light() -> Color32 {
|
|
352 |
+ |
THEME.read().bevel_light
|
|
353 |
+ |
}
|
|
354 |
+ |
/// The shadowed edge of a bevel: bottom and right on a raised surface.
|
|
355 |
+ |
pub fn bevel_dark() -> Color32 {
|
|
356 |
+ |
THEME.read().bevel_dark
|
|
357 |
+ |
}
|
|
358 |
+ |
|
|
359 |
+ |
/// Corner radius for things you press: buttons, chips, toggles.
|
|
360 |
+ |
pub fn radius_control() -> egui::CornerRadius {
|
|
361 |
+ |
egui::CornerRadius::same(THEME.read().radius_control as u8)
|
|
362 |
+ |
}
|
|
363 |
+ |
/// Corner radius for things that hold other things: panels, cards, wells,
|
|
364 |
+ |
/// modals, scrollbars, separators. Square under the Platinum default.
|
|
365 |
+ |
pub fn radius_container() -> egui::CornerRadius {
|
|
366 |
+ |
egui::CornerRadius::same(THEME.read().radius_container as u8)
|
|
367 |
+ |
}
|
|
368 |
+ |
|
|
369 |
+ |
/// The Platinum light model: a two-tone 1px frame that makes a rectangle read
|
|
370 |
+ |
/// as raised off its background or recessed into it.
|
|
371 |
+ |
///
|
|
372 |
+ |
/// A free function rather than a widget, per the layering table in
|
|
373 |
+ |
/// `docs/design-system.md`: `theme.rs` holds tokens and the primitives that
|
|
374 |
+ |
/// paint them, `widgets.rs` holds anything that allocates space or answers a
|
|
375 |
+ |
/// click.
|
|
376 |
+ |
///
|
|
377 |
+ |
/// Hand-painted because egui has no `box-shadow: inset` and
|
|
378 |
+ |
/// `Visuals.widgets.*.bg_stroke` is a single stroke with no per-side control,
|
|
379 |
+ |
/// so no amount of theme tuning produces a two-tone frame.
|
|
380 |
+ |
pub mod bevel {
|
|
381 |
+ |
use super::{bevel_dark, bevel_light, stroke};
|
|
382 |
+ |
|
|
383 |
+ |
/// Which way the light falls.
|
|
384 |
+ |
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
385 |
+ |
pub enum Bevel {
|
|
386 |
+ |
/// Lit from the top left: light top and left, dark bottom and right.
|
|
387 |
+ |
Raised,
|
|
388 |
+ |
/// The same frame inverted, which is also the pressed state of
|
|
389 |
+ |
/// anything that draws itself `Raised`.
|
|
390 |
+ |
Inset,
|
|
391 |
+ |
}
|
|
392 |
+ |
|
|
393 |
+ |
/// The (top-left, bottom-right) edge colors for a bevel.
|
|
394 |
+ |
///
|
|
395 |
+ |
/// Split out from [`paint`] because the inversion is the load-bearing part
|
|
396 |
+ |
/// and a `Painter` needs a live render context to build.
|
|
397 |
+ |
fn edges(kind: Bevel) -> (egui::Color32, egui::Color32) {
|
|
398 |
+ |
match kind {
|
|
399 |
+ |
Bevel::Raised => (bevel_light(), bevel_dark()),
|
|
400 |
+ |
Bevel::Inset => (bevel_dark(), bevel_light()),
|
|
401 |
+ |
}
|
|
402 |
+ |
}
|
|
403 |
+ |
|
|
404 |
+ |
/// Paint a 1px two-tone frame just inside `rect`.
|
|
405 |
+ |
///
|
|
406 |
+ |
/// Fill first, bevel after: this paints two polylines and nothing else, so
|
|
407 |
+ |
/// it composes onto whatever is already there rather than clearing it.
|
|
408 |
+ |
/// That is what lets it go over an `egui::TextEdit` after `ui.add`, where
|
|
409 |
+ |
/// the widget's own fill has already landed.
|
|
410 |
+ |
pub fn paint(painter: &egui::Painter, rect: egui::Rect, kind: Bevel) {
|
|
411 |
+ |
let (light, dark) = edges(kind);
|
|
412 |
+ |
|
|
413 |
+ |
// Inset by half a stroke so the 1px line lands inside `rect` rather
|
|
414 |
+ |
// than straddling its edge, which on a fractional-scale display is the
|
|
415 |
+ |
// difference between one crisp pixel and two dim ones.
|
|
416 |
+ |
let r = rect.shrink(stroke::DEFAULT / 2.0);
|
|
417 |
+ |
|
|
418 |
+ |
// Two 3-point polylines meeting at the opposite corners. A polyline
|
|
419 |
+ |
// rather than three segments so the corner joins are mitered by egui
|
|
420 |
+ |
// instead of leaving a notch.
|
|
421 |
+ |
painter.add(egui::Shape::line(
|
|
422 |
+ |
vec![r.left_bottom(), r.left_top(), r.right_top()],
|
|
423 |
+ |
egui::Stroke::new(stroke::DEFAULT, light),
|
|
424 |
+ |
));
|
|
425 |
+ |
painter.add(egui::Shape::line(
|
|
426 |
+ |
vec![r.right_top(), r.right_bottom(), r.left_bottom()],
|
|
427 |
+ |
egui::Stroke::new(stroke::DEFAULT, dark),
|
|
428 |
+ |
));
|
|
429 |
+ |
}
|
|
430 |
+ |
|
|
431 |
+ |
#[cfg(test)]
|
|
432 |
+ |
mod tests {
|
|
433 |
+ |
use super::*;
|
|
434 |
+ |
|
|
435 |
+ |
#[test]
|
|
436 |
+ |
fn inset_is_raised_with_the_light_moved() {
|
|
437 |
+ |
// The whole idiom in one assertion: the pressed state of a raised
|
|
438 |
+ |
// control is the same frame with its two edges swapped, which is
|
|
439 |
+ |
// why a bevel is cheap to invert and expensive to fake.
|
|
440 |
+ |
let (rl, rd) = edges(Bevel::Raised);
|
|
441 |
+ |
let (il, id) = edges(Bevel::Inset);
|
|
442 |
+ |
assert_eq!((rl, rd), (id, il));
|
|
443 |
+ |
}
|
|
444 |
+ |
|
|
445 |
+ |
#[test]
|
|
446 |
+ |
fn a_raised_edge_is_lit_from_the_top_left() {
|
|
447 |
+ |
let (top_left, bottom_right) = edges(Bevel::Raised);
|
|
448 |
+ |
assert_eq!(top_left, bevel_light());
|
|
449 |
+ |
assert_eq!(bottom_right, bevel_dark());
|
|
450 |
+ |
}
|
|
451 |
+ |
}
|
|
452 |
+ |
}
|
|
453 |
+ |
|
| 335 |
454 |
|
/// Section spacing for detail panel (between waveform, metadata, tags, actions).
|
| 336 |
455 |
|
///
|
| 337 |
456 |
|
/// Kept as a named function because the detail panel reads it in several
|
| 524 |
643 |
|
.unwrap_or(default)
|
| 525 |
644 |
|
};
|
| 526 |
645 |
|
|
|
646 |
+ |
// `rounding` was the single radius before it split in two. A theme still
|
|
647 |
+ |
// setting it asked for that radius on the things it could see, which are
|
|
648 |
+ |
// the controls; containers go square with everything else. Reading it as
|
|
649 |
+ |
// the control default rather than ignoring it keeps a user's own theme
|
|
650 |
+ |
// file working without freezing the old key into the vocabulary.
|
|
651 |
+ |
let legacy_rounding = get_f32("rounding", 4.0);
|
|
652 |
+ |
|
| 527 |
653 |
|
Ok(ThemeColors {
|
| 528 |
654 |
|
surface_page: c("surface-page", Color32::BLACK),
|
| 529 |
655 |
|
surface_overlay: c("surface-overlay", Color32::BLACK),
|
| 539 |
665 |
|
category_five: c("category-five", Color32::from_rgb(0xBD, 0x93, 0xF9)),
|
| 540 |
666 |
|
category_six: c("category-six", Color32::from_rgb(0x88, 0xC0, 0xD0)),
|
| 541 |
667 |
|
border: c("border", Color32::DARK_GRAY),
|
| 542 |
|
- |
rounding: get_f32("rounding", 4.0),
|
|
668 |
+ |
// No fallback worth naming: makeover derives both from
|
|
669 |
+ |
// `surface-raised`, so a theme reaching here without them has no
|
|
670 |
+ |
// raised surface either and the bevel has nothing to sit on.
|
|
671 |
+ |
bevel_light: c("bevel-light", Color32::WHITE),
|
|
672 |
+ |
bevel_dark: c("bevel-dark", Color32::DARK_GRAY),
|
|
673 |
+ |
radius_control: get_f32("radius_control", legacy_rounding),
|
|
674 |
+ |
radius_container: get_f32("radius_container", 0.0),
|
| 543 |
675 |
|
})
|
| 544 |
676 |
|
}
|
| 545 |
677 |
|
|
| 779 |
911 |
|
visuals.window_stroke = egui::Stroke::new(1.0, t.border);
|
| 780 |
912 |
|
visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, t.border);
|
| 781 |
913 |
|
|
| 782 |
|
- |
// Softer edges on all widgets
|
| 783 |
|
- |
let rounding = egui::CornerRadius::same(t.rounding as u8);
|
| 784 |
|
- |
visuals.widgets.noninteractive.corner_radius = rounding;
|
| 785 |
|
- |
visuals.widgets.inactive.corner_radius = rounding;
|
| 786 |
|
- |
visuals.widgets.hovered.corner_radius = rounding;
|
| 787 |
|
- |
visuals.widgets.active.corner_radius = rounding;
|
| 788 |
|
- |
visuals.widgets.open.corner_radius = rounding;
|
|
914 |
+ |
// The radius split. egui's `noninteractive` state is what panels, frames
|
|
915 |
+ |
// and separators draw themselves with, so it takes the container radius;
|
|
916 |
+ |
// the four interactive states are buttons and take the control radius.
|
|
917 |
+ |
// Windows and menus are containers by any reading.
|
|
918 |
+ |
let control = egui::CornerRadius::same(t.radius_control as u8);
|
|
919 |
+ |
let container = egui::CornerRadius::same(t.radius_container as u8);
|
|
920 |
+ |
visuals.widgets.noninteractive.corner_radius = container;
|
|
921 |
+ |
visuals.widgets.inactive.corner_radius = control;
|
|
922 |
+ |
visuals.widgets.hovered.corner_radius = control;
|
|
923 |
+ |
visuals.widgets.active.corner_radius = control;
|
|
924 |
+ |
visuals.widgets.open.corner_radius = control;
|
|
925 |
+ |
visuals.window_corner_radius = container;
|
|
926 |
+ |
visuals.menu_corner_radius = container;
|
| 789 |
927 |
|
|
| 790 |
928 |
|
// Softer widget borders: thinner strokes on inactive/hover states
|
| 791 |
929 |
|
visuals.widgets.inactive.bg_stroke =
|
| 1058 |
1196 |
|
assert_eq!(theme.surface_page, Color32::from_rgb(0xaa, 0xbb, 0xcc));
|
| 1059 |
1197 |
|
}
|
| 1060 |
1198 |
|
|
|
1199 |
+ |
// Bevel and the radius split
|
|
1200 |
+ |
|
|
1201 |
+ |
#[test]
|
|
1202 |
+ |
fn parse_theme_reads_the_derived_bevel_pair() {
|
|
1203 |
+ |
// No theme file writes these: makeover derives them from
|
|
1204 |
+ |
// `surface-raised`, so a theme with a raised surface has a light model
|
|
1205 |
+ |
// whether or not its author thought about one.
|
|
1206 |
+ |
let theme = parse_theme(
|
|
1207 |
+ |
r##"
|
|
1208 |
+ |
[surface]
|
|
1209 |
+ |
raised = "#808080"
|
|
1210 |
+ |
"##,
|
|
1211 |
+ |
)
|
|
1212 |
+ |
.unwrap();
|
|
1213 |
+ |
assert_ne!(theme.bevel_light, theme.surface_raised);
|
|
1214 |
+ |
assert_ne!(theme.bevel_dark, theme.surface_raised);
|
|
1215 |
+ |
assert_ne!(theme.bevel_light, theme.bevel_dark);
|
|
1216 |
+ |
}
|
|
1217 |
+ |
|
|
1218 |
+ |
#[test]
|
|
1219 |
+ |
fn the_bundled_skin_can_hold_a_bevel() {
|
|
1220 |
+ |
// The reason the ramp moved: a highlight derived from white is white,
|
|
1221 |
+ |
// so a white `raised` bevels on two sides and never resolves as a lit
|
|
1222 |
+ |
// object. makeover has its own version of this assertion over the
|
|
1223 |
+ |
// whole shipped set; this one guards the skin this app defaults to.
|
|
1224 |
+ |
let d = ThemeColors::default();
|
|
1225 |
+ |
assert_ne!(
|
|
1226 |
+ |
d.bevel_light, d.surface_raised,
|
|
1227 |
+ |
"the lit edge vanished into the face it sits on"
|
|
1228 |
+ |
);
|
|
1229 |
+ |
assert_ne!(
|
|
1230 |
+ |
d.bevel_dark, d.surface_raised,
|
|
1231 |
+ |
"the shadowed edge vanished into the face it sits on"
|
|
1232 |
+ |
);
|
|
1233 |
+ |
}
|
|
1234 |
+ |
|
|
1235 |
+ |
/// The two radii a theme source parses to.
|
|
1236 |
+ |
///
|
|
1237 |
+ |
/// A helper rather than four inline pairs of `assert_eq!` because the
|
|
1238 |
+ |
/// comparison is exact and clippy is right to ask about that in general:
|
|
1239 |
+ |
/// a radius is a small integer that made a round trip through TOML and
|
|
1240 |
+ |
/// `f32`, so it is exactly representable and an epsilon would be a weaker
|
|
1241 |
+ |
/// assertion, not a safer one. Saying that once beats saying it seven
|
|
1242 |
+ |
/// times.
|
|
1243 |
+ |
#[allow(clippy::float_cmp)]
|
|
1244 |
+ |
fn assert_radii(source: &str, control: f32, container: f32) {
|
|
1245 |
+ |
let theme = parse_theme(source).unwrap();
|
|
1246 |
+ |
assert_eq!(theme.radius_control, control, "radius_control");
|
|
1247 |
+ |
assert_eq!(theme.radius_container, container, "radius_container");
|
|
1248 |
+ |
}
|
|
1249 |
+ |
|
|
1250 |
+ |
#[test]
|
|
1251 |
+ |
fn radius_splits_into_control_and_container() {
|
|
1252 |
+ |
assert_radii(
|
|
1253 |
+ |
"[geometry]\nradius_control = 6\nradius_container = 2\n",
|
|
1254 |
+ |
6.0,
|
|
1255 |
+ |
2.0,
|
|
1256 |
+ |
);
|
|
1257 |
+ |
}
|
|
1258 |
+ |
|
|
1259 |
+ |
#[test]
|
|
1260 |
+ |
fn radius_defaults_are_platinum() {
|
|
1261 |
+ |
// Square containers, a touch of a corner on things you press.
|
|
1262 |
+ |
assert_radii("", 4.0, 0.0);
|
|
1263 |
+ |
}
|
|
1264 |
+ |
|
|
1265 |
+ |
#[test]
|
|
1266 |
+ |
fn the_old_rounding_key_still_means_something() {
|
|
1267 |
+ |
// A user's custom theme predating the split asked for one radius on
|
|
1268 |
+ |
// the things it could see, which were the controls.
|
|
1269 |
+ |
assert_radii("[geometry]\nrounding = 10\n", 10.0, 0.0);
|
|
1270 |
+ |
}
|
|
1271 |
+ |
|
|
1272 |
+ |
#[test]
|
|
1273 |
+ |
fn the_new_keys_win_over_the_old_one() {
|
|
1274 |
+ |
assert_radii("[geometry]\nrounding = 10\nradius_control = 3\n", 3.0, 0.0);
|
|
1275 |
+ |
}
|
|
1276 |
+ |
|
| 1061 |
1277 |
|
#[test]
|
| 1062 |
1278 |
|
fn parse_theme_invalid_hex_in_field_uses_fallback() {
|
| 1063 |
1279 |
|
let toml = r##"
|
| 1150 |
1366 |
|
|
| 1151 |
1367 |
|
#[test]
|
| 1152 |
1368 |
|
fn theme_colors_default_is_audiofiles() {
|
| 1153 |
|
- |
let d = ThemeColors::default();
|
| 1154 |
|
- |
// Mac OS 8 Platinum: warm-neutral chrome, white wells, black text,
|
| 1155 |
|
- |
// Appearance-Manager navy for the action ring.
|
| 1156 |
|
- |
assert_eq!(d.surface_page, Color32::from_rgb(0xDD, 0xDD, 0xDD));
|
| 1157 |
|
- |
assert_eq!(d.content, Color32::from_rgb(0x00, 0x00, 0x00));
|
| 1158 |
|
- |
assert_eq!(d.action, Color32::from_rgb(0x3B, 0x5A, 0x9F));
|
| 1159 |
|
- |
assert_eq!(d.border, Color32::from_rgb(0x80, 0x80, 0x80));
|
|
1369 |
+ |
// The default is the pre-load fallback for this app's own skin, so it
|
|
1370 |
+ |
// is only correct while it equals what parsing that skin produces.
|
|
1371 |
+ |
// Asserting the two are equal rather than restating the hex values
|
|
1372 |
+ |
// means a change to `themes/audiofiles.toml` in makeover fails here
|
|
1373 |
+ |
// instead of drifting: the old version of this test named four
|
|
1374 |
+ |
// literals and passed happily through a ramp change that moved all of
|
|
1375 |
+ |
// them.
|
|
1376 |
+ |
let bundled = BUNDLED_THEMES
|
|
1377 |
+ |
.iter()
|
|
1378 |
+ |
.find(|(id, _)| *id == DEFAULT_THEME_ID)
|
|
1379 |
+ |
.map(|(_, content)| *content)
|
|
1380 |
+ |
.expect("the app's own skin is bundled");
|
|
1381 |
+ |
|
|
1382 |
+ |
assert_eq!(
|
|
1383 |
+ |
parse_theme(bundled).expect("the app's own skin parses"),
|
|
1384 |
+ |
ThemeColors::default(),
|
|
1385 |
+ |
"ThemeColors::default() has drifted from themes/{DEFAULT_THEME_ID}.toml"
|
|
1386 |
+ |
);
|
| 1160 |
1387 |
|
}
|
| 1161 |
1388 |
|
|
| 1162 |
1389 |
|
// Bundled theme parsing (round-trip all embedded themes)
|