diff --git a/crates/affinity/src/tests.rs b/crates/affinity/src/tests.rs index 006f9e7bd..817012fa7 100644 --- a/crates/affinity/src/tests.rs +++ b/crates/affinity/src/tests.rs @@ -261,6 +261,32 @@ mod archive { assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_)))); } + // Regression test for #1710: ruzstd 0.9.0 let a block decompress to more than its frame's + // window allows (RUSTSEC-2026-0344), so a crafted entry made the decoder produce far more than + // the format permits for a block. 0.9.1 refuses it. + #[test] + fn a_zstd_block_larger_than_its_frames_window_is_refused() { + const LEN: u32 = 100_000; + let plain = vec![0x61u8; LEN as usize]; + let file = synth::container(&[("doc.dat", &plain, Method::Zstd)], None); + // A Zstandard frame with a 1 KiB window (descriptor byte 0, window byte 0) holding one RLE + // block, the last, that repeats a byte LEN times: block header = last | RLE << 1 | LEN << 3. + let mut frame = vec![0x28, 0xB5, 0x2F, 0xFD, 0x00, 0x00]; + frame.extend((1 | (1 << 1) | (LEN << 3)).to_le_bytes().get(..3).unwrap()); + frame.push(0x61); + // Swap it in for the entry's compressed bytes (they follow the "#Fil" tag at offset 72). + let fat = u64::from_le_bytes(file[16..24].try_into().unwrap()) as usize; + let old_len = u64::from_le_bytes(file[fat + 59 + 21..fat + 59 + 29].try_into().unwrap()) as usize; + let mut swapped = file[..76].to_vec(); + swapped.extend(&frame); + swapped.extend(&file[76 + old_len..]); + let new_fat = fat - old_len + frame.len(); + swapped[16..24].copy_from_slice(&(new_fat as u64).to_le_bytes()); + swapped[new_fat + 59 + 21..new_fat + 59 + 29].copy_from_slice(&(frame.len() as u64).to_le_bytes()); + let mut a = Archive::open(&swapped, Limits::default()).unwrap(); + assert!(a.read("doc.dat").is_err(), "a block of {LEN} bytes was decompressed from a frame with a 1 KiB window"); + } + #[test] fn a_declared_size_smaller_than_the_data_is_rejected_not_truncated() { let big = vec![7u8; 1 << 20]; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index e943a0524..7fcbcd742 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4132,6 +4132,37 @@ mod tests { assert_eq!(edges(&mut app), active(2), "Artboard 3 is the active one of the two selected"); } + // Regression test for #824: with the Artboard tool, the active artboard's name was drawn twice, + // once by the canvas as for every artboard and once, a little higher and in blue, by the tool. + // Now each artboard is named once, and the active one's name sits where the others' do. + #[test] + fn the_artboard_tool_names_its_active_artboard_once() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); + app.select_tool("artboard"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let drawn = shapes(&mut app, &ctx); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + // Each name drawn: how far its left edge is right of the artboard's corner, and its bottom above it. + let named = |name: &str, board: usize| -> Vec<(f32, f32)> { + let r = app.session.active().unwrap().doc.artboards[board].rect; + let corner = xf.to_screen(Point::new(r.x0, r.y0)); + drawn + .iter() + .filter_map(|s| match s { + Shape::Text(t) if t.galley.text() == name => Some((t.pos.x - corner.x, corner.y - (t.pos.y + t.galley.size().y))), + _ => None, + }) + .collect() + }; + let (active, other) = (named("01 - Artboard 1", 0), named("02 - Artboard 2", 1)); + assert_eq!(other.len(), 1, "the other artboard is named once: {other:?}"); + assert_eq!(active.len(), 1, "the active artboard is named once: {active:?}"); + assert!((active[0].0 - other[0].0).abs() < 0.6, "flush with the artboard's left edge like the other name: {active:?} vs {other:?}"); + assert!((active[0].1 - other[0].1).abs() < 2.0, "on the artboard's top edge like the other name: {active:?} vs {other:?}"); + } + /// Enable Touch Gestures (#585): a two-finger tap undoes and a three-finger one redoes, and the /// first finger's press (the pointer egui makes of it) draws nothing. Off, a tap does nothing. #[test] diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index bc2242f38..58ea3b2ed 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -894,3 +894,38 @@ mod typed_number_tests { assert!(typed_number(1e20).is_f64()); } } + +#[cfg(test)] +mod selected_text_tests { + use super::*; + use crate::widgets::look_tests::{frame, glyph_colours, themed}; + + // Regression test for #1465: selected text in a dialog's text field was in the accent, on a + // selection tinted with the same accent. It is in the strong text colour. + #[test] + fn selected_text_in_a_dialog_text_field_is_in_the_strong_text_colour() { + for brightness in crate::theme::Brightness::ALL { + let ctx = themed(brightness); + let t = Tokens::get(&ctx); + let mut d = Dialog::new("test", json!({"name": "Artboard 1"})); + let id = std::cell::Cell::new(egui::Id::NULL); + let mut add = |ui: &mut egui::Ui| { + id.set(ui.id().with(("dlg-text", "name"))); + text_edit(ui, &mut d, "name", 120.0); + }; + frame(&ctx, 0.0, vec![], &mut add); + // Focus the field and select all of its text. + ctx.memory_mut(|m| m.request_focus(id.get())); + frame(&ctx, 0.1, vec![], &mut add); + let mut state = egui::TextEdit::load_state(&ctx, id.get()).unwrap_or_default(); + state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(10)))); + state.store(&ctx, id.get()); + let shapes = frame(&ctx, 0.2, vec![], &mut add); + let colours = glyph_colours(&shapes); + assert!(!colours.is_empty(), "{brightness:?}: the text is drawn"); + let mut used = colours.clone(); + used.dedup(); + assert_eq!(used, [t.text_strong], "{brightness:?}: selected text in {used:?}"); + } + } +} diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 52476db6f..8987cc64c 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -187,6 +187,31 @@ mod tests { assert!(appearance.contains(">(); + let pen = shapes(&svg("tool-pen")); + assert!(!pen.is_empty(), "the Pen's icon has shapes"); + let (add, delete) = (shapes(&svg("tool-pen-add")), shapes(&svg("tool-pen-delete"))); + for (tool, icon) in [("tool-pen-add", &add), ("tool-pen-delete", &delete)] { + for shape in &pen { + assert!(icon.contains(shape), "{tool}'s icon lacks the Pen's {shape}"); + } + assert!(icon.len() > pen.len(), "{tool}'s icon has a badge of its own"); + } + assert_ne!(add, delete, "plus and minus differ"); + } + #[test] fn shared_catalog_preserves_fallback_and_tool_aliases() { let ImageSource::Bytes { uri, bytes } = source("missing-icon") else { panic!("expected SVG bytes") }; diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 42403f222..33cc41439 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -802,6 +802,25 @@ mod tests { assert_eq!(selected(&app), std::slice::from_ref(&mask), "Edit Clipping Path selects the clipping path"); } + // Regression test for #1910: the Properties panel's flip buttons are named as the reference + // app's Properties panel names them, not Flip Horizontal and Flip Vertical (the Transform + // panel menu's names). + #[test] + fn the_flip_buttons_keep_the_reference_properties_names() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 50, "y": 50, "width": 120, "height": 80})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + let tree = crate::widgets::accessibility_tests::tree(|ui| transform_section(&mut app, ui)); + let names: Vec<&str> = tree.iter().filter_map(|n| n.label()).collect(); + for want in ["Flip Along Horizontal Axis", "Flip Along Vertical Axis"] { + assert!(names.contains(&want), "{want} in {names:?}"); + } + for old in ["Flip Horizontal", "Flip Vertical"] { + assert!(!names.contains(&old), "{old} in {names:?}"); + } + } + /// #3451: the Properties panel Transform section shows ellipse pie angles and Invert Pie. #[test] fn transform_section_shows_ellipse_pie_properties() { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 00452d98d..7a1050afd 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -2970,3 +2970,246 @@ mod button_tests { 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 { + use super::*; + + /// A context with the fonts and the theme in `brightness`, in English. + pub(crate) fn themed(brightness: theme::Brightness) -> egui::Context { + crate::i18n::set_current(crate::i18n::Lang::EN); + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + theme::apply(&ctx, brightness); + ctx + } + + /// One frame at `time` seconds of `add` in a 600 × 400 window with `events` → the shapes it + /// drew, `Shape::Vec`s flattened. + pub(crate) fn frame(ctx: &egui::Context, time: f64, events: Vec, add: impl FnMut(&mut Ui)) -> Vec { + fn flat(s: egui::Shape, out: &mut Vec) { + match s { + egui::Shape::Vec(v) => v.into_iter().for_each(|s| flat(s, out)), + s => out.push(s), + } + } + let raw = + egui::RawInput { time: Some(time), screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(600.0, 400.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, add); + out.textures_delta.clear(); + let mut shapes = vec![]; + out.shapes.into_iter().for_each(|c| flat(c.shape, &mut shapes)); + shapes + } + + // Regression test for #808: the scroll bar of the toolbar and the dock's icon column widened to + // 10 points while it was dragged or the pointer was on it, covering the group labels and the + // right edge of the tool icons. It stays a slim line. + #[test] + fn the_strip_scroll_bar_stays_a_slim_line_while_the_pointer_is_on_it() { + let ctx = themed(Default::default()); + let area = std::cell::Cell::new(Rect::NOTHING); + let mut add = |ui: &mut Ui| { + area.set( + ui.allocate_ui(vec2(80.0, 200.0), |ui| { + strip_scroll(ui, "strip", |ui| { + for i in 0..40 { + ui.label(format!("Tool {i}")); + } + }); + }) + .response + .rect, + ); + }; + frame(&ctx, 0.0, vec![], &mut add); + let area = area.get(); + assert!(area.height() >= 199.0, "the strip fills the height it was given: {area:?}"); + // The pointer comes onto the bar's edge and stays there while the bar settles. + let on_the_bar = egui::Event::PointerMoved(pos2(area.right() - 1.0, area.center().y)); + let mut widest = 0.0_f32; + for i in 1..=15 { + let shapes = frame(&ctx, f64::from(i) * 0.1, if i == 1 { vec![on_the_bar.clone()] } else { vec![] }, &mut add); + for s in shapes { + // The bar's track and handle are the filled rectangles. + if let egui::Shape::Rect(r) = s + && r.fill != Color32::TRANSPARENT + { + widest = widest.max(r.rect.width()); + } + } + } + assert!(widest > 0.0, "the scroll bar is drawn"); + assert!(widest <= 4.5, "the scroll bar is {widest} points wide"); + } + // Regression test for #900: a number field with a spinner and a preset list drew as three + // boxes side by side: its text box was a point taller than the 26-point row, and each joint had + // two borders. + #[test] + fn a_spinner_field_reads_as_one_box_in_three_parts() { + let ctx = themed(Default::default()); + let mut add = |ui: &mut Ui| { + ui.horizontal(|ui| spin_field(ui, "weight", Some(4.0), Unit::Points, 100.0, 1.0, 0.0, &[1.0, 2.0])); + }; + frame(&ctx, 0.0, vec![], &mut add); + let shapes = frame(&ctx, 0.1, vec![], &mut add); + // The boxes of the row: the spinner, the field and the preset chevron, left to right. + let mut boxes: Vec = vec![]; + for s in &shapes { + if let egui::Shape::Rect(r) = s + && r.rect.height() > 20.0 + && !boxes.contains(&r.rect) + { + boxes.push(r.rect); + } + } + boxes.sort_by(|a, b| a.min.x.total_cmp(&b.min.x)); + assert_eq!(boxes.len(), 3, "{boxes:?}"); + for b in &boxes { + assert_eq!((b.min.y, b.height()), (boxes[0].min.y, 26.0), "all as tall as the row: {boxes:?}"); + } + for pair in boxes.windows(2) { + assert!((pair[1].min.x - pair[0].max.x + 1.0).abs() < 0.01, "neighbours share one border: {boxes:?}"); + } + } + + /// The colour of every glyph of the text in `shapes`: selected glyphs are drawn in the + /// selection stroke's colour. + pub(crate) fn glyph_colours(shapes: &[egui::Shape]) -> Vec { + let mut colours = vec![]; + for s in shapes { + if let egui::Shape::Text(t) = s { + for row in &t.galley.rows { + let visuals = &row.row.visuals; + colours.extend(visuals.mesh.vertices[visuals.glyph_vertex_range.clone()].iter().map(|v| v.color)); + } + } + } + colours + } + + // Regression test for #1465: the selected number in a value field was hard to read in every + // brightness: egui draws selected text in the selection stroke's colour, the accent, on a + // selection tinted with the same accent. It is in the strong text colour. + #[test] + fn selected_text_in_a_value_field_is_in_the_strong_text_colour() { + for brightness in theme::Brightness::ALL { + let ctx = themed(brightness); + let t = Tokens::get(&ctx); + let id = std::cell::Cell::new(egui::Id::NULL); + let mut add = |ui: &mut Ui| { + id.set(ui.id().with("w")); + num_field(ui, "w", Some(1234.0), Unit::Points, 80.0); + }; + frame(&ctx, 0.0, vec![], &mut add); + // The field takes focus, and all of its text is selected. + ctx.memory_mut(|m| m.request_focus(id.get())); + frame(&ctx, 0.1, vec![], &mut add); + assert!(ctx.memory(|m| m.has_focus(id.get())), "{brightness:?}: the field has focus"); + let len = Unit::Points.format(1234.0).chars().count(); + let mut state = egui::TextEdit::load_state(&ctx, id.get()).unwrap_or_default(); + state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(len)))); + state.store(&ctx, id.get()); + let shapes = frame(&ctx, 0.2, vec![], &mut add); + let colours = glyph_colours(&shapes); + assert!(!colours.is_empty(), "{brightness:?}: the number is drawn"); + let mut used = colours.clone(); + used.dedup(); + assert_eq!(used, [t.text_strong], "{brightness:?}: selected text in {used:?}"); + assert_ne!(t.text_strong, t.accent); + } + } + + // Regression test for #1483: the theme's widget fill is the panel's colour, so a slider in a + // dialog showed only its handle. `slider` draws the rail in the buttons' border colour and + // fills it up to the handle in the accent. + #[test] + fn a_slider_shows_its_rail_filled_up_to_the_handle() { + for brightness in theme::Brightness::ALL { + let ctx = themed(brightness); + let t = Tokens::get(&ctx); + let mut value = 30.0_f64; + let mut add = |ui: &mut Ui| { + slider(ui, egui::Slider::new(&mut value, 0.0..=100.0).show_value(false)); + }; + frame(&ctx, 0.0, vec![], &mut add); + let shapes = frame(&ctx, 0.1, vec![], &mut add); + let rects: Vec<(Rect, Color32)> = + shapes.iter().filter_map(|s| if let egui::Shape::Rect(r) = s { Some((r.rect, r.fill)) } else { None }).collect(); + let rail = rects.iter().find(|(_, c)| *c == t.button_border).map(|(r, _)| *r); + let filled = rects.iter().find(|(_, c)| *c == t.accent).map(|(r, _)| *r); + let (Some(rail), Some(filled)) = (rail, filled) else { + panic!("{brightness:?}: the rail ({:?}) and its fill ({:?}) are drawn: {rects:?}", t.button_border, t.accent); + }; + assert!(filled.width() > 1.0 && filled.width() < rail.width(), "{brightness:?}: filled {filled:?} of the rail {rail:?}"); + assert!((filled.min.x - rail.min.x).abs() < 1.0, "{brightness:?}: the fill starts at the rail's start"); + } + } + + // Regression test for #2083: the rows of the panels' menus didn't light up under the pointer, as + // the menu bar's items do. + #[test] + fn a_panel_menu_row_lights_up_under_the_pointer() { + let ctx = themed(Default::default()); + let row_at = std::cell::Cell::new(Rect::NOTHING); + let mut add = |ui: &mut Ui| { + row_at.set( + ui.scope(|ui| { + menu_item_name(ui, "Rename", true, false); + }) + .response + .rect, + ); + }; + // A filled rectangle behind the row's text. + let lit = |shapes: &[egui::Shape], row: Rect| { + shapes.iter().any(|s| matches!(s, egui::Shape::Rect(r) if r.fill != Color32::TRANSPARENT && r.rect.contains(row.center()))) + }; + frame(&ctx, 0.0, vec![], &mut add); + let row = row_at.get(); + let at_rest = frame(&ctx, 0.1, vec![], &mut add); + assert!(!lit(&at_rest, row), "no frame at rest"); + frame(&ctx, 0.2, vec![egui::Event::PointerMoved(row.center())], &mut add); + let hovered = frame(&ctx, 0.3, vec![], &mut add); + assert!(lit(&hovered, row), "the row under the pointer is lit: {row:?}"); + } + + // Regression test for #3440: the Fill/Stroke chip's popover closed on the click that ended a + // drag between its proxies. It stays open for a click on its own anchor and while a panel + // drag is on; a click elsewhere still closes it. + #[test] + fn a_popover_stays_open_for_a_click_on_its_anchor_or_during_a_panel_drag() { + let press = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + // Opens the popover, then clicks at `click` (with `drag` as the panel drag under way). + let open_after_click = |click: Option, drag: Option| -> bool { + let ctx = themed(Default::default()); + let toggle = std::cell::Cell::new(true); + let shown = std::cell::Cell::new(false); + let anchor = std::cell::Cell::new(Rect::NOTHING); + let mut add = |ui: &mut Ui| { + let resp = ui.allocate_response(vec2(40.0, 24.0), Sense::hover()); + anchor.set(resp.rect); + shown.set(popover(&resp, toggle.replace(false), |ui| ui.label("Colour")).is_some()); + }; + frame(&ctx, 0.0, vec![], &mut add); + assert!(shown.get(), "the toggle opens it"); + frame(&ctx, 0.2, vec![], &mut add); + assert!(shown.get(), "it stays open"); + if let Some(d) = drag { + egui::DragAndDrop::set_payload(&ctx, d); + } + let pos = click.unwrap_or_else(|| anchor.get().center()); + frame(&ctx, 0.3, vec![egui::Event::PointerMoved(pos), press(pos, true)], &mut add); + frame(&ctx, 0.4, vec![press(pos, false)], &mut add); + frame(&ctx, 0.5, vec![], &mut add); + shown.get() + }; + let far = Some(pos2(500.0, 350.0)); + let panel_drag = || Some(PanelDrag::Symbol("s".into())); + // Whether it is open after: a click elsewhere; a click on its anchor that doesn't toggle it; + // a panel drag released elsewhere. + let open = [open_after_click(far, None), open_after_click(None, None), open_after_click(far, panel_drag())]; + assert_eq!(open, [false, true, true]); + } +}