Preferences redesign 2/11: shared widgets and painters in ui_kit (#9874) (#9890)

* claim: #9874

* Add shared Preferences cards, figure painters and gallery entries to ui_kit

* Preferences: Arabic, Hebrew and Vietnamese carousel labels

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Use the app's selection colours in the Preferences cards

* Measure Preferences figure inks and use shared palette metrics

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nathan Dane
2026-11-01 21:58:35 +00:00
committed by GitHub
parent b6aabb88ff
commit 5c7e50cb67
15 changed files with 6726 additions and 1 deletions
@@ -0,0 +1,10 @@
{
"ar": { "prefs.carousel_next": "التالي", "prefs.carousel_previous": "السابق" },
"de": { "prefs.carousel_next": "Weiter", "prefs.carousel_previous": "Zurück" },
"en": { "prefs.carousel_next": "Next", "prefs.carousel_previous": "Previous" },
"he": { "prefs.carousel_next": "הבא", "prefs.carousel_previous": "הקודם" },
"ja": { "prefs.carousel_next": "次へ", "prefs.carousel_previous": "前へ" },
"ko": { "prefs.carousel_next": "다음", "prefs.carousel_previous": "이전" },
"vi": { "prefs.carousel_next": "Tiếp", "prefs.carousel_previous": "Trước" },
"zh": { "prefs.carousel_next": "下一个", "prefs.carousel_previous": "上一个" }
}
+42
View File
@@ -226,6 +226,28 @@ impl Tokens {
dialog_label: Color32::from_rgb(58, 70, 86),
selected_ink: Color32::from_rgb(26, 80, 127),
selected_bg: Color32::from_rgb(232, 241, 250),
figure_ground: Color32::from_rgb(247, 248, 250),
figure_line: Color32::from_rgb(111, 124, 143),
figure_face_top: Color32::from_rgb(255, 255, 255),
figure_face_left: Color32::from_rgb(228, 233, 240),
figure_face_right: Color32::from_rgb(201, 210, 222),
figure_card: Color32::from_rgb(255, 255, 255),
figure_white: Color32::WHITE,
figure_black: Color32::BLACK,
figure_accent: Color32::from_rgb(35, 110, 173),
figure_reference: Color32::from_rgb(138, 83, 0),
figure_axis_x: Color32::from_rgb(214, 69, 69),
figure_axis_y: Color32::from_rgb(46, 158, 91),
figure_axis_z: Color32::from_rgb(47, 111, 208),
figure_ok: Color32::from_rgb(47, 125, 91),
figure_warn: Color32::from_rgb(201, 138, 0),
pref_well: Color32::from_rgb(238, 241, 245),
pref_note: Color32::from_rgb(184, 74, 0),
pref_note_fill: Color32::from_rgb(251, 235, 221),
pref_on_accent: Color32::from_rgb(255, 255, 255),
pref_ok: Color32::from_rgb(39, 107, 44),
pref_ok_fill: Color32::from_rgb(229, 242, 230),
pref_disabled: Color32::from_rgb(138, 148, 163),
timeline_item: Color32::WHITE,
timeline_item_error: Color32::from_rgb(250, 220, 220),
timeline_marker: Color32::from_rgb(70, 76, 88),
@@ -345,6 +367,26 @@ impl Tokens {
t.dialog_label = t.text;
t.selected_ink = Color32::from_rgb(156, 199, 245);
t.selected_bg = Color32::from_rgb(42, 70, 110);
t.figure_ground = Color32::from_rgb(28, 31, 38);
t.figure_line = Color32::from_rgb(163, 174, 194);
t.figure_face_top = Color32::from_rgb(107, 119, 144);
t.figure_face_left = Color32::from_rgb(81, 91, 114);
t.figure_face_right = Color32::from_rgb(57, 66, 86);
t.figure_card = Color32::from_rgb(47, 52, 62);
t.figure_accent = Color32::from_rgb(140, 190, 245);
t.figure_reference = Color32::from_rgb(232, 194, 74);
t.figure_axis_x = Color32::from_rgb(242, 122, 122);
t.figure_axis_y = Color32::from_rgb(98, 212, 142);
t.figure_axis_z = Color32::from_rgb(108, 168, 247);
t.figure_ok = Color32::from_rgb(127, 209, 174);
t.figure_warn = Color32::from_rgb(232, 194, 74);
t.pref_well = Color32::from_rgb(47, 52, 62);
t.pref_note = Color32::from_rgb(240, 162, 122);
t.pref_note_fill = Color32::from_rgb(74, 51, 38);
t.pref_on_accent = Color32::from_rgb(15, 22, 32);
t.pref_ok = Color32::from_rgb(127, 209, 174);
t.pref_ok_fill = Color32::from_rgb(31, 58, 49);
t.pref_disabled = Color32::from_rgb(138, 148, 163);
t.construction_geom = icon_primary(crate::icon_svg::CONSTRUCTION_PRIMARY[1]);
t.error = Color32::from_rgb(240, 100, 96);
t.sketch_conflict = t.error;
@@ -31,6 +31,8 @@ fn moved_colours_keep_the_values_they_were_drawn_with() {
assert_eq!(t.logo, [Color32::from_rgb(96, 158, 236), Color32::from_rgb(58, 136, 238), Color32::from_rgb(55, 108, 197)]);
assert_eq!(t.icons.marker_halo, Color32::from_black_alpha(150));
assert_eq!(t.icons.agent, Color32::from_rgb(168, 85, 247));
assert_eq!(t.figure_white, Color32::WHITE);
assert_eq!(t.figure_black, Color32::BLACK);
}
let (l, d) = (Tokens::light(), Tokens::dark());
assert_eq!((l.bend_up, l.bend_down), (Color32::from_rgb(30, 140, 50), Color32::from_rgb(200, 90, 20)));
+35
View File
@@ -396,6 +396,35 @@ fn groups(t: &Tokens) -> Vec<Group> {
out.push(group(name, &[(key, fill)], &[("panel", t.panel)]));
}
out.push(group("dark pill text", &[("tab_on_fill_dark", t.tab_on_fill_dark)], &[("mesh_fill", t.mesh_fill)]));
// Miniature figures identify axes by labels and direction, reference points by
// position. Each ink must remain visible on its surfaces;
// the hues are not the only way to distinguish these marks from one another.
for (key, ink) in [
("figure_line", t.figure_line),
("figure_accent", t.figure_accent),
("figure_reference", t.figure_reference),
("figure_axis_x", t.figure_axis_x),
("figure_axis_y", t.figure_axis_y),
("figure_axis_z", t.figure_axis_z),
("figure_ok", t.figure_ok),
("figure_warn", t.figure_warn),
] {
out.push(group(key, &[(key, ink)], &[("figure_ground", t.figure_ground), ("figure_card", t.figure_card)]));
}
// The static sketch-state illustration uses success and warm alert inks on
// the top face. Keep success apart from each alert; the two warm warning
// shades are not a colour-only legend for live solver states.
out.push(group(
"preferences sketch states",
&[("figure_ok", t.figure_ok), ("figure_warn", t.figure_warn)],
&[("figure_face_top", t.figure_face_top)],
));
out.push(group("preferences sketch note", &[("figure_ok", t.figure_ok), ("pref_note", t.pref_note)], &[("figure_face_top", t.figure_face_top)]));
// Badge words identify the status; check the ink against its own fill, as for
// tab text above, rather than treating an ink and its tint as competing states.
out.push(group("preferences note", &[("pref_note", t.pref_note)], &[("pref_note_fill", t.pref_note_fill)]));
out.push(group("preferences success", &[("pref_ok", t.pref_ok)], &[("pref_ok_fill", t.pref_ok_fill)]));
out.push(group("preferences accent ink", &[("pref_on_accent", t.pref_on_accent)], &[("form_primary", t.form_primary)]));
// The removed part of a cut preview is translucent over the body.
if let Some(sel) = out.iter_mut().find(|g| g.name == "selection") {
sel.members.push(("preview_cut", over(t.preview_cut, t.body)));
@@ -409,6 +438,12 @@ const SHADED: [&str; 2] = ["analysis.draft", "analysis.access"];
/// Tokens no colour vision mode needs to keep apart, and why. A new token fails
/// `every_token_is_measured_or_carries_no_meaning` until it is in a group above or here.
const NO_MEANING: &[(&str, &str)] = &[
("figure_face_left", "neutral cube shading; face geometry identifies the orientation"),
("figure_face_right", "neutral cube shading; face geometry identifies the orientation"),
("figure_white", "neutral axis label and highlight ink; letters identify the axes"),
("figure_black", "neutral hatching and shadows; pattern and geometry carry the meaning"),
("pref_well", "neutral well behind a labelled control or illustration"),
("pref_disabled", "dimmed control ink; label and disabled interaction identify availability"),
("nav_hover", "neutral hover surface; placement and pointer feedback identify the control"),
("nav_press", "neutral pressed/open surface; interaction and menu presence identify the state"),
("nav_dim", "neutral secondary ink; the Later label identifies the unavailable option"),
+44
View File
@@ -1187,6 +1187,50 @@ pub struct Tokens {
pub dialog_label: Color32,
pub selected_ink: Color32,
pub selected_bg: Color32,
/// Ground under every Preferences figure.
pub figure_ground: Color32,
/// Outlines and reference lines of Preferences figures.
pub figure_line: Color32,
/// Top face of the Preferences figure cube.
pub figure_face_top: Color32,
/// Left face of the Preferences figure cube.
pub figure_face_left: Color32,
/// Right face of the Preferences figure cube.
pub figure_face_right: Color32,
/// Paper, panels and pads drawn inside Preferences figures.
pub figure_card: Color32,
/// Fixed white label and highlight ink inside Preferences illustrations.
pub figure_white: Color32,
/// Fixed black hatch and shadow ink inside Preferences illustrations.
pub figure_black: Color32,
/// What a Preferences figure or selected card acts on.
pub figure_accent: Color32,
/// Reference point in a Preferences figure: pivot, origin, pointer.
pub figure_reference: Color32,
/// X axis in a Preferences figure.
pub figure_axis_x: Color32,
/// Y axis in a Preferences figure.
pub figure_axis_y: Color32,
/// Z axis in a Preferences figure.
pub figure_axis_z: Color32,
/// Done or fully constrained mark in a Preferences figure.
pub figure_ok: Color32,
/// Warning mark in a Preferences figure.
pub figure_warn: Color32,
/// Segmented control well and tint behind figures in Preferences.
pub pref_well: Color32,
/// Warning badge text and stroke in Preferences.
pub pref_note: Color32,
/// Warning badge fill in Preferences.
pub pref_note_fill: Color32,
/// Ink on a Preferences accent fill.
pub pref_on_accent: Color32,
/// Success badge text in Preferences.
pub pref_ok: Color32,
/// Success badge fill in Preferences.
pub pref_ok_fill: Color32,
/// Ink of a dimmed Preferences control.
pub pref_disabled: Color32,
/// Timeline items: normal and failed.
pub timeline_item: Color32,
pub timeline_item_error: Color32,
+1
View File
@@ -470,6 +470,7 @@ macro_rules! theme_keys {
form_card_width: _, form_icon_size: _, form_close_size: _, form_inset: _, form_icon_inset: _, form_section_icon_size: _,
form_checkbox_height: _, form_button_height: _, sketch_palette_width: _, sketch_mode_size: _, sketch_mode_radius: _, sketch_mode_border: _,
sketch_palette_alpha: _, sketch_mode_tint: _, sketch_footer_icon_size: _, font_section_tracking: _,
figure_ground: _, figure_line: _, figure_face_top: _, figure_face_left: _, figure_face_right: _, figure_card: _, figure_white: _, figure_black: _, figure_accent: _, figure_reference: _, figure_axis_x: _, figure_axis_y: _, figure_axis_z: _, figure_ok: _, figure_warn: _, pref_well: _, pref_note: _, pref_note_fill: _, pref_on_accent: _, pref_ok: _, pref_ok_fill: _, pref_disabled: _,
} = t;
let IconColors { $($ione: _,)* } = &t.icons;
}
+347
View File
@@ -1,4 +1,5 @@
//! Table-driven layout/paint references in both themes. No window or GPU required.
use super::preferences_figures::{ThemeSample, paint_theme_system, paint_theme_thumbnail};
use super::*;
use crate::ui_kit::{Container, Surface};
use crate::{scenario, theme::Tokens};
@@ -530,6 +531,177 @@ const ELEMENTS: &[Element] = &[
},
);
}),
element!("picture_card", |ui, _| {
use super::preferences_figure_data as figures;
let gap = ui.spacing().item_spacing.x;
let width = card_width(ui.available_width(), 4, gap);
ui.horizontal(|ui| {
picture_card(
ui,
&Card {
label: "Coarse",
sub: Some("fastest"),
selected: false,
enabled: true,
options: None,
check: false,
aspect: 120.0 / 64.0,
figure: &figures::paint_tess_coarse,
},
width,
);
picture_card(
ui,
&Card {
label: "Normal",
sub: Some("default"),
selected: true,
enabled: true,
options: None,
check: true,
aspect: 120.0 / 64.0,
figure: &figures::paint_tess_normal,
},
width,
);
picture_card(
ui,
&Card {
label: "Fine",
sub: Some("smoother"),
selected: false,
enabled: true,
options: Some("Has options"),
check: false,
aspect: 120.0 / 64.0,
figure: &figures::paint_tess_fine,
},
width,
);
picture_card(
ui,
&Card {
label: "8×",
sub: Some("not on this GPU"),
selected: false,
enabled: false,
options: None,
check: false,
aspect: 120.0 / 64.0,
figure: &figures::paint_aa_8x,
},
width,
);
});
}),
element!("toggle_tile", |ui, _| {
use super::preferences_figure_data as figures;
let gap = ui.spacing().item_spacing.x;
let width = card_width(ui.available_width(), 3, gap);
ui.horizontal(|ui| {
toggle_tile(
ui,
&Card {
label: "Grid",
sub: None,
selected: true,
enabled: true,
options: None,
check: false,
aspect: 120.0 / 64.0,
figure: &figures::paint_view_grid,
},
width,
);
toggle_tile(
ui,
&Card {
label: "Ground shadow",
sub: None,
selected: false,
enabled: true,
options: None,
check: false,
aspect: 120.0 / 64.0,
figure: &figures::paint_view_shadow,
},
width,
);
toggle_tile(
ui,
&Card {
label: "Perspective",
sub: None,
selected: false,
enabled: false,
options: None,
check: false,
aspect: 120.0 / 64.0,
figure: &figures::paint_view_perspective,
},
width,
);
});
}),
element!("preset_card", |ui, _| {
let icon = |p: &egui::Painter, r: egui::Rect, ink: egui::Color32| crate::icons::paint_ink(p, r, "settings", ink);
let width = ui.available_width();
for (name, word, selected, enabled) in
[("Performance", "Fastest", false, true), ("Quality", "Sharpest", true, true), ("Custom", "Your own mix", false, false)]
{
preset_card(ui, name, word, selected, enabled, &icon, width);
}
}),
element!("help_icon", |ui, _| {
ui.horizontal(|ui| {
text(ui, "Anti-aliasing", Text::Hint("gallery_help"));
help_icon(ui, "Applies when SolidCraft restarts.");
});
}),
element!("carousel", |ui, _| {
let sample = ThemeSample::from_tokens(&Tokens::get());
let width = Tokens::get().space_24 * 2.0 + Tokens::get().space_24;
carousel(ui, "gallery_carousel", 6, width, width * 0.8, |ui, index| {
let label = ["Light", "Dark", "System", "Gruvbox", "Studio", "Mono"][index % 6];
picture_card(
ui,
&Card {
label,
sub: Some("Built in"),
selected: index == 1,
enabled: true,
options: None,
check: false,
aspect: 124.0 / 76.0,
figure: &|p, r| paint_theme_thumbnail(p, r, &sample),
},
width,
);
});
}),
element!("preference_theme_thumbnails", |ui, _| {
let (light, dark) = (ThemeSample::from_tokens(&Tokens::light()), ThemeSample::from_tokens(&Tokens::dark()));
ui.horizontal(|ui| {
for kind in 0..3 {
let (rect, _) = ui.allocate_exact_size(vec2(88.0, 88.0 * 76.0 / 124.0), egui::Sense::hover());
match kind {
0 => paint_theme_thumbnail(ui.painter(), rect, &light),
1 => paint_theme_thumbnail(ui.painter(), rect, &dark),
_ => paint_theme_system(ui.painter(), rect, &light, &dark),
}
}
});
}),
element!("preference_figures", |ui, _| {
ui.horizontal_wrapped(|ui| {
for (name, size, paint) in super::preferences_figure_data::FIGURES {
let width = 96.0;
let (rect, response) = ui.allocate_exact_size(vec2(width, width * size[1] / size[0]), egui::Sense::hover());
paint(ui.painter(), rect);
response.on_hover_text(*name);
}
});
}),
element!("preference_option_cards", |ui, _| {
ui.horizontal_wrapped(|ui| {
for figure in [
@@ -1956,6 +2128,7 @@ fn gallery_covers_every_inline_element() {
"look_chain.rs",
"menu.rs",
"metrics.rs",
"preferences_cards.rs",
"radial.rs",
"reports.rs",
"sphere.rs",
@@ -2567,3 +2740,177 @@ fn tree_icon_actions_keep_repeated_light_rows_independent() {
});
assert_eq!(ids.len(), 3);
}
#[test]
fn card_looks_follow_selected_hover_disabled_and_focus() {
use super::preferences_cards::{CardFill, CardInk, card_look};
let rest = card_look(false, true, false, false);
assert_eq!((rest.border, rest.fill, rest.ink, rest.bold, rest.alpha, rest.focus_ring), (1.0, CardFill::Face, CardInk::Text, false, 1.0, false));
let hover = card_look(false, true, true, false);
assert_eq!(hover.fill, CardFill::Hover);
let selected = card_look(true, true, true, false);
assert_eq!((selected.border, selected.fill, selected.ink, selected.bold), (2.0, CardFill::Selected, CardInk::Accent, true));
let disabled = card_look(false, false, true, true);
assert_eq!((disabled.fill, disabled.alpha, disabled.focus_ring), (CardFill::Face, 0.5, false), "a disabled card neither hovers nor shows focus");
assert!(card_look(false, true, false, true).focus_ring);
assert!(card_look(true, false, false, false).alpha < 1.0, "a selected card can be disabled too");
}
#[test]
fn cards_size_to_their_container_and_survive_hostile_sizes() {
use super::preferences_cards::{card_width, carousel_window};
assert_eq!(card_width(320.0, 4, 10.0), 72.5);
assert_eq!(card_width(320.0, 0, 10.0), 320.0);
for bad in [0.0, -5.0, f32::NAN, f32::INFINITY, f32::MAX] {
let w = card_width(bad, 3, 10.0);
assert!(w.is_finite() && w >= 0.0, "{bad}: {w}");
let (first, visible) = carousel_window(5, 9, bad, 88.0, 8.0, 26.0);
assert!(first + visible <= 5 && visible >= 1, "{bad}: {first} {visible}");
}
assert_eq!(carousel_window(0, 3, 400.0, 88.0, 8.0, 26.0), (0, 0));
// 400 wide: two arrows and their gaps leave 332, which fits three 88-wide items with gaps.
assert_eq!(carousel_window(6, 0, 400.0, 88.0, 8.0, 26.0), (0, 3));
assert_eq!(carousel_window(6, 5, 400.0, 88.0, 8.0, 26.0), (3, 3), "the offset stops at the last full window");
}
fn sample_card<'a>(label: &'a str, selected: bool, enabled: bool) -> super::preferences_cards::Card<'a> {
super::preferences_cards::Card {
label,
sub: Some("sub"),
selected,
enabled,
options: None,
check: selected,
aspect: 120.0 / 64.0,
figure: &super::preferences_figure_data::paint_tess_normal,
}
}
#[test]
fn picture_cards_and_tiles_click_only_when_enabled_and_never_panic() {
use super::preferences_cards::{picture_card, toggle_tile};
let ctx = crate::fonts::test_context();
for tile in [false, true] {
for enabled in [true, false] {
let mut clicked = false;
let mut at = None;
for pass in 0..3 {
let events = match (pass, at) {
(1, Some(p)) => super::test_support::pointer(p, true),
(2, Some(p)) => super::test_support::pointer(p, false),
_ => vec![],
};
super::test_support::frame(&ctx, events, |ui| {
let card = sample_card("Normal", false, enabled);
let response = if tile { toggle_tile(ui, &card, 120.0) } else { picture_card(ui, &card, 120.0) };
at = Some(response.rect.center());
assert!(response.rect.width() == 120.0 && response.rect.height() > 40.0);
clicked |= response.clicked();
});
}
assert_eq!(clicked, enabled, "tile {tile}, enabled {enabled}");
// Zero, negative and non-finite widths must not panic and must stay finite.
super::test_support::frame(&ctx, vec![], |ui| {
for width in [0.0, -3.0, f32::NAN, f32::INFINITY, 1.0e6] {
let response = if tile {
toggle_tile(ui, &sample_card("x", true, enabled), width)
} else {
picture_card(ui, &sample_card("x", true, enabled), width)
};
assert!(response.rect.is_finite());
}
});
}
}
}
#[test]
fn preset_card_help_icon_and_carousel_behave() {
use super::preferences_cards::{carousel, help_icon, preset_card};
let ctx = crate::fonts::test_context();
let icon = |p: &egui::Painter, r: egui::Rect, ink: egui::Color32| crate::icons::paint_ink(p, r, "settings", ink);
super::test_support::frame(&ctx, vec![], |ui| {
for width in [0.0, 10.0, 204.0, f32::NAN] {
let response = preset_card(ui, "Performance", "Fastest", true, true, &icon, width);
assert!(response.rect.is_finite());
}
let help = help_icon(ui, "Help text");
assert!(help.rect.width() > 8.0 && help.rect.width() < 20.0);
});
// The next arrow moves the window by one and stays inert at the end.
let mut first = vec![];
let mut at = None;
for pass in 0..6 {
let events = match (pass, at) {
(2, Some(p)) => super::test_support::pointer(p, true),
(3, Some(p)) => super::test_support::pointer(p, false),
_ => vec![],
};
super::test_support::frame(&ctx, events, |ui| {
let (f, visible) = carousel(ui, "carousel_test", 5, 60.0, 40.0, |ui, _| {
ui.allocate_exact_size(egui::vec2(60.0, 40.0), egui::Sense::hover());
});
first.push((f, visible));
if let Some(rect) = scenario::rect_of("prefs:carousel:Next") {
at = Some(rect.center());
}
});
}
let (f0, visible) = first[0];
assert_eq!(f0, 0);
assert!((1..5).contains(&visible), "{visible}");
assert!(first.last().unwrap().0 >= 1, "the next arrow advanced the window: {first:?}");
}
/// Light and dark boards of the shared Preferences widgets and of every figure, rendered on the CPU.
/// Set `SOLIDCRAFT_PREFERENCE_WIDGET_EVIDENCE` to a directory to keep the PNGs.
#[test]
fn preference_widgets_and_figures_board_in_both_themes() {
use super::checks::tests::{raster, textures_update};
use std::collections::HashMap;
let previous = crate::theme::is_dark();
for dark in [false, true] {
crate::theme::set_dark(dark);
for (board, names) in [
("widgets", &["picture_card", "toggle_tile", "preset_card", "help_icon", "carousel", "preference_theme_thumbnails"][..]),
("figures", &["preference_figures"][..]),
] {
for scale in [1.0_f32, 2.0] {
let ctx = crate::fonts::test_context();
ctx.set_pixels_per_point(scale);
let bounds = egui::Rect::from_min_size(
egui::Pos2::ZERO,
vec2(if board == "figures" { 700.0 } else { 360.0 }, if board == "figures" { 920.0 } else { 800.0 }),
);
let mut textures = HashMap::new();
let mut image = None;
for _ in 0..3 {
let mut output = ctx.run_ui(egui::RawInput { screen_rect: Some(bounds), ..Default::default() }, |ui| {
ui.set_width(bounds.width() - 40.0);
ui.painter().rect_filled(bounds, 0.0, Tokens::get().dialog_body);
ui.add_space(12.0);
scope(ui, |ui| {
for name in names {
example(ui, ELEMENTS.iter().find(|e| e.name == *name).unwrap());
ui.add_space(Tokens::get().space_12);
}
});
});
textures_update(&mut textures, &output.textures_delta);
output.textures_delta.clear();
image = Some(raster(&ctx, output.shapes, output.pixels_per_point, bounds, &textures));
}
let image = image.unwrap();
assert!(image.pixels().any(|p| p.0[3] != 0));
if let Some(dir) = std::env::var_os("SOLIDCRAFT_PREFERENCE_WIDGET_EVIDENCE") {
std::fs::create_dir_all(&dir).unwrap();
let suffix = if scale > 1.0 { "@2x" } else { "" };
image
.save(std::path::Path::new(&dir).join(format!("preferences-{board}-{}{suffix}.png", if dark { "dark" } else { "light" })))
.unwrap();
}
}
}
}
crate::theme::set_dark(previous);
}
+8
View File
@@ -28,6 +28,12 @@ mod navigation_settings;
pub(crate) mod panel;
mod preferences;
mod preferences_assistant;
#[allow(dead_code, reason = "composed by the Preferences pages of #9875 to #9883")]
mod preferences_cards;
#[allow(dead_code, reason = "painted by the Preferences pages of #9875 to #9883")]
mod preferences_figure_data;
#[allow(dead_code, reason = "painted by the Preferences pages of #9875 to #9883")]
mod preferences_figure_ops;
mod preferences_figures;
mod preferences_layout;
mod preferences_navigation;
@@ -85,6 +91,8 @@ pub use panel::{DialogButtons, Window};
pub(crate) use panel::{collapsible_header, header};
pub(crate) use preferences::*;
pub(crate) use preferences_assistant::*;
#[allow(unused_imports, reason = "composed by the Preferences pages of #9875 to #9883")]
pub(crate) use preferences_cards::*;
pub(crate) use preferences_navigation::*;
pub(crate) use preferences_small::*;
pub(crate) use radial::{RADIAL_RADIUS, radial_menu};
+1 -1
View File
@@ -729,7 +729,7 @@ pub(crate) fn option_card(ui: &mut Ui, label: &str, note: Option<&str>, selected
if enabled { response.on_hover_cursor(egui::CursorIcon::PointingHand) } else { response }
}
fn centred_text(p: &egui::Painter, rect: Rect, label: &str, font: FontId, ink: Color32) {
pub(super) fn centred_text(p: &egui::Painter, rect: Rect, label: &str, font: FontId, ink: Color32) {
centred_text_rows(p, rect, label, font, ink, 1);
}
@@ -0,0 +1,319 @@
//! The controls the redesigned Preferences pages share: picture cards, toggle tiles, preset cards,
//! the hover-help icon and the carousel. Sizes follow `docs/design/preferences/measurements.md`;
//! every colour comes from `Tokens`. A card sizes to the width its container gives it and its
//! figure keeps the figure's aspect ratio.
use super::{metrics::dimension, preferences::centred_text};
use crate::{scenario, theme::Tokens};
use egui::{Color32, FontId, Painter, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
/// How a card or tile looks in a state. Kept separate from painting so the state rules can be tested.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct CardLook {
/// Border width in points: 2 when selected, else 1.
pub border: f32,
pub fill: CardFill,
pub ink: CardInk,
pub bold: bool,
/// Multiplier on every colour; a disabled card is dimmed.
pub alpha: f32,
/// A keyboard focus ring outside the border.
pub focus_ring: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum CardFill {
Face,
Hover,
Selected,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum CardInk {
Text,
Accent,
}
pub(super) fn card_look(selected: bool, enabled: bool, hovered: bool, focused: bool) -> CardLook {
CardLook {
border: if selected { 2.0 } else { 1.0 },
fill: if selected {
CardFill::Selected
} else if enabled && hovered {
CardFill::Hover
} else {
CardFill::Face
},
ink: if selected { CardInk::Accent } else { CardInk::Text },
bold: selected,
alpha: if enabled { 1.0 } else { 0.5 },
focus_ring: enabled && focused,
}
}
/// What a picture card or toggle tile shows.
pub(crate) struct Card<'a> {
pub label: &'a str,
/// One short line under the label ("default", "Built in").
pub sub: Option<&'a str>,
/// Selected (radio) or on (tile).
pub selected: bool,
pub enabled: bool,
/// The small "has options" disc at the top right.
pub options: Option<&'a str>,
/// An accent check at the top right (picture cards only).
pub check: bool,
/// Width over height of the figure.
pub aspect: f32,
pub figure: &'a dyn Fn(&Painter, Rect),
}
/// The width of one of `columns` cards in `available` points with `gap` between them.
pub(super) fn card_width(available: f32, columns: usize, gap: f32) -> f32 {
let columns = columns.max(1) as f32;
let available = dimension(available);
((available - gap.max(0.0) * (columns - 1.0)) / columns).max(0.0)
}
fn card_height(t: &Tokens, width: f32, card: &Card) -> f32 {
let pad = t.space_6;
let aspect = if card.aspect.is_finite() && card.aspect > 0.01 { card.aspect } else { 1.0 };
let figure = ((width - 2.0 * pad).max(0.0) / aspect).min(dimension(f32::MAX));
let sub = if card.sub.is_some() { t.space_14 } else { 0.0 };
pad + figure + gap(t) + t.space_16 + sub + pad + 1.0
}
fn gap(t: &Tokens) -> f32 {
(t.space_4 + t.space_6) * 0.5
}
fn paint_card(ui: &mut Ui, card: &Card, width: f32, tile: bool) -> Response {
let t = Tokens::get();
let width = dimension(width);
let enabled = card.enabled && ui.is_enabled();
let (rect, response) = ui.allocate_exact_size(vec2(width, card_height(&t, width, card)), if enabled { Sense::click() } else { Sense::hover() });
let look = card_look(card.selected, enabled, response.hovered(), response.has_focus());
let p = ui.painter();
let dim = |c: Color32| c.gamma_multiply(look.alpha);
let fill = match look.fill {
CardFill::Selected => t.selected_bg,
CardFill::Hover => t.hover,
CardFill::Face => t.control_fill,
};
let border = if card.selected { t.form_primary } else { t.dialog_border };
let radius = f32::from(t.radius_lg);
p.rect(rect, radius, dim(fill), Stroke::new(look.border, dim(border)), StrokeKind::Inside);
if look.focus_ring {
p.rect_stroke(rect.expand(t.space_2), radius + t.space_2, Stroke::new(t.space_2, t.form_primary), StrokeKind::Outside);
}
let inset = t.space_6;
let figure = Rect::from_min_size(
rect.min + vec2(inset, inset),
vec2((rect.width() - 2.0 * inset).max(0.0), ((rect.width() - 2.0 * inset).max(0.0) / card.aspect.max(0.01)).min(rect.height())),
);
if figure.is_positive() {
let figure_painter = p.with_clip_rect(figure.intersect(p.clip_rect()));
(card.figure)(&figure_painter, figure);
if !enabled {
p.rect_filled(figure, 0.0, t.control_fill.gamma_multiply(0.5));
}
}
let ink = match look.ink {
CardInk::Accent => t.selected_ink,
CardInk::Text => t.form_ink,
};
let font = if look.bold { crate::fonts::semibold(t.font_label) } else { FontId::proportional(t.font_label) };
let label_rect =
Rect::from_min_size(pos2(rect.left() + t.space_2, figure.bottom() + gap(&t)), vec2((rect.width() - 2.0 * t.space_2).max(0.0), t.space_16));
centred_text(p, label_rect, card.label, font, dim(ink));
if let Some(sub) = card.sub {
let sub_rect = Rect::from_min_size(pos2(label_rect.left(), label_rect.bottom()), vec2(label_rect.width(), t.space_14));
centred_text(p, sub_rect, sub, FontId::proportional(t.font_small), dim(t.form_dim));
}
let corner = pos2(rect.right() - t.space_6, rect.top() + t.space_6);
if tile {
// The checkbox in the corner: 15 points, radius 4, accent with a tick when on.
let size = t.space_14 + 1.0;
let box_rect = Rect::from_min_size(corner - vec2(size, 0.0), vec2(size, size));
if card.selected {
p.rect_filled(box_rect, t.space_4, dim(t.form_primary));
tick(p, box_rect, dim(t.on_accent));
} else {
p.rect(box_rect, t.space_4, dim(t.control_fill), Stroke::new(1.0, dim(t.control_border)), StrokeKind::Inside);
}
} else if card.options.is_some() || card.check {
let size = t.space_16;
let centre = pos2(rect.right() - t.space_6 + 1.0 - size * 0.5, rect.top() + t.space_6 - 1.0 + size * 0.5);
p.circle_filled(centre, size * 0.5, dim(t.form_primary));
if card.check {
tick(p, Rect::from_center_size(centre, vec2(size, size) * 0.75), dim(t.on_accent));
} else {
let dot = Rect::from_center_size(centre, vec2(size, size) * 0.6);
crate::icons::paint_ink(p, dot, "settings", dim(t.on_accent));
}
}
let kind = if tile { egui::WidgetType::Checkbox } else { egui::WidgetType::RadioButton };
response.widget_info(|| egui::WidgetInfo::selected(kind, enabled, card.selected, card.label));
scenario::publish_target(&format!("prefs:card:{}", card.label), rect);
let response = if let Some(options) = card.options { response.on_hover_text(options) } else { response };
if enabled { response.on_hover_cursor(egui::CursorIcon::PointingHand) } else { response }
}
fn tick(p: &Painter, r: Rect, ink: Color32) {
let w = (r.width() * 0.16).max(1.0);
let a = pos2(r.left() + r.width() * 0.24, r.top() + r.height() * 0.52);
let b = pos2(r.left() + r.width() * 0.42, r.top() + r.height() * 0.70);
let c = pos2(r.left() + r.width() * 0.78, r.top() + r.height() * 0.32);
p.line_segment([a, b], Stroke::new(w, ink));
p.line_segment([b, c], Stroke::new(w, ink));
}
/// A radio card: a figure, a label and an optional sub-label. Click it to choose it.
pub(crate) fn picture_card(ui: &mut Ui, card: &Card, width: f32) -> Response {
paint_card(ui, card, width, false)
}
/// A toggle tile: the same picture with a checkbox in the corner. Click it to flip `card.selected`.
pub(crate) fn toggle_tile(ui: &mut Ui, card: &Card, width: f32) -> Response {
paint_card(ui, card, width, true)
}
/// A preset card: an icon, a name, one word and a check. `icon` paints into the 38-point box in
/// the colour it is given.
pub(crate) fn preset_card(
ui: &mut Ui,
name: &str,
word: &str,
selected: bool,
enabled: bool,
icon: &dyn Fn(&Painter, Rect, Color32),
width: f32,
) -> Response {
let t = Tokens::get();
let enabled = enabled && ui.is_enabled();
let width = dimension(width);
let height = t.space_24 * 3.0 - t.space_2;
let (rect, response) = ui.allocate_exact_size(vec2(width, height), if enabled { Sense::click() } else { Sense::hover() });
let look = card_look(selected, enabled, response.hovered(), response.has_focus());
let dim = |c: Color32| c.gamma_multiply(look.alpha);
let p = ui.painter();
let fill = match look.fill {
CardFill::Selected => t.selected_bg,
CardFill::Hover => t.hover,
CardFill::Face => t.control_fill,
};
let radius = f32::from(t.radius_lg) + t.space_2;
p.rect(rect, radius, dim(fill), Stroke::new(look.border, dim(if selected { t.form_primary } else { t.dialog_border })), StrokeKind::Inside);
if look.focus_ring {
p.rect_stroke(rect.expand(t.space_2), radius + t.space_2, Stroke::new(t.space_2, t.form_primary), StrokeKind::Outside);
}
let box_size = t.space_24 + t.space_14;
let box_rect = Rect::from_min_size(pos2(rect.left() + t.space_14, rect.center().y - box_size * 0.5), vec2(box_size, box_size));
let box_fill = if selected { t.form_primary } else { t.pref_well };
let box_ink = if selected { t.on_accent } else { t.form_dim };
p.rect_filled(box_rect, f32::from(t.radius_lg) + t.space_2, dim(box_fill));
icon(p, Rect::from_center_size(box_rect.center(), box_rect.size() * 0.55), dim(box_ink));
let text_left = box_rect.right() + t.space_12;
let text_width = (rect.right() - t.space_14 - t.space_24 - text_left).max(0.0);
let name_font = crate::fonts::semibold(t.font_heading.min(t.font_subtitle));
let name_ink = if selected { t.selected_ink } else { t.form_ink };
let name_rect = Rect::from_min_size(pos2(text_left, rect.center().y - t.space_16), vec2(text_width, t.space_16));
super::text::paint_text(ui, name_rect, name, name_font, dim(name_ink), false);
let word_rect = Rect::from_min_size(pos2(text_left, name_rect.bottom()), vec2(text_width, t.space_16));
super::text::paint_text(ui, word_rect, word, FontId::proportional(t.font_small), dim(t.form_dim), false);
let p = ui.painter();
if selected {
let centre = pos2(rect.right() - t.space_14 - t.space_8 + 1.0, rect.center().y);
p.circle_filled(centre, t.space_8 + 1.0, dim(t.form_primary));
tick(p, Rect::from_center_size(centre, vec2(t.space_12, t.space_12)), dim(t.on_accent));
}
response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, enabled, selected, name));
scenario::publish_target(&format!("prefs:preset:{name}"), rect);
if enabled { response.on_hover_cursor(egui::CursorIcon::PointingHand) } else { response }
}
/// The (i) icon after a label: shows `text` as a tooltip and tells assistive technology the same.
pub(crate) fn help_icon(ui: &mut Ui, text: &str) -> Response {
let t = Tokens::get();
let size = t.space_12 + 1.0;
let (rect, response) = ui.allocate_exact_size(vec2(size, size), Sense::hover());
let ink = if response.hovered() { t.form_ink } else { t.form_dim };
let p = ui.painter();
p.circle_stroke(rect.center(), size * 0.5 - 0.5, Stroke::new(1.0, ink));
p.circle_filled(pos2(rect.center().x, rect.top() + size * 0.3), 0.9, ink);
p.line_segment([pos2(rect.center().x, rect.top() + size * 0.46), pos2(rect.center().x, rect.bottom() - size * 0.24)], Stroke::new(1.2, ink));
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, ui.is_enabled(), text));
scenario::publish_target("prefs:help_icon", rect);
response.on_hover_text(text)
}
/// Which items of a carousel are shown: the first one and how many fit between the two arrows.
pub(super) fn carousel_window(count: usize, offset: usize, available: f32, item_width: f32, gap: f32, arrow: f32) -> (usize, usize) {
if count == 0 {
return (0, 0);
}
let room = dimension(available) - 2.0 * (arrow + gap);
let unit = dimension(item_width).max(1.0) + gap.max(0.0);
let visible = (((room + gap) / unit).floor() as usize).clamp(1, count);
(offset.min(count - visible), visible)
}
/// A row of items with a previous and a next arrow; an arrow is dimmed and inert at its end.
/// `item` paints the item at an index into a column `item_width` wide and `item_height` high.
pub(crate) fn carousel(
ui: &mut Ui,
id: impl std::hash::Hash + std::fmt::Debug,
count: usize,
item_width: f32,
item_height: f32,
mut item: impl FnMut(&mut Ui, usize),
) -> (usize, usize) {
let t = Tokens::get();
let key = ui.make_persistent_id(id);
let gap = t.space_8;
let arrow = t.space_24 + 2.0;
let offset = ui.data(|d| d.get_temp::<usize>(key)).unwrap_or(0);
let (first, visible) = carousel_window(count, offset, ui.available_width(), item_width, gap, arrow);
let height = dimension(item_height);
let mut next_offset = first;
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = gap;
let can_back = first > 0;
if carousel_arrow(ui, vec2(arrow, height), -1.0, can_back, crate::i18n::tr("prefs.carousel_previous")).clicked() {
next_offset = first.saturating_sub(1);
}
for index in first..first + visible {
ui.allocate_ui_with_layout(vec2(dimension(item_width), height), egui::Layout::top_down(egui::Align::Min), |ui| {
ui.set_width(dimension(item_width));
item(ui, index);
});
}
let can_forward = first + visible < count;
if carousel_arrow(ui, vec2(arrow, height), 1.0, can_forward, crate::i18n::tr("prefs.carousel_next")).clicked() {
next_offset = (first + 1).min(count.saturating_sub(visible));
}
});
ui.data_mut(|d| d.insert_temp(key, next_offset));
(first, visible)
}
fn carousel_arrow(ui: &mut Ui, size: egui::Vec2, direction: f32, enabled: bool, label: &str) -> Response {
let t = Tokens::get();
let (rect, response) = ui.allocate_exact_size(size, if enabled { Sense::click() } else { Sense::hover() });
let alpha = if enabled { 1.0 } else { 0.35 };
let p = ui.painter();
let fill = if enabled && response.hovered() { t.hover } else { t.control_fill };
p.rect(rect, f32::from(t.radius_lg), fill.gamma_multiply(alpha), Stroke::new(1.0, t.dialog_border.gamma_multiply(alpha)), StrokeKind::Inside);
let icon = Rect::from_center_size(rect.center(), vec2(t.space_16, t.space_16));
let ink = t.form_ink.gamma_multiply(alpha);
if direction < 0.0 {
crate::icons::paint_ink_mirrored(p, icon, "chevron_right", ink);
} else {
crate::icons::paint_ink(p, icon, "chevron_right", ink);
}
if enabled && response.has_focus() {
p.rect_stroke(rect.expand(t.space_2), f32::from(t.radius_lg) + t.space_2, Stroke::new(t.space_2, t.form_primary), StrokeKind::Outside);
}
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, label));
scenario::publish_target(&format!("prefs:carousel:{label}"), rect);
if enabled { response.on_hover_cursor(egui::CursorIcon::PointingHand) } else { response }
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,583 @@
//! The drawing engine behind the Preferences figures: a figure is a short list of operations in its
//! own coordinates (`preferences_figure_data.rs`, compiled from the SVGs in
//! `docs/design/preferences/figures/`), painted with egui shapes in the colours of the current
//! `Tokens`. Nothing is rasterised and no image is loaded; every figure costs a few dozen shapes.
//! Any rectangle, including an empty, infinite or enormous one, is safe to paint into.
use crate::theme::Tokens;
use egui::{Align2, Color32, CornerRadius, FontId, Mesh, Painter, Pos2, Rect, Shape, Stroke, StrokeKind, pos2, vec2};
/// Colour roles of the figures. Each reads one `Tokens` field (`docs/design/preferences/tokens.md`).
#[derive(Clone, Copy, Debug)]
pub(super) enum Ink {
Ground,
Line,
Top,
Left,
Right,
Card,
Accent,
Reference,
AxisX,
AxisY,
AxisZ,
Ok,
Warn,
Dim,
Text,
Border,
ControlBorder,
Control,
Note,
NoteFill,
White,
Black,
/// Colour vision sample colours: Z axis blue, projected, X axis, bend up and bend down.
Cv0,
Cv1,
Cv2,
Cv3,
Cv4,
/// A colour of a theme thumbnail, from its `ThemeSample`.
Slot(u8),
}
#[derive(Clone, Copy, Debug)]
pub(super) struct P {
pub ink: Ink,
pub a: f32,
}
#[derive(Clone, Copy, Debug)]
pub(super) struct L {
pub ink: Ink,
pub a: f32,
pub w: f32,
/// Dash and gap, `[0.0, 0.0]` for a solid line.
pub dash: [f32; 2],
pub round: bool,
}
/// A vertical gradient over the figure's `y` range.
#[derive(Clone, Copy, Debug)]
pub(super) struct G {
pub top: Ink,
pub bottom: Ink,
pub y: [f32; 2],
}
#[derive(Clone, Copy, Debug)]
pub(super) enum Op {
Rect {
r: [f32; 4],
radius: f32,
fill: Option<P>,
line: Option<L>,
},
Circle {
c: [f32; 3],
fill: Option<P>,
line: Option<L>,
},
Poly {
pts: &'static [[f32; 2]],
closed: bool,
fill: Option<P>,
grad: Option<G>,
line: Option<L>,
},
/// A soft black ellipse: opaque `a` at the centre, clear at the edge.
Glow {
c: [f32; 4],
a: f32,
},
Text {
at: [f32; 2],
size: f32,
anchor: u8,
ink: Ink,
a: f32,
s: &'static str,
bold: bool,
},
Clip(&'static [[f32; 2]]),
Unclip,
}
#[derive(Clone, Copy, Debug)]
pub(super) struct Figure {
pub size: [f32; 2],
pub ops: &'static [Op],
}
/// The twelve colours a theme thumbnail is drawn with: ground, bar and side panel, muted marks,
/// accent, the window face's top and bottom, grid, cube top, cube outline, cube left, cube right
/// and the thumbnail's border. A colour may carry alpha (a glass theme's panels).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ThemeSample {
pub slots: [Color32; 12],
}
impl ThemeSample {
/// A thumbnail of the theme whose tokens these are.
pub fn from_tokens(t: &Tokens) -> ThemeSample {
let mix = |a: Color32, b: Color32, f: f32| a.lerp_to_gamma(b, f);
ThemeSample {
slots: [
t.dialog_footer,
t.control_fill,
t.form_dim.gamma_multiply(0.7),
t.form_primary,
t.viewport_top,
t.viewport_bottom,
t.form_dim.gamma_multiply(0.1),
t.body,
t.form_dim,
mix(t.body, t.body_edge, 0.25),
mix(t.body, t.body_edge, 0.5),
t.dialog_border,
],
}
}
/// The same colours with every alpha scaled (a glass theme's fills).
pub fn glass(mut self, alpha: f32) -> ThemeSample {
for slot in &mut self.slots[1..2] {
*slot = slot.gamma_multiply(alpha);
}
self
}
}
/// Largest side, in points, a figure is painted at; anything larger is skipped, not drawn wrong.
const MAX_SIDE: f32 = 16_384.0;
const MAX_POINTS: usize = 512;
struct Ctx<'a> {
t: &'a Tokens,
sample: Option<&'a ThemeSample>,
origin: Pos2,
scale: f32,
}
impl Ctx<'_> {
fn ink(&self, ink: Ink, a: f32) -> Color32 {
let t = self.t;
let c = match ink {
Ink::Ground => t.figure_ground,
Ink::Line => t.figure_line,
Ink::Top => t.figure_face_top,
Ink::Left => t.figure_face_left,
Ink::Right => t.figure_face_right,
Ink::Card => t.figure_card,
Ink::Accent => t.figure_accent,
Ink::Reference => t.figure_reference,
Ink::AxisX => t.figure_axis_x,
Ink::AxisY => t.figure_axis_y,
Ink::AxisZ => t.figure_axis_z,
Ink::Ok => t.figure_ok,
Ink::Warn => t.figure_warn,
Ink::Dim => t.form_dim,
Ink::Text => t.form_ink,
Ink::Border => t.dialog_border,
Ink::ControlBorder => t.control_border,
Ink::Control => t.control_fill,
Ink::Note => t.pref_note,
Ink::NoteFill => t.pref_note_fill,
Ink::White => t.figure_white,
Ink::Black => t.figure_black,
Ink::Cv0 => t.axis_z,
Ink::Cv1 => t.sketch_projected,
Ink::Cv2 => t.axis_x,
Ink::Cv3 => t.bend_up,
Ink::Cv4 => t.bend_down,
Ink::Slot(n) => self.sample.and_then(|s| s.slots.get(n as usize).copied()).unwrap_or(Color32::TRANSPARENT),
};
if a >= 1.0 { c } else { c.gamma_multiply(a.clamp(0.0, 1.0)) }
}
fn at(&self, p: [f32; 2]) -> Pos2 {
self.origin + vec2(p[0] * self.scale, p[1] * self.scale)
}
fn stroke(&self, line: &L) -> Stroke {
Stroke::new((line.w * self.scale).clamp(0.0, 64.0), self.ink(line.ink, line.a))
}
}
/// Paints `figure` into `rect`, scaled to fit and centred, with `Tokens::get()` colours.
pub(super) fn paint(p: &Painter, rect: Rect, figure: &Figure, sample: Option<&ThemeSample>, clip: Option<&[[f32; 2]]>) {
let size = rect.size();
if !rect.min.is_finite() || !size.is_finite() || size.x < 1.0 || size.y < 1.0 || size.x > MAX_SIDE || size.y > MAX_SIDE {
return;
}
let [fw, fh] = figure.size;
if !(fw > 0.0 && fh > 0.0) {
return;
}
let scale = (size.x / fw).min(size.y / fh);
let fitted = Rect::from_center_size(rect.center(), vec2(fw * scale, fh * scale));
let t = Tokens::get();
let ctx = Ctx { t: &t, sample, origin: fitted.min, scale };
let p = p.with_clip_rect(fitted.intersect(p.clip_rect()));
let outer: Option<Vec<Pos2>> = clip.map(|c| c.iter().map(|&q| ctx.at(q)).collect());
// Nested clips are inside one another in every figure, so the innermost one is the clip.
let mut stack: Vec<Vec<Pos2>> = Vec::new();
for op in figure.ops {
match op {
Op::Clip(points) if outer.is_none() => stack.push(points.iter().map(|&q| ctx.at(q)).collect()),
Op::Unclip if outer.is_none() => {
stack.pop();
}
Op::Clip(_) | Op::Unclip => {}
_ => draw(&p, &ctx, op, outer.as_deref().or(stack.last().map(Vec::as_slice))),
}
}
}
fn draw(p: &Painter, ctx: &Ctx, op: &Op, clip: Option<&[Pos2]>) {
match op {
Op::Rect { r, radius, fill, line } => {
let rect = Rect::from_min_size(ctx.at([r[0], r[1]]), vec2(r[2] * ctx.scale, r[3] * ctx.scale));
if clip.is_some() {
let pts = rounded(rect, radius * ctx.scale);
poly(p, ctx, &pts, true, fill, None, line, clip);
return;
}
let corner = CornerRadius::same((radius * ctx.scale).clamp(0.0, 255.0).round() as u8);
if let Some(f) = fill {
p.rect_filled(rect, corner, ctx.ink(f.ink, f.a));
}
if let Some(l) = line {
p.rect_stroke(rect, corner, ctx.stroke(l), StrokeKind::Middle);
}
}
Op::Circle { c, fill, line } => {
let centre = ctx.at([c[0], c[1]]);
let radius = c[2] * ctx.scale;
if clip.is_some() {
let pts: Vec<Pos2> = (0..24)
.map(|i| {
let a = i as f32 * std::f32::consts::TAU / 24.0;
centre + vec2(a.cos(), a.sin()) * radius
})
.collect();
poly(p, ctx, &pts, true, fill, None, line, clip);
return;
}
if let Some(f) = fill {
p.circle_filled(centre, radius, ctx.ink(f.ink, f.a));
}
if let Some(l) = line {
p.circle_stroke(centre, radius, ctx.stroke(l));
}
}
Op::Poly { pts, closed, fill, grad, line } => {
let points: Vec<Pos2> = pts.iter().map(|&q| ctx.at(q)).collect();
poly(p, ctx, &points, *closed, fill, grad.as_ref(), line, clip);
}
Op::Glow { c, a } => {
let centre = ctx.at([c[0], c[1]]);
let radius = vec2(c[2] * ctx.scale, c[3] * ctx.scale);
glow(p, centre, radius, ctx.ink(Ink::Black, *a));
}
Op::Text { at, size, anchor, ink, a, s, .. } => {
if clip.is_some() {
return;
}
let align = match anchor {
1 => Align2::CENTER_CENTER,
2 => Align2::RIGHT_CENTER,
_ => Align2::LEFT_CENTER,
};
// The board's captions are 6.5 to 7.5 units: too small to read at card size, so they
// are drawn a third larger (never under 8 points). The SVG's `y` is the baseline, which
// sits about 0.36 em below the middle of a line of digits and capitals.
// Longer captions sit in tight pills and on cube faces, so they are drawn slightly smaller than the board (egui's monospace face is wider than the board's).
let grow = match s.chars().count() {
0..=4 => 1.2,
5..=6 => 0.9,
_ => 0.76,
};
let size = (size * ctx.scale * grow).clamp(7.0, 256.0);
let at = ctx.at(*at) - vec2(0.0, size * 0.36);
p.text(at, align, s, FontId::monospace(size), ctx.ink(*ink, *a));
}
Op::Clip(_) | Op::Unclip => {}
}
}
#[allow(clippy::too_many_arguments)]
fn poly(p: &Painter, ctx: &Ctx, pts: &[Pos2], closed: bool, fill: &Option<P>, grad: Option<&G>, line: &Option<L>, clip: Option<&[Pos2]>) {
if pts.len() < 2 || pts.len() > MAX_POINTS || pts.iter().any(|q| !q.is_finite()) {
return;
}
let clipped;
let (shape, fillable) = match clip {
Some(c) if pts.len() >= 3 && closed => {
clipped = clip_polygon(pts, c);
(&clipped[..], clipped.len() >= 3)
}
_ => (pts, pts.len() >= 3 && closed),
};
if fillable {
if let Some(g) = grad {
let (top, bottom) = (ctx.ink(g.top, 1.0), ctx.ink(g.bottom, 1.0));
let (y0, y1) = (ctx.at([0.0, g.y[0]]).y, ctx.at([0.0, g.y[1]]).y);
let span = (y1 - y0).max(1e-3);
fill_polygon(p, shape, |q| top.lerp_to_gamma(bottom, ((q.y - y0) / span).clamp(0.0, 1.0)));
} else if let Some(f) = fill {
let c = ctx.ink(f.ink, f.a);
fill_polygon(p, shape, |_| c);
}
}
if let Some(l) = line {
let stroke = ctx.stroke(l);
if stroke.width <= 0.0 || stroke.color == Color32::TRANSPARENT {
return;
}
let mut path = pts.to_vec();
if closed {
path.push(pts[0]);
}
let runs = match clip {
Some(c) => clip_path(&path, c),
None => vec![path],
};
for run in runs {
if l.dash[0] > 0.0 && l.dash[1] > 0.0 {
let dash = (l.dash[0] * ctx.scale).max(0.5);
let gap = (l.dash[1] * ctx.scale).max(0.5);
p.extend(crate::viewport::dashed_line(&run, p.clip_rect(), stroke, dash, gap));
} else {
p.add(Shape::line(run, stroke));
}
}
}
}
fn rounded(rect: Rect, radius: f32) -> Vec<Pos2> {
let r = radius.min(rect.width() * 0.5).min(rect.height() * 0.5).max(0.0);
if r < 0.01 {
return vec![rect.left_top(), rect.right_top(), rect.right_bottom(), rect.left_bottom()];
}
let mut out = Vec::with_capacity(28);
for (centre, start) in [
(rect.right_top() + vec2(-r, r), -90.0_f32),
(rect.right_bottom() + vec2(-r, -r), 0.0),
(rect.left_bottom() + vec2(r, -r), 90.0),
(rect.left_top() + vec2(r, r), 180.0),
] {
for k in 0..=6 {
let a = (start + 90.0 * k as f32 / 6.0).to_radians();
out.push(centre + vec2(a.cos(), a.sin()) * r);
}
}
out
}
fn cross(o: Pos2, a: Pos2, b: Pos2) -> f32 {
(a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x)
}
fn is_convex(pts: &[Pos2]) -> bool {
let n = pts.len();
let mut sign = 0.0_f32;
for i in 0..n {
let c = cross(pts[i], pts[(i + 1) % n], pts[(i + 2) % n]);
if c.abs() < 1e-4 {
continue;
}
if sign == 0.0 {
sign = c.signum();
} else if c.signum() != sign {
return false;
}
}
true
}
/// Fills a simple polygon with `colour_at` per vertex: as a convex polygon when it is one, else by
/// ear clipping (at most `MAX_POINTS` vertices).
fn fill_polygon(p: &Painter, pts: &[Pos2], colour_at: impl Fn(Pos2) -> Color32) {
let n = pts.len();
if !(3..=MAX_POINTS).contains(&n) {
return;
}
let mut mesh = Mesh::default();
for &q in pts {
mesh.colored_vertex(q, colour_at(q));
}
if is_convex(pts) {
for i in 1..n as u32 - 1 {
mesh.add_triangle(0, i, i + 1);
}
} else {
let area: f32 = (0..n).map(|i| pts[i].x * pts[(i + 1) % n].y - pts[(i + 1) % n].x * pts[i].y).sum();
let mut idx: Vec<usize> = if area >= 0.0 { (0..n).collect() } else { (0..n).rev().collect() };
let mut guard = 0;
while idx.len() > 3 && guard < n * n {
guard += 1;
let m = idx.len();
let mut cut = None;
for k in 0..m {
let (a, b, c) = (idx[(k + m - 1) % m], idx[k], idx[(k + 1) % m]);
if cross(pts[a], pts[b], pts[c]) <= 0.0 {
continue;
}
let inside = idx.iter().any(|&o| {
o != a
&& o != b
&& o != c
&& cross(pts[a], pts[b], pts[o]) >= 0.0
&& cross(pts[b], pts[c], pts[o]) >= 0.0
&& cross(pts[c], pts[a], pts[o]) >= 0.0
});
if !inside {
cut = Some(k);
break;
}
}
let Some(k) = cut else { break };
let (a, b, c) = (idx[(k + m - 1) % m], idx[k], idx[(k + 1) % m]);
mesh.add_triangle(a as u32, b as u32, c as u32);
idx.remove(k);
}
if idx.len() == 3 {
mesh.add_triangle(idx[0] as u32, idx[1] as u32, idx[2] as u32);
}
}
p.add(Shape::mesh(mesh));
}
fn glow(p: &Painter, centre: Pos2, radius: egui::Vec2, ink: Color32) {
const SEGMENTS: u32 = 32;
if !(radius.x > 0.0 && radius.y > 0.0) {
return;
}
let mut mesh = Mesh::default();
mesh.colored_vertex(centre, ink);
for i in 0..=SEGMENTS {
let a = std::f32::consts::TAU * i as f32 / SEGMENTS as f32;
mesh.colored_vertex(centre + vec2(a.cos() * radius.x, a.sin() * radius.y), Color32::TRANSPARENT);
}
for i in 1..=SEGMENTS {
mesh.add_triangle(0, i, i + 1);
}
p.add(Shape::mesh(mesh));
}
/// Sutherland-Hodgman clip of a polygon against a convex clip polygon of either winding.
fn clip_polygon(subject: &[Pos2], clip: &[Pos2]) -> Vec<Pos2> {
let n = clip.len();
if n < 3 {
return Vec::new();
}
let area: f32 = (0..n).map(|i| clip[i].x * clip[(i + 1) % n].y - clip[(i + 1) % n].x * clip[i].y).sum();
let side = if area >= 0.0 { 1.0 } else { -1.0 };
let mut out = subject.to_vec();
for i in 0..n {
let (a, b) = (clip[i], clip[(i + 1) % n]);
let inside = |q: Pos2| side * cross(a, b, q) >= 0.0;
let input = std::mem::take(&mut out);
if input.is_empty() {
break;
}
for j in 0..input.len() {
let (cur, prev) = (input[j], input[(j + input.len() - 1) % input.len()]);
if inside(cur) {
if !inside(prev) {
out.push(intersect(prev, cur, a, b));
}
out.push(cur);
} else if inside(prev) {
out.push(intersect(prev, cur, a, b));
}
}
}
out
}
fn intersect(p1: Pos2, p2: Pos2, a: Pos2, b: Pos2) -> Pos2 {
let (d1, d2) = (p2 - p1, b - a);
let den = d1.x * d2.y - d1.y * d2.x;
if den.abs() < 1e-9 {
return p1;
}
let t = ((a.x - p1.x) * d2.y - (a.y - p1.y) * d2.x) / den;
pos2(p1.x + d1.x * t, p1.y + d1.y * t)
}
/// The parts of a polyline inside a convex clip polygon (Cyrus-Beck per segment).
fn clip_path(path: &[Pos2], clip: &[Pos2]) -> Vec<Vec<Pos2>> {
let n = clip.len();
if n < 3 {
return Vec::new();
}
let area: f32 = (0..n).map(|i| clip[i].x * clip[(i + 1) % n].y - clip[(i + 1) % n].x * clip[i].y).sum();
let side = if area >= 0.0 { 1.0 } else { -1.0 };
let mut runs: Vec<Vec<Pos2>> = Vec::new();
for w in path.windows(2) {
let (mut t0, mut t1) = (0.0_f32, 1.0_f32);
let d = w[1] - w[0];
let mut visible = true;
for i in 0..n {
let (a, b) = (clip[i], clip[(i + 1) % n]);
let num = side * cross(a, b, w[0]);
// The signed distance of the point at t from this edge is num + t * den.
let den = side * cross(a, b, w[1]) - num;
if den.abs() < 1e-9 {
if num < 0.0 {
visible = false;
break;
}
} else {
let t = -num / den;
if den > 0.0 {
t0 = t0.max(t);
} else {
t1 = t1.min(t);
}
}
}
if visible && t0 < t1 {
let (a, b) = (w[0] + d * t0, w[0] + d * t1);
match runs.last_mut() {
Some(run) if run.last().is_some_and(|l| l.distance(a) < 1e-3) => run.push(b),
_ => runs.push(vec![a, b]),
}
}
}
runs
}
#[cfg(test)]
mod tests {
use super::*;
fn square() -> Vec<Pos2> {
vec![pos2(0.0, 0.0), pos2(10.0, 0.0), pos2(10.0, 10.0), pos2(0.0, 10.0)]
}
#[test]
fn clipping_a_square_to_a_triangle_keeps_the_overlap() {
let tri = [pos2(0.0, 0.0), pos2(10.0, 0.0), pos2(0.0, 10.0)];
let out = clip_polygon(&square(), &tri);
let n = out.len();
let area: f32 = (0..n).map(|i| out[i].x * out[(i + 1) % n].y - out[(i + 1) % n].x * out[i].y).sum::<f32>().abs() / 2.0;
assert!((area - 50.0).abs() < 1e-3, "{area}");
}
#[test]
fn a_path_is_cut_at_the_clip_edge() {
let tri = [pos2(0.0, 0.0), pos2(10.0, 0.0), pos2(0.0, 10.0)];
let runs = clip_path(&[pos2(-5.0, 2.0), pos2(15.0, 2.0)], &tri);
assert_eq!(runs.len(), 1);
assert!((runs[0][0].x - 0.0).abs() < 1e-3 && (runs[0][1].x - 8.0).abs() < 1e-3, "{:?}", runs);
}
#[test]
fn concave_polygons_are_detected() {
assert!(is_convex(&square()));
assert!(!is_convex(&[pos2(0.0, 0.0), pos2(10.0, 0.0), pos2(5.0, 2.0), pos2(10.0, 10.0), pos2(0.0, 10.0)]));
}
}
@@ -260,6 +260,26 @@ pub(crate) fn paint_layout(ui: &mut egui::Ui, state: super::preferences::LayoutP
super::text(ui, crate::i18n::tr("prefs.preview_choices"), super::Text::Note("preview"));
}
#[allow(unused_imports, reason = "painted by the Preferences pages of #9875 to #9883")]
pub(crate) use super::preferences_figure_ops::ThemeSample;
use super::preferences_figure_ops::paint;
#[allow(dead_code, reason = "painted by the Preferences pages of #9875 to #9883")]
/// A theme card's miniature application window, drawn with the theme's own `sample` colours.
pub(crate) fn paint_theme_thumbnail(p: &Painter, rect: Rect, sample: &ThemeSample) {
paint(p, rect, &super::preferences_figure_data::THEME_THUMBNAIL, Some(sample), None);
}
#[allow(dead_code, reason = "painted by the Preferences pages of #9875 to #9883")]
/// The "System" theme card: `light` with `dark` over a slanted band of it (the board clips the dark
/// half to the polygon 56 %,0 / 100 %,0 / 100 %,100 % / 40 %,100 %).
pub(crate) fn paint_theme_system(p: &Painter, rect: Rect, light: &ThemeSample, dark: &ThemeSample) {
const BAND: [[f32; 2]; 4] = [[69.44, 0.0], [124.0, 0.0], [124.0, 76.0], [49.6, 76.0]];
let figure = &super::preferences_figure_data::THEME_THUMBNAIL;
paint(p, rect, figure, Some(light), None);
paint(p, rect, figure, Some(dark), Some(&BAND));
}
#[cfg(test)]
mod tests {
use super::orientation_axes;
@@ -281,4 +301,101 @@ mod tests {
}
}
}
use super::{ThemeSample, paint_theme_system, paint_theme_thumbnail};
use crate::theme::Tokens;
use crate::ui_kit::preferences_figure_data::FIGURES;
use egui::{Pos2, Rect, vec2};
/// Shapes a figure paints into `rect` on a fresh frame, in the given theme.
fn shapes(rect: Rect, paint: impl Fn(&egui::Painter, Rect)) -> Vec<egui::epaint::ClippedShape> {
let ctx = crate::fonts::test_context();
let mut output =
ctx.run_ui(egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0))), ..Default::default() }, |ui| {
paint(ui.painter(), rect);
});
output.textures_delta.clear();
output.shapes
}
fn rects() -> Vec<Rect> {
let nan = f32::NAN;
vec![
Rect::from_min_size(Pos2::ZERO, vec2(0.0, 0.0)),
Rect::from_min_size(Pos2::ZERO, vec2(0.2, 0.2)),
Rect::from_min_size(Pos2::new(5.0, 5.0), vec2(1.0, 1.0)),
Rect::from_min_size(Pos2::new(5.0, 5.0), vec2(3.0, 200.0)),
Rect::from_min_size(Pos2::new(5.0, 5.0), vec2(120.0, 64.0)),
Rect::from_min_size(Pos2::new(-50.0, -50.0), vec2(300.0, 300.0)),
Rect::from_min_size(Pos2::ZERO, vec2(1.0e5, 1.0e5)),
Rect::from_min_size(Pos2::ZERO, vec2(f32::INFINITY, 10.0)),
Rect::from_min_size(Pos2::new(nan, 0.0), vec2(10.0, 10.0)),
Rect::from_min_size(Pos2::ZERO, vec2(-40.0, 30.0)),
Rect::from_min_size(Pos2::ZERO, vec2(nan, nan)),
]
}
/// Every figure, in both themes, paints at size zero, tiny, normal, huge and non-finite without
/// panicking, and at a normal size stays inside its rectangle with a bounded number of shapes.
#[test]
fn every_preference_figure_paints_safely_at_any_size_in_both_themes() {
let previous = crate::theme::is_dark();
for dark in [false, true] {
crate::theme::set_dark(dark);
assert!(!FIGURES.is_empty());
for (name, size, paint) in FIGURES {
for rect in rects() {
let _ = shapes(rect, paint);
}
let rect = Rect::from_min_size(Pos2::new(20.0, 10.0), vec2(size[0] * 2.0, size[1] * 2.0));
let painted = shapes(rect, paint);
assert!(!painted.is_empty() && painted.len() < 700, "{name}: {} shapes", painted.len());
for shape in &painted {
let bounds = shape.shape.visual_bounding_rect().intersect(shape.clip_rect);
if bounds.is_positive() {
assert!(rect.expand(1.0).contains_rect(bounds), "{name}: {bounds:?} outside {rect:?}");
}
}
}
}
crate::theme::set_dark(previous);
}
/// The figures read the theme: the same figure paints different colours in light and dark.
#[test]
fn preference_figures_follow_the_theme() {
let previous = crate::theme::is_dark();
let rect = Rect::from_min_size(Pos2::ZERO, vec2(120.0, 64.0));
let colours = |dark: bool| {
crate::theme::set_dark(dark);
let mut all = Vec::new();
for shape in shapes(rect, super::super::preferences_figure_data::paint_orbit_origin) {
if let egui::Shape::Rect(r) = shape.shape {
all.push(r.fill);
}
}
all
};
let (light, dark) = (colours(false), colours(true));
assert!(!light.is_empty() && light != dark);
crate::theme::set_dark(previous);
}
#[test]
fn theme_thumbnails_paint_with_the_sample_colours_and_a_slanted_system_band() {
let (light, dark) = (ThemeSample::from_tokens(&Tokens::light()), ThemeSample::from_tokens(&Tokens::dark()));
assert_ne!(light, dark);
let rect = Rect::from_min_size(Pos2::new(4.0, 4.0), vec2(124.0, 76.0));
for rect in rects().into_iter().chain([rect]) {
let _ = shapes(rect, |p, r| paint_theme_thumbnail(p, r, &light));
let _ = shapes(rect, |p, r| paint_theme_system(p, r, &light, &dark));
}
let single = shapes(rect, |p, r| paint_theme_thumbnail(p, r, &light)).len();
let system = shapes(rect, |p, r| paint_theme_system(p, r, &light, &dark)).len();
assert!(system > single, "the dark band adds shapes over the light card");
for shape in shapes(rect, |p, r| paint_theme_system(p, r, &light, &dark)) {
let bounds = shape.shape.visual_bounding_rect().intersect(shape.clip_rect);
assert!(!bounds.is_positive() || rect.expand(1.0).contains_rect(bounds), "{bounds:?}");
}
}
}
+49
View File
@@ -858,3 +858,52 @@ and its shared gap above an inline result toolbar.
`number_pair` extends the shared value-pair row to bounded numeric fields (such as
image width and height), preserving label alignment, clipping and number-field behavior.
## Preferences cards, figures and help (redesign)
The redesigned Preferences pages (tracker #9863, reference in `docs/design/preferences/`) compose
these shared pieces; the pages themselves arrive in later steps. Sizes follow
`docs/design/preferences/measurements.md`.
| Element | Use | Main tokens |
| --- | --- | --- |
| `picture_card(ui, &Card, width)` | A radio card: a figure, a label, an optional sub-label, an optional "has options" disc (its text is the hover) and an optional accent check. The figure keeps its aspect ratio and fills the card's inside width; the card sizes to the `width` its container gives it (`card_width(available, columns, gap)` divides a row). Selected: 2 pt `form_primary` border, `selected_bg` fill, `selected_ink` semibold label. Disabled: dimmed, no hover, no click, no focus ring. Keyboard focus draws a ring outside the border. Reports `RadioButton` to accessibility. | `form_primary`, `selected_bg`, `selected_ink`, `hover`, `control_fill`, `dialog_border`, `form_ink`, `form_dim`, `on_accent`, `radius_lg`, `space_*`, `font_label/small` |
| `toggle_tile(ui, &Card, width)` | The same picture with a 15 pt checkbox in the top-right corner (`Card::selected` is "on"). Reports `Checkbox`. | as above, `control_border` |
| `preset_card(ui, name, word, selected, enabled, &icon, width)` | A 70 pt card with a 38 pt icon box (`icon` paints into it in the ink it is given), a name, one word and an accent check. Used for Performance, Quality and Custom. | as above, `pref_well` |
| `help_icon(ui, text)` | The (i) after a label. Shows `text` as a tooltip and exposes it as the widget's label. | `form_dim`, `form_ink` |
| `carousel(ui, id, count, item_width, item_height, item)` | A row of items between a previous and a next arrow (both labelled from the `prefs.carousel_*` messages). `carousel_window(count, offset, available, item_width, gap, arrow)` says which items show; the arrow at an end is dimmed (0.35) and inert. The first shown index is kept in the Ui's temporary memory under `id`. | `dialog_border`, `control_fill`, `hover`, `form_ink`, `radius_lg` |
`card_look(selected, enabled, hovered, focused)` holds the state rules (border width, fill, ink, weight,
dimming, focus ring) apart from the painting so they can be tested.
### Figures
`ui_kit/preferences_figures.rs` paints the illustrations of the Preferences boards. The geometry of
each comes from its SVG in `docs/design/preferences/figures/`: `tools/preferences-figures/generate.py`
compiles those files into `ui_kit/preferences_figure_data.rs`, one constant and one painter per
figure (`paint_tess_normal`, `paint_orbit_origin`, ...; `FIGURES` lists them all), and
`preferences_figure_ops.rs` paints the operations with egui shapes. A figure is a list of rectangles,
circles, polygons, soft shadows and tiny captions in the figure's own coordinates. Nothing is
rasterised and no image is loaded; a figure costs a few dozen shapes and scales to any rectangle,
keeping its aspect ratio. A zero, tiny, huge or non-finite rectangle paints nothing or something
clipped, never panics.
To change a figure, edit its SVG (light and dark) and run
`python3 tools/preferences-figures/generate.py`; never edit `preferences_figure_data.rs`. The colour
of each element is a role read from `Tokens`: `figure_ground`, `figure_line`, `figure_face_top`,
`figure_face_left`, `figure_face_right`, `figure_card`, `figure_accent`, `figure_reference`,
`figure_axis_x/y/z`, `figure_ok`, `figure_warn`, plus `form_ink`, `form_dim`, `dialog_border`,
`control_border` and `control_fill` (`docs/design/preferences/tokens.md`). The new tokens, with light
and dark values, are in `assets/styles/ui/palettes.rs`; theme files cannot override them yet and keep
the built-in values. The colour vision samples read `axis_z`, `sketch_projected`, `axis_x`, `bend_up`
and `bend_down`, so they follow the colour vision mode.
Theme thumbnails (`paint_theme_thumbnail`, `paint_theme_system`) take a `ThemeSample` (12 colours,
`ThemeSample::from_tokens(&tokens)`), not the dialog's tokens, because a card shows its own theme.
The Orientation figures and the visual-style icon are not repeated here: the app already has them.
New pieces and figures each have an entry in `gallery::ELEMENTS` (`picture_card`, `toggle_tile`,
`preset_card`, `help_icon`, `carousel`, `preference_theme_thumbnails`, `preference_figures`), checked
in both themes. `SOLIDCRAFT_PREFERENCE_WIDGET_EVIDENCE=<dir>` keeps the CPU-rendered boards
(`preferences-widgets-*.png`, `preferences-figures-*.png`) from
`preference_widgets_and_figures_board_in_both_themes`.
+588
View File
@@ -0,0 +1,588 @@
#!/usr/bin/env python3
"""Compile docs/design/preferences/figures/*.svg into ui_kit/preferences_figure_data.rs.
Each figure becomes a list of drawing operations (rectangles, circles, flattened polygons, text) in
the figure's own coordinates, with colours as roles (`Ink`) instead of hex values. A role is the
light/dark colour pair the board used at that position; docs/design/preferences/tokens.md says which
`theme::Tokens` field each role reads. Run from the repository root:
python3 tools/preferences-figures/generate.py
"""
import collections
import json
import math
import os
import re
import sys
import xml.etree.ElementTree as ET
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
FIGS = os.path.join(ROOT, 'docs/design/preferences/figures')
OUT = os.path.join(ROOT, 'crates/ui-egui/src/ui_kit/preferences_figure_data.rs')
# Skipped: the Orientation figures already exist in ui_kit and stay as they are.
SKIP = {'orientation-z-up', 'orientation-y-up', 'style-shaded-edges'}
THEMES = ['theme-light', 'theme-dark', 'theme-gruvbox', 'theme-imported']
PAIRS = {
('#F7F8FA', '#1C1F26'): 'Ground',
('#6F7C8F', '#A3AEC2'): 'Line',
('#236EAD', '#8CBEF5'): 'Accent',
('#FFFFFF', '#6B7790'): 'Top',
('#4A5868', '#B4BBC8'): 'Dim',
('#E4E9F0', '#515B72'): 'Left',
('#C9D2DE', '#394256'): 'Right',
('#D5DBE3', '#3F4552'): 'Border',
('#C3CBD6', '#4A5262'): 'ControlBorder',
('#FFFFFF', '#2F343E'): 'Card',
('#1B2430', '#DEE2E8'): 'Text',
('#8A5300', '#E8C24A'): 'Reference',
('#2F6FD0', '#6CA8F7'): 'AxisZ',
('#D64545', '#F27A7A'): 'AxisX',
('#2E9E5B', '#62D48E'): 'AxisY',
('#FFFFFF', '#FFFFFF'): 'White',
('#B84A00', '#F0A27A'): 'Note',
('#FBEBDD', '#4A3326'): 'NoteFill',
('#2F7D5B', '#7FD1AE'): 'Ok',
('#C98A00', '#E8C24A'): 'Warn',
('#FFFFFF', '#1C1F26'): 'Control',
('#0072B2', '#56B4E9'): 'Cv0',
('#E69F00', '#F0C060'): 'Cv1',
('#E69F00', '#E69F00'): 'Cv1',
('#D55E00', '#F0A27A'): 'Cv2',
('#56B4E9', '#8CBEF5'): 'Cv3',
('#56B4E9', '#56B4E9'): 'Cv3',
('#CC79A7', '#CC79A7'): 'Cv4',
}
def num(v, d=0.0):
return d if v is None else float(re.match(r'-?[\d.]+(?:e-?\d+)?', v.strip()).group(0))
def parse_color(v):
"""(hex6 upper, alpha) or None for none/url."""
if v is None or v == 'none':
return None
v = v.strip()
if v.startswith('url('):
return ('url', re.search(r'#([\w-]+)', v).group(1))
m = re.fullmatch(r'#([0-9A-Fa-f]{3,8})', v)
if not m:
raise ValueError(v)
h = m.group(1)
if len(h) == 3:
h = ''.join(c * 2 for c in h)
a = 1.0
if len(h) == 8:
a = int(h[6:], 16) / 255.0
h = h[:6]
return ('#' + h.upper(), a)
def mat_mul(a, b):
return (a[0] * b[0] + a[2] * b[1], a[1] * b[0] + a[3] * b[1], a[0] * b[2] + a[2] * b[3], a[1] * b[2] + a[3] * b[3],
a[0] * b[4] + a[2] * b[5] + a[4], a[1] * b[4] + a[3] * b[5] + a[5])
def parse_transform(s):
m = (1, 0, 0, 1, 0, 0)
for name, args in re.findall(r'(\w+)\(([^)]*)\)', s or ''):
v = [float(x) for x in re.split(r'[ ,]+', args.strip())]
if name == 'translate':
t = (1, 0, 0, 1, v[0], v[1] if len(v) > 1 else 0)
elif name == 'scale':
t = (v[0], 0, 0, v[1] if len(v) > 1 else v[0], 0, 0)
elif name == 'matrix':
t = tuple(v)
else:
raise ValueError(name)
m = mat_mul(m, t)
return m
def ap(m, x, y):
return (m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5])
def flatten_path(d, m):
toks = re.findall(r'[MmLlHhVvZzAaQqCc]|-?[\d.]+(?:e-?\d+)?', d)
i = 0
subs = []
cur = None
x = y = sx = sy = 0.0
cmd = None
def nxt():
nonlocal i
v = float(toks[i])
i += 1
return v
def add(px, py):
cur.append(ap(m, px, py))
while i < len(toks):
if re.fullmatch(r'[A-Za-z]', toks[i]):
cmd = toks[i]
i += 1
if cmd in 'Zz':
if cur:
subs.append((cur, True))
cur = None
x, y = sx, sy
continue
c = cmd
if c in 'Mm':
if cur and len(cur) > 0:
subs.append((cur, False))
nx, ny = nxt(), nxt()
if c == 'm':
nx, ny = x + nx, y + ny
x, y = sx, sy = nx, ny
cur = []
add(x, y)
cmd = 'L' if c == 'M' else 'l'
elif c in 'Ll':
nx, ny = nxt(), nxt()
if c == 'l':
nx, ny = x + nx, y + ny
x, y = nx, ny
if cur is None:
cur = []
add(sx, sy)
add(x, y)
elif c in 'Hh':
nx = nxt()
x = x + nx if c == 'h' else nx
if cur is None:
cur = []
add(sx, sy)
add(x, y)
elif c in 'Vv':
ny = nxt()
y = y + ny if c == 'v' else ny
if cur is None:
cur = []
add(sx, sy)
add(x, y)
elif c in 'Qq':
a, b, e, f = nxt(), nxt(), nxt(), nxt()
if c == 'q':
a, b, e, f = x + a, y + b, x + e, y + f
for k in range(1, 13):
t = k / 12
add((1 - t) ** 2 * x + 2 * (1 - t) * t * a + t * t * e, (1 - t) ** 2 * y + 2 * (1 - t) * t * b + t * t * f)
x, y = e, f
elif c in 'Cc':
a, b, e, f, g, h = (nxt() for _ in range(6))
if c == 'c':
a, b, e, f, g, h = x + a, y + b, x + e, y + f, x + g, y + h
for k in range(1, 17):
t = k / 16
u = 1 - t
add(u ** 3 * x + 3 * u * u * t * a + 3 * u * t * t * e + t ** 3 * g, u ** 3 * y + 3 * u * u * t * b + 3 * u * t * t * f + t ** 3 * h)
x, y = g, h
elif c in 'Aa':
rx, ry, rot, la, sw, ex, ey = (nxt() for _ in range(7))
if c == 'a':
ex, ey = x + ex, y + ey
pts = arc(x, y, rx, ry, rot, int(la), int(sw), ex, ey)
for px, py in pts:
add(px, py)
x, y = ex, ey
else:
raise ValueError(c)
if cur:
subs.append((cur, False))
return subs
def arc(x1, y1, rx, ry, phi, fa, fs, x2, y2):
if rx == 0 or ry == 0:
return [(x2, y2)]
ph = math.radians(phi)
cp, sp = math.cos(ph), math.sin(ph)
dx, dy = (x1 - x2) / 2, (y1 - y2) / 2
x1p, y1p = cp * dx + sp * dy, -sp * dx + cp * dy
rx, ry = abs(rx), abs(ry)
lam = x1p ** 2 / rx ** 2 + y1p ** 2 / ry ** 2
if lam > 1:
rx *= math.sqrt(lam)
ry *= math.sqrt(lam)
num_ = rx ** 2 * ry ** 2 - rx ** 2 * y1p ** 2 - ry ** 2 * x1p ** 2
den = rx ** 2 * y1p ** 2 + ry ** 2 * x1p ** 2
co = math.sqrt(max(0, num_ / den)) * (-1 if fa == fs else 1)
cxp, cyp = co * rx * y1p / ry, -co * ry * x1p / rx
cx, cy = cp * cxp - sp * cyp + (x1 + x2) / 2, sp * cxp + cp * cyp + (y1 + y2) / 2
def ang(ux, uy, vx, vy):
a = math.atan2(ux * vy - uy * vx, ux * vx + uy * vy)
return a
th1 = ang(1, 0, (x1p - cxp) / rx, (y1p - cyp) / ry)
dth = ang((x1p - cxp) / rx, (y1p - cyp) / ry, (-x1p - cxp) / rx, (-y1p - cyp) / ry)
if not fs and dth > 0:
dth -= 2 * math.pi
if fs and dth < 0:
dth += 2 * math.pi
n = max(6, int(abs(dth) / (math.pi / 16)))
out = []
for k in range(1, n + 1):
t = th1 + dth * k / n
out.append((cp * rx * math.cos(t) - sp * ry * math.sin(t) + cx, sp * rx * math.cos(t) + cp * ry * math.sin(t) + cy))
return out
def rr_poly(x, y, w, h, r, m, seg=6):
pts = []
if r <= 0:
for px, py in ((x, y), (x + w, y), (x + w, y + h), (x, y + h)):
pts.append(ap(m, px, py))
return pts
r = min(r, w / 2, h / 2)
for cx, cy, a0 in ((x + w - r, y + r, -90), (x + w - r, y + h - r, 0), (x + r, y + h - r, 90), (x + r, y + r, 180)):
for k in range(seg + 1):
a = math.radians(a0 + 90 * k / seg)
pts.append(ap(m, cx + r * math.cos(a), cy + r * math.sin(a)))
return pts
class Walker:
"""Walks one SVG into a flat op list; colours stay as hex until paired."""
def __init__(self, root):
self.defs = {}
self.clips = {}
self.ops = []
self.root = root
@staticmethod
def tag(e):
return e.tag.split('}')[-1]
def collect(self, e):
for c in e:
t = self.tag(c)
if t in ('linearGradient', 'radialGradient'):
self.defs[c.get('id')] = c
elif t == 'clipPath':
self.clips[c.get('id')] = c
elif t == 'defs':
self.collect(c)
else:
self.collect(c)
def run(self):
self.collect(self.root)
vb = [float(v) for v in self.root.get('viewBox').split()]
self.size = (vb[2], vb[3])
self.walk(self.root, (1, 0, 0, 1, -vb[0], -vb[1]), None, root=True)
return self.ops
def style(self, e, mscale):
f = parse_color(e.get('fill', '#000000'))
s = parse_color(e.get('stroke'))
fo = num(e.get('fill-opacity'), 1.0) * num(e.get('opacity'), 1.0)
so = num(e.get('stroke-opacity'), 1.0) * num(e.get('opacity'), 1.0)
sw = num(e.get('stroke-width'), 1.0) * mscale if s else 0
dash = None
if e.get('stroke-dasharray'):
dv = [float(v) for v in re.split(r'[ ,]+', e.get('stroke-dasharray').strip())]
if len(dv) >= 2 and dv[1] > 0:
dash = (dv[0] * mscale, dv[1] * mscale)
round_cap = e.get('stroke-linecap') == 'round'
return f, fo, s, so, sw, dash, round_cap
def walk(self, e, m, clip, root=False):
for c in e:
t = self.tag(c)
if t in ('defs', 'linearGradient', 'radialGradient', 'clipPath', 'stop'):
continue
mc = m
if t == 'svg':
x, y = num(c.get('x'), 0), num(c.get('y'), 0)
w, h = num(c.get('width')), num(c.get('height'))
vb = [float(v) for v in c.get('viewBox').split()]
mc = mat_mul(m, (1, 0, 0, 1, x, y))
mc = mat_mul(mc, (w / vb[2], 0, 0, h / vb[3], -vb[0] * w / vb[2], -vb[1] * h / vb[3]))
self.collect_clips(c)
# A nested svg clips its content to its own viewport.
self.ops.append(('clip', rr_poly(x, y, w, h, 0, m)))
self.walk(c, mc, clip)
self.ops.append(('unclip',))
continue
if t == 'g':
mc = mat_mul(m, parse_transform(c.get('transform')))
cl = clip
if c.get('clip-path'):
cid = re.search(r'#([\w-]+)', c.get('clip-path')).group(1)
cp = self.clips[cid]
pts = self.clip_points(cp, mc)
self.ops.append(('clip', pts))
self.walk(c, mc, cl)
self.ops.append(('unclip',))
else:
self.walk(c, mc, cl)
continue
if c.get('transform'):
mc = mat_mul(m, parse_transform(c.get('transform')))
det = abs(mc[0] * mc[3] - mc[1] * mc[2])
ms = math.sqrt(det)
f, fo, s, so, sw, dash, rc = self.style(c, ms)
axis = abs(mc[1]) < 1e-9 and abs(mc[2]) < 1e-9
if t == 'rect':
x, y, w, h = num(c.get('x'), 0), num(c.get('y'), 0), num(c.get('width')), num(c.get('height'))
r = num(c.get('rx'), 0)
if f and f[0] == 'url' and f[1] in self.defs:
g = self.defs[f[1]]
stops = [(num(st.get('offset')), parse_color(st.get('stop-color'))[0]) for st in g]
self.ops.append(('poly', rr_poly(x, y, w, h, r, mc), True, ('grad', stops[0][1], stops[-1][1], ap(mc, 0, y)[1], ap(mc, 0, y + h)[1]), fo, s, so, sw, dash, rc))
elif axis and mc[0] > 0 and mc[3] > 0:
p0 = ap(mc, x, y)
self.ops.append(('rect', p0[0], p0[1], w * mc[0], h * mc[3], r * mc[0], f, fo, s, so, sw, dash))
else:
self.ops.append(('poly', rr_poly(x, y, w, h, r, mc), True, f and ('solid', f), fo, s, so, sw, dash, rc))
elif t == 'circle':
cx, cy, r = num(c.get('cx')), num(c.get('cy')), num(c.get('r'))
if abs(mc[1]) < 1e-9 and abs(mc[2]) < 1e-9 and abs(mc[0] - mc[3]) < 1e-9:
p0 = ap(mc, cx, cy)
self.ops.append(('circle', p0[0], p0[1], r * mc[0], f, fo, s, so, sw, dash))
else:
pts = [ap(mc, cx + r * math.cos(a * math.pi / 12), cy + r * math.sin(a * math.pi / 12)) for a in range(24)]
self.ops.append(('poly', pts, True, f and ('solid', f), fo, s, so, sw, dash, rc))
elif t == 'ellipse':
cx, cy, rx, ry = num(c.get('cx')), num(c.get('cy')), num(c.get('rx')), num(c.get('ry'))
if f and f[0] == 'url':
g = self.defs[f[1]]
stops = [num(st.get('stop-opacity'), 1.0) for st in g]
p0 = ap(mc, cx, cy)
self.ops.append(('glow', p0[0], p0[1], rx * mc[0], ry * mc[3], stops[0] * fo))
else:
pts = [ap(mc, cx + rx * math.cos(a * math.pi / 16), cy + ry * math.sin(a * math.pi / 16)) for a in range(32)]
self.ops.append(('poly', pts, True, f and ('solid', f), fo, s, so, sw, dash, rc))
elif t in ('polygon', 'polyline'):
nums = [float(v) for v in re.split(r'[ ,]+', c.get('points').strip())]
pts = [ap(mc, nums[k], nums[k + 1]) for k in range(0, len(nums), 2)]
self.ops.append(('poly', pts, t == 'polygon', f and ('solid', f), fo, s, so, sw, dash, rc))
elif t == 'path' and s and s[0] == 'url':
g = self.defs[s[1]]
stops = [(num(st.get('offset')), parse_color(st.get('stop-color')), num(st.get('stop-opacity'), 1.0)) for st in g]
for pts, closed in flatten_path(c.get('d'), mc):
(x0, y0), (x1, y1) = pts[0], pts[-1]
n = 10
for k in range(n):
a0, a1 = k / n, (k + 1) / n
am = (a0 + a1) / 2
al = stops[0][2] + (stops[-1][2] - stops[0][2]) * am
seg = [(x0 + (x1 - x0) * a0, y0 + (y1 - y0) * a0), (x0 + (x1 - x0) * a1, y0 + (y1 - y0) * a1)]
self.ops.append(('poly', seg, False, None, 1.0, stops[0][1], al, sw, None, rc))
elif t == 'path':
for pts, closed in flatten_path(c.get('d'), mc):
if len(pts) >= 2:
fill = f and ('solid', f) if (closed or len(pts) >= 3) else None
self.ops.append(('poly', pts, closed, fill, fo, s, so, sw, dash, rc))
elif t == 'text':
x, y = num(c.get('x')), num(c.get('y'))
p0 = ap(mc, x, y)
self.ops.append(('text', p0[0], p0[1], num(c.get('font-size')) * ms, c.get('text-anchor', 'start'), f, fo, ''.join(c.itertext()), num(c.get('font-weight'), 400) >= 600))
elif t == 'g':
pass
else:
raise ValueError('unhandled ' + t)
def collect_clips(self, e):
for c in e:
if self.tag(c) == 'clipPath':
self.clips[c.get('id')] = c
def clip_points(self, cp, m):
out = []
for c in cp:
t = self.tag(c)
if t == 'path':
for pts, _ in flatten_path(c.get('d'), m):
out.extend(pts)
elif t == 'rect':
out.extend(rr_poly(num(c.get('x'), 0), num(c.get('y'), 0), num(c.get('width')), num(c.get('height')), 0, m))
else:
raise ValueError('clip ' + t)
return out
def load(name, theme):
tree = ET.parse(os.path.join(FIGS, '%s.%s.svg' % (name, theme)))
w = Walker(tree.getroot())
return w.run(), w.size
def color_fields(op):
"""Positions of (colour, alpha) tuples in an op, as (index) list."""
k = op[0]
if k == 'rect':
return [6, 9]
if k == 'circle':
return [4, 7]
if k == 'poly':
return [None, 5]
if k == 'text':
return [5]
return []
def ink_of(l, d, slots=None):
if slots is not None:
key = tuple(slots)
if key not in SLOTS:
SLOTS[key] = len(SLOTS)
return 'Slot(%d)' % SLOTS[key]
if l == '#000000':
return 'Black'
r = PAIRS.get((l, d))
if r is None:
raise KeyError((l, d))
return r
SLOTS = collections.OrderedDict()
def f32(v):
s = ('%.3f' % v).rstrip('0').rstrip('.')
if s in ('', '-0'):
s = '0'
return s + ('' if '.' in s else '.0')
def paint(l, d, alpha, slots=None):
"""Option<Paint> source."""
if l is None:
return 'None'
return 'Some(P { ink: Ink::%s, a: %s })' % (ink_of(l[0], d[0], slots), f32(alpha))
def line(sl, sd, so, sw, dash, rc, slots=None):
if sl is None or sw <= 0:
return 'None'
ds = dash or (0, 0)
return 'Some(L { ink: Ink::%s, a: %s, w: %s, dash: [%s, %s], round: %s })' % (ink_of(sl[0], sd[0], slots), f32(so), f32(sw), f32(ds[0]), f32(ds[1]), 'true' if rc else 'false')
def emit(name, ops_l, ops_d, slotsets=None):
out = []
assert len(ops_l) == len(ops_d), (name, len(ops_l), len(ops_d))
for idx, (a, b) in enumerate(zip(ops_l, ops_d)):
assert a[0] == b[0], (name, idx, a[0], b[0])
sl = None
if slotsets is not None:
sl = [s[idx] for s in slotsets]
k = a[0]
if k == 'rect':
_, x, y, w, h, r, f, fo, s, so, sw, dash = a
_, _, _, _, _, _, fd, _, sd, _, _, _ = b
fs = fsl = None
if slotsets is not None:
fs = [t[6][0] if t[6] else None for t in sl]
ss = [t[8][0] if t[8] else None for t in sl]
out.append('Op::Rect { r: [%s, %s, %s, %s], radius: %s, fill: %s, line: %s }' % (
f32(x), f32(y), f32(w), f32(h), f32(r),
paint(f, fd, fo, fs if slotsets is not None and f else None),
line(s, sd, so, sw, dash, False, ss if slotsets is not None and s else None)))
elif k == 'circle':
_, x, y, r, f, fo, s, so, sw, dash = a
fd, sd = b[4], b[6]
fs = ss = None
if slotsets is not None:
fs = [t[4][0] if t[4] else None for t in sl]
ss = [t[6][0] if t[6] else None for t in sl]
out.append('Op::Circle { c: [%s, %s, %s], fill: %s, line: %s }' % (
f32(x), f32(y), f32(r), paint(f, fd, fo, fs if f and slotsets is not None else None),
line(s, sd, so, sw, dash, False, ss if s and slotsets is not None else None)))
elif k == 'poly':
_, pts, closed, fill, fo, s, so, sw, dash, rc = a
fd, sd = b[3], b[5]
pts_s = ', '.join('[%s, %s]' % (f32(px), f32(py)) for px, py in pts)
fs = ss = None
if slotsets is not None:
fs = [t[3][1][0] if t[3] and t[3][0] == 'solid' else None for t in sl]
ss = [t[5][0] if t[5] else None for t in sl]
grad = 'None'
fill_s = 'None'
if fill and fill[0] == 'grad':
_, top, bot, y0, y1 = fill
gd = b[3]
if slotsets is not None:
tops = [t[3][1] for t in sl]
bots = [t[3][2] for t in sl]
grad = 'Some(G { top: Ink::%s, bottom: Ink::%s, y: [%s, %s] })' % (ink_of(top, gd[1], tops), ink_of(bot, gd[2], bots), f32(y0), f32(y1))
else:
grad = 'Some(G { top: Ink::%s, bottom: Ink::%s, y: [%s, %s] })' % (ink_of(top, gd[1]), ink_of(bot, gd[2]), f32(y0), f32(y1))
elif fill:
fill_s = paint(fill[1], fd[1], fo, fs if slotsets is not None else None)
out.append('Op::Poly { pts: &[%s], closed: %s, fill: %s, grad: %s, line: %s }' % (
pts_s, 'true' if closed else 'false', fill_s, grad,
line(s, sd, so, sw, dash, rc, ss if s and slotsets is not None else None)))
elif k == 'glow':
_, x, y, rx, ry, al = a
out.append('Op::Glow { c: [%s, %s, %s, %s], a: %s }' % (f32(x), f32(y), f32(rx), f32(ry), f32(al)))
elif k == 'text':
_, x, y, size, anchor, f, fo, txt, bold = a
fd = b[5]
fs = [t[5][0] for t in sl] if slotsets is not None else None
out.append('Op::Text { at: [%s, %s], size: %s, anchor: %d, ink: Ink::%s, a: %s, s: %s, bold: %s }' % (
f32(x), f32(y), f32(size), {'start': 0, 'middle': 1, 'end': 2}[anchor], ink_of(f[0], fd[0], fs), f32(fo), '"' + txt.replace('\\', '\\\\').replace('"', '\\"') + '"', 'true' if bold else 'false'))
elif k == 'clip':
pts_s = ', '.join('[%s, %s]' % (f32(px), f32(py)) for px, py in a[1])
out.append('Op::Clip(&[%s])' % pts_s)
elif k == 'unclip':
out.append('Op::Unclip')
else:
raise ValueError(k)
return out
def const_name(n):
return n.upper().replace('-', '_')
def fn_name(n):
return 'paint_' + n.replace('-', '_')
def main():
man = sorted({f.split('.')[0] for f in os.listdir(FIGS) if f.endswith('.svg')})
parts = []
parts.append('//! Generated by `tools/preferences-figures/generate.py` from `docs/design/preferences/figures/*.svg`.\n'
'//! Do not edit by hand: change the SVG and run the script again.\n'
'#![allow(clippy::approx_constant, clippy::excessive_precision)]\n'
'use super::preferences_figure_ops::{Figure, G, Ink, L, Op, P};\n')
fns = []
names = []
for name in man:
if name in SKIP or name.startswith('theme-'):
continue
ol, size = load(name, 'light')
od, size2 = load(name, 'dark')
assert size == size2
ops = emit(name, ol, od)
parts.append('pub(super) const %s: Figure = Figure { size: [%s, %s], ops: &[\n %s,\n] };\n' % (const_name(name), f32(size[0]), f32(size[1]), ',\n '.join(ops)))
names.append(name)
# theme thumbnail: slots from the four themes
loaded = [load(t, 'light')[0] for t in THEMES]
size = load('theme-light', 'light')[1]
ops = emit('theme-thumbnail', loaded[0], loaded[0], slotsets=loaded)
parts.append('pub(super) const THEME_THUMBNAIL: Figure = Figure { size: [%s, %s], ops: &[\n %s,\n] };\n' % (f32(size[0]), f32(size[1]), ',\n '.join(ops)))
slot_table = ['// Theme thumbnail slots (light, dark, gruvbox, imported sample colours):']
for key, i in SLOTS.items():
slot_table.append('// %d: %s' % (i, ' '.join(str(k) for k in key)))
parts.append('\n'.join(slot_table) + '\n')
parts.append('use egui::{Painter, Rect};\nuse super::preferences_figure_ops::paint;\n')
for n in names:
parts.append('/// The `%s` figure of `docs/design/preferences/figures`.\npub(crate) fn %s(p: &Painter, rect: Rect) {\n paint(p, rect, &%s, None, None);\n}\n' % (n, fn_name(n), const_name(n)))
parts.append('/// Every figure painter by file name, for the gallery and the tests.\npub(crate) const FIGURES: &[(&str, [f32; 2], fn(&Painter, Rect))] = &[\n%s];\n' % ''.join(' ("%s", %s.size, %s),\n' % (n, const_name(n), fn_name(n)) for n in names))
open(OUT, 'w', encoding='utf8', newline='\n').write('\n'.join(parts))
print('figures', len(names), 'slots', len(SLOTS), file=sys.stderr)
if __name__ == '__main__':
main()