Compare commits

...

1 Commits

Author SHA1 Message Date
bherbruck 8537f1c658 Themes: readable with the newer UI in every built-in theme
Text and icons the newer UI paints on a selected, highlighted or accent fill now
fall back to the theme's selected or body ink where the preferred ink does not
read there (theme::readable, Tokens::accent_on, Tokens::on_selected), so
SolidCraft 95's navy-on-navy selections, 1-bit and CGA's same-colour pairs and
the light-accent title bar pill are readable. Themes that predate the
navigation bar take its hover, press, dim, field, panel and tooltip colours from
their own palette instead of their base's blue-greys. SolidCraft 95, Amber
Phosphor, One Dark, Solarized Light, Nord and Gruvbox Dark Soft get the few
colour tokens that read below 3:1 adjusted.

newer_ui_text_and_icons_stay_readable_in_every_built_in_theme walks every fill
and ink pair these surfaces paint and checks it in every built-in theme.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 09:57:50 -04:00
21 changed files with 241 additions and 35 deletions
+2 -2
View File
@@ -57,7 +57,7 @@
"error": "#fff0ca",
"field": "#100b04",
"form_accent": "#ca892b",
"form_dim": "#9b6922",
"form_dim": "#ad7a2c",
"form_error": "#fff0ca",
"form_error_fill": "#46300f",
"form_fill": "#e19e45",
@@ -180,7 +180,7 @@
"surface_soft": "#2a1e0b",
"tab_on_fill_dark": "#170d03",
"text": "#f3ca87",
"text_dim": "#9b6922",
"text_dim": "#ad7a2c",
"timeline": "#090602",
"timeline_item": "#1d1406",
"timeline_item_error": "#46300f",
@@ -113,7 +113,7 @@
"form_dim": "#bdae93",
"form_primary": "#83a598",
"form_error": "#fb4934",
"form_error_fill": "#702b28",
"form_error_fill": "#5f2320",
"form_success": "#b8bb26",
"form_success_fill": "#343a28",
"dialog_body": "#32302f",
+1 -1
View File
@@ -59,7 +59,7 @@
"form_accent": "#cfa7c7",
"form_dim": "#a4b0c2",
"form_error": "#bf616a",
"form_error_fill": "#4b3d48",
"form_error_fill": "#352b33",
"form_fill": "#e7bfe0",
"form_ink": "#eceff4",
"form_primary": "#88c0d0",
+1 -1
View File
@@ -180,7 +180,7 @@
"surface_soft": "#363332",
"tab_on_fill_dark": "#000000",
"text": "#abb2bf",
"text_dim": "#7f848e",
"text_dim": "#90959f",
"timeline": "#1e2227",
"timeline_item": "#323842",
"timeline_item_error": "#5c343d",
+5 -3
View File
@@ -59,12 +59,12 @@
"form_accent": "#6b70c3",
"form_dim": "#657b83",
"form_error": "#dc322f",
"form_error_fill": "#eac4b4",
"form_error_fill": "#f7ddd3",
"form_fill": "#a2a9f8",
"form_ink": "#586e75",
"form_primary": "#268bd2",
"form_soft": "#e4ded4",
"form_success": "#859900",
"form_success": "#5f6e00",
"form_success_fill": "#dcdbb1",
"form_tab_ink": "#565a9d",
"glyph_bg": "#e3ddc9",
@@ -130,6 +130,8 @@
"mesh_fill": "#d7aa3c",
"mesh_ink": "#7b5d00",
"mesh_soft": "#e9e0c4",
"nav_tip": "#073642",
"nav_tip_ink": "#eee8d5",
"on_accent": "#ffffff",
"origin_plane": "#b5890028",
"origin_plane_edge": "#b5890088",
@@ -179,7 +181,7 @@
"surface_ink": "#a83e12",
"surface_soft": "#ebdbc6",
"tab_on_fill_dark": "#fdf6e3",
"text": "#586e75",
"text": "#536970",
"text_dim": "#657b83",
"timeline": "#e3ddc9",
"timeline_item": "#e3ddc9",
+6 -2
View File
@@ -64,7 +64,7 @@
"form_ink": "#000000",
"form_primary": "#000080",
"form_soft": "#bbb1bb",
"form_success": "#008000",
"form_success": "#006000",
"form_success_fill": "#c0dcc0",
"form_tab_ink": "#7d007d",
"glyph_bg": "#ffffff",
@@ -130,6 +130,10 @@
"mesh_fill": "#808000",
"mesh_ink": "#4c4c00",
"mesh_soft": "#bbbbb1",
"nav_press": "#a0a0a0",
"nav_tip": "#ffffe1",
"nav_tip_ink": "#000000",
"nav_tip_key_border": "#808080",
"on_accent": "#ffffff",
"origin_plane": "#ffff005e",
"origin_plane_edge": "#808000c8",
@@ -180,7 +184,7 @@
"surface_soft": "#bbb6b1",
"tab_on_fill_dark": "#000000",
"text": "#000000",
"text_dim": "#808080",
"text_dim": "#606060",
"timeline": "#c0c0c0",
"timeline_item": "#c0c0c0",
"timeline_item_error": "#ffffc0",
+1 -1
View File
@@ -297,7 +297,7 @@ fn paint_icon(ui: &egui::Ui, rect: egui::Rect, tex: Option<egui::TextureId>, nam
egui::Align2::CENTER_CENTER,
letter,
egui::FontId::proportional(crate::theme::Tokens::get().font_small),
t.text,
crate::theme::readable(&[t.chip], crate::theme::TEXT_CONTRAST, &[t.text, t.on_accent]),
);
}
}
+33
View File
@@ -286,6 +286,28 @@ pub(crate) fn contrast_ratio(a: Color32, b: Color32) -> f64 {
(a.max(b) + 0.05) / (a.min(b) + 0.05)
}
/// WCAG floors: body text, and icons, bold labels and other large marks.
pub(crate) const TEXT_CONTRAST: f64 = 4.5;
pub(crate) const MARK_CONTRAST: f64 = 3.0;
/// The first of `inks` that reads at `min` on every one of `fills`, else whichever of them reads
/// best on its worst fill. Themes older than a surface can pair a fill with an ink that vanishes
/// on it (SolidCraft 95's navy accent on its navy selection); this keeps such a pair readable
/// without changing any theme where the preferred ink already reads.
pub(crate) fn readable(fills: &[Color32], min: f64, inks: &[Color32]) -> Color32 {
let worst = |ink: Color32| fills.iter().map(|fill| contrast_ratio(ink, *fill)).fold(f64::INFINITY, f64::min);
inks.iter()
.copied()
.find(|ink| worst(*ink) >= min)
.or_else(|| inks.iter().copied().max_by(|a, b| worst(*a).total_cmp(&worst(*b))))
.unwrap_or(Color32::BLACK)
}
/// Is `fill` dark enough that light artwork reads better on it?
pub(crate) fn is_dark_fill(fill: Color32) -> bool {
contrast_ratio(Color32::WHITE, fill) > contrast_ratio(Color32::BLACK, fill)
}
impl TabAccent {
pub fn for_tab(t: &Tokens, tab: &str) -> Self {
let (color, ink, soft) = match tab {
@@ -872,6 +894,17 @@ impl Tokens {
egui::Shadow { offset: [0, self.space_2 as i8], blur: self.space_8 as u8, spread: 0, color: self.app_bar.gamma_multiply(0.18) }
}
/// The accent ink (`form_primary`) on `fill`, or the theme's selected or body ink where the
/// accent does not read there: a theme whose selection or highlight is its accent colour.
pub(crate) fn accent_on(&self, fill: Color32) -> Color32 {
readable(&[fill], MARK_CONTRAST, &[self.form_primary, self.selected_ink, self.form_ink])
}
/// `ink` on the selected fill, or `selected_ink` where `ink` does not read at `min` there.
pub(crate) fn on_selected(&self, ink: Color32, min: f64) -> Color32 {
readable(&[self.selected_bg], min, &[ink, self.selected_ink])
}
/// The X, Y and Z axis colours, in that order.
pub const fn axes(&self) -> [Color32; 3] {
[self.axis_x, self.axis_y, self.axis_z]
+28
View File
@@ -509,10 +509,38 @@ pub fn resolve(theme: &ThemeFile) -> Tokens {
// A file that sets no colours is its base palette, exactly.
if !theme.colors.is_empty() {
file_view_from_theme(&mut t, theme.base.is_dark(), |k| theme.colors.contains_key(k));
navigation_from_theme(&mut t, |k| theme.colors.contains_key(k));
}
t
}
/// The navigation bar and its menus are newer than most theme files: the navigation colours a
/// theme leaves unset follow its own hover, dim text, control and dialog colours instead of its
/// base's blue-greys, and its tooltip is the theme's ink and dialog colours turned around.
fn navigation_from_theme(t: &mut Tokens, set: impl Fn(&str) -> bool) {
if !set("nav_hover") {
t.nav_hover = t.hover;
}
if !set("nav_press") {
t.nav_press = t.nav_hover.lerp_to_gamma(t.form_ink, 0.12);
}
if !set("nav_dim") {
t.nav_dim = t.text_dim;
}
if !set("nav_field") {
t.nav_field = t.control_fill;
}
if !set("nav_viewport") {
t.nav_viewport = t.dialog_body;
}
if !set("nav_tip") {
t.nav_tip = t.form_ink;
}
if !set("nav_tip_ink") {
t.nav_tip_ink = t.dialog_body;
}
}
/// The File view's colours a theme leaves unset follow the theme's own panel, border, accent and
/// text, not its base's fixed blue rail and grey cards (#4868). Each ink is whichever candidate
/// reads best on the fills it sits on.
+7 -1
View File
@@ -290,7 +290,13 @@ pub(crate) fn offer_pill(
let glyph = Rect::from_center_size(pos2(glyph_x, rect.center().y), vec2(16.0, 16.0));
icons::paint_on(ui.painter(), glyph, icon, Some(true), 1.0);
if let Some((text, _)) = label {
ui.painter().text(pos2(rect.left() + text_x, rect.center().y), Align2::LEFT_CENTER, text, font, t.app_bar_text);
// The pill is the theme's accent: a light accent (the phosphor themes) needs dark ink.
let ink = if hot {
t.app_bar_text
} else {
crate::theme::readable(&[fill], crate::theme::MARK_CONTRAST, &[t.app_bar_text, t.on_accent, t.tab_on_fill_dark])
};
ui.painter().text(pos2(rect.left() + text_x, rect.center().y), Align2::LEFT_CENTER, text, font, ink);
}
Some((rect, response))
}
+10 -4
View File
@@ -269,6 +269,8 @@ fn action_button(ui: &mut Ui, label: &str, kind: Action, look: &ActionLook, padd
look.primary_ink
} else if primary || !live {
t.form_dim
} else if marked {
t.on_selected(t.form_ink, crate::theme::TEXT_CONTRAST)
} else {
t.form_ink
});
@@ -403,22 +405,26 @@ pub(crate) fn button_pair(ui: &mut egui::Ui, key: &str, states: [bool; 2], butto
egui::StrokeKind::Inside,
);
let label = crate::i18n::tr_label(button.label);
let galley = ui.painter().layout_no_wrap(label.to_string(), egui::FontId::proportional(t.font_label), t.form_ink);
let ink = if on { t.on_selected(t.form_ink, crate::theme::TEXT_CONTRAST) } else { t.form_ink };
let galley = ui.painter().layout_no_wrap(label.to_string(), egui::FontId::proportional(t.font_label), ink);
let icon_width = if button.icon.is_some() { t.sketch_footer_icon_size + t.space_4 } else { 0.0 };
let left = rect.center().x - (galley.size().x + icon_width) / 2.0;
let painter = ui.painter().with_clip_rect(ui.clip_rect().intersect(rect));
if let Some(icon) = button.icon {
crate::icons::paint_icon(
// A selection darker or lighter than the theme takes that side's artwork.
let side = (on && crate::theme::is_dark_fill(t.selected_bg) != crate::theme::is_dark()).then_some(!crate::theme::is_dark());
crate::icons::paint_on(
&painter,
egui::Rect::from_center_size(
egui::pos2(left + t.sketch_footer_icon_size / 2.0, rect.center().y),
vec2(t.sketch_footer_icon_size, t.sketch_footer_icon_size),
),
icon,
side,
f32::from(t.icon.a()) / 255.0,
);
}
painter.galley(egui::pos2(left + icon_width, rect.center().y - galley.size().y / 2.0), galley, t.form_ink);
painter.galley(egui::pos2(left + icon_width, rect.center().y - galley.size().y / 2.0), galley, ink);
response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), on, label));
t.button_bevel(ui, &response, on);
pointer_on_hover(ui, &response);
@@ -602,7 +608,7 @@ pub(crate) fn compact_button(ui: &mut Ui, glyph: &str, tooltip: &str, enabled: b
let ink = if !enabled {
t.form_dim.gamma_multiply(0.35)
} else if selected {
t.form_primary
t.accent_on(t.selected_bg)
} else {
t.form_dim
};
+26 -3
View File
@@ -164,7 +164,18 @@ pub(crate) fn segmented(ui: &mut Ui, selected: &mut usize, options: &[&str], con
egui::pos2(text_rect.left() - t.space_6 - t.space_14 / 2.0, segment.center().y),
vec2(t.space_14, t.space_14),
);
crate::icons::paint_ink(ui.painter(), icon_rect, icon, if on && workspace_accent { accent.ink } else { t.form_ink });
crate::icons::paint_ink(
ui.painter(),
icon_rect,
icon,
if on && workspace_accent {
accent.ink
} else if on && !primary {
t.on_selected(t.form_ink, crate::theme::MARK_CONTRAST)
} else {
t.form_ink
},
);
}
let text_rect = if compact { text_rect.intersect(segment.shrink2(vec2(t.space_4, 0.0))) } else { text_rect };
paint_text(
@@ -262,6 +273,10 @@ pub(crate) fn segmented_icons(ui: &mut Ui, selected: &mut usize, options: &[Icon
if catalog {
let icon_rect = egui::Rect::from_center_size(segment.center(), vec2(t.space_14, t.space_14));
crate::icons::paint_ink(ui.painter(), icon_rect, option.icon, if on { accent.ink } else { t.form_dim });
} else if on && crate::theme::is_dark_fill(t.selected_bg) != crate::theme::is_dark() {
// A selection darker or lighter than the theme (SolidCraft 95's navy) takes the
// artwork drawn for that side, so the option's line or dash still shows.
crate::icons::paint_on(ui.painter(), icon_rect, option.icon, Some(!crate::theme::is_dark()), f32::from(t.icon.a()) / 255.0);
} else {
crate::icons::paint_icon(ui.painter(), icon_rect, option.icon, f32::from(t.icon.a()) / 255.0);
}
@@ -662,8 +677,16 @@ pub(crate) fn popup_list(
inner.min,
egui::pos2((inner.right() - kind_width - t.space_8).max(inner.left()), inner.bottom()),
);
super::text::paint_text(ui, text_rect, option.text, mono.clone(), if picked { t.form_primary } else { option.color }, false);
ui.painter().text(inner.right_center(), egui::Align2::RIGHT_CENTER, kind, kind_font.clone(), t.form_dim);
super::text::paint_text(
ui,
text_rect,
option.text,
mono.clone(),
if picked { t.accent_on(t.selected_bg) } else { option.color },
false,
);
let kind_ink = if picked { t.on_selected(t.form_dim, crate::theme::MARK_CONTRAST) } else { t.form_dim };
ui.painter().text(inner.right_center(), egui::Align2::RIGHT_CENTER, kind, kind_font.clone(), kind_ink);
response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, ui.is_enabled(), picked, option.text));
// Tip the full name first: long identifiers still clip inside a capped popup.
let tip = if option.detail.is_empty() { option.text.to_owned() } else { format!("{}\n{}", option.text, option.detail) };
+7 -1
View File
@@ -56,7 +56,13 @@ pub(crate) fn count_field(ui: &mut Ui, value: &mut String, limits: &Count<'_>) -
if enabled && arrow.hovered() {
painter.rect_filled(half.shrink(1.0), t.control_radius, t.selected_bg);
}
let ink = if enabled { t.form_ink } else { t.form_dim };
let ink = if !enabled {
t.form_dim
} else if arrow.hovered() {
t.on_selected(t.form_ink, crate::theme::MARK_CONTRAST)
} else {
t.form_ink
};
let (c, w, h) = (half.center(), (reserve * 0.18).max(2.0), (half.height() * 0.2).max(2.0));
let tip = if delta > 0 { -h } else { h };
painter.add(egui::Shape::convex_polygon(
+4 -4
View File
@@ -277,14 +277,14 @@ impl<'a> MenuItem<'a> {
let ink = if self.danger {
t.form_error
} else if hot {
t.form_primary
t.accent_on(t.accent_soft)
} else {
t.form_ink
};
let dim = if self.danger {
t.form_error
} else if hot {
t.form_primary
t.accent_on(t.accent_soft)
} else {
t.form_dim
};
@@ -299,7 +299,7 @@ impl<'a> MenuItem<'a> {
let icon = Rect::from_center_size(pos2(r.left() + t.control_height / 2.0, r.center().y), vec2(t.space_16, t.space_16));
if let Some(on) = self.checked {
if on {
super::checks::paint_check_glyph(&painter, icon, false, t.form_primary);
super::checks::paint_check_glyph(&painter, icon, false, if hot { ink } else { t.form_primary });
}
} else if let Some(color) = self.swatch {
painter.rect(icon, t.radius_sm, color, t.control_border_stroke(t.control_border), egui::StrokeKind::Inside);
@@ -419,7 +419,7 @@ pub(crate) fn workspace_menu(ui: &mut egui::Ui, items: &[Item], key_row: Option<
let badge = ui.painter().layout_no_wrap(
badge.to_string(),
FontId::proportional(crate::theme::Tokens::get().font_small),
if current { t.on_accent } else { accent },
if current { crate::theme::readable(&[accent], crate::theme::MARK_CONTRAST, &[t.on_accent, t.tab_on_fill_dark]) } else { accent },
);
let badge_rect = Rect::from_min_size(pos2(badge_x, y - 8.0), badge.size() + vec2(t.space_12, t.space_3));
ui.painter().rect_filled(badge_rect, t.radius_lg, if current { accent } else { soft });
+92 -3
View File
@@ -104,11 +104,16 @@ pub(crate) fn navigation_fill(t: &Tokens, fill: NavigationFill) -> Option<egui::
/// The label and icon colour of a slot. An active slot keeps the accent ink on its selected fill
/// where that reads; a theme whose selected fill is as dark as its accent (SolidCraft 95's navy)
/// gets the theme's selected-text ink instead, so an active toggle never turns into a blank block.
/// A hovered or pressed slot keeps the body ink unless the theme's press colour is as dark as
/// it (1-bit's black press), where the selected-text ink takes over too.
pub(crate) fn navigation_ink(t: &Tokens, fill: NavigationFill) -> egui::Color32 {
if fill != NavigationFill::Active {
return t.form_ink;
let readable = |fill, ink| crate::theme::readable(&[fill], NAV_ACTIVE_MIN_CONTRAST, &[ink, t.selected_ink]);
match fill {
NavigationFill::None => t.form_ink,
NavigationFill::Hover => readable(t.nav_hover, t.form_ink),
NavigationFill::Press => readable(t.nav_press, t.form_ink),
NavigationFill::Active => readable(t.selected_bg, t.form_primary),
}
if crate::theme::contrast_ratio(t.form_primary, t.selected_bg) >= NAV_ACTIVE_MIN_CONTRAST { t.form_primary } else { t.selected_ink }
}
/// WCAG small-text contrast for an active slot's label on its selected fill.
@@ -246,4 +251,88 @@ mod active_ink_tests {
}
assert!(failures.is_empty(), "active navigation slot unreadable:\n{}", failures.join("\n"));
}
/// Every fill the newer UI paints text or an icon on, with the ink it paints there, reads in
/// every built-in theme: the navigation bar and its menus, viewport status items, the
/// ribbon's drop-downs and workspace menu, the Render stages, toggles and segmented options,
/// badges and the sketch status drawn on the view. A theme older than one of these surfaces
/// left it with its base palette's colour, or with its accent on an accent-coloured fill
/// (SolidCraft 95's navy on navy).
#[test]
fn newer_ui_text_and_icons_stay_readable_in_every_built_in_theme() {
use crate::theme::{MARK_CONTRAST, TEXT_CONTRAST, TabAccent, Tokens, contrast_ratio, readable};
use egui::Color32;
type Pick = fn(&Tokens) -> Color32;
let status: Pick = |t| readable(&[t.viewport_top, t.viewport_bottom], MARK_CONTRAST, &[t.sketch_accent, t.sketch_ink, t.text]);
let conflict: Pick = |t| readable(&[t.viewport_top, t.viewport_bottom], MARK_CONTRAST, &[t.error, t.form_error, t.text]);
// (where, fill, ink, floor)
let pairs: &[(&str, Pick, Pick, f64)] = &[
("selected label: navigation menu, Preferences pages, picked rows", |t| t.selected_bg, |t| t.accent_on(t.selected_bg), MARK_CONTRAST),
("navigation menu: selected row detail", |t| t.selected_bg, |t| t.on_selected(t.form_dim, MARK_CONTRAST), MARK_CONTRAST),
("navigation bar: hovered slot", |t| t.nav_hover, |t| navigation_ink(t, NavigationFill::Hover), TEXT_CONTRAST),
("navigation bar: pressed slot", |t| t.nav_press, |t| navigation_ink(t, NavigationFill::Press), TEXT_CONTRAST),
("navigation menu: hovered row icon", |t| t.nav_hover, |t| t.form_dim, MARK_CONTRAST),
("navigation settings: add slot", |t| t.nav_hover, |t| t.accent_on(t.nav_hover), MARK_CONTRAST),
("navigation tooltip", |t| t.nav_tip, |t| t.nav_tip_ink, TEXT_CONTRAST),
("viewport status item: name", |t| t.overlay, |t| t.text, TEXT_CONTRAST),
("viewport status item: setting", |t| t.overlay, |t| t.text_dim, MARK_CONTRAST),
("ribbon: section label", |t| t.toolbar, |t| t.text_dim, MARK_CONTRAST),
("ribbon: hovered section label", |t| t.hover, |t| t.text_dim, MARK_CONTRAST),
("panels: secondary text", |t| t.panel, |t| t.text_dim, MARK_CONTRAST),
("ribbon drop-down: highlighted row", |t| t.accent_soft, |t| t.accent_on(t.accent_soft), MARK_CONTRAST),
(
"workspace menu: current badge",
|t| t.accent,
|t| readable(&[t.accent], MARK_CONTRAST, &[t.on_accent, t.tab_on_fill_dark]),
MARK_CONTRAST,
),
(
"Render stage bar: current stage number",
|t| TabAccent::for_tab(t, "RENDER").color,
|t| TabAccent::for_tab(t, "RENDER").on_fill(t),
MARK_CONTRAST,
),
("toggle buttons, keyboard-marked action: on", |t| t.selected_bg, |t| t.on_selected(t.form_ink, TEXT_CONTRAST), TEXT_CONTRAST),
("toggle and segmented icons: on", |t| t.selected_bg, |t| t.on_selected(t.form_ink, MARK_CONTRAST), MARK_CONTRAST),
("segmented choice: selected label", |t| t.selected_bg, |t| t.selected_ink, TEXT_CONTRAST),
("settings and search badges: valid", |t| t.form_success_fill, |t| t.form_success, MARK_CONTRAST),
("search badges: invalid", |t| t.form_error_fill, |t| t.form_error, MARK_CONTRAST),
("dialogs: success text", |t| t.dialog_body, |t| t.form_success, MARK_CONTRAST),
(
"title bar: offer pill",
|t| t.accent,
|t| readable(&[t.accent], MARK_CONTRAST, &[t.app_bar_text, t.on_accent, t.tab_on_fill_dark]),
MARK_CONTRAST,
),
("save mesh: lettered tile", |t| t.chip, |t| readable(&[t.chip], TEXT_CONTRAST, &[t.text, t.on_accent]), MARK_CONTRAST),
("sketch status on the view, top", |t| t.viewport_top, status, MARK_CONTRAST),
("sketch status on the view, bottom", |t| t.viewport_bottom, status, MARK_CONTRAST),
("sketch conflict status on the view, top", |t| t.viewport_top, conflict, MARK_CONTRAST),
("sketch conflict status on the view, bottom", |t| t.viewport_bottom, conflict, MARK_CONTRAST),
];
// A translucent fill is seen over the panel it floats on, translucent ink over its fill.
let solid = |c: Color32, under: Color32| {
if c.a() == 255 {
return c;
}
let k = f32::from(c.a()) / 255.0;
let [r, g, b, _] = c.to_srgba_unmultiplied();
let mix = |x: u8, y: u8| (f32::from(x) * k + f32::from(y) * (1.0 - k)).round() as u8;
Color32::from_rgb(mix(r, under.r()), mix(g, under.g()), mix(b, under.b()))
};
let mut themes = vec![("Dark".to_owned(), Tokens::dark()), ("Light".to_owned(), Tokens::light())];
themes.extend(crate::theme_files::builtin().iter().map(|theme| (theme.name.clone(), crate::theme_files::resolve(theme))));
let mut failures = Vec::new();
for (name, t) in &themes {
for (place, fill, ink, floor) in pairs {
let fill = solid(fill(t), t.panel);
let ink = solid(ink(t), fill);
let ratio = contrast_ratio(ink, fill);
if ratio < *floor {
failures.push(format!("{name}: {place}: {ratio:.2}:1 ({ink:?} on {fill:?})"));
}
}
}
assert!(failures.is_empty(), "unreadable in a built-in theme:\n{}", failures.join("\n"));
}
}
+9 -3
View File
@@ -42,7 +42,7 @@ impl<'a> NavigationMenuRow<'a> {
let t = Tokens::get();
let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width().max(0.0), NAV_MENU_ROW), Sense::hover());
let response = ui.interact(rect, ui.id().with(self.id), if self.enabled { Sense::click() } else { Sense::hover() });
let ink = if self.selected { t.form_primary } else { t.form_ink };
let ink = if self.selected { t.accent_on(t.selected_bg) } else { t.form_ink };
let bg = if self.selected {
Some(t.selected_bg)
} else if response.hovered() && self.enabled {
@@ -69,7 +69,13 @@ impl<'a> NavigationMenuRow<'a> {
Align2::RIGHT_CENTER,
detail,
FontId::proportional(if self.enabled { t.font_body } else { t.font_small }),
if self.enabled { t.form_dim } else { t.nav_dim },
if !self.enabled {
t.nav_dim
} else if self.selected {
t.on_selected(t.form_dim, crate::theme::MARK_CONTRAST)
} else {
t.form_dim
},
);
end = end.min(detail.left() - t.space_8);
}
@@ -81,7 +87,7 @@ impl<'a> NavigationMenuRow<'a> {
ui.painter().galley(pos2(left, rect.center().y - galley.size().y / 2.0), galley, ink);
let tail = Rect::from_center_size(pos2(rect.right() - NAV_MENU_INSET - t.space_14 / 2.0, rect.center().y), vec2(t.space_14, t.space_14));
if self.selected {
super::checks::paint_check_glyph(ui.painter(), tail, false, t.form_primary);
super::checks::paint_check_glyph(ui.painter(), tail, false, ink);
} else if self.submenu {
let arrow =
Rect::from_center_size(pos2(rect.right() - NAV_MENU_INSET - NAV_CHEVRON / 2.0, rect.center().y), vec2(NAV_CHEVRON, NAV_CHEVRON));
@@ -63,7 +63,7 @@ pub(crate) fn navigation_tile(ui: &mut Ui, id: &str, label: &str, icon: &str, dr
if add.hovered() {
painter.rect_filled(add_rect, t.control_radius.saturating_sub(NAV_BORDER as u8), t.nav_hover);
}
icons::paint_ink(&painter, Rect::from_center_size(add_rect.center(), vec2(t.space_14, t.space_14)), "plus", t.form_primary);
icons::paint_ink(&painter, Rect::from_center_size(add_rect.center(), vec2(t.space_14, t.space_14)), "plus", t.accent_on(t.nav_hover));
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label));
add.widget_info(|| {
egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), crate::i18n::format("navbar.custom.add_slot", &[("slot", label)]))
+1 -1
View File
@@ -1234,7 +1234,7 @@ fn page_list_sized<S>(
if on || response.hovered() || response.has_focus() {
ui.painter().rect_filled(rect, t.control_radius, if on { t.selected_bg } else { t.hover });
}
let ink = if on { t.form_primary } else { t.form_ink };
let ink = if on { t.accent_on(t.selected_bg) } else { t.form_ink };
let icon =
egui::Rect::from_center_size(egui::pos2(rect.left() + t.space_10 + t.space_8, rect.center().y), vec2(t.space_16, t.space_16));
crate::icons::paint_ink(ui.painter(), icon, page.icon, if on { ink } else { t.form_dim });
+1 -1
View File
@@ -186,7 +186,7 @@ impl StageBar {
},
&(index + 1).to_string(),
crate::fonts::semibold(t.font_small),
if on { t.on_accent } else { t.text_dim },
if on { accent.on_fill(&t) } else { t.text_dim },
false,
);
let text = Rect::from_min_max(pos2(badge.right() + t.space_6 + 1.0, chip.top()), chip.right_bottom() - vec2(t.space_14, 0.0));
+1 -1
View File
@@ -352,7 +352,7 @@ pub(crate) fn plus_header<R>(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> Opt
let response = super::actions::icon_button_colored(ui, rect, "plus", "Add Column", Some(t.form_dim));
if response.hovered() {
ui.painter().rect_filled(rect, t.control_radius, t.selected_bg);
crate::icons::paint_ink(ui.painter(), rect.shrink(t.form_icon_inset), "plus", t.form_primary);
crate::icons::paint_ink(ui.painter(), rect.shrink(t.form_icon_inset), "plus", t.accent_on(t.selected_bg));
for x in [rect.left() - t.form_gap_y, rect.right() + t.form_gap_y] {
ui.painter().vline(x, rect.y_range(), egui::Stroke::new(t.space_2, t.form_primary));
}
+4 -1
View File
@@ -5949,7 +5949,10 @@ fn overlays(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
crate::i18n::format("sketch.degrees_of_freedom", &[("count", &ss.report.dof.to_string())])
};
let msg = format!("{} — {status}", crate::i18n::tr_name(&ss.name));
let c = if ss.report.ok() { v.sketch_accent } else { v.error };
// Straight on the view's gradient: a theme whose sketch or error colour is close to its
// background (SolidCraft 95's green and red on teal) gets the view's text colour.
let inks = if ss.report.ok() { [v.sketch_accent, v.sketch_ink, v.text] } else { [v.error, v.form_error, v.text] };
let c = crate::theme::readable(&[v.viewport_top, v.viewport_bottom], crate::theme::MARK_CONTRAST, &inks);
painter.text(pos2(r.left() + 12.0, r.top() + 12.0), Align2::LEFT_TOP, msg, FontId::proportional(t.font_button), c);
}
}