mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 02:25:55 +00:00
* 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:
@@ -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": "上一个" }
|
||||
}
|
||||
@@ -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)));
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user