Files
vectorcraft/crates/ui-egui/src/widgets.rs
T
NetterSilverandClaude Haiku 5.5 c90cea9e19 Round stepper button steps like arrow-key steps
Fixes #4490

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 15:16:20 +07:00

570 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Shared widgets. Colours come from theme tokens.
use std::ops::RangeInclusive;
use egui::{Color32, CornerRadius, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, pos2, vec2};
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::Unit;
use crate::theme::{self, Tokens};
use crate::{icons, scrub};
mod blend;
mod buttons;
mod chips;
mod color;
mod drag;
mod dropdowns;
mod labels;
mod numeric;
mod panel_chrome;
mod text;
mod toggles;
pub use blend::{
TransparencyEdit, angle_dial, blend_dropdown, blend_param, blend_param_dropdown, blend_separator_before, opacity_blend, reference_point,
};
pub use buttons::{
SPLIT_ARROW, flat_button, flat_button_fit, icon_button, icon_button_enabled, menu_item, menu_item_name, paint_icon_button, primary_button,
primary_button_enabled, secondary_button, split_button, split_button_named, toggle_icon,
};
pub(crate) use buttons::{icon_button_info, primary_button_width, secondary_button_width};
#[cfg(test)]
use chips::gradient_chip_colors;
pub use chips::{ProxyClicks, chip_button, chip_button_named, fill_stroke_proxy, gradient_chip, paint_chip, proxy_chip, swatch_tile};
pub(crate) use color::pointer_phase;
pub use color::{
Live, SliderValue, WheelResponse, channel_slider, channel_slider_named, channel_slider_valued, color_field, color_mesh, color_slider,
color_slider_named, color_slider_stepped, color_slider_valued, harmony_wheel, hex_field, hex_field_named, region_texture,
};
pub use drag::{PanelDrag, SwatchRows, art_drop, doc_preview, drag_source};
#[cfg(test)]
use dropdowns::combo_button_id;
pub(crate) use dropdowns::{combo, combo_menu};
pub use dropdowns::{
dropdown, dropdown_named, dropdown_names, dropdown_names_named, dropdown_with, dropdown_with_named, list_scroll_style, menu_room, menu_scroll,
panel_scroll, popover, set_menu_floor, strip_scroll,
};
pub use labels::{dim_label, dim_name, divider, field_label, label_row, link_label, section_header, section_toggle, section_toggle_name, subheader};
pub(crate) use labels::{label_field, take_previous_label};
pub use numeric::{
SpinPick, mixed_field, mixed_field_named, num_field, num_field_named, opt_field, opt_field_named, range_field, range_field_named,
range_field_stepped, size_field, size_field_named, slider, spin_field, spin_field_auto, spin_field_auto_named, spin_field_named,
spin_field_stops_named, spin_plain, spin_plain_named,
};
#[cfg(test)]
use numeric::{decimal_point, share_of, spinner_step, step_through, step_to_multiple, within_a_millionth};
pub(crate) use numeric::{round_to, set_bare_numbers_are_points, typed_unit};
pub use panel_chrome::{bottom_bar, layout_button, list_box, orientation_button, tab_bar};
pub(crate) use text::{dialog_focus_flag, enter_modifiers, focus_field, own_field_shift_enter, set_dialog_open, take_dialog_focus};
pub use text::{
exact_text_field, exact_text_field_named, plain_field, plain_field_named, search_field, text_field, text_field_named, text_presets,
text_presets_named,
};
pub use toggles::{check, check_box, check_name, check_tip, check3, radio, radio_button};
/// A widget id salt that needs only [`std::hash::Hash`]. egui 0.37's [`egui::AsIdSalt`] also
/// requires `Debug` for its debug-only id-source registry; VectorCraft's public field helpers do
/// not need a `Debug` bound merely to salt a widget id, and never make an accessible name from it.
#[derive(Clone, Copy)]
struct HashOnly<T>(T);
impl<T: std::hash::Hash> std::hash::Hash for HashOnly<T> {
/// Hash only the wrapped id salt, deliberately omitting the debug-only identity that egui 0.37's
/// `AsIdSalt` requires.
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.0.hash(state);
}
}
impl<T> std::fmt::Debug for HashOnly<T> {
/// Format any `HashOnly` salt as the fixed text `widget id` so ids do not expose their value.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("widget id")
}
}
/// `parent.with(salt)` for a salt that needs only `Hash`.
fn id_with(parent: egui::Id, salt: impl std::hash::Hash) -> egui::Id {
parent.with(HashOnly(salt))
}
#[cfg(test)]
pub(crate) mod accessibility_tests {
use egui::accesskit::{Node, Role, Toggled};
use super::*;
/// The screen-reader tree of the second of two frames of `add` in a 1280 × 900 window, in
/// English.
pub(crate) fn tree(mut add: impl FnMut(&mut Ui)) -> Vec<Node> {
crate::i18n::set_current(crate::i18n::Lang::EN);
let ctx = egui::Context::default();
ctx.enable_accesskit();
theme::install_fonts(&ctx);
theme::apply(&ctx, Default::default());
let screen = Rect::from_min_size(Pos2::ZERO, vec2(1280.0, 900.0));
let mut nodes = vec![];
for time in [0.0, 0.1] {
let input = egui::RawInput { time: Some(time), screen_rect: Some(screen), ..Default::default() };
let mut out = ctx.run_ui(input, &mut add);
out.textures_delta.clear();
nodes = out.platform_output.accesskit_update.map(|u| u.nodes.into_iter().map(|(_, n)| n).collect()).unwrap_or_default();
}
nodes
}
/// The node of `tree` that screen readers name `label`.
pub(crate) fn named<'a>(tree: &'a [Node], label: &str) -> &'a Node {
tree.iter().find(|n| n.label() == Some(label)).unwrap_or_else(|| panic!("nothing is named {label:?}"))
}
/// The role of the node named `label`, and whether it is pressed, checked or chosen.
pub(crate) fn role_and_state(tree: &[Node], label: &str) -> (Role, Option<Toggled>) {
let n = named(tree, label);
(n.role(), n.toggled())
}
#[test]
fn buttons_are_named_by_their_text_or_tip() {
let tree = tree(|ui| {
icon_button(ui, "x", "Remove", false, 24.0);
flat_button(ui, "Make", 64.0);
split_button_named(ui, "Image Trace", "Tracing Presets", 104.0);
primary_button(ui, "OK");
primary_button_enabled(ui, "Create", false);
secondary_button(ui, "Cancel");
link_label(ui, "Stroke:");
icon_button_enabled(ui, "trash-2", "Delete Brush", false, false, 24.0);
fill_stroke_proxy(ui, &Paint::None, &Paint::None, (false, false), true, 36.0);
chip_button_named(ui, 22.0, true, "Graphic Style", |_, _| {});
spin_field(ui, "weight", Some(1.0), Unit::Points, 80.0, 1.0, 0.0, &[1.0, 2.0]);
opacity_blend(ui, "opacity", Some(1.0), Some(BlendMode::Normal), true);
});
for label in [
"Remove",
"Make",
"Image Trace",
"Tracing Presets",
"OK",
"Create",
"Cancel",
"Stroke:",
"Delete Brush",
"Swap Fill and Stroke (Shift+X)",
"Graphic Style",
"Step up or down",
"Presets",
"Opacity",
] {
assert_eq!(role_and_state(&tree, label), (Role::Button, None), "{label}");
}
assert_eq!(role_and_state(&tree, "Default Fill and Stroke (D)"), (Role::Button, None));
assert_eq!(role_and_state(&tree, "Fill (X), double-click for the Color Picker"), (Role::Button, Some(Toggled::True)));
assert_eq!(role_and_state(&tree, "Stroke (X), double-click for the Color Picker"), (Role::Button, Some(Toggled::False)));
assert!(named(&tree, "Create").is_disabled(), "a primary button that is not enabled");
assert!(named(&tree, "Delete Brush").is_disabled(), "an icon button that is not enabled");
assert!(!named(&tree, "OK").is_disabled());
}
#[test]
fn toggles_say_whether_they_are_on() {
let tree = tree(|ui| {
icon_button(ui, "link", "Constrain Proportions", true, 24.0);
toggle_icon(ui, "eye", true, 18.0, "Show all plates");
toggle_icon(ui, "eye", false, 18.0, "Show/hide plate (Alt-click: only this plate)");
orientation_button(ui, false, true, "Portrait");
orientation_button(ui, true, false, "Landscape");
layout_button(ui, &[(0, 0), (1, 0)], true, "Grid by Row");
tab_bar(ui, &["Recent", "Saved"], 1);
});
let on = |label| assert_eq!(role_and_state(&tree, label), (Role::Button, Some(Toggled::True)), "{label}");
let off = |label| assert_eq!(role_and_state(&tree, label), (Role::Button, Some(Toggled::False)), "{label}");
on("Constrain Proportions");
on("Show all plates");
off("Show/hide plate (Alt-click: only this plate)");
on("Portrait");
off("Landscape");
on("Grid by Row");
off("Recent");
on("Saved");
}
#[test]
fn checkboxes_and_radio_buttons_are_named_and_checked() {
let tree = tree(|ui| {
check(ui, "Preview", true, true);
check(ui, "Snap to Pixel", false, false);
check3(ui, "Overprint Fill", None, true);
check_name(ui, "Artboard 1", true);
check_box(ui, "Cyan", false, true);
radio(ui, "Relative", true, true);
radio(ui, "Absolute", false, true);
radio_button(ui, "per slice", true, true);
reference_point(ui, 4);
});
assert_eq!(role_and_state(&tree, "Preview"), (Role::CheckBox, Some(Toggled::True)));
assert_eq!(role_and_state(&tree, "Snap to Pixel"), (Role::CheckBox, Some(Toggled::False)));
assert!(named(&tree, "Snap to Pixel").is_disabled());
assert_eq!(role_and_state(&tree, "Overprint Fill"), (Role::CheckBox, Some(Toggled::Mixed)));
assert_eq!(role_and_state(&tree, "Artboard 1"), (Role::CheckBox, Some(Toggled::True)));
assert_eq!(role_and_state(&tree, "Relative"), (Role::RadioButton, Some(Toggled::True)));
assert_eq!(role_and_state(&tree, "Absolute"), (Role::RadioButton, Some(Toggled::False)));
assert_eq!(role_and_state(&tree, "Cyan"), (Role::CheckBox, Some(Toggled::False)));
assert_eq!(role_and_state(&tree, "per slice"), (Role::RadioButton, Some(Toggled::True)));
for (i, label) in ["Top Left", "Top", "Top Right", "Left", "Center", "Right", "Bottom Left", "Bottom", "Bottom Right"].into_iter().enumerate()
{
let chosen = if i == 4 { Toggled::True } else { Toggled::False };
assert_eq!(role_and_state(&tree, label), (Role::RadioButton, Some(chosen)), "{label}");
}
}
#[test]
fn sliders_dials_and_combo_boxes_are_named() {
let red = Color::from_hsb(0.0, 1.0, 1.0);
let tree = tree(|ui| {
color_slider_named(ui, "fidelity", "Fidelity:", 0.25, 120.0, &|_| Color32::GRAY);
angle_dial(ui, "angle", 45.0, 28.0);
channel_slider_named(ui, "channel", "H", 0.5, vec2(22.0, 80.0), &|_| Color32::GRAY);
harmony_wheel(ui, "wheel", 120.0, &[red], Some(0));
combo_menu(ui, "font", "Regular", 120.0, |_| None::<usize>);
});
let slider = |label| {
let n = named(&tree, label);
(n.role(), n.numeric_value())
};
assert_eq!(slider("Fidelity:"), (Role::Slider, None));
assert_eq!(slider("Angle"), (Role::Slider, Some(45.0)));
assert_eq!(slider("H"), (Role::Slider, None));
assert_eq!(slider("Color wheel"), (Role::Slider, None));
assert!(tree.iter().any(|n| n.role() == Role::ComboBox && n.value() == Some("Regular")), "the searchable combo box shows its text");
}
/// The number, range and text of the slider of `tree` that screen readers name `label`.
pub(crate) fn slider_value(tree: &[Node], label: &str) -> (Option<f64>, Option<f64>, Option<f64>, Option<String>) {
let n = tree.iter().find(|n| n.role() == Role::Slider && n.label() == Some(label)).unwrap_or_else(|| panic!("no slider is named {label:?}"));
(n.numeric_value(), n.min_numeric_value(), n.max_numeric_value(), n.value().map(str::to_string))
}
/// The `_valued` sliders give screen readers the number and text of their field, with the rail's
/// range extended to a number past it; the harmony wheel's Brightness gives its percent.
#[test]
fn sliders_report_the_value_their_field_shows() {
let dark_red = Color::from_hsb(0.0, 1.0, 0.5);
let gray = |_: f32| Color32::GRAY;
let tree = tree(|ui| {
color_slider_valued(ui, "opacity", "Opacity:", Some(SliderValue::field(49.6, 0.0..=100.0, "%", 0)), 0.5, 120.0, &gray);
color_slider_valued(ui, "scale", "Horizontal:", Some(SliderValue::field(312.5, 0.0..=200.0, "%", 2)), 1.0, 120.0, &gray);
color_slider_valued(ui, "move", "Vertical:", Some(SliderValue::text(-1.5, -14.0..=14.0, "-1.5 in".into())), 0.4, 120.0, &gray);
color_slider_valued(ui, "count", "Ridges per segment:", Some(SliderValue::number(4.0, 0.0..=100.0)), 0.04, 120.0, &gray);
color_slider_valued(ui, "axis", "Weight", None, 0.5, 120.0, &gray);
channel_slider_valued(ui, "channel", "H", Some(SliderValue::field(210.0, 0.0..=360.0, "°", 0)), 210.0 / 360.0, vec2(22.0, 80.0), &gray);
harmony_wheel(ui, "wheel", 120.0, &[dark_red], Some(0));
});
let s = |label| slider_value(&tree, label);
assert_eq!(s("Opacity:"), (Some(50.0), Some(0.0), Some(100.0), Some("50%".into())));
assert_eq!(s("Horizontal:"), (Some(312.5), Some(0.0), Some(312.5), Some("312.5%".into())));
assert_eq!(s("Vertical:"), (Some(-1.5), Some(-14.0), Some(14.0), Some("-1.5 in".into())));
assert_eq!(s("Ridges per segment:"), (Some(4.0), Some(0.0), Some(100.0), None));
assert_eq!(s("Weight"), (None, None, None, None), "a mixed value");
assert_eq!(s("H"), (Some(210.0), Some(0.0), Some(360.0), Some("210°".into())));
assert_eq!(s("Brightness"), (Some(50.0), Some(0.0), Some(100.0), Some("50%".into())));
}
}
#[cfg(test)]
mod spinner_modifier_tests {
use super::*;
fn pt(v: f64) -> String {
Unit::Points.format(v)
}
#[test]
fn shift_steps_stroke_weight_by_ten_points() {
assert_eq!(spinner_step(14.0, 1.0, 0.0, true, egui::Modifiers::SHIFT, &[], &pt), 24.0);
assert_eq!(spinner_step(14.0, 1.0, 0.0, false, egui::Modifiers::SHIFT, &[], &pt), 4.0);
}
#[test]
fn unmodified_and_command_steps_keep_existing_behavior() {
assert_eq!(spinner_step(14.0, 1.0, 0.0, true, egui::Modifiers::NONE, &[], &pt), 15.0);
assert_eq!(spinner_step(14.0, 1.0, 0.0, false, egui::Modifiers::COMMAND, &[], &pt), 13.9);
assert_eq!(spinner_step(0.0, 1.0, 0.0, false, egui::Modifiers::SHIFT, &[], &pt), 0.0);
}
#[test]
fn plain_and_command_steps_go_to_the_next_multiple_as_arrow_keys_do() {
// As ↑/↓ in the field (#2881): a plain step goes to the next whole number, Ctrl/Cmd to the
// next tenth, rounded to the places fields keep; Shift adds or takes ten.
assert_eq!(spinner_step(1.37, 1.0, 0.0, true, egui::Modifiers::NONE, &[], &pt), 2.0);
assert_eq!(spinner_step(1.37, 1.0, 0.0, false, egui::Modifiers::NONE, &[], &pt), 1.0);
assert_eq!(spinner_step(1.37, 1.0, 0.0, true, egui::Modifiers::COMMAND, &[], &pt), 1.4);
assert_eq!(spinner_step(1.37, 1.0, 0.0, false, egui::Modifiers::COMMAND, &[], &pt), 1.3);
assert_eq!(spinner_step(1.37, 1.0, 0.0, true, egui::Modifiers::SHIFT, &[], &pt), 11.37);
assert_eq!(spinner_step(0.37, 1.0, 0.0, false, egui::Modifiers::NONE, &[], &pt), 0.0);
}
/// A plain click on the spinner stops at each stop; Shift and Ctrl/Cmd steps do not.
#[test]
fn a_plain_spinner_step_stops_at_the_stops() {
let stops = [0.25, 0.5, 0.75, 1.0, 2.0, 10.0, 20.0];
let plain = egui::Modifiers::NONE;
assert_eq!(spinner_step(0.52, 1.0, 0.0, false, plain, &stops, &pt), 0.5);
assert_eq!(spinner_step(0.52, 1.0, 0.0, true, plain, &stops, &pt), 0.75);
assert_eq!(spinner_step(0.1, 1.0, 0.0, false, plain, &stops, &pt), 0.0);
assert_eq!(spinner_step(14.0, 1.0, 0.0, true, plain, &stops, &pt), 15.0);
assert_eq!(spinner_step(14.0, 1.0, 0.0, false, egui::Modifiers::SHIFT, &stops, &pt), 4.0);
assert_eq!(spinner_step(0.52, 1.0, 0.0, true, egui::Modifiers::COMMAND, &stops, &pt), 0.6);
}
/// A stop the spinner's text shows as the value counts as reached: 2.004 pt and the 2 pt stop
/// both show as `0.167 p`, so a click down goes on to 1 pt.
#[test]
fn a_spinner_stop_shown_as_the_value_is_passed() {
let pc = |v: f64| Unit::Picas.format(v);
assert_eq!(spinner_step(2.004, 1.0, 0.0, false, egui::Modifiers::NONE, &[1.0, 2.0, 3.0], &pc), 1.0);
assert_eq!(spinner_step(2.004, 1.0, 0.0, true, egui::Modifiers::NONE, &[1.0, 2.0, 3.0], &pc), 3.0);
}
}
#[cfg(test)]
mod step_through_tests {
use super::*;
/// Each step goes to the nearer of the next whole step and the next stop in its direction.
#[test]
fn a_step_goes_to_the_nearer_of_the_next_multiple_and_the_next_stop() {
let stops = [0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 10.0, 20.0, 100.0];
for (v, n, want) in [
(0.52, -1.0, 0.5),
(0.51, -1.0, 0.5),
(0.4, -1.0, 0.25),
(0.52, 1.0, 0.75),
(0.1, 1.0, 0.25),
(0.1, -1.0, 0.0),
(0.25, -1.0, 0.0),
(0.75, -1.0, 0.5),
(1.0, -1.0, 0.75),
(1.0, 1.0, 2.0),
(1.5, -1.0, 1.0),
(14.0, 1.0, 15.0),
(14.0, -1.0, 13.0),
(14.5, 1.0, 15.0),
(0.4, -2.0, 0.0),
(0.4, 3.0, 1.0),
(100.0, 1.0, 101.0),
(0.52, 0.0, 0.52),
] {
let got = step_through(v, 1.0, n, &stops, within_a_millionth(1.0));
assert!((got - want).abs() < 1e-9, "{v} × {n}: {got}");
}
}
/// A value a float error away from a stop moves on to the next one.
#[test]
fn a_value_next_to_a_stop_moves_past_it() {
assert_eq!(step_through(0.5 + 1e-9, 1.0, -1.0, &[0.25, 0.5], within_a_millionth(1.0)), 0.25);
assert_eq!(step_through(0.5 - 1e-9, 1.0, 1.0, &[0.5, 0.75], within_a_millionth(1.0)), 0.75);
}
/// A stop shown with the same text as the value moves on to the next one: 2 pt shows as
/// `0.167 p`. A stop shown differently is still reached: `0.016 in` goes down to `0.0156 in`.
#[test]
fn a_stop_shown_as_the_value_moves_past_it() {
let shown = |unit: Unit| move |a: f64, b: f64| unit.number(unit.to_pt(a)) == unit.number(unit.to_pt(b));
let picas = [1.0 / 12.0, 2.0 / 12.0, 3.0 / 12.0];
assert_eq!(step_through(0.167, 1.0, -1.0, &picas, shown(Unit::Picas)), 1.0 / 12.0);
assert_eq!(step_through(0.083, 1.0, 1.0, &picas, shown(Unit::Picas)), 2.0 / 12.0);
assert_eq!(step_through(0.016, 1.0, -1.0, &[0.0078, 0.0156], shown(Unit::Inches)), 0.0156);
// The next whole step shown as the value is passed too: 2.0004 pt shows as `2 pt`.
assert_eq!(step_through(2.0004, 1.0, -1.0, &[1.0, 2.0], shown(Unit::Points)), 1.0);
assert_eq!(step_through(2.0004, 1.0, -1.0, &[], shown(Unit::Points)), 1.0);
}
}
#[cfg(test)]
mod step_to_multiple_tests {
use super::*;
/// #2881: the first step goes to the next multiple of its size in its direction, and each further
/// step adds or takes one size.
#[test]
fn a_step_goes_to_the_next_multiple_of_its_size() {
for (v, size, n, want) in [
(5.1, 1.0, 1.0, 6.0),
(5.1, 1.0, -1.0, 5.0),
(5.0, 1.0, 1.0, 6.0),
(5.0, 1.0, -1.0, 4.0),
(-2.5, 1.0, 1.0, -2.0),
(-2.5, 1.0, -1.0, -3.0),
(24.8, 1.0, 2.0, 26.0),
(24.8, 1.0, -2.0, 23.0),
(5.13, 0.1, 1.0, 5.2),
(5.13, 0.1, -1.0, 5.1),
(5.1, 1.0, 0.0, 5.1),
] {
assert!((step_to_multiple(v, size, n) - want).abs() < 1e-9, "{v} by {size} × {n}: {}", step_to_multiple(v, size, n));
}
}
/// A value a float error away from a multiple moves a whole step from it: 0.7 is 6.999… tenths.
#[test]
fn a_value_next_to_a_multiple_moves_a_whole_step() {
assert!((step_to_multiple(0.7, 0.1, 1.0) - 0.8).abs() < 1e-9);
assert!((step_to_multiple(0.3, 0.1, -1.0) - 0.2).abs() < 1e-9);
assert!((step_to_multiple(5.000_000_1, 1.0, 1.0) - 6.0).abs() < 1e-9);
assert!((step_to_multiple(4.999_999_9, 1.0, -1.0) - 4.0).abs() < 1e-9);
}
}
#[cfg(test)]
mod decimal_point_tests {
use super::*;
/// A decimal comma reads as a point, in a length, with arithmetic and in a percentage; text
/// with a point keeps its commas and is refused as before, and so does a thousands comma
/// (#3837).
#[test]
fn a_decimal_comma_reads_as_a_point() {
let mm = |s: &str| Unit::Millimeters.parse(&decimal_point(s)).map(|pt| Unit::Millimeters.from_pt(pt));
for (text, want) in [("0,1", 0.1), ("0,1 mm", 0.1), ("0,1mm", 0.1), ("-0,5", -0.5), ("1,5+0,25", 1.75), ("0.1", 0.1)] {
let got = mm(text).unwrap_or(f64::NAN);
assert!((got - want).abs() < 1e-9, "{text}: {got}");
}
assert_eq!(mm("1,000.5"), None, "a point and a comma");
assert_eq!(mm("1,2,3"), None, "two commas");
for thousand in ["1,000", "12,500 mm", "-1,000", "999,999mm"] {
assert_eq!(mm(thousand), None, "{thousand} reads as thousands: refused, not a thousand times too small");
}
for (text, want) in [("0,125", 0.125), ("1,25", 1.25), ("1,0000", 1.0), ("1,500+2", 3.5)] {
let got = mm(text).unwrap_or(f64::NAN);
assert!((got - want).abs() < 1e-9, "{text} is a decimal: {got}");
}
assert_eq!(share_of(&decimal_point("12,5%"), 200.0), Some(25.0));
}
}
#[cfg(test)]
mod menu_room_tests {
use super::*;
#[test]
fn menu_stops_above_the_bottom_bars() {
// Window 1000 pt tall, bars start at 948: a menu opening at y = 100 gets 836 pt, not 888.
assert_eq!(menu_room(948.0, 100.0), 836.0);
}
#[test]
fn menu_keeps_a_minimum_height() {
assert_eq!(menu_room(300.0, 290.0), 120.0);
}
}
#[cfg(test)]
mod gradient_chip_tests {
use super::*;
use vectorcraft_color::{Gradient, GradientKind, GradientPaint};
fn paint(kind: GradientKind, angle: f64) -> GradientPaint {
let mut gp = GradientPaint::new(Gradient { kind, ..Gradient::default() });
gp.angle = angle;
gp
}
#[test]
fn linear_chip_follows_the_angle() {
// 5 x 5 grid points on a 20 x 20 chip; White (start) to Black (end).
let at = |colors: &[Color32], i: usize, j: usize| colors[j * 5 + i];
let side = gradient_chip_colors(&paint(GradientKind::Linear, 0.0), 20.0, 20.0, 4, 4);
assert_eq!(at(&side, 0, 2), Color32::WHITE);
assert_eq!(at(&side, 4, 2), Color32::BLACK);
assert_eq!(at(&side, 2, 0), at(&side, 2, 4));
// 90 degrees runs bottom to top: constant along rows, white at the bottom.
let up = gradient_chip_colors(&paint(GradientKind::Linear, 90.0), 20.0, 20.0, 4, 4);
assert_eq!(at(&up, 0, 4), Color32::WHITE);
assert_eq!(at(&up, 0, 0), Color32::BLACK);
assert_eq!(at(&up, 0, 2), at(&up, 4, 2));
}
#[test]
fn radial_chip_runs_from_the_centre_out() {
let colors = gradient_chip_colors(&paint(GradientKind::Radial, 0.0), 20.0, 20.0, 4, 4);
assert_eq!(colors[2 * 5 + 2], Color32::WHITE);
assert_eq!(colors[0], Color32::BLACK);
}
}
#[cfg(test)]
mod combo_keyboard_tests {
use super::*;
fn key(key: egui::Key, pressed: bool) -> egui::Event {
egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::NONE }
}
fn frame(ctx: &egui::Context, events: Vec<egui::Event>, picked: &mut Option<usize>) {
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| {
if let Some(i) = dropdown(ui, "combo-test", "One", &["One", "Two", "Three"], 200.0) {
*picked = Some(i);
}
});
// No renderer takes the font atlas updates in a test; dropping them unapplied panics.
out.textures_delta.clear();
}
/// An option picked with the keyboard closes the list (it used to stay open and keep the keys).
#[test]
fn option_picked_with_space_closes_the_list() {
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
let mut picked = None;
frame(&ctx, vec![], &mut picked);
frame(&ctx, vec![], &mut picked);
// Tab to the combo, open it with Space.
for k in [egui::Key::Tab, egui::Key::Space] {
frame(&ctx, vec![key(k, true)], &mut picked);
frame(&ctx, vec![key(k, false)], &mut picked);
}
assert!(egui::Popup::is_any_open(&ctx), "Space opens the list");
// Tab into the list, then Space picks the option under the focus.
for _ in 0..8 {
frame(&ctx, vec![key(egui::Key::Tab, true)], &mut picked);
frame(&ctx, vec![key(egui::Key::Tab, false)], &mut picked);
frame(&ctx, vec![key(egui::Key::Space, true)], &mut picked);
frame(&ctx, vec![key(egui::Key::Space, false)], &mut picked);
if picked.is_some() {
break;
}
}
assert!(picked.is_some(), "an option was picked");
frame(&ctx, vec![], &mut picked);
assert!(!egui::Popup::is_any_open(&ctx), "the list closes after a keyboard pick");
}
}
#[cfg(test)]
mod button_tests {
use super::*;
/// [`flat_button_fit`] preserves minimum width for short labels and expands for long ones.
#[test]
fn flat_button_fit_expands_for_long_text() {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let raw = egui::RawInput::default();
let mut out = ctx.run_ui(raw, |ui| {
let resp_short = flat_button_fit(ui, "OK", 100.0);
assert_eq!(resp_short.rect.width(), 100.0);
let resp_long = flat_button_fit(ui, "Çalışma Yüzeylerini Düzenle", 100.0);
assert!(resp_long.rect.width() > 160.0);
});
out.textures_delta.clear();
}
}
/// How the shared widgets look once laid out and painted: shapes of whole frames, measured.
#[cfg(test)]
pub(crate) mod look_tests;