mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 19:57:05 -06:00
refactor(ui-egui): move dialogs/color_picker.rs tests to dialogs/color_picker/tests.rs
This commit is contained in:
@@ -640,677 +640,4 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use super::*;
|
||||
use crate::panels::color::in_gamut;
|
||||
|
||||
fn app() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
fn frame(app: &mut VectorcraftApp) {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| super::super::show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
fn fill_hex(app: &VectorcraftApp) -> String {
|
||||
crate::panels::current_paints(app).0.color().unwrap().to_hex()
|
||||
}
|
||||
|
||||
/// Runs one frame of the dialog with `events` in `ctx`, which keeps the keyboard focus from
|
||||
/// frame to frame, and returns the texts drawn with their screen rects.
|
||||
fn frame_in(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec<egui::Event>) -> Vec<(String, Rect)> {
|
||||
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| super::super::show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
fn collect(s: &egui::Shape, out: &mut Vec<(String, Rect)>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut texts = vec![];
|
||||
out.shapes.iter().for_each(|c| collect(&c.shape, &mut texts));
|
||||
texts
|
||||
}
|
||||
|
||||
/// Returns the label of the value field that has the keyboard, which is the letter (or `#`)
|
||||
/// nearest to the field's left on its row, and the field's rect. Returns `None` when no text
|
||||
/// field has the keyboard.
|
||||
fn focused_field(ctx: &egui::Context, texts: &[(String, Rect)]) -> Option<(String, Rect)> {
|
||||
let id = ctx.memory(|m| m.focused()).filter(|&id| egui::TextEdit::load_state(ctx, id).is_some())?;
|
||||
let field = ctx.read_response(id)?.rect;
|
||||
let labels = ["H", "S", "B", "R", "G", "#", "L", "a", "b", "C", "M", "Y", "K"];
|
||||
let (label, _) = texts
|
||||
.iter()
|
||||
.filter(|(t, r)| labels.contains(&t.as_str()) && r.max.x <= field.min.x && field.y_range().contains(r.center().y))
|
||||
.max_by(|a, b| a.1.max.x.total_cmp(&b.1.max.x))?;
|
||||
Some((label.clone(), field))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_and_slider_map_back_per_channel() {
|
||||
let p = Picked::of(Color::rgb8(51, 153, 204));
|
||||
for ch in Channel::ALL {
|
||||
let (z, x, y) = field_pos(ch, &p);
|
||||
assert_eq!(field_pick(ch, z, x, y).color.to_hex(), "#3399cc", "{ch:?}");
|
||||
assert_eq!(Channel::parse(ch.id()), Some(ch));
|
||||
}
|
||||
// Hue: x is saturation, y brightness (white at the top left, black along the bottom).
|
||||
assert_eq!(field_pick(Channel::Hue, 0.0, 0.0, 0.0).color.to_hex(), "#ffffff");
|
||||
assert_eq!(field_pick(Channel::Hue, 0.0, 1.0, 0.0).color.to_hex(), "#ff0000");
|
||||
assert_eq!(field_pick(Channel::Hue, 0.5, 1.0, 1.0).color.to_hex(), "#000000");
|
||||
// Red: the slider is red, x blue and y green (green grows upwards).
|
||||
assert_eq!(field_pick(Channel::Red, 1.0, 1.0, 0.0).color.to_hex(), "#ffffff");
|
||||
assert_eq!(field_pick(Channel::Red, 0.0, 1.0, 1.0).color.to_hex(), "#0000ff");
|
||||
assert_eq!(field_pick(Channel::Green, 1.0, 0.0, 1.0).color.to_hex(), "#00ff00");
|
||||
assert_eq!(field_pick(Channel::Blue, 0.0, 1.0, 1.0).color.to_hex(), "#ff0000");
|
||||
// The hue slider shows the spectrum whatever the colour; the others vary one component.
|
||||
assert_eq!(slider_color(Channel::Hue, 1.0 / 3.0, 0.0, 1.0).to_hex(), "#00ff00");
|
||||
assert_eq!(slider_color(Channel::Saturation, 0.0, 0.0, 0.0).to_hex(), "#ffffff");
|
||||
// A grey keeps the hue it was picked with.
|
||||
let grey = field_pick(Channel::Hue, 0.5, 0.0, 0.5);
|
||||
assert_eq!(grey.hsb[0], 180.0);
|
||||
assert_eq!(field_pos(Channel::Hue, &grey).0, 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_web_colors_snaps() {
|
||||
let p = Picked::of(Color::rgb8(230, 120, 40));
|
||||
assert_eq!(p.snapped(false), p);
|
||||
assert_eq!(p.snapped(true).color.to_hex(), "#ff6633");
|
||||
assert!(is_web_safe(&p.snapped(true).color));
|
||||
let mut d = Dialog::new(KIND, json!({"color": "#e67828", "webOnly": true}));
|
||||
assert_eq!(picked(&d).color.to_hex(), "#ff6633");
|
||||
d.fields.insert("webOnly".into(), json!(false));
|
||||
assert_eq!(picked(&d).color.to_hex(), "#e67828");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamut_correction_gives_a_printable_colour() {
|
||||
let mut app = app();
|
||||
let blue = Color::rgb(0.0, 0.0, 1.0);
|
||||
assert!(blue.out_of_gamut());
|
||||
let fix = in_gamut(&mut app, &blue).expect("pure blue can't be printed");
|
||||
assert!(!fix.out_of_gamut(), "{fix:?}");
|
||||
assert!(matches!(fix, Color::Rgb { .. }));
|
||||
assert_eq!(in_gamut(&mut app, &Color::rgb8(128, 128, 128)), None);
|
||||
// The dialog and the Color panel ask through a cache.
|
||||
let ctx = egui::Context::default();
|
||||
assert_eq!(gamut_fix(&mut app, &ctx, "picker-gamut", &blue), Some(fix));
|
||||
assert_eq!(gamut_fix(&mut app, &ctx, "picker-gamut", &blue), Some(fix));
|
||||
assert_eq!(gamut_fix(&mut app, &ctx, "picker-gamut", &Color::rgb8(128, 128, 128)), None);
|
||||
}
|
||||
|
||||
/// The selected art takes each colour as it is picked; OK keeps the last as one undo step and
|
||||
/// Cancel puts the art back.
|
||||
#[test]
|
||||
fn the_selection_shows_the_colour_live() {
|
||||
let mut app = app();
|
||||
let undo = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len();
|
||||
let before = undo(&app);
|
||||
app.run("ui.colorPicker", json!({})).unwrap();
|
||||
frame(&mut app);
|
||||
assert!(!app.session.in_interaction(), "nothing previews before the colour changes");
|
||||
for hex in ["ff0000", "00ff00"] {
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!(hex));
|
||||
frame(&mut app);
|
||||
assert_eq!(fill_hex(&app), format!("#{hex}"), "live, before OK");
|
||||
}
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(fill_hex(&app), "#00ff00");
|
||||
assert_eq!(undo(&app), before + 1, "one step");
|
||||
// Cancel: back to the colour it had.
|
||||
app.run("ui.colorPicker", json!({})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("0000ff"));
|
||||
frame(&mut app);
|
||||
assert_eq!(fill_hex(&app), "#0000ff");
|
||||
super::super::cancel(&mut app);
|
||||
assert_eq!(fill_hex(&app), "#00ff00");
|
||||
assert_eq!(undo(&app), before + 1);
|
||||
}
|
||||
|
||||
/// Opened for an artboard (Properties › Artboards › Fill), the picker starts from the artboard's
|
||||
/// background color (white without one) and shows each color on its paper live, with nothing
|
||||
/// selected. OK keeps the last as one undo step, the art's fill unchanged, and Cancel puts the
|
||||
/// artboard back.
|
||||
#[test]
|
||||
fn an_artboard_shows_its_background_color_live() {
|
||||
let mut app = app();
|
||||
app.run("select.set", json!({"ids": []})).unwrap();
|
||||
app.run("artboard.new", json!({})).unwrap();
|
||||
let color = |app: &VectorcraftApp| app.session.doc().unwrap().doc.artboards[1].color.map(|c| c.to_hex());
|
||||
let undo = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len();
|
||||
let art = app.session.doc().unwrap().doc.layers.clone();
|
||||
let before = undo(&app);
|
||||
app.run("ui.colorPicker", json!({"artboard": 1})).unwrap();
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().str("hex"), "FFFFFF", "starts from white paper");
|
||||
frame(&mut app);
|
||||
assert!(!app.session.in_interaction(), "nothing previews before the color changes");
|
||||
for hex in ["ff0000", "00ff00"] {
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!(hex));
|
||||
frame(&mut app);
|
||||
assert_eq!(color(&app), Some(format!("#{hex}")), "live, before OK");
|
||||
}
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(color(&app).as_deref(), Some("#00ff00"));
|
||||
assert_eq!(undo(&app), before + 1, "one step");
|
||||
assert_eq!(app.session.doc().unwrap().doc.layers, art, "the art keeps its fill");
|
||||
// Cancel: back to the color it had, which the picker starts from.
|
||||
app.run("ui.colorPicker", json!({"artboard": 1})).unwrap();
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().str("hex"), "00FF00");
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("0000ff"));
|
||||
frame(&mut app);
|
||||
assert_eq!(color(&app).as_deref(), Some("#0000ff"));
|
||||
super::super::cancel(&mut app);
|
||||
assert_eq!(color(&app).as_deref(), Some("#00ff00"));
|
||||
assert_eq!(undo(&app), before + 1);
|
||||
assert!(app.run("ui.colorPicker", json!({"artboard": 9})).is_err());
|
||||
}
|
||||
|
||||
/// OK on a picker opened for an artboard without a background color, with the color left at the
|
||||
/// white it starts from, leaves the artboard without one and adds no undo step. A white the
|
||||
/// opener passes as `color` is set.
|
||||
#[test]
|
||||
fn an_untouched_picker_leaves_an_artboard_without_a_color() {
|
||||
let mut app = app();
|
||||
app.run("artboard.new", json!({})).unwrap();
|
||||
let color = |app: &VectorcraftApp| app.session.doc().unwrap().doc.artboards[1].color.map(|c| c.to_hex());
|
||||
let undo = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len();
|
||||
let before = undo(&app);
|
||||
app.run("ui.colorPicker", json!({"artboard": 1})).unwrap();
|
||||
frame(&mut app);
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none(), "OK closes the picker");
|
||||
assert_eq!(color(&app), None, "no color");
|
||||
assert_eq!(undo(&app), before, "no undo step");
|
||||
app.run("ui.colorPicker", json!({"artboard": 1, "color": "#ffffff"})).unwrap();
|
||||
frame(&mut app);
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(color(&app).as_deref(), Some("#ffffff"));
|
||||
assert_eq!(undo(&app), before + 1);
|
||||
}
|
||||
|
||||
/// OK on an untouched picker opened for an artboard with an RGB background color in a CMYK
|
||||
/// document keeps the RGB color and adds no undo step. A color picked in it is set as CMYK.
|
||||
#[test]
|
||||
fn an_untouched_picker_keeps_an_artboards_rgb_color_in_a_cmyk_document() {
|
||||
let mut app = app();
|
||||
app.run("artboard.new", json!({})).unwrap();
|
||||
app.run("file.documentColorMode", json!({"mode": "cmyk"})).unwrap();
|
||||
app.run("artboard.setProps", json!({"index": 1, "color": "#ff0000"})).unwrap();
|
||||
let color = |app: &VectorcraftApp| app.session.doc().unwrap().doc.artboards[1].color;
|
||||
let undo = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len();
|
||||
let start = color(&app);
|
||||
assert!(matches!(start, Some(Color::Rgb { .. })), "set as RGB");
|
||||
let before = undo(&app);
|
||||
app.run("ui.colorPicker", json!({"artboard": 1})).unwrap();
|
||||
frame(&mut app);
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none(), "OK closes the picker");
|
||||
assert_eq!(color(&app), start, "the RGB color is kept");
|
||||
assert_eq!(undo(&app), before, "no undo step");
|
||||
app.run("ui.colorPicker", json!({"artboard": 1})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("00ff00"));
|
||||
frame(&mut app);
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert!(matches!(color(&app), Some(Color::Cmyk { .. })), "a picked color is set as CMYK");
|
||||
assert_eq!(undo(&app), before + 1);
|
||||
}
|
||||
|
||||
/// OK on an untouched picker opened for an artboard with a background color in an RGB
|
||||
/// document keeps the color and adds no undo step.
|
||||
#[test]
|
||||
fn an_untouched_picker_adds_no_undo_step_for_an_artboards_color() {
|
||||
let mut app = app();
|
||||
app.run("artboard.new", json!({})).unwrap();
|
||||
app.run("artboard.setProps", json!({"index": 1, "color": "#ff0000"})).unwrap();
|
||||
let color = |app: &VectorcraftApp| app.session.doc().unwrap().doc.artboards[1].color.map(|c| c.to_hex());
|
||||
let undo = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len();
|
||||
let before = undo(&app);
|
||||
app.run("ui.colorPicker", json!({"artboard": 1})).unwrap();
|
||||
frame(&mut app);
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none(), "OK closes the picker");
|
||||
assert_eq!(color(&app).as_deref(), Some("#ff0000"));
|
||||
assert_eq!(undo(&app), before, "no undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_set_hex_and_confirm_fills() {
|
||||
let mut app = app();
|
||||
app.run("ui.colorPicker", json!({})).unwrap();
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!(super::super::DialogKind::of(&d.kind), Some(super::super::DialogKind::ColorPicker));
|
||||
assert_eq!(d.str("hex"), "FFFFFF", "starts from the fill");
|
||||
assert!(!d.bool("stroke"));
|
||||
frame(&mut app);
|
||||
// `ui.dialog.set {field: "hex"}` then `ui.dialog.confirm`.
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("00ff00"));
|
||||
frame(&mut app);
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().str("hex"), "00FF00");
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert_eq!(fill_hex(&app), "#00ff00");
|
||||
// Without a frame in between, a set hex still wins; `color` works as well.
|
||||
app.run("ui.colorPicker", json!({"stroke": true})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("#3366CC"));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(crate::panels::current_paints(&app).1.color().unwrap().to_hex(), "#3366cc");
|
||||
// The stroke proxy is in front now: the opener follows it unless told otherwise.
|
||||
app.run("ui.colorPicker", json!({})).unwrap();
|
||||
assert!(app.ui.dialog.as_ref().unwrap().bool("stroke"));
|
||||
app.run("ui.colorPicker", json!({"stroke": false})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("color".into(), json!({"c": 0, "m": 1, "y": 1, "k": 0}));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert!(matches!(crate::panels::current_paints(&app).0.color(), Some(Color::Cmyk { .. })), "CMYK is kept");
|
||||
assert!(app.run("ui.colorPicker", json!({"color": "nope"})).is_err());
|
||||
// Colours take every form the paint commands take (CMYK in percent too).
|
||||
app.run("ui.colorPicker", json!({"stroke": false, "color": {"c": 0, "m": 100, "y": 100, "k": 0}})).unwrap();
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(crate::panels::current_paints(&app).0.color(), Some(Color::cmyk(0.0, 1.0, 1.0, 0.0)));
|
||||
}
|
||||
|
||||
/// #1692: Tab goes through one color model's fields before the next: H, S, B, then R, G, B, the
|
||||
/// hex field, then L, a, b, then C, M, Y, K. Each Lab or CMYK field is level with the field on
|
||||
/// its left.
|
||||
#[test]
|
||||
fn tab_goes_through_one_color_models_fields_before_the_next() {
|
||||
let mut app = app();
|
||||
app.run("ui.colorPicker", json!({})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let tab = egui::Event::Key { key: egui::Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE };
|
||||
// The picker opens with the keyboard in H. The loop presses Tab until K has the keyboard
|
||||
// and records each stop. A stop is `None` when no text field has the keyboard, as at a
|
||||
// channel's radio button.
|
||||
let mut texts = vec![];
|
||||
for _ in 0..3 {
|
||||
texts = frame_in(&ctx, &mut app, vec![]);
|
||||
}
|
||||
let mut stops = vec![focused_field(&ctx, &texts)];
|
||||
while stops.len() < 40 && stops.last().and_then(|s| s.as_ref()).is_none_or(|(label, _)| label != "K") {
|
||||
texts = frame_in(&ctx, &mut app, vec![tab.clone()]);
|
||||
stops.push(focused_field(&ctx, &texts));
|
||||
}
|
||||
let names: Vec<Option<&str>> = stops.iter().map(|s| s.as_ref().map(|(label, _)| label.as_str())).collect();
|
||||
let fields: Vec<&str> = names.iter().flatten().copied().collect();
|
||||
assert_eq!(fields, ["H", "S", "B", "R", "G", "B", "#", "L", "a", "b", "C", "M", "Y", "K"], "{names:?}");
|
||||
assert_eq!(names[names.len() - 2..], [Some("Y"), Some("K")], "one Tab takes Y to K");
|
||||
let found: Vec<&(String, Rect)> = stops.iter().flatten().collect();
|
||||
for (left, right) in found[..7].iter().zip(&found[7..]) {
|
||||
// Two grids: their rows land on the same subpixel-rounded line, not necessarily bit-equal.
|
||||
assert!((left.1.center().y - right.1.center().y).abs() <= 0.5, "{} and {} are level", left.0, right.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_mode_draws_and_cmyk_documents_get_cmyk() {
|
||||
let mut app = app();
|
||||
for fields in
|
||||
[json!({"channel": "red"}), json!({"channel": "brightness", "webOnly": true}), json!({"swatches": true}), json!({"color": "#0000ff"})]
|
||||
{
|
||||
app.run("ui.colorPicker", json!({})).unwrap();
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
for (k, v) in fields.as_object().unwrap() {
|
||||
d.fields.insert(k.clone(), v.clone());
|
||||
}
|
||||
frame(&mut app);
|
||||
assert!(app.ui.dialog.is_some());
|
||||
}
|
||||
app.run("file.documentColorMode", json!({"mode": "cmyk"})).unwrap();
|
||||
app.run("ui.colorPicker", json!({"color": "#ff0000"})).unwrap();
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert!(matches!(crate::panels::current_paints(&app).0.color(), Some(Color::Cmyk { .. })));
|
||||
}
|
||||
/// Inspect actual painted controls, rather than a second implementation of the layout.
|
||||
#[derive(Default)]
|
||||
struct Drawn {
|
||||
texts: Vec<(String, Rect)>,
|
||||
visible_texts: Vec<String>,
|
||||
boxes: Vec<Rect>,
|
||||
fields: Vec<Rect>,
|
||||
}
|
||||
|
||||
impl Drawn {
|
||||
/// Collects painted text, control bounds and color meshes, recording text visibility
|
||||
/// against the clip rectangle so tests can detect clipped labels.
|
||||
fn collect(&mut self, shape: &egui::Shape, clip: Rect) {
|
||||
match shape {
|
||||
egui::Shape::Text(t) => {
|
||||
self.texts.push((t.galley.text().into(), t.visual_bounding_rect()));
|
||||
if clip.expand(1.0).contains_rect(t.visual_bounding_rect()) {
|
||||
self.visible_texts.push(t.galley.text().into());
|
||||
}
|
||||
}
|
||||
egui::Shape::Rect(r) => self.boxes.push(r.rect),
|
||||
egui::Shape::Mesh(m) if m.vertices.len() > 100 => self.fields.push(m.calc_bounds()),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| self.collect(s, clip)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the bounds of exactly one painted label, failing on missing or duplicate text.
|
||||
fn text(&self, label: &str) -> Rect {
|
||||
let found: Vec<_> = self.texts.iter().filter(|(s, _)| s == label).collect();
|
||||
assert_eq!(found.len(), 1, "one {label}, including no duplicate footer: {:?}", self.texts);
|
||||
found[0].1
|
||||
}
|
||||
|
||||
/// Finds the smallest painted control containing a unique label's center.
|
||||
fn control(&self, label: &str) -> Rect {
|
||||
let center = self.text(label).center();
|
||||
self.boxes.iter().filter(|r| r.contains(center)).min_by(|a, b| a.area().total_cmp(&b.area())).copied().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a dialog frame with real egui input at a physical window size, accounting for UI zoom,
|
||||
/// and returns the painted controls for layout and interaction assertions.
|
||||
fn draw(ctx: &egui::Context, app: &mut VectorcraftApp, size: egui::Vec2, events: Vec<egui::Event>) -> Drawn {
|
||||
let input =
|
||||
egui::RawInput { screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, size / ctx.zoom_factor())), events, ..Default::default() };
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
crate::shortcuts::handle(app, ui.ctx());
|
||||
super::super::show(app, ui.ctx());
|
||||
});
|
||||
let mut drawn = Drawn::default();
|
||||
for shape in &out.shapes {
|
||||
drawn.collect(&shape.shape, shape.clip_rect);
|
||||
}
|
||||
out.textures_delta.clear();
|
||||
drawn
|
||||
}
|
||||
|
||||
/// The physical window size used for pointer and keyboard interaction tests.
|
||||
fn screen() -> egui::Vec2 {
|
||||
vec2(1280.0, 900.0)
|
||||
}
|
||||
|
||||
/// Lets the auto-sized dialog settle over six frames and returns the next frame's controls.
|
||||
fn settled(ctx: &egui::Context, app: &mut VectorcraftApp, size: egui::Vec2) -> Drawn {
|
||||
for _ in 0..6 {
|
||||
draw(ctx, app, size, vec![]);
|
||||
}
|
||||
draw(ctx, app, size, vec![])
|
||||
}
|
||||
|
||||
/// Creates a persistent test context with the application's fonts and default theme.
|
||||
fn context() -> egui::Context {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
crate::theme::apply(&ctx, Default::default());
|
||||
ctx
|
||||
}
|
||||
|
||||
/// Sends a primary pointer press and release in separate frames at the given position.
|
||||
fn click_at(ctx: &egui::Context, app: &mut VectorcraftApp, at: egui::Pos2) {
|
||||
for pressed in [true, false] {
|
||||
draw(
|
||||
ctx,
|
||||
app,
|
||||
screen(),
|
||||
vec![
|
||||
egui::Event::PointerMoved(at),
|
||||
egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() },
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an unmodified, non-repeating key press for dialog shortcut tests.
|
||||
fn key(key: egui::Key) -> egui::Event {
|
||||
egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }
|
||||
}
|
||||
|
||||
/// Checks accessible action names and each channel slider's displayed value, range and units.
|
||||
#[test]
|
||||
fn compact_picker_preserves_screen_reader_controls_and_channel_values() {
|
||||
use egui::accesskit::Role;
|
||||
|
||||
for (channel, value, maximum, text) in [
|
||||
(Channel::Hue, 210.0, 360.0, "210°"),
|
||||
(Channel::Saturation, 67.0, 100.0, "67%"),
|
||||
(Channel::Brightness, 60.0, 100.0, "60%"),
|
||||
(Channel::Red, 51.0, 255.0, "51"),
|
||||
(Channel::Green, 102.0, 255.0, "102"),
|
||||
(Channel::Blue, 153.0, 255.0, "153"),
|
||||
] {
|
||||
let mut app = app();
|
||||
app.services.screen_color = Some(Box::new(|| Ok(std::sync::mpsc::channel().1)));
|
||||
open(&mut app, &json!({"color": "#336699"})).unwrap();
|
||||
let mut d = app.ui.dialog.take().unwrap();
|
||||
d.fields.insert("channel".into(), json!(channel.id()));
|
||||
let tree = widgets::accessibility_tests::tree(|ui| {
|
||||
body(&mut app, ui, &mut d);
|
||||
});
|
||||
let slider = tree.iter().find(|n| n.role() == Role::Slider && n.label() == Some(channel.label())).unwrap();
|
||||
assert_eq!(slider.numeric_value(), Some(value));
|
||||
assert_eq!(slider.min_numeric_value(), Some(0.0));
|
||||
assert_eq!(slider.max_numeric_value(), Some(maximum));
|
||||
assert_eq!(slider.value(), Some(text));
|
||||
for label in ["OK", "Cancel", "Color Swatches", "Sample Color from Screen"] {
|
||||
assert!(tree.iter().any(|n| n.role() == Role::Button && n.label() == Some(label)), "missing button: {label}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks aligned actions and input rows at two UI scales, and stable dimensions when
|
||||
/// switching between color models and swatches.
|
||||
#[test]
|
||||
fn inline_actions_are_aligned_and_modes_keep_the_window_size() {
|
||||
// Language selection is process-global; German is checked in the native app instead
|
||||
// of racing the other parallel UI tests by changing the locale here.
|
||||
for zoom in [1.0, 1.5] {
|
||||
let (ctx, mut app) = (context(), app());
|
||||
ctx.set_zoom_factor(zoom);
|
||||
app.run("ui.colorPicker", json!({})).unwrap();
|
||||
let drawn = settled(&ctx, &mut app, screen());
|
||||
let ok = drawn.control(tl!("OK"));
|
||||
let cancel = drawn.control(tl!("Cancel"));
|
||||
let toggle = drawn.control(tl!("Color Swatches"));
|
||||
assert!(ok.bottom() < cancel.top() && cancel.bottom() < toggle.top());
|
||||
for r in [cancel, toggle] {
|
||||
assert!((r.left() - ok.left()).abs() < 1.0 && (r.width() - ok.width()).abs() < 1.0);
|
||||
}
|
||||
let field = drawn.fields.iter().max_by(|a, b| a.area().total_cmp(&b.area())).unwrap();
|
||||
assert!((field.width() - field.height()).abs() < 1.0);
|
||||
assert!((field.top() - ok.top()).abs() < 1.0);
|
||||
let k = drawn.text("K");
|
||||
let last_input = drawn
|
||||
.boxes
|
||||
.iter()
|
||||
.find(|r| (r.width() - VALUE_W).abs() < 0.5 && r.left() > k.right() && r.contains(egui::pos2(r.center().x, k.center().y)))
|
||||
.unwrap();
|
||||
assert!((last_input.bottom() - field.bottom()).abs() < 2.0, "{last_input:?} vs {field:?}");
|
||||
// Fonts and translated actions may leave one normal gutter, but never an unused column.
|
||||
assert!((last_input.right() - ok.right()).abs() < COLUMN_GAP, "no unused column beside the inputs: {last_input:?} vs {ok:?}");
|
||||
let id = egui::Id::unique(("dialog", KIND));
|
||||
let before = ctx.memory(|m| m.area_rect(id)).unwrap();
|
||||
assert!(drawn.boxes.iter().all(|r| r.left() >= before.left() - 1.0 && r.right() <= before.right() + 1.0), "controls fit horizontally");
|
||||
let title = drawn.text(tl!("Color Picker"));
|
||||
assert!((title.center().x - before.center().x).abs() < 1.0, "centred heading");
|
||||
assert!(title.bottom() < field.top(), "a separate title band above the content");
|
||||
assert!(drawn.visible_texts.iter().any(|s| s == tl!("Only Web Colors")), "the checkbox label is fully visible");
|
||||
assert!(drawn.text("L").top() - toggle.bottom() >= PREVIEW_GAP, "space below the actions");
|
||||
click_at(&ctx, &mut app, toggle.center());
|
||||
assert!(app.ui.dialog.as_ref().unwrap().bool("swatches"));
|
||||
let drawn = settled(&ctx, &mut app, screen());
|
||||
assert!((drawn.control(tl!("Color Models")).width() - ok.width()).abs() < 1.0);
|
||||
let after = ctx.memory(|m| m.area_rect(id)).unwrap();
|
||||
assert!((before.size() - after.size()).length() < 1.0, "{before:?} vs {after:?}");
|
||||
assert!(
|
||||
drawn.boxes.iter().all(|r| r.left() >= after.left() - 1.0 && r.right() <= after.right() + 1.0),
|
||||
"swatch controls fit horizontally"
|
||||
);
|
||||
click_at(&ctx, &mut app, drawn.text(tl!("Color Models")).center());
|
||||
assert!(!app.ui.dialog.as_ref().unwrap().bool("swatches"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Clicks the sampler above the field, receives a color and checks Cancel restores the paint.
|
||||
#[test]
|
||||
fn sampler_above_the_field_previews_and_cancel_restores_the_original() {
|
||||
let (ctx, mut app) = (context(), app());
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let mut receiver = Some(rx);
|
||||
app.services.screen_color = Some(Box::new(move || receiver.take().ok_or("sampler already started".into())));
|
||||
app.run("ui.colorPicker", json!({})).unwrap();
|
||||
let original = fill_hex(&app);
|
||||
let drawn = settled(&ctx, &mut app, screen());
|
||||
let field = drawn.fields.iter().max_by(|a, b| a.area().total_cmp(&b.area())).unwrap();
|
||||
let hover = egui::pos2(field.right() - 12.0, field.top() - 20.0);
|
||||
draw(&ctx, &mut app, screen(), vec![egui::Event::PointerMoved(hover)]);
|
||||
let drawn = settled(&ctx, &mut app, screen());
|
||||
let sampler = drawn.boxes.iter().find(|r| r.size() == vec2(24.0, 24.0) && r.bottom() < field.top()).unwrap();
|
||||
assert!(sampler.top() > drawn.text(tl!("Color Picker")).bottom() && sampler.right() <= field.right());
|
||||
click_at(&ctx, &mut app, sampler.center());
|
||||
assert!(app.screen_sampler.is_some(), "the button above the field starts the host sampler");
|
||||
tx.send(Ok(Some([0.0, 1.0, 0.0]))).unwrap();
|
||||
crate::screen_color::poll(&mut app, &ctx);
|
||||
let drawn = settled(&ctx, &mut app, screen());
|
||||
assert_eq!(fill_hex(&app), "#00ff00");
|
||||
click_at(&ctx, &mut app, drawn.text(tl!("Cancel")).center());
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert_eq!(fill_hex(&app), original);
|
||||
}
|
||||
|
||||
/// Checks both correction warnings fit below the preview without widening the dialog,
|
||||
/// and that clicking the web warning still corrects the color.
|
||||
#[test]
|
||||
fn warnings_fit_below_the_preview_without_widening_the_dialog() {
|
||||
let (ctx, mut app) = (context(), app());
|
||||
app.run("ui.colorPicker", json!({"color":"#ffffff"})).unwrap();
|
||||
settled(&ctx, &mut app, screen());
|
||||
let id = egui::Id::unique(("dialog", KIND));
|
||||
let before = ctx.memory(|m| m.area_rect(id)).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("0000FE"));
|
||||
let drawn = settled(&ctx, &mut app, screen());
|
||||
let ok = drawn.control(tl!("OK"));
|
||||
let toggle = drawn.control(tl!("Color Swatches"));
|
||||
let h = drawn.text("H");
|
||||
let mut warnings: Vec<_> = drawn
|
||||
.boxes
|
||||
.iter()
|
||||
.filter(|r| (r.width() - 16.0).abs() < 0.5 && (r.height() - 16.0).abs() < 0.5 && r.bottom() < h.top())
|
||||
.copied()
|
||||
.collect();
|
||||
warnings.dedup(); // The tile's fill and border share one rectangle.
|
||||
assert_eq!(warnings.len(), 2, "gamut and web correction chips are both present");
|
||||
for r in &warnings {
|
||||
assert!(r.right() < ok.left() && r.bottom() <= toggle.bottom());
|
||||
assert!(r.top() > ok.top() + CHIP_H, "warnings below the preview");
|
||||
}
|
||||
let after = ctx.memory(|m| m.area_rect(id)).unwrap();
|
||||
assert!((before.size() - after.size()).length() < 1.0, "{before:?} vs {after:?}");
|
||||
click_at(&ctx, &mut app, warnings[1].center());
|
||||
assert!(is_web_safe(&picked(app.ui.dialog.as_ref().unwrap()).color), "web correction remains clickable");
|
||||
}
|
||||
|
||||
/// Drags the custom title band and checks the dialog moves by the pointer's displacement.
|
||||
#[test]
|
||||
fn colour_picker_moves_by_its_title_band() {
|
||||
let (ctx, mut app) = (context(), app());
|
||||
app.run("ui.colorPicker", json!({})).unwrap();
|
||||
let drawn = settled(&ctx, &mut app, screen());
|
||||
let title = drawn.text(tl!("Color Picker")).center();
|
||||
let id = egui::Id::unique(("dialog", KIND));
|
||||
let before = ctx.memory(|m| m.area_rect(id)).unwrap();
|
||||
let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
draw(&ctx, &mut app, screen(), vec![egui::Event::PointerMoved(title), pointer(title, true)]);
|
||||
for i in 1..=4 {
|
||||
draw(&ctx, &mut app, screen(), vec![egui::Event::PointerMoved(title + vec2(6.0, 4.0) * i as f32)]);
|
||||
}
|
||||
draw(&ctx, &mut app, screen(), vec![pointer(title + vec2(24.0, 16.0), false)]);
|
||||
settled(&ctx, &mut app, screen());
|
||||
let after = ctx.memory(|m| m.area_rect(id)).unwrap();
|
||||
assert!((after.center() - before.center() - vec2(24.0, 16.0)).length() < 1.5);
|
||||
assert!(app.ui.dialog.is_some());
|
||||
}
|
||||
|
||||
/// Checks OK and Enter commit typed values as one undo step, while Cancel and Escape
|
||||
/// restore the original paint after a live preview.
|
||||
#[test]
|
||||
fn inline_ok_commits_typed_input_and_cancel_restores_the_preview() {
|
||||
for enter in [false, true] {
|
||||
let (ctx, mut app) = (context(), app());
|
||||
app.run("ui.colorPicker", json!({"color": "#ff0000"})).unwrap();
|
||||
let drawn = settled(&ctx, &mut app, screen());
|
||||
let undo = app.session.doc().unwrap().history.undo.len();
|
||||
// The first numeric field has selected text, just as in the other form dialogs.
|
||||
draw(&ctx, &mut app, screen(), vec![egui::Event::Text("120".into())]);
|
||||
if enter {
|
||||
draw(&ctx, &mut app, screen(), vec![key(egui::Key::Enter)]);
|
||||
} else {
|
||||
click_at(&ctx, &mut app, drawn.text(tl!("OK")).center());
|
||||
}
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert_eq!(fill_hex(&app), "#00ff00");
|
||||
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1);
|
||||
app.run("ui.colorPicker", json!({})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("0000ff"));
|
||||
let drawn = settled(&ctx, &mut app, screen());
|
||||
assert_eq!(fill_hex(&app), "#0000ff");
|
||||
if enter {
|
||||
draw(&ctx, &mut app, screen(), vec![key(egui::Key::Escape)]);
|
||||
} else {
|
||||
click_at(&ctx, &mut app, drawn.text(tl!("Cancel")).center());
|
||||
}
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert_eq!(fill_hex(&app), "#00ff00");
|
||||
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks shrinking and stacking keep the dialog inside small windows, vertical scrolling
|
||||
/// reaches the actions and last input, and Enter can still confirm.
|
||||
#[test]
|
||||
fn compact_picker_shrinks_then_scrolls_without_enlarging_the_window() {
|
||||
for size in [vec2(740.0, 600.0), vec2(540.0, 420.0), vec2(400.0, 280.0)] {
|
||||
let (ctx, mut app) = (context(), app());
|
||||
app.run("ui.colorPicker", json!({"color": "#0000ff"})).unwrap();
|
||||
let drawn = settled(&ctx, &mut app, size);
|
||||
let outer = ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", KIND)))).unwrap();
|
||||
assert!(outer.width() <= size.x && outer.height() <= size.y, "{outer:?} in {size:?}");
|
||||
let field = drawn.fields.iter().max_by(|a, b| a.area().total_cmp(&b.area())).unwrap();
|
||||
assert!((MIN_FIELD..=FIELD).contains(&field.width()));
|
||||
assert!((field.width() - field.height()).abs() < 1.0);
|
||||
assert!(drawn.boxes.iter().all(|r| r.left() >= outer.left() - 1.0 && r.right() <= outer.right() + 1.0), "controls fit horizontally");
|
||||
if size.x < 500.0 {
|
||||
assert!(drawn.control(tl!("OK")).top() > field.bottom(), "narrow windows stack the controls below the field");
|
||||
let mut actions_visible = false;
|
||||
let mut last_input_visible = false;
|
||||
for _ in 0..30 {
|
||||
draw(
|
||||
&ctx,
|
||||
&mut app,
|
||||
size,
|
||||
vec![
|
||||
egui::Event::PointerMoved(outer.min + vec2(30.0, 80.0)),
|
||||
egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: vec2(0.0, -20.0),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: Default::default(),
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
},
|
||||
],
|
||||
);
|
||||
let scrolled = settled(&ctx, &mut app, size);
|
||||
actions_visible |= scrolled.visible_texts.iter().any(|s| s == tl!("OK"));
|
||||
last_input_visible |= scrolled.visible_texts.iter().any(|s| s == "K");
|
||||
}
|
||||
assert!(actions_visible && last_input_visible, "vertical scrolling reveals actions and the last input");
|
||||
}
|
||||
// Keyboard confirmation remains available even when the actions require scrolling.
|
||||
draw(&ctx, &mut app, size, vec![key(egui::Key::Enter)]);
|
||||
assert!(app.ui.dialog.is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,672 @@
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use super::*;
|
||||
use crate::panels::color::in_gamut;
|
||||
|
||||
fn app() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
fn frame(app: &mut VectorcraftApp) {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| super::super::show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
fn fill_hex(app: &VectorcraftApp) -> String {
|
||||
crate::panels::current_paints(app).0.color().unwrap().to_hex()
|
||||
}
|
||||
|
||||
/// Runs one frame of the dialog with `events` in `ctx`, which keeps the keyboard focus from
|
||||
/// frame to frame, and returns the texts drawn with their screen rects.
|
||||
fn frame_in(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec<egui::Event>) -> Vec<(String, Rect)> {
|
||||
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| super::super::show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
fn collect(s: &egui::Shape, out: &mut Vec<(String, Rect)>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut texts = vec![];
|
||||
out.shapes.iter().for_each(|c| collect(&c.shape, &mut texts));
|
||||
texts
|
||||
}
|
||||
|
||||
/// Returns the label of the value field that has the keyboard, which is the letter (or `#`)
|
||||
/// nearest to the field's left on its row, and the field's rect. Returns `None` when no text
|
||||
/// field has the keyboard.
|
||||
fn focused_field(ctx: &egui::Context, texts: &[(String, Rect)]) -> Option<(String, Rect)> {
|
||||
let id = ctx.memory(|m| m.focused()).filter(|&id| egui::TextEdit::load_state(ctx, id).is_some())?;
|
||||
let field = ctx.read_response(id)?.rect;
|
||||
let labels = ["H", "S", "B", "R", "G", "#", "L", "a", "b", "C", "M", "Y", "K"];
|
||||
let (label, _) = texts
|
||||
.iter()
|
||||
.filter(|(t, r)| labels.contains(&t.as_str()) && r.max.x <= field.min.x && field.y_range().contains(r.center().y))
|
||||
.max_by(|a, b| a.1.max.x.total_cmp(&b.1.max.x))?;
|
||||
Some((label.clone(), field))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_and_slider_map_back_per_channel() {
|
||||
let p = Picked::of(Color::rgb8(51, 153, 204));
|
||||
for ch in Channel::ALL {
|
||||
let (z, x, y) = field_pos(ch, &p);
|
||||
assert_eq!(field_pick(ch, z, x, y).color.to_hex(), "#3399cc", "{ch:?}");
|
||||
assert_eq!(Channel::parse(ch.id()), Some(ch));
|
||||
}
|
||||
// Hue: x is saturation, y brightness (white at the top left, black along the bottom).
|
||||
assert_eq!(field_pick(Channel::Hue, 0.0, 0.0, 0.0).color.to_hex(), "#ffffff");
|
||||
assert_eq!(field_pick(Channel::Hue, 0.0, 1.0, 0.0).color.to_hex(), "#ff0000");
|
||||
assert_eq!(field_pick(Channel::Hue, 0.5, 1.0, 1.0).color.to_hex(), "#000000");
|
||||
// Red: the slider is red, x blue and y green (green grows upwards).
|
||||
assert_eq!(field_pick(Channel::Red, 1.0, 1.0, 0.0).color.to_hex(), "#ffffff");
|
||||
assert_eq!(field_pick(Channel::Red, 0.0, 1.0, 1.0).color.to_hex(), "#0000ff");
|
||||
assert_eq!(field_pick(Channel::Green, 1.0, 0.0, 1.0).color.to_hex(), "#00ff00");
|
||||
assert_eq!(field_pick(Channel::Blue, 0.0, 1.0, 1.0).color.to_hex(), "#ff0000");
|
||||
// The hue slider shows the spectrum whatever the colour; the others vary one component.
|
||||
assert_eq!(slider_color(Channel::Hue, 1.0 / 3.0, 0.0, 1.0).to_hex(), "#00ff00");
|
||||
assert_eq!(slider_color(Channel::Saturation, 0.0, 0.0, 0.0).to_hex(), "#ffffff");
|
||||
// A grey keeps the hue it was picked with.
|
||||
let grey = field_pick(Channel::Hue, 0.5, 0.0, 0.5);
|
||||
assert_eq!(grey.hsb[0], 180.0);
|
||||
assert_eq!(field_pos(Channel::Hue, &grey).0, 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_web_colors_snaps() {
|
||||
let p = Picked::of(Color::rgb8(230, 120, 40));
|
||||
assert_eq!(p.snapped(false), p);
|
||||
assert_eq!(p.snapped(true).color.to_hex(), "#ff6633");
|
||||
assert!(is_web_safe(&p.snapped(true).color));
|
||||
let mut d = Dialog::new(KIND, json!({"color": "#e67828", "webOnly": true}));
|
||||
assert_eq!(picked(&d).color.to_hex(), "#ff6633");
|
||||
d.fields.insert("webOnly".into(), json!(false));
|
||||
assert_eq!(picked(&d).color.to_hex(), "#e67828");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamut_correction_gives_a_printable_colour() {
|
||||
let mut app = app();
|
||||
let blue = Color::rgb(0.0, 0.0, 1.0);
|
||||
assert!(blue.out_of_gamut());
|
||||
let fix = in_gamut(&mut app, &blue).expect("pure blue can't be printed");
|
||||
assert!(!fix.out_of_gamut(), "{fix:?}");
|
||||
assert!(matches!(fix, Color::Rgb { .. }));
|
||||
assert_eq!(in_gamut(&mut app, &Color::rgb8(128, 128, 128)), None);
|
||||
// The dialog and the Color panel ask through a cache.
|
||||
let ctx = egui::Context::default();
|
||||
assert_eq!(gamut_fix(&mut app, &ctx, "picker-gamut", &blue), Some(fix));
|
||||
assert_eq!(gamut_fix(&mut app, &ctx, "picker-gamut", &blue), Some(fix));
|
||||
assert_eq!(gamut_fix(&mut app, &ctx, "picker-gamut", &Color::rgb8(128, 128, 128)), None);
|
||||
}
|
||||
|
||||
/// The selected art takes each colour as it is picked; OK keeps the last as one undo step and
|
||||
/// Cancel puts the art back.
|
||||
#[test]
|
||||
fn the_selection_shows_the_colour_live() {
|
||||
let mut app = app();
|
||||
let undo = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len();
|
||||
let before = undo(&app);
|
||||
app.run("ui.colorPicker", json!({})).unwrap();
|
||||
frame(&mut app);
|
||||
assert!(!app.session.in_interaction(), "nothing previews before the colour changes");
|
||||
for hex in ["ff0000", "00ff00"] {
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!(hex));
|
||||
frame(&mut app);
|
||||
assert_eq!(fill_hex(&app), format!("#{hex}"), "live, before OK");
|
||||
}
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(fill_hex(&app), "#00ff00");
|
||||
assert_eq!(undo(&app), before + 1, "one step");
|
||||
// Cancel: back to the colour it had.
|
||||
app.run("ui.colorPicker", json!({})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("0000ff"));
|
||||
frame(&mut app);
|
||||
assert_eq!(fill_hex(&app), "#0000ff");
|
||||
super::super::cancel(&mut app);
|
||||
assert_eq!(fill_hex(&app), "#00ff00");
|
||||
assert_eq!(undo(&app), before + 1);
|
||||
}
|
||||
|
||||
/// Opened for an artboard (Properties › Artboards › Fill), the picker starts from the artboard's
|
||||
/// background color (white without one) and shows each color on its paper live, with nothing
|
||||
/// selected. OK keeps the last as one undo step, the art's fill unchanged, and Cancel puts the
|
||||
/// artboard back.
|
||||
#[test]
|
||||
fn an_artboard_shows_its_background_color_live() {
|
||||
let mut app = app();
|
||||
app.run("select.set", json!({"ids": []})).unwrap();
|
||||
app.run("artboard.new", json!({})).unwrap();
|
||||
let color = |app: &VectorcraftApp| app.session.doc().unwrap().doc.artboards[1].color.map(|c| c.to_hex());
|
||||
let undo = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len();
|
||||
let art = app.session.doc().unwrap().doc.layers.clone();
|
||||
let before = undo(&app);
|
||||
app.run("ui.colorPicker", json!({"artboard": 1})).unwrap();
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().str("hex"), "FFFFFF", "starts from white paper");
|
||||
frame(&mut app);
|
||||
assert!(!app.session.in_interaction(), "nothing previews before the color changes");
|
||||
for hex in ["ff0000", "00ff00"] {
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!(hex));
|
||||
frame(&mut app);
|
||||
assert_eq!(color(&app), Some(format!("#{hex}")), "live, before OK");
|
||||
}
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(color(&app).as_deref(), Some("#00ff00"));
|
||||
assert_eq!(undo(&app), before + 1, "one step");
|
||||
assert_eq!(app.session.doc().unwrap().doc.layers, art, "the art keeps its fill");
|
||||
// Cancel: back to the color it had, which the picker starts from.
|
||||
app.run("ui.colorPicker", json!({"artboard": 1})).unwrap();
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().str("hex"), "00FF00");
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("0000ff"));
|
||||
frame(&mut app);
|
||||
assert_eq!(color(&app).as_deref(), Some("#0000ff"));
|
||||
super::super::cancel(&mut app);
|
||||
assert_eq!(color(&app).as_deref(), Some("#00ff00"));
|
||||
assert_eq!(undo(&app), before + 1);
|
||||
assert!(app.run("ui.colorPicker", json!({"artboard": 9})).is_err());
|
||||
}
|
||||
|
||||
/// OK on a picker opened for an artboard without a background color, with the color left at the
|
||||
/// white it starts from, leaves the artboard without one and adds no undo step. A white the
|
||||
/// opener passes as `color` is set.
|
||||
#[test]
|
||||
fn an_untouched_picker_leaves_an_artboard_without_a_color() {
|
||||
let mut app = app();
|
||||
app.run("artboard.new", json!({})).unwrap();
|
||||
let color = |app: &VectorcraftApp| app.session.doc().unwrap().doc.artboards[1].color.map(|c| c.to_hex());
|
||||
let undo = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len();
|
||||
let before = undo(&app);
|
||||
app.run("ui.colorPicker", json!({"artboard": 1})).unwrap();
|
||||
frame(&mut app);
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none(), "OK closes the picker");
|
||||
assert_eq!(color(&app), None, "no color");
|
||||
assert_eq!(undo(&app), before, "no undo step");
|
||||
app.run("ui.colorPicker", json!({"artboard": 1, "color": "#ffffff"})).unwrap();
|
||||
frame(&mut app);
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(color(&app).as_deref(), Some("#ffffff"));
|
||||
assert_eq!(undo(&app), before + 1);
|
||||
}
|
||||
|
||||
/// OK on an untouched picker opened for an artboard with an RGB background color in a CMYK
|
||||
/// document keeps the RGB color and adds no undo step. A color picked in it is set as CMYK.
|
||||
#[test]
|
||||
fn an_untouched_picker_keeps_an_artboards_rgb_color_in_a_cmyk_document() {
|
||||
let mut app = app();
|
||||
app.run("artboard.new", json!({})).unwrap();
|
||||
app.run("file.documentColorMode", json!({"mode": "cmyk"})).unwrap();
|
||||
app.run("artboard.setProps", json!({"index": 1, "color": "#ff0000"})).unwrap();
|
||||
let color = |app: &VectorcraftApp| app.session.doc().unwrap().doc.artboards[1].color;
|
||||
let undo = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len();
|
||||
let start = color(&app);
|
||||
assert!(matches!(start, Some(Color::Rgb { .. })), "set as RGB");
|
||||
let before = undo(&app);
|
||||
app.run("ui.colorPicker", json!({"artboard": 1})).unwrap();
|
||||
frame(&mut app);
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none(), "OK closes the picker");
|
||||
assert_eq!(color(&app), start, "the RGB color is kept");
|
||||
assert_eq!(undo(&app), before, "no undo step");
|
||||
app.run("ui.colorPicker", json!({"artboard": 1})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("00ff00"));
|
||||
frame(&mut app);
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert!(matches!(color(&app), Some(Color::Cmyk { .. })), "a picked color is set as CMYK");
|
||||
assert_eq!(undo(&app), before + 1);
|
||||
}
|
||||
|
||||
/// OK on an untouched picker opened for an artboard with a background color in an RGB
|
||||
/// document keeps the color and adds no undo step.
|
||||
#[test]
|
||||
fn an_untouched_picker_adds_no_undo_step_for_an_artboards_color() {
|
||||
let mut app = app();
|
||||
app.run("artboard.new", json!({})).unwrap();
|
||||
app.run("artboard.setProps", json!({"index": 1, "color": "#ff0000"})).unwrap();
|
||||
let color = |app: &VectorcraftApp| app.session.doc().unwrap().doc.artboards[1].color.map(|c| c.to_hex());
|
||||
let undo = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len();
|
||||
let before = undo(&app);
|
||||
app.run("ui.colorPicker", json!({"artboard": 1})).unwrap();
|
||||
frame(&mut app);
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none(), "OK closes the picker");
|
||||
assert_eq!(color(&app).as_deref(), Some("#ff0000"));
|
||||
assert_eq!(undo(&app), before, "no undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_set_hex_and_confirm_fills() {
|
||||
let mut app = app();
|
||||
app.run("ui.colorPicker", json!({})).unwrap();
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!(super::super::DialogKind::of(&d.kind), Some(super::super::DialogKind::ColorPicker));
|
||||
assert_eq!(d.str("hex"), "FFFFFF", "starts from the fill");
|
||||
assert!(!d.bool("stroke"));
|
||||
frame(&mut app);
|
||||
// `ui.dialog.set {field: "hex"}` then `ui.dialog.confirm`.
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("00ff00"));
|
||||
frame(&mut app);
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().str("hex"), "00FF00");
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert_eq!(fill_hex(&app), "#00ff00");
|
||||
// Without a frame in between, a set hex still wins; `color` works as well.
|
||||
app.run("ui.colorPicker", json!({"stroke": true})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("#3366CC"));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(crate::panels::current_paints(&app).1.color().unwrap().to_hex(), "#3366cc");
|
||||
// The stroke proxy is in front now: the opener follows it unless told otherwise.
|
||||
app.run("ui.colorPicker", json!({})).unwrap();
|
||||
assert!(app.ui.dialog.as_ref().unwrap().bool("stroke"));
|
||||
app.run("ui.colorPicker", json!({"stroke": false})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("color".into(), json!({"c": 0, "m": 1, "y": 1, "k": 0}));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert!(matches!(crate::panels::current_paints(&app).0.color(), Some(Color::Cmyk { .. })), "CMYK is kept");
|
||||
assert!(app.run("ui.colorPicker", json!({"color": "nope"})).is_err());
|
||||
// Colours take every form the paint commands take (CMYK in percent too).
|
||||
app.run("ui.colorPicker", json!({"stroke": false, "color": {"c": 0, "m": 100, "y": 100, "k": 0}})).unwrap();
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(crate::panels::current_paints(&app).0.color(), Some(Color::cmyk(0.0, 1.0, 1.0, 0.0)));
|
||||
}
|
||||
|
||||
/// #1692: Tab goes through one color model's fields before the next: H, S, B, then R, G, B, the
|
||||
/// hex field, then L, a, b, then C, M, Y, K. Each Lab or CMYK field is level with the field on
|
||||
/// its left.
|
||||
#[test]
|
||||
fn tab_goes_through_one_color_models_fields_before_the_next() {
|
||||
let mut app = app();
|
||||
app.run("ui.colorPicker", json!({})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let tab = egui::Event::Key { key: egui::Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE };
|
||||
// The picker opens with the keyboard in H. The loop presses Tab until K has the keyboard
|
||||
// and records each stop. A stop is `None` when no text field has the keyboard, as at a
|
||||
// channel's radio button.
|
||||
let mut texts = vec![];
|
||||
for _ in 0..3 {
|
||||
texts = frame_in(&ctx, &mut app, vec![]);
|
||||
}
|
||||
let mut stops = vec![focused_field(&ctx, &texts)];
|
||||
while stops.len() < 40 && stops.last().and_then(|s| s.as_ref()).is_none_or(|(label, _)| label != "K") {
|
||||
texts = frame_in(&ctx, &mut app, vec![tab.clone()]);
|
||||
stops.push(focused_field(&ctx, &texts));
|
||||
}
|
||||
let names: Vec<Option<&str>> = stops.iter().map(|s| s.as_ref().map(|(label, _)| label.as_str())).collect();
|
||||
let fields: Vec<&str> = names.iter().flatten().copied().collect();
|
||||
assert_eq!(fields, ["H", "S", "B", "R", "G", "B", "#", "L", "a", "b", "C", "M", "Y", "K"], "{names:?}");
|
||||
assert_eq!(names[names.len() - 2..], [Some("Y"), Some("K")], "one Tab takes Y to K");
|
||||
let found: Vec<&(String, Rect)> = stops.iter().flatten().collect();
|
||||
for (left, right) in found[..7].iter().zip(&found[7..]) {
|
||||
// Two grids: their rows land on the same subpixel-rounded line, not necessarily bit-equal.
|
||||
assert!((left.1.center().y - right.1.center().y).abs() <= 0.5, "{} and {} are level", left.0, right.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_mode_draws_and_cmyk_documents_get_cmyk() {
|
||||
let mut app = app();
|
||||
for fields in
|
||||
[json!({"channel": "red"}), json!({"channel": "brightness", "webOnly": true}), json!({"swatches": true}), json!({"color": "#0000ff"})]
|
||||
{
|
||||
app.run("ui.colorPicker", json!({})).unwrap();
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
for (k, v) in fields.as_object().unwrap() {
|
||||
d.fields.insert(k.clone(), v.clone());
|
||||
}
|
||||
frame(&mut app);
|
||||
assert!(app.ui.dialog.is_some());
|
||||
}
|
||||
app.run("file.documentColorMode", json!({"mode": "cmyk"})).unwrap();
|
||||
app.run("ui.colorPicker", json!({"color": "#ff0000"})).unwrap();
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert!(matches!(crate::panels::current_paints(&app).0.color(), Some(Color::Cmyk { .. })));
|
||||
}
|
||||
/// Inspect actual painted controls, rather than a second implementation of the layout.
|
||||
#[derive(Default)]
|
||||
struct Drawn {
|
||||
texts: Vec<(String, Rect)>,
|
||||
visible_texts: Vec<String>,
|
||||
boxes: Vec<Rect>,
|
||||
fields: Vec<Rect>,
|
||||
}
|
||||
|
||||
impl Drawn {
|
||||
/// Collects painted text, control bounds and color meshes, recording text visibility
|
||||
/// against the clip rectangle so tests can detect clipped labels.
|
||||
fn collect(&mut self, shape: &egui::Shape, clip: Rect) {
|
||||
match shape {
|
||||
egui::Shape::Text(t) => {
|
||||
self.texts.push((t.galley.text().into(), t.visual_bounding_rect()));
|
||||
if clip.expand(1.0).contains_rect(t.visual_bounding_rect()) {
|
||||
self.visible_texts.push(t.galley.text().into());
|
||||
}
|
||||
}
|
||||
egui::Shape::Rect(r) => self.boxes.push(r.rect),
|
||||
egui::Shape::Mesh(m) if m.vertices.len() > 100 => self.fields.push(m.calc_bounds()),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| self.collect(s, clip)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the bounds of exactly one painted label, failing on missing or duplicate text.
|
||||
fn text(&self, label: &str) -> Rect {
|
||||
let found: Vec<_> = self.texts.iter().filter(|(s, _)| s == label).collect();
|
||||
assert_eq!(found.len(), 1, "one {label}, including no duplicate footer: {:?}", self.texts);
|
||||
found[0].1
|
||||
}
|
||||
|
||||
/// Finds the smallest painted control containing a unique label's center.
|
||||
fn control(&self, label: &str) -> Rect {
|
||||
let center = self.text(label).center();
|
||||
self.boxes.iter().filter(|r| r.contains(center)).min_by(|a, b| a.area().total_cmp(&b.area())).copied().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a dialog frame with real egui input at a physical window size, accounting for UI zoom,
|
||||
/// and returns the painted controls for layout and interaction assertions.
|
||||
fn draw(ctx: &egui::Context, app: &mut VectorcraftApp, size: egui::Vec2, events: Vec<egui::Event>) -> Drawn {
|
||||
let input =
|
||||
egui::RawInput { screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, size / ctx.zoom_factor())), events, ..Default::default() };
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
crate::shortcuts::handle(app, ui.ctx());
|
||||
super::super::show(app, ui.ctx());
|
||||
});
|
||||
let mut drawn = Drawn::default();
|
||||
for shape in &out.shapes {
|
||||
drawn.collect(&shape.shape, shape.clip_rect);
|
||||
}
|
||||
out.textures_delta.clear();
|
||||
drawn
|
||||
}
|
||||
|
||||
/// The physical window size used for pointer and keyboard interaction tests.
|
||||
fn screen() -> egui::Vec2 {
|
||||
vec2(1280.0, 900.0)
|
||||
}
|
||||
|
||||
/// Lets the auto-sized dialog settle over six frames and returns the next frame's controls.
|
||||
fn settled(ctx: &egui::Context, app: &mut VectorcraftApp, size: egui::Vec2) -> Drawn {
|
||||
for _ in 0..6 {
|
||||
draw(ctx, app, size, vec![]);
|
||||
}
|
||||
draw(ctx, app, size, vec![])
|
||||
}
|
||||
|
||||
/// Creates a persistent test context with the application's fonts and default theme.
|
||||
fn context() -> egui::Context {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
crate::theme::apply(&ctx, Default::default());
|
||||
ctx
|
||||
}
|
||||
|
||||
/// Sends a primary pointer press and release in separate frames at the given position.
|
||||
fn click_at(ctx: &egui::Context, app: &mut VectorcraftApp, at: egui::Pos2) {
|
||||
for pressed in [true, false] {
|
||||
draw(
|
||||
ctx,
|
||||
app,
|
||||
screen(),
|
||||
vec![
|
||||
egui::Event::PointerMoved(at),
|
||||
egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() },
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an unmodified, non-repeating key press for dialog shortcut tests.
|
||||
fn key(key: egui::Key) -> egui::Event {
|
||||
egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }
|
||||
}
|
||||
|
||||
/// Checks accessible action names and each channel slider's displayed value, range and units.
|
||||
#[test]
|
||||
fn compact_picker_preserves_screen_reader_controls_and_channel_values() {
|
||||
use egui::accesskit::Role;
|
||||
|
||||
for (channel, value, maximum, text) in [
|
||||
(Channel::Hue, 210.0, 360.0, "210°"),
|
||||
(Channel::Saturation, 67.0, 100.0, "67%"),
|
||||
(Channel::Brightness, 60.0, 100.0, "60%"),
|
||||
(Channel::Red, 51.0, 255.0, "51"),
|
||||
(Channel::Green, 102.0, 255.0, "102"),
|
||||
(Channel::Blue, 153.0, 255.0, "153"),
|
||||
] {
|
||||
let mut app = app();
|
||||
app.services.screen_color = Some(Box::new(|| Ok(std::sync::mpsc::channel().1)));
|
||||
open(&mut app, &json!({"color": "#336699"})).unwrap();
|
||||
let mut d = app.ui.dialog.take().unwrap();
|
||||
d.fields.insert("channel".into(), json!(channel.id()));
|
||||
let tree = widgets::accessibility_tests::tree(|ui| {
|
||||
body(&mut app, ui, &mut d);
|
||||
});
|
||||
let slider = tree.iter().find(|n| n.role() == Role::Slider && n.label() == Some(channel.label())).unwrap();
|
||||
assert_eq!(slider.numeric_value(), Some(value));
|
||||
assert_eq!(slider.min_numeric_value(), Some(0.0));
|
||||
assert_eq!(slider.max_numeric_value(), Some(maximum));
|
||||
assert_eq!(slider.value(), Some(text));
|
||||
for label in ["OK", "Cancel", "Color Swatches", "Sample Color from Screen"] {
|
||||
assert!(tree.iter().any(|n| n.role() == Role::Button && n.label() == Some(label)), "missing button: {label}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks aligned actions and input rows at two UI scales, and stable dimensions when
|
||||
/// switching between color models and swatches.
|
||||
#[test]
|
||||
fn inline_actions_are_aligned_and_modes_keep_the_window_size() {
|
||||
// Language selection is process-global; German is checked in the native app instead
|
||||
// of racing the other parallel UI tests by changing the locale here.
|
||||
for zoom in [1.0, 1.5] {
|
||||
let (ctx, mut app) = (context(), app());
|
||||
ctx.set_zoom_factor(zoom);
|
||||
app.run("ui.colorPicker", json!({})).unwrap();
|
||||
let drawn = settled(&ctx, &mut app, screen());
|
||||
let ok = drawn.control(tl!("OK"));
|
||||
let cancel = drawn.control(tl!("Cancel"));
|
||||
let toggle = drawn.control(tl!("Color Swatches"));
|
||||
assert!(ok.bottom() < cancel.top() && cancel.bottom() < toggle.top());
|
||||
for r in [cancel, toggle] {
|
||||
assert!((r.left() - ok.left()).abs() < 1.0 && (r.width() - ok.width()).abs() < 1.0);
|
||||
}
|
||||
let field = drawn.fields.iter().max_by(|a, b| a.area().total_cmp(&b.area())).unwrap();
|
||||
assert!((field.width() - field.height()).abs() < 1.0);
|
||||
assert!((field.top() - ok.top()).abs() < 1.0);
|
||||
let k = drawn.text("K");
|
||||
let last_input = drawn
|
||||
.boxes
|
||||
.iter()
|
||||
.find(|r| (r.width() - VALUE_W).abs() < 0.5 && r.left() > k.right() && r.contains(egui::pos2(r.center().x, k.center().y)))
|
||||
.unwrap();
|
||||
assert!((last_input.bottom() - field.bottom()).abs() < 2.0, "{last_input:?} vs {field:?}");
|
||||
// Fonts and translated actions may leave one normal gutter, but never an unused column.
|
||||
assert!((last_input.right() - ok.right()).abs() < COLUMN_GAP, "no unused column beside the inputs: {last_input:?} vs {ok:?}");
|
||||
let id = egui::Id::unique(("dialog", KIND));
|
||||
let before = ctx.memory(|m| m.area_rect(id)).unwrap();
|
||||
assert!(drawn.boxes.iter().all(|r| r.left() >= before.left() - 1.0 && r.right() <= before.right() + 1.0), "controls fit horizontally");
|
||||
let title = drawn.text(tl!("Color Picker"));
|
||||
assert!((title.center().x - before.center().x).abs() < 1.0, "centred heading");
|
||||
assert!(title.bottom() < field.top(), "a separate title band above the content");
|
||||
assert!(drawn.visible_texts.iter().any(|s| s == tl!("Only Web Colors")), "the checkbox label is fully visible");
|
||||
assert!(drawn.text("L").top() - toggle.bottom() >= PREVIEW_GAP, "space below the actions");
|
||||
click_at(&ctx, &mut app, toggle.center());
|
||||
assert!(app.ui.dialog.as_ref().unwrap().bool("swatches"));
|
||||
let drawn = settled(&ctx, &mut app, screen());
|
||||
assert!((drawn.control(tl!("Color Models")).width() - ok.width()).abs() < 1.0);
|
||||
let after = ctx.memory(|m| m.area_rect(id)).unwrap();
|
||||
assert!((before.size() - after.size()).length() < 1.0, "{before:?} vs {after:?}");
|
||||
assert!(
|
||||
drawn.boxes.iter().all(|r| r.left() >= after.left() - 1.0 && r.right() <= after.right() + 1.0),
|
||||
"swatch controls fit horizontally"
|
||||
);
|
||||
click_at(&ctx, &mut app, drawn.text(tl!("Color Models")).center());
|
||||
assert!(!app.ui.dialog.as_ref().unwrap().bool("swatches"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Clicks the sampler above the field, receives a color and checks Cancel restores the paint.
|
||||
#[test]
|
||||
fn sampler_above_the_field_previews_and_cancel_restores_the_original() {
|
||||
let (ctx, mut app) = (context(), app());
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let mut receiver = Some(rx);
|
||||
app.services.screen_color = Some(Box::new(move || receiver.take().ok_or("sampler already started".into())));
|
||||
app.run("ui.colorPicker", json!({})).unwrap();
|
||||
let original = fill_hex(&app);
|
||||
let drawn = settled(&ctx, &mut app, screen());
|
||||
let field = drawn.fields.iter().max_by(|a, b| a.area().total_cmp(&b.area())).unwrap();
|
||||
let hover = egui::pos2(field.right() - 12.0, field.top() - 20.0);
|
||||
draw(&ctx, &mut app, screen(), vec![egui::Event::PointerMoved(hover)]);
|
||||
let drawn = settled(&ctx, &mut app, screen());
|
||||
let sampler = drawn.boxes.iter().find(|r| r.size() == vec2(24.0, 24.0) && r.bottom() < field.top()).unwrap();
|
||||
assert!(sampler.top() > drawn.text(tl!("Color Picker")).bottom() && sampler.right() <= field.right());
|
||||
click_at(&ctx, &mut app, sampler.center());
|
||||
assert!(app.screen_sampler.is_some(), "the button above the field starts the host sampler");
|
||||
tx.send(Ok(Some([0.0, 1.0, 0.0]))).unwrap();
|
||||
crate::screen_color::poll(&mut app, &ctx);
|
||||
let drawn = settled(&ctx, &mut app, screen());
|
||||
assert_eq!(fill_hex(&app), "#00ff00");
|
||||
click_at(&ctx, &mut app, drawn.text(tl!("Cancel")).center());
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert_eq!(fill_hex(&app), original);
|
||||
}
|
||||
|
||||
/// Checks both correction warnings fit below the preview without widening the dialog,
|
||||
/// and that clicking the web warning still corrects the color.
|
||||
#[test]
|
||||
fn warnings_fit_below_the_preview_without_widening_the_dialog() {
|
||||
let (ctx, mut app) = (context(), app());
|
||||
app.run("ui.colorPicker", json!({"color":"#ffffff"})).unwrap();
|
||||
settled(&ctx, &mut app, screen());
|
||||
let id = egui::Id::unique(("dialog", KIND));
|
||||
let before = ctx.memory(|m| m.area_rect(id)).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("0000FE"));
|
||||
let drawn = settled(&ctx, &mut app, screen());
|
||||
let ok = drawn.control(tl!("OK"));
|
||||
let toggle = drawn.control(tl!("Color Swatches"));
|
||||
let h = drawn.text("H");
|
||||
let mut warnings: Vec<_> = drawn
|
||||
.boxes
|
||||
.iter()
|
||||
.filter(|r| (r.width() - 16.0).abs() < 0.5 && (r.height() - 16.0).abs() < 0.5 && r.bottom() < h.top())
|
||||
.copied()
|
||||
.collect();
|
||||
warnings.dedup(); // The tile's fill and border share one rectangle.
|
||||
assert_eq!(warnings.len(), 2, "gamut and web correction chips are both present");
|
||||
for r in &warnings {
|
||||
assert!(r.right() < ok.left() && r.bottom() <= toggle.bottom());
|
||||
assert!(r.top() > ok.top() + CHIP_H, "warnings below the preview");
|
||||
}
|
||||
let after = ctx.memory(|m| m.area_rect(id)).unwrap();
|
||||
assert!((before.size() - after.size()).length() < 1.0, "{before:?} vs {after:?}");
|
||||
click_at(&ctx, &mut app, warnings[1].center());
|
||||
assert!(is_web_safe(&picked(app.ui.dialog.as_ref().unwrap()).color), "web correction remains clickable");
|
||||
}
|
||||
|
||||
/// Drags the custom title band and checks the dialog moves by the pointer's displacement.
|
||||
#[test]
|
||||
fn colour_picker_moves_by_its_title_band() {
|
||||
let (ctx, mut app) = (context(), app());
|
||||
app.run("ui.colorPicker", json!({})).unwrap();
|
||||
let drawn = settled(&ctx, &mut app, screen());
|
||||
let title = drawn.text(tl!("Color Picker")).center();
|
||||
let id = egui::Id::unique(("dialog", KIND));
|
||||
let before = ctx.memory(|m| m.area_rect(id)).unwrap();
|
||||
let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
draw(&ctx, &mut app, screen(), vec![egui::Event::PointerMoved(title), pointer(title, true)]);
|
||||
for i in 1..=4 {
|
||||
draw(&ctx, &mut app, screen(), vec![egui::Event::PointerMoved(title + vec2(6.0, 4.0) * i as f32)]);
|
||||
}
|
||||
draw(&ctx, &mut app, screen(), vec![pointer(title + vec2(24.0, 16.0), false)]);
|
||||
settled(&ctx, &mut app, screen());
|
||||
let after = ctx.memory(|m| m.area_rect(id)).unwrap();
|
||||
assert!((after.center() - before.center() - vec2(24.0, 16.0)).length() < 1.5);
|
||||
assert!(app.ui.dialog.is_some());
|
||||
}
|
||||
|
||||
/// Checks OK and Enter commit typed values as one undo step, while Cancel and Escape
|
||||
/// restore the original paint after a live preview.
|
||||
#[test]
|
||||
fn inline_ok_commits_typed_input_and_cancel_restores_the_preview() {
|
||||
for enter in [false, true] {
|
||||
let (ctx, mut app) = (context(), app());
|
||||
app.run("ui.colorPicker", json!({"color": "#ff0000"})).unwrap();
|
||||
let drawn = settled(&ctx, &mut app, screen());
|
||||
let undo = app.session.doc().unwrap().history.undo.len();
|
||||
// The first numeric field has selected text, just as in the other form dialogs.
|
||||
draw(&ctx, &mut app, screen(), vec![egui::Event::Text("120".into())]);
|
||||
if enter {
|
||||
draw(&ctx, &mut app, screen(), vec![key(egui::Key::Enter)]);
|
||||
} else {
|
||||
click_at(&ctx, &mut app, drawn.text(tl!("OK")).center());
|
||||
}
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert_eq!(fill_hex(&app), "#00ff00");
|
||||
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1);
|
||||
app.run("ui.colorPicker", json!({})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("0000ff"));
|
||||
let drawn = settled(&ctx, &mut app, screen());
|
||||
assert_eq!(fill_hex(&app), "#0000ff");
|
||||
if enter {
|
||||
draw(&ctx, &mut app, screen(), vec![key(egui::Key::Escape)]);
|
||||
} else {
|
||||
click_at(&ctx, &mut app, drawn.text(tl!("Cancel")).center());
|
||||
}
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert_eq!(fill_hex(&app), "#00ff00");
|
||||
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks shrinking and stacking keep the dialog inside small windows, vertical scrolling
|
||||
/// reaches the actions and last input, and Enter can still confirm.
|
||||
#[test]
|
||||
fn compact_picker_shrinks_then_scrolls_without_enlarging_the_window() {
|
||||
for size in [vec2(740.0, 600.0), vec2(540.0, 420.0), vec2(400.0, 280.0)] {
|
||||
let (ctx, mut app) = (context(), app());
|
||||
app.run("ui.colorPicker", json!({"color": "#0000ff"})).unwrap();
|
||||
let drawn = settled(&ctx, &mut app, size);
|
||||
let outer = ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", KIND)))).unwrap();
|
||||
assert!(outer.width() <= size.x && outer.height() <= size.y, "{outer:?} in {size:?}");
|
||||
let field = drawn.fields.iter().max_by(|a, b| a.area().total_cmp(&b.area())).unwrap();
|
||||
assert!((MIN_FIELD..=FIELD).contains(&field.width()));
|
||||
assert!((field.width() - field.height()).abs() < 1.0);
|
||||
assert!(drawn.boxes.iter().all(|r| r.left() >= outer.left() - 1.0 && r.right() <= outer.right() + 1.0), "controls fit horizontally");
|
||||
if size.x < 500.0 {
|
||||
assert!(drawn.control(tl!("OK")).top() > field.bottom(), "narrow windows stack the controls below the field");
|
||||
let mut actions_visible = false;
|
||||
let mut last_input_visible = false;
|
||||
for _ in 0..30 {
|
||||
draw(
|
||||
&ctx,
|
||||
&mut app,
|
||||
size,
|
||||
vec![
|
||||
egui::Event::PointerMoved(outer.min + vec2(30.0, 80.0)),
|
||||
egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: vec2(0.0, -20.0),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: Default::default(),
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
},
|
||||
],
|
||||
);
|
||||
let scrolled = settled(&ctx, &mut app, size);
|
||||
actions_visible |= scrolled.visible_texts.iter().any(|s| s == tl!("OK"));
|
||||
last_input_visible |= scrolled.visible_texts.iter().any(|s| s == "K");
|
||||
}
|
||||
assert!(actions_visible && last_input_visible, "vertical scrolling reveals actions and the last input");
|
||||
}
|
||||
// Keyboard confirmation remains available even when the actions require scrolling.
|
||||
draw(&ctx, &mut app, size, vec![key(egui::Key::Enter)]);
|
||||
assert!(app.ui.dialog.is_none());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user