Files
vectorcraft/crates/ui-egui/src/widgets.rs
T
BFlat 1664f6de66 Merge pull request #7139 from dotCooCoo/feat/ctb-type-tools-font-fill
Contextual Task Bar: the Type tools show font family, style, size and Fill
2026-11-14 01:54:39 +07:00

1010 lines
49 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;
mod wheel_stream;
pub use blend::{
TransparencyEdit, angle_dial, blend_dropdown, blend_param, blend_param_dropdown, blend_separator_before, opacity_blend, reference_point,
reference_point_named,
};
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::{button_width, 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 use color::{
FieldAxis, Live, RingResponse, 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, hsb32, hue_ring,
region_texture, ring_square, segment_color, segmented_wheel, stepped_area,
};
pub(crate) use color::{HEX_FIELD_CHARS, hex_text, pointer_phase};
pub use drag::{
PanelDrag, RowClick, SwatchRows, apply_row_click, art_drop, doc_preview, drag_ghost, drag_source, drop_target_outline, ghost_row, row_click,
};
#[cfg(test)]
use dropdowns::combo_button_id;
pub(crate) use dropdowns::{combo, combo_menu, combo_menu_field_named, combo_named, field_focused_in, popover_is_open};
pub use dropdowns::{
dropdown, dropdown_icon_names, 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,
};
#[cfg(test)]
pub(crate) use dropdowns::{menu_floor, menu_room_either};
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, text_width};
pub(crate) use numeric::{ANY, round_to, set_bare_numbers_are_points, typed_unit};
pub use numeric::{
SpinPick, mixed_field, mixed_field_named, num_field, num_field_live, num_field_named, num_field_within, num_field_within_empty, opt_field,
opt_field_named, plain_field_live, range_field, range_field_live, 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_kerning, spin_length_within, spin_plain,
spin_plain_named, spin_range,
};
#[cfg(test)]
use numeric::{decimal_point, field_number, share_of, spinner_step, step_through, step_to_multiple, within_a_millionth};
pub(crate) use panel_chrome::{PANEL_FOOTER, panel_footer, panel_footer_bottom, panel_footer_space, panel_list_height};
pub use panel_chrome::{bottom_bar, layout_button, list_box, orientation_button, tab_bar};
pub(crate) use text::{
arm_field_focus, dialog_focus_flag, dialog_reject_commit_flag, disarm_field_focus, enter_modifiers, focus_field, own_field_shift_enter,
reject_dialog_commit, 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, check3_tip, radio, radio_button};
#[cfg(test)]
pub(crate) use wheel_stream::WHEEL_STREAM_GAP;
pub(crate) use wheel_stream::{ArmedWheelField, WheelStream, WheelStreamOwner, taken_from_raw_id};
/// 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`.
pub(crate) 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 widgets that `tabs` presses of Tab give the keyboard focus to, in order. `frame` runs
/// one frame of `ctx` with the events it is given.
pub(crate) fn tab_stops(ctx: &egui::Context, tabs: usize, mut frame: impl FnMut(Vec<egui::Event>)) -> Vec<egui::Id> {
let tab = egui::Event::Key { key: egui::Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE };
let mut stops = vec![];
for _ in 0..tabs {
frame(vec![tab.clone()]);
frame(vec![]);
stops.extend(ctx.memory(|m| m.focused()));
}
stops
}
/// 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}");
}
let grid = named(&tree, "Reference Point");
assert_eq!((grid.role(), grid.value()), (Role::RadioGroup, Some("Center")), "the grid is a radio group showing the current point");
}
/// Screen readers find the nine squares inside the Reference Point radio group.
#[test]
fn the_reference_point_group_holds_its_squares() {
crate::i18n::set_current(crate::i18n::Lang::EN);
let ctx = egui::Context::default();
ctx.enable_accesskit();
theme::install_fonts(&ctx);
let mut nodes = vec![];
for time in [0.0, 0.1] {
let mut out = ctx.run_ui(egui::RawInput { time: Some(time), ..Default::default() }, |ui| {
reference_point(ui, 0);
});
out.textures_delta.clear();
nodes = out.platform_output.accesskit_update.map(|u| u.nodes).unwrap_or_default();
}
let node = |id: &egui::accesskit::NodeId| nodes.iter().find(|(n, _)| n == id).map(|(_, n)| n);
let Some((group, _)) = nodes.iter().find(|(_, n)| n.label() == Some("Reference Point")) else { panic!("no Reference Point group") };
let mut inside = vec![];
let mut todo = vec![*group];
while let Some(id) = todo.pop() {
for child in node(&id).map(|n| n.children()).unwrap_or_default() {
inside.extend(node(child).and_then(|n| n.label()).map(str::to_owned));
todo.push(*child);
}
}
for label in ["Top Left", "Top", "Top Right", "Left", "Center", "Right", "Bottom Left", "Bottom", "Bottom Right"] {
assert!(inside.iter().any(|l| l == label), "{label} is in the group: {inside:?}");
}
}
/// A frame of a button, the reference point grid at `point` and another button, after `events`;
/// a new point from the grid replaces `point`. Returns the two buttons' ids.
fn grid_frame(ctx: &egui::Context, events: Vec<egui::Event>, point: &mut usize) -> (egui::Id, egui::Id) {
let mut ids = (egui::Id::NULL, egui::Id::NULL);
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| {
ids.0 = ui.button("Before").id;
if let Some(i) = reference_point(ui, *point) {
*point = i;
}
ids.1 = ui.button("After").id;
});
// No renderer takes the font atlas updates in a test; dropping them unapplied panics.
out.textures_delta.clear();
ids
}
/// Tab stops once on the reference point grid, not on each of its nine squares.
#[test]
fn the_reference_point_grid_is_one_tab_stop() {
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
let mut point = 4;
let mut ids = (egui::Id::NULL, egui::Id::NULL);
let stops = tab_stops(&ctx, 3, |events| ids = grid_frame(&ctx, events, &mut point));
assert_eq!(stops.len(), 3, "{stops:?}");
assert_eq!(stops[0], ids.0, "the first Tab reaches the button before the grid");
let size = ctx.read_response(stops[1]).map(|r| r.rect.size());
assert_eq!(size, Some(egui::Vec2::splat(23.0)), "the second Tab reaches the whole grid, not one of its squares");
assert_eq!(stops[2], ids.1, "the third Tab leaves the grid for the button after it");
assert_eq!(point, 4, "Tab leaves the point as it was");
}
/// While the grid has the focus, the arrow keys move the point one square, stop at the edges and
/// keep the focus on the grid.
#[test]
fn arrow_keys_move_the_reference_point() {
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
let mut point = 4;
let stops = tab_stops(&ctx, 2, |events| {
grid_frame(&ctx, events, &mut point);
});
let grid = stops[1];
let arrow = |key| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE };
for (key, want) in [
(egui::Key::ArrowRight, 5),
(egui::Key::ArrowUp, 2),
(egui::Key::ArrowRight, 2),
(egui::Key::ArrowUp, 2),
(egui::Key::ArrowLeft, 1),
(egui::Key::ArrowDown, 4),
(egui::Key::ArrowDown, 7),
(egui::Key::ArrowLeft, 6),
(egui::Key::ArrowDown, 6),
] {
grid_frame(&ctx, vec![arrow(key)], &mut point);
grid_frame(&ctx, vec![], &mut point);
assert_eq!(point, want, "{key:?}");
assert_eq!(ctx.memory(|m| m.focused()), Some(grid), "{key:?} keeps the focus on the grid");
}
// From no point, an arrow moves from the center.
point = 9;
grid_frame(&ctx, vec![arrow(egui::Key::ArrowLeft)], &mut point);
grid_frame(&ctx, vec![], &mut point);
assert_eq!(point, 3, "← from no point chose the middle of the left side");
assert_eq!(ctx.memory(|m| m.focused()), Some(grid), "← from no point keeps the focus on the grid");
}
/// A locator under another label takes that label, without its colon, as the group's name.
#[test]
fn a_named_reference_point_group_takes_its_label() {
// Each locator gets its own id scope, so their ids differ.
let tree = tree(|ui| {
for (point, label) in [(4, "Pivot:"), (0, "Origine :"), (8, "基準点:")] {
ui.push_id(label, |ui| reference_point_named(ui, point, label));
}
});
for (label, value) in [("Pivot", "Center"), ("Origine", "Top Left"), ("基準点", "Bottom Right")] {
let grid = named(&tree, label);
assert_eq!((grid.role(), grid.value()), (Role::RadioGroup, Some(value)), "{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, next to
/// numbers written with a point too (#5684); a number with both separators is refused, and so
/// is 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}");
}
// Each number reads its own separator: a comma typed after a value shown with a point
// (#5684). A number with both separators is refused.
for (text, want) in [("100,5-20", 80.5), ("100-0,5", 99.5), ("10,5*2", 21.0), ("100.5 mm-0,5", 100.0), ("0,5+100.25", 100.75)] {
let got = mm(text).unwrap_or(f64::NAN);
assert!((got - want).abs() < 1e-9, "{text}: {got}");
}
for both in ["1.234,5", "1,234.5", "1.5,5-2"] {
assert_eq!(mm(both), None, "{both}: a point and a comma in one number");
}
assert_eq!(share_of("12,5%", 200.0), Some(25.0));
assert_eq!(share_of("1,000%", 200.0), None, "1,000% reads as thousands: refused, not 1%");
}
}
#[cfg(test)]
mod field_number_tests {
use super::*;
/// Kerning drops one pair of parentheses around its whole text, so its automatic value `(12)`
/// reads as 12. Parentheses inside the text are refused rather than dropped, which would read
/// `(10+5)*2` as `10+5*2`, and fields without the Kerning flag refuse parentheses too.
#[test]
fn only_kerning_drops_one_pair_of_parentheses_around_the_text() {
assert_eq!(field_number("(12)", "", true), Some(12.0));
assert_eq!(field_number(" (-25) ", "", true), Some(-25.0));
assert_eq!(field_number("(10+5)", "", true), Some(15.0));
assert_eq!(field_number("12", "", true), Some(12.0));
for text in ["(10+5)*2", "2*(10+5)", "(10)+(5)", "((12))", "(12", "12)"] {
assert_eq!(field_number(text, "", true), None, "{text}");
}
for text in ["(12)", "(10+5)*2"] {
assert_eq!(field_number(text, "", false), None, "{text} without the Kerning flag");
}
assert_eq!(field_number("45*2°", "°", false), Some(90.0));
assert_eq!(field_number("50% / 2", "%", false), Some(25.0));
}
/// A thousands comma is refused inside Kerning's pair of parentheses and before `%` or the
/// suffix, as it is in plain text, rather than read as a decimal comma (`(1,000)` as 1).
#[test]
fn a_thousands_comma_is_refused_inside_parentheses_and_before_a_suffix() {
for text in ["(1,000)", " (-1,000) ", "(12,500)"] {
assert_eq!(field_number(text, "", true), None, "{text}");
}
assert_eq!(field_number("1,000%", "%", false), None);
assert_eq!(field_number("1,000°", "°", false), None);
assert_eq!(field_number("(1,5)", "", true), Some(1.5));
assert_eq!(field_number("(0,125)", "", true), Some(0.125));
assert_eq!(field_number("12,5%", "%", false), Some(12.5));
}
}
#[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, 980.0), 836.0);
}
#[test]
fn menu_keeps_a_minimum_height() {
assert_eq!(menu_room(300.0, 290.0, 280.0), 120.0);
}
/// A native panel window can be 100 pt tall, shorter than the minimum: the list then takes the
/// whole window, so its last items can scroll into view.
#[test]
fn a_window_shorter_than_the_minimum_limits_the_menu() {
assert_eq!(menu_room(100.0, 24.0, 92.0), 92.0);
}
/// A popup opens below its button while that leaves the minimum height, even when the list
/// then scrolls.
#[test]
fn a_menu_opens_below_while_that_side_has_the_minimum() {
assert_eq!(menu_room_either(300.0, 800.0, 980.0), 288.0);
assert_eq!(menu_room_either(132.0, 800.0, 980.0), 120.0);
}
/// With less than the minimum below, the list takes the room above, up to the window's height.
#[test]
fn a_menu_opens_above_when_below_is_too_short() {
assert_eq!(menu_room_either(40.0, 500.0, 980.0), 488.0);
assert_eq!(menu_room_either(40.0, 500.0, 300.0), 300.0);
}
/// With little room on either side, the list keeps the minimum height.
#[test]
fn a_menu_keeps_the_minimum_with_little_room_either_side() {
assert_eq!(menu_room_either(40.0, 60.0, 980.0), 120.0);
}
/// The floor one window records does not reach a menu in another window of the same context.
#[test]
fn the_menu_floor_applies_in_the_window_that_recorded_it() {
let ctx = egui::Context::default();
let frame = |viewport_id: egui::ViewportId, height: f32, floor: Option<f32>| {
let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(400.0, height)));
let mut seen = 0.0;
let viewports = std::iter::once((viewport_id, egui::ViewportInfo::default())).collect();
let mut full = ctx.run_ui(egui::RawInput { viewport_id, viewports, screen_rect, ..Default::default() }, |ui| {
if let Some(y) = floor {
set_menu_floor(ui.ctx(), y);
}
seen = dropdowns::menu_floor(ui.ctx());
});
full.textures_delta.clear();
seen
};
assert_eq!(frame(egui::ViewportId::ROOT, 1000.0, Some(948.0)), 948.0, "the main window stops at its bottom bars");
let panel = egui::ViewportId::from_hash_of("panel window");
assert_eq!(frame(panel, 240.0, None), 240.0, "a shorter panel window stops at its own bottom edge");
assert_eq!(frame(panel, 1400.0, None), 1400.0, "and a taller one too");
assert_eq!(frame(egui::ViewportId::ROOT, 1000.0, None), 948.0, "the main window keeps its floor");
}
}
#[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));
}
/// A conic chip sweeps clockwise from its angle, or counterclockwise when the gradient is
/// mirrored.
#[test]
fn conic_chip_sweeps_the_way_the_gradient_turns() {
let at = |colors: &[Color32], i: usize, j: usize| colors[j * 5 + i].r();
let mut gp = paint(GradientKind::Conic, 0.0);
let chip = gradient_chip_colors(&gp, 20.0, 20.0, 4, 4);
// From the right: a quarter turn on is below the center, halfway around left of it.
assert!(at(&chip, 2, 4).abs_diff(191) <= 2 && at(&chip, 2, 0).abs_diff(64) <= 2, "{chip:?}");
assert!(at(&chip, 0, 2).abs_diff(128) <= 2, "{chip:?}");
let c = vectorcraft_geom::kurbo::Point::new(50.0, 50.0);
gp.geom =
Some(vectorcraft_color::GradientGeom { start: c, end: c + vectorcraft_geom::kurbo::Vec2::new(10.0, 0.0), aspect: -1.0, focal: None });
let mirrored = gradient_chip_colors(&gp, 20.0, 20.0, 4, 4);
assert!(at(&mirrored, 2, 4).abs_diff(64) <= 2 && at(&mirrored, 2, 0).abs_diff(191) <= 2, "{mirrored:?}");
}
/// A diamond chip runs from the center out to a square whose edges pass through the chip's
/// corners: halfway along an edge and halfway to a corner are the same color.
#[test]
fn diamond_chip_runs_out_to_the_corners() {
let at = |colors: &[Color32], i: usize, j: usize| colors[j * 5 + i].r();
let chip = gradient_chip_colors(&paint(GradientKind::Diamond, 0.0), 20.0, 20.0, 4, 4);
assert_eq!((at(&chip, 2, 2), at(&chip, 0, 0), at(&chip, 4, 4)), (255, 0, 0), "{chip:?}");
for (i, j) in [(2, 0), (0, 2), (1, 1), (3, 3)] {
assert!(at(&chip, i, j).abs_diff(128) <= 2, "({i}, {j}): {chip:?}");
}
}
/// A gradient that repeats or reflects as many times as a chip has cells (8 on a 24-point chip)
/// still shows its runs: the grid gets more cells than runs, so its points do not all sample
/// the same place in a run.
#[test]
fn a_chip_repeated_once_a_cell_is_not_one_color() {
for spread in [vectorcraft_color::GradientSpread::Repeat, vectorcraft_color::GradientSpread::Reflect] {
let mut gp = paint(GradientKind::Linear, 0.0);
gp.gradient.spread = spread;
gp.gradient.repeats = 8;
let (cols, rows) = chips::chip_grid(&gp.gradient, 24.0, 24.0);
assert!(cols > 8 && rows > 8, "{spread:?}: {cols} x {rows}");
let chip = gradient_chip_colors(&gp, 24.0, 24.0, cols, rows);
let row: std::collections::HashSet<Color32> = chip.iter().take(cols + 1).copied().collect();
assert!(row.len() > 2, "{spread:?}: {row:?}");
}
// A gradient that pads keeps a cell about every 3 points.
assert_eq!(chips::chip_grid(&Gradient::default(), 24.0, 24.0), (8, 8));
// Many runs give a small chip at most a cell a point.
let mut many = paint(GradientKind::Linear, 0.0).gradient;
many.spread = vectorcraft_color::GradientSpread::Repeat;
many.repeats = 100;
assert_eq!(chips::chip_grid(&many, 24.0, 12.0), (24, 12));
assert_eq!(chips::chip_grid(&many, 400.0, 400.0), (128, 128));
// A count that matches a chip's points still shows its runs.
let mut gp = paint(GradientKind::Linear, 0.0);
gp.gradient.spread = vectorcraft_color::GradientSpread::Repeat;
gp.gradient.repeats = 23;
let (cols, rows) = chips::chip_grid(&gp.gradient, 23.0, 23.0);
let chip = gradient_chip_colors(&gp, 23.0, 23.0, cols, rows);
let row: std::collections::HashSet<Color32> = chip.iter().take(cols + 1).copied().collect();
assert!(row.len() > 2, "{cols}: {row:?}");
}
#[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);
}
/// A stop at opacity 0 is clear on the chip of every kind, and one at 50% half clear.
#[test]
fn chip_shows_the_stops_opacity() {
use vectorcraft_color::{Color, GradientStop};
for kind in [GradientKind::Linear, GradientKind::Radial, GradientKind::Conic] {
let mut gp = paint(kind, 0.0);
gp.gradient.stops = vec![
GradientStop { opacity: 0.5, ..GradientStop::new(0.0, Color::BLACK) },
GradientStop { opacity: 0.0, ..GradientStop::new(1.0, Color::BLACK) },
];
let chip = gradient_chip_colors(&gp, 20.0, 20.0, 4, 4);
let (lo, hi) = chip.iter().fold((u8::MAX, 0), |(lo, hi), c| (lo.min(c.a()), hi.max(c.a())));
// No grid point of a conic chip lands exactly at the end of its sweep.
assert!(lo <= if kind == GradientKind::Conic { 12 } else { 0 }, "{kind:?}: {chip:?}");
assert!(hi.abs_diff(128) <= 1, "{kind:?}: {chip:?}");
}
let at = |colors: &[Color32], i: usize, j: usize| colors[j * 5 + i];
let mut gp = paint(GradientKind::Linear, 0.0);
gp.gradient.stops[1].opacity = 0.0;
let chip = gradient_chip_colors(&gp, 20.0, 20.0, 4, 4);
assert_eq!(at(&chip, 0, 2), Color32::WHITE);
assert_eq!(at(&chip, 4, 2).a(), 0);
// Radial: an opaque edge around a clear center.
let mut gp = paint(GradientKind::Radial, 0.0);
gp.gradient.stops[0].opacity = 0.0;
let chip = gradient_chip_colors(&gp, 20.0, 20.0, 4, 4);
assert_eq!(at(&chip, 2, 2).a(), 0);
assert_eq!(chip[0], Color32::BLACK);
}
/// A chip with a clear stop draws a checkerboard under the gradient; an opaque one does not.
#[test]
fn a_clear_gradient_chip_has_a_checkerboard_under_it() {
let ctx = egui::Context::default();
let shapes = |gp: &GradientPaint| {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| gradient_chip(ui, Rect::from_min_size(pos2(10.0, 10.0), vec2(20.0, 20.0)), gp));
out.textures_delta.clear();
out.shapes.into_iter().map(|s| s.shape).collect::<Vec<_>>()
};
let opaque = shapes(&paint(GradientKind::Linear, 0.0));
assert!(matches!(opaque[..], [egui::Shape::Mesh(_)]), "{opaque:?}");
let mut gp = paint(GradientKind::Linear, 0.0);
gp.gradient.stops[1].opacity = 0.0;
let clear = shapes(&gp);
let n = clear.len();
assert!(n > 2 && matches!(clear[n - 1], egui::Shape::Mesh(_)), "{clear:?}");
let fills: std::collections::HashSet<_> =
clear[..n - 1].iter().filter_map(|s| if let egui::Shape::Rect(r) = s { Some(r.fill) } else { None }).collect();
assert_eq!(fills, [Color32::WHITE, Color32::from_gray(204)].into_iter().collect(), "{clear:?}");
}
#[test]
fn freeform_chip_shows_the_points_opacity() {
use vectorcraft_color::{Color, Freeform, FreeformPoint};
use vectorcraft_geom::kurbo::Point;
let mut gp = paint(GradientKind::Freeform, 0.0);
let clear = FreeformPoint { opacity: 0.0, ..FreeformPoint::new(Point::new(10.0, 10.0), Color::BLACK) };
gp.freeform = Some(Freeform { points: vec![clear], ..Default::default() });
let chip = gradient_chip_colors(&gp, 20.0, 20.0, 4, 4);
assert!(chip.iter().all(|c| c.a() == 0), "{chip:?}");
}
/// A Holographic glow's chip is clear at the corners and nearly opaque at the center; a foil's
/// chip is opaque everywhere.
#[test]
fn holographic_chips_show_a_glow_clear_at_its_edge_and_a_foil_opaque() {
use vectorcraft_color::Paint;
let lib = vectorcraft_color::libraries::builtin_library("holographic").unwrap();
let chip = |name: &str| match &lib.swatch(name).unwrap().paint {
Paint::Gradient(g) => gradient_chip_colors(g, 20.0, 20.0, 4, 4),
p => panic!("{name}: {p:?}"),
};
let glow = chip("Mint Halo");
assert!([0, 4, 20, 24].iter().all(|&i| glow[i].a() == 0), "{glow:?}");
assert!(glow[2 * 5 + 2].a() >= 240, "{glow:?}");
let foil = chip("Prism Veil");
assert!(foil.iter().all(|c| c.a() == 255), "{foil:?}");
}
#[test]
fn freeform_chip_mixes_points_in_the_model_they_share() {
use vectorcraft_color::{Color, Freeform, FreeformPoint};
use vectorcraft_geom::kurbo::Point;
let chip = |a: Color, b: Color| {
let p = |x, c| FreeformPoint::new(Point::new(x, 10.0), c);
let mut gp = paint(GradientKind::Freeform, 0.0);
gp.freeform = Some(Freeform { points: vec![p(0.0, a), p(20.0, b)], ..Default::default() });
gradient_chip_colors(&gp, 20.0, 20.0, 4, 4)[2 * 5 + 2]
};
let near = |got: Color32, want: [u8; 4]| [got.r(), got.g(), got.b()].iter().zip(want).all(|(g, w)| g.abs_diff(w) <= 3);
let got = chip(Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 0.0, 0.0, 1.0));
let want = Color::cmyk(0.5, 0.0, 0.0, 0.5).to_rgba8(1.0);
assert!(near(got, want), "CMYK: {got:?} vs {want:?}");
let got = chip(Color::lab(90.0, -60.0, 40.0), Color::lab(30.0, 60.0, -40.0));
let want = Color::lab(60.0, 0.0, 0.0).to_rgba8(1.0);
assert!(near(got, want), "Lab: {got:?} vs {want:?}");
}
/// At a position and display scale that fall between pixels, the gradient chip covers the same
/// pixel rows as a colour or None chip beside it (#3826). Filled rectangles are drawn on whole
/// pixels, so the toolbar's Gradient button sat up to half a pixel off its neighbors.
#[test]
fn gradient_chip_lines_up_with_a_solid_chip() {
let ctx = egui::Context::default();
ctx.set_pixels_per_point(1.5);
// Without anti-aliasing, a filled rectangle's mesh is exactly the rectangle it fills.
ctx.tessellation_options_mut(|o| o.feathering = false);
let chip = |x: f32| Rect::from_min_size(pos2(x, 40.3), vec2(11.0, 11.0));
let mut shapes = Vec::new();
for _ in 0..2 {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
paint_chip(ui, chip(10.2), &Paint::solid(Color::BLACK));
gradient_chip(ui, chip(23.2), &paint(GradientKind::Linear, 0.0));
});
out.textures_delta.clear();
assert_eq!(out.pixels_per_point, 1.5);
shapes = out.shapes;
}
let bounds = |shape: egui::epaint::ClippedShape| {
let mut r = Rect::NOTHING;
for p in ctx.tessellate(vec![shape], 1.5) {
if let egui::epaint::Primitive::Mesh(m) = p.primitive {
m.vertices.iter().for_each(|v| r.extend_with(v.pos));
}
}
r
};
let n = shapes.len();
assert!(n >= 2, "{n} shapes");
let gradient = bounds(shapes.remove(n - 1));
let solid = bounds(shapes.remove(n - 2));
assert_eq!((gradient.top(), gradient.bottom()), (solid.top(), solid.bottom()), "gradient {gradient:?}, solid {solid:?}");
assert_eq!(gradient, egui::emath::GuiRounding::round_to_pixels(chip(23.2), 1.5));
}
}
#[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;