diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 40f4de7ad..3eddf8258 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -948,637 +948,4 @@ fn reset_panel(app: &mut VectorcraftApp) { } #[cfg(test)] -mod tests { - use super::*; - use vectorcraft_engine::Session; - - #[test] - fn scaled_dimensions_do_not_show_false_mixed_values_from_roundoff() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - let a = app.run("text.create", json!({"x": 20, "y": 50, "text": "Axis", "size": 12})).unwrap()["id"].clone(); - app.run("object.scale", json!({"sx": 120, "sy": 110})).unwrap(); - let b = app.run("text.create", json!({"x": 20, "y": 90, "text": "Label", "size": 24})).unwrap()["id"].clone(); - app.run("select.set", json!({"ids": [a, b]})).unwrap(); - app.run("text.setStyle", json!({"size": 7, "leading": 9})).unwrap(); - app.run("text.setFormat", json!({"hScale": 130, "baselineShift": 3})).unwrap(); - for (actual, expected) in [ - (shared_effective(&app, |s| s.size), 7.0), - (shared_effective(&app, CharStyle::effective_leading), 9.0), - (shared_effective(&app, |s| s.h_scale), 130.0), - (shared_effective(&app, |s| s.baseline_shift), 3.0), - ] { - assert!((actual.expect("equal dimensions must not be mixed") - expected).abs() < 1e-8); - } - } - - /// A final empty paragraph counts: with the object selected, its Auto Leading differing from - /// the paragraph before it shows Leading as mixed. - #[test] - fn leading_field_counts_the_final_empty_paragraph() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "One\n", "size": 10})).unwrap()["id"].clone(); - app.run("select.set", json!({"ids": [id]})).unwrap(); - assert!((shared_leading(&app).unwrap() - 12.0).abs() < 1e-9); - app.run("text.setFormat", json!({"autoLeading": 200, "start": 4, "end": 4})).unwrap(); - assert_eq!(shared_leading(&app), None, "the empty last paragraph differs"); - } - - /// Auto leading shows the leading the paragraph's Justification › Auto Leading sets. - #[test] - fn leading_field_shows_the_paragraph_auto_leading() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "One\nTwo", "size": 10})).unwrap()["id"].clone(); - app.run("select.set", json!({"ids": [id]})).unwrap(); - assert!((shared_leading(&app).unwrap() - 12.0).abs() < 1e-9); - app.run("text.setFormat", json!({"autoLeading": 200, "start": 5, "end": 5})).unwrap(); - assert_eq!(shared_leading(&app), None, "the paragraphs differ"); - assert_eq!(shared(&app, |s| s.leading.is_none()), Some(true), "still Auto"); - app.run("text.setFormat", json!({"autoLeading": 200})).unwrap(); - assert!((shared_leading(&app).unwrap() - 20.0).abs() < 1e-9); - app.run("text.setStyle", json!({"leading": 15})).unwrap(); - assert!((shared_leading(&app).unwrap() - 15.0).abs() < 1e-9, "a set leading is its own"); - } - - #[test] - fn scaled_collapsed_text_keeps_nondimensional_controls_available() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - app.run("text.create", json!({"x": 20, "y": 50, "text": "Axis", "size": 12})).unwrap(); - app.run("object.transform", json!({"matrix": [0, 0, 0, 0, 0, 0]})).unwrap(); - assert!(text_style(&app).is_some()); - assert_eq!(shared(&app, |s| s.tracking), Some(0.0)); - assert_eq!(shared_effective(&app, |s| s.size), None); - app.run("text.setStyle", json!({"tracking": 40})).unwrap(); - app.run("text.setFormat", json!({"leftIndent": 4})).unwrap(); - assert_eq!(shared(&app, |s| s.tracking), Some(40.0)); - assert_eq!(text_style(&app).unwrap().1.left_indent, 4.0); - let ctx = egui::Context::default(); - let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], show); - assert!(!texts.iter().any(|(s, _)| s == "No text selected")); - } - - #[test] - fn scaled_character_fields_show_document_sizes_and_mixed_group_values() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - let a = app.run("text.create", json!({"x": 20, "y": 50, "text": "Axis", "size": 12})).unwrap()["id"].clone(); - app.run("object.scale", json!({"sx": 200})).unwrap(); - assert_eq!(shared_effective(&app, |s| s.size), Some(24.0)); - assert_eq!(text_style(&app).unwrap().0.size, 12.0, "style definitions keep stored attributes"); - let b = app.run("text.create", json!({"x": 20, "y": 90, "text": "Label", "size": 24})).unwrap()["id"].clone(); - app.run("select.set", json!({"ids": [a, b]})).unwrap(); - app.run("object.group", json!({})).unwrap(); - assert_eq!(shared_effective(&app, |s| s.size), Some(24.0), "different local sizes, same displayed size"); - app.run("object.scale", json!({"sx": 200, "sy": 100})).unwrap(); - assert_eq!(shared_effective(&app, |s| s.h_scale), Some(200.0)); - app.run("text.setStyle", json!({"size": 10})).unwrap(); - assert_eq!(shared_effective(&app, |s| s.size), Some(10.0)); - app.run("text.setFormat", json!({"hScale": 100})).unwrap(); - assert_eq!(shared_effective(&app, |s| s.h_scale), Some(100.0)); - } - - #[test] - fn scaled_range_fields_follow_the_type_tool_selection() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - app.session.select_tool("type", Default::default()).unwrap(); - let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "Axis label", "size": 12})).unwrap()["id"].clone(); - app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); - assert!(text_editing(&app).is_some()); - app.run("object.scale", json!({"sx": 200})).unwrap(); - app.run("text.setRangeStyle", json!({"id": id, "start": 0, "end": 4, "size": 10})).unwrap(); - app.session.set_tool_option("selectAll", &json!(true)); - assert_eq!(shared_effective(&app, |s| s.size), None); - app.session.set_tool_option("select", &json!({"start": 0, "end": 4})); - assert_eq!(shared_effective(&app, |s| s.size), Some(10.0)); - assert_eq!(text_style(&app).unwrap().0.size, 5.0); - } - - /// The Kerning field at a caret kerns that pair, not the whole text (#2217). - #[test] - fn kerning_at_a_caret_sets_the_pair_not_the_whole_text() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - app.session.select_tool("type", Default::default()).unwrap(); - let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "AVAX", "size": 12})).unwrap()["id"].clone(); - app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); - app.session.set_tool_option("select", &json!({"start": 2, "end": 2})); - set_kerning(&mut app, json!(-50)); - let kerning = |app: &VectorcraftApp, at: usize| { - let st = app.session.active().unwrap(); - let Some(NodeKind::Text(t)) = st.doc.node(NodeId(id.as_u64().unwrap())).map(|n| &n.kind) else { panic!("text") }; - vectorcraft_text::edit::style_at(&t.runs, at + 1).kerning // the character at `at` - }; - assert_eq!(kerning(&app, 1), Some(-50.0), "the character before the caret"); - assert_eq!(kerning(&app, 3), None, "the rest is untouched"); - assert_eq!(kerning(&app, 0), None); - // A selected range still takes it whole. - app.session.set_tool_option("select", &json!({"start": 2, "end": 4})); - set_kerning(&mut app, json!(30)); - assert_eq!((kerning(&app, 1), kerning(&app, 2), kerning(&app, 3)), (Some(-50.0), Some(30.0), Some(30.0))); - } - - /// A variable font gets the Variable Font button, which opens one slider per axis named as the - /// font names it; a drag along one sets that axis of the selected type in one undo step. - #[test] - fn variable_fonts_get_a_slider_per_axis() { - use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font}; - let db = vectorcraft_text::FontDb::global(); - db.add_font(two_axis_font().unwrap()); - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - let plain = app.run("text.create", json!({"x": 20, "y": 50, "text": "Static"})).unwrap()["id"].clone(); - let id = app.run("text.create", json!({"x": 20, "y": 120, "text": "lIn", "font": TWO_AXIS_FAMILY})).unwrap()["id"].clone(); - app.run("select.set", json!({"ids": [plain]})).unwrap(); - assert!(!crate::tests_labels::painted_text(&mut app, show).contains("Variable Font"), "a static font"); - app.run("select.set", json!({"ids": [id]})).unwrap(); - assert!(crate::tests_labels::painted_text(&mut app, show).contains("Variable Font")); - let face = db.face(TWO_AXIS_FAMILY, "Regular").unwrap(); - let text = crate::tests_labels::painted_text(&mut app, |app, ui| axis_sliders(app, ui, &face)); - assert!(text.contains("Weight\n") && text.contains("Width\n") && text.contains("400") && text.contains("100"), "{text}"); - // The labels are the font's names in every interface language, not catalog words (ja - // translates the interface's Weight as the stroke weight). - let ja = crate::i18n::Lang::from_code("ja").unwrap(); - crate::i18n::set_current(ja); - let text = crate::tests_labels::painted_text(&mut app, |app, ui| axis_sliders(app, ui, &face)); - crate::i18n::set_current(crate::i18n::Lang::EN); - let weight = crate::i18n::tr(ja, "Weight"); - assert!(text.contains("Weight\n") && text.contains("Width\n") && (weight == "Weight" || !text.contains(weight)), "{text}"); - // Screen readers name each slider by its axis. - let tree = crate::widgets::accessibility_tests::tree(|ui| axis_sliders(&mut app, ui, &face)); - for name in ["Weight", "Width"] { - assert!(tree.iter().any(|n| n.role() == egui::accesskit::Role::Slider && n.label() == Some(name)), "{name}"); - } - // ...with the number its field shows, on the axis's range. - use crate::widgets::accessibility_tests::slider_value; - let wght = face.axes().into_iter().find(|a| &a.tag == b"wght").unwrap(); - let range = (Some(f64::from(wght.min)), Some(f64::from(wght.max))); - assert_eq!(slider_value(&tree, "Weight"), (Some(400.0), range.0, range.1, Some("400".into()))); - // Drag the Weight slider from its start to past its end. - let ctx = egui::Context::default(); - let frame = |app: &mut VectorcraftApp, events: Vec| { - let mut slider = None; - let input = egui::RawInput { events, ..Default::default() }; - ctx.run_ui(input, |ui| { - axis_sliders(app, ui, &face); - slider = ctx.read_response(ui.scope_id().with(("ch-axis", *b"wght"))).map(|r| r.rect); - }) - .textures_delta - .clear(); - slider - }; - frame(&mut app, vec![]); - let r = frame(&mut app, vec![]).expect("the Weight slider is drawn"); - let none = egui::Modifiers::NONE; - let (from, to) = (egui::pos2(r.left() + 4.0, r.center().y), egui::pos2(r.right() + 20.0, r.center().y)); - let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: none }; - frame(&mut app, vec![egui::Event::PointerMoved(from), button(from, true)]); - for i in 1..=4 { - frame(&mut app, vec![egui::Event::PointerMoved(from + (to - from) * i as f32 / 4.0)]); - } - frame(&mut app, vec![button(to, false)]); - frame(&mut app, vec![]); - let axes = |app: &VectorcraftApp| shared(app, |s| s.font_axes.clone()).unwrap(); - assert_eq!(axes(&app).into_iter().collect::>(), [("wght".to_string(), 700.0)]); - app.run("edit.undo", json!({})).unwrap(); - assert!(axes(&app).is_empty(), "one undo step"); - // Regular and Bold selected: the weights differ and the field is blank; the widths agree. - let bold = app.run("text.create", json!({"x": 20, "y": 160, "text": "lIn", "font": TWO_AXIS_FAMILY, "style": "Bold"})).unwrap()["id"].clone(); - app.run("select.set", json!({"ids": [id, bold]})).unwrap(); - let text = crate::tests_labels::painted_text(&mut app, |app, ui| axis_sliders(app, ui, &face)); - assert!(!text.contains("400") && !text.contains("700") && text.contains("100"), "{text}"); - let tree = crate::widgets::accessibility_tests::tree(|ui| axis_sliders(&mut app, ui, &face)); - assert_eq!(slider_value(&tree, "Weight"), (None, None, None, None), "a blank field: no value"); - assert_eq!(slider_value(&tree, "Width").0, Some(100.0)); - // Reset Panel leaves the settings, as it leaves the font and style. - app.run("select.set", json!({"ids": [id]})).unwrap(); - app.run("text.setFormat", json!({"fontAxes": {"wght": 650}})).unwrap(); - reset_panel(&mut app); - assert_eq!(axes(&app).into_iter().collect::>(), [("wght".to_string(), 650.0)]); - } - - /// Bold and Italic pick the family's own faces by name, and toggle back (#724). - #[test] - fn bold_and_italic_pick_the_familys_own_faces() { - let styles: Vec = - ["Light", "Narrow", "Regular", "Italic", "Narrow Italic", "SemiBold", "Bold", "Bold Condensed", "Narrow Bold", "Bold Italic", "Black"] - .iter() - .map(|s| s.to_string()) - .collect(); - assert_eq!(pick_face(&styles, true, false, "Regular"), Some("Bold"), "not Bold Condensed, Narrow Bold or SemiBold"); - assert_eq!(pick_face(&styles, true, true, "Italic"), Some("Bold Italic")); - assert_eq!(pick_face(&styles, false, true, "Regular"), Some("Italic")); - assert_eq!(pick_face(&styles, false, false, "Italic"), Some("Regular"), "upright again, not Narrow"); - assert_eq!(pick_face(&styles, true, false, "Narrow"), Some("Narrow Bold"), "a Narrow face stays Narrow"); - assert_eq!(pick_face(&styles, false, true, "Narrow"), Some("Narrow Italic")); - let oblique: Vec = ["Book", "Oblique", "Bold-Oblique"].iter().map(|s| s.to_string()).collect(); - assert_eq!(pick_face(&oblique, true, true, "Oblique"), Some("Bold-Oblique")); - assert_eq!(pick_face(&oblique, false, false, "Oblique"), None, "Book has a weight word: no regular face"); - assert_eq!(face_of("Bold Italic"), (true, false, true, vec![])); - assert_eq!(face_of("SemiBold"), (false, false, false, vec![])); - assert_eq!(face_of("Narrow Italic"), (false, true, true, vec!["narrow".to_string()])); - // No type selected: a message, nothing changed. - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - assert!(toggle_face(&mut app, Face::Bold).is_err()); - assert!(!crate::menus::enabled(&app, "type.bold")); - } - - /// From one frame of the panel: a point inside each of the Small Caps, Superscript and - /// Subscript buttons, and which of them are drawn on. The two buttons with a "1" are - /// Superscript (the "1" up) and Subscript; Small Caps is the button before them. - fn script_buttons(ctx: &egui::Context, app: &mut VectorcraftApp) -> ([egui::Pos2; 3], [bool; 3]) { - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui)); - out.textures_delta.clear(); - fn walk(s: &egui::Shape, on: egui::Color32, ones: &mut Vec, lit: &mut Vec) { - match s { - egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, on, ones, lit)), - egui::Shape::Text(t) if t.galley.text() == "1" => ones.push(t.visual_bounding_rect().center()), - egui::Shape::Rect(r) if r.fill == on && r.rect.size() == vec2(30.0, 26.0) => lit.push(r.rect), - _ => {} - } - } - let (mut ones, mut lit) = (vec![], vec![]); - for c in &out.shapes { - walk(&c.shape, Tokens::get(ctx).tool_active, &mut ones, &mut lit); - } - let [sup, sub] = ones[..] else { panic!("two script buttons: {ones:?}") }; - assert!(sup.x < sub.x && sup.y < sub.y, "Superscript, then Subscript: {ones:?}"); - // Each "1" is drawn 6 points right of its button's center and 5 points above or below it. - let y = (sup.y + sub.y) / 2.0; - let (sup, sub) = (egui::pos2(sup.x - 6.0, y), egui::pos2(sub.x - 6.0, y)); - let points = [sup - vec2(sub.x - sup.x, 0.0), sup, sub]; - (points, points.map(|p| lit.iter().any(|r| r.contains(p)))) - } - - /// Each run's (small caps, position). - fn scripts(app: &VectorcraftApp, id: &Value) -> Vec<(Option, &'static str)> { - match &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind { - NodeKind::Text(t) => t.runs.iter().map(|r| (r.style.small_caps, r.style.position.id())).collect(), - _ => panic!("text"), - } - } - - /// The Small Caps, Superscript and Subscript buttons set the selected type's small caps and - /// position, with one undo step for each click, and show them; Superscript and Subscript turn - /// each other off. While the Type tool edits, they set and show the selected characters. - #[test] - fn small_caps_superscript_and_subscript_buttons_set_and_show_the_text() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - app.session.select_tool("type", Default::default()).unwrap(); - let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "E=mc2", "size": 12})).unwrap()["id"].clone(); - let ctx = egui::Context::default(); - let (at, lit) = script_buttons(&ctx, &mut app); - assert_eq!(lit, [false; 3]); - let small = Some(app.session.active().unwrap().doc.setup.small_caps_size); - for (button, runs, shown) in [ - (0, (small, "normal"), [true, false, false]), - (1, (small, "superscript"), [true, true, false]), - (2, (small, "subscript"), [true, false, true]), - (2, (small, "normal"), [true, false, false]), - (1, (small, "superscript"), [true, true, false]), - (1, (small, "normal"), [true, false, false]), - (0, (None, "normal"), [false; 3]), - ] { - let undo = app.session.active().unwrap().history.undo.len(); - super::super::tests_appearance::click(&ctx, &mut app, at[button], show); - assert_eq!(scripts(&app, &id), [runs], "button {button}"); - assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1, "button {button}"); - assert_eq!(script_buttons(&ctx, &mut app).1, shown, "button {button}"); - } - // The Type tool's selection: "2" alone. - app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); - app.session.set_tool_option("select", &json!({"start": 4, "end": 5})); - assert!(text_editing(&app).is_some()); - super::super::tests_appearance::click(&ctx, &mut app, at[1], show); - assert_eq!(scripts(&app, &id), [(None, "normal"), (None, "superscript")]); - assert_eq!(script_buttons(&ctx, &mut app).1, [false, true, false]); - app.session.set_tool_option("select", &json!({"start": 0, "end": 4})); - assert_eq!(script_buttons(&ctx, &mut app).1, [false; 3]); - } - - /// The panel menu's Small Caps, Superscript and Subscript set the selected type's small caps - /// and position and show a check mark when on; Superscript and Subscript turn each other off. - #[test] - fn small_caps_superscript_and_subscript_menu_items_set_and_check_the_text() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "E=mc2", "size": 12})).unwrap()["id"].clone(); - let ctx = egui::Context::default(); - let checked = |app: &mut VectorcraftApp| { - let texts = super::super::tests_appearance::frame_events(&ctx, app, vec![], menu); - ["Small Caps", "Superscript", "Subscript"].map(|l| texts.iter().any(|(t, _)| *t == format!("✓ {l}"))) - }; - assert_eq!(checked(&mut app), [false; 3]); - let small = Some(app.session.active().unwrap().doc.setup.small_caps_size); - for (item, runs, shown) in [ - ("Small Caps", (small, "normal"), [true, false, false]), - ("Superscript", (small, "superscript"), [true, true, false]), - ("Subscript", (small, "subscript"), [true, false, true]), - ("Subscript", (small, "normal"), [true, false, false]), - ("Small Caps", (None, "normal"), [false; 3]), - ] { - let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], menu); - let at = texts.iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == item).unwrap_or_else(|| panic!("{item}: {texts:?}")).1; - super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); - assert_eq!(scripts(&app, &id), [runs], "{item}"); - assert_eq!(checked(&mut app), shown, "{item}"); - } - } - - /// Character panel menu › System Layout is off for a new document, and a click turns whole-point - /// spacing on and back off for every text object. - #[test] - fn system_layout_menu_item_toggles_the_document() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "Hi", "size": 13})).unwrap()["id"].as_u64().unwrap(); - let ctx = egui::Context::default(); - let on = |app: &VectorcraftApp| { - let st = app.session.active().unwrap(); - let object = match &st.doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.system_layout, - _ => panic!("text"), - }; - (st.doc.setup.system_layout, object) - }; - let row = |app: &mut VectorcraftApp| { - let texts = super::super::tests_appearance::frame_events(&ctx, app, vec![], menu); - texts.into_iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == "System Layout") - }; - assert_eq!(on(&app), (false, false)); - assert!(row(&mut app).is_some_and(|(t, _)| !t.starts_with('✓'))); - let at = row(&mut app).unwrap().1; - super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); - assert_eq!(on(&app), (true, true)); - assert!(row(&mut app).is_some_and(|(t, _)| t.starts_with('✓'))); - let at = row(&mut app).unwrap().1; - super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); - assert_eq!(on(&app), (false, false)); - assert!(row(&mut app).is_some_and(|(t, _)| !t.starts_with('✓'))); - } - - /// Character panel menu › Fractional Widths is on for new type, and a click turns it off and - /// back on for the whole type object. - #[test] - fn fractional_widths_menu_item_toggles_the_type_object() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "Hi", "size": 13})).unwrap()["id"].as_u64().unwrap(); - let ctx = egui::Context::default(); - let flag = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.fractional_widths, - _ => panic!("text"), - }; - let row = |app: &mut VectorcraftApp| { - let texts = super::super::tests_appearance::frame_events(&ctx, app, vec![], menu); - texts.into_iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == "Fractional Widths") - }; - assert!(flag(&app)); - assert!(row(&mut app).is_some_and(|(t, _)| t.starts_with('✓'))); - let at = row(&mut app).unwrap().1; - super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); - assert!(!flag(&app)); - assert!(row(&mut app).is_some_and(|(t, _)| !t.starts_with('✓'))); - let at = row(&mut app).unwrap().1; - super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); - assert!(flag(&app)); - assert!(row(&mut app).is_some_and(|(t, _)| t.starts_with('✓'))); - } - - /// Character Alignment and Proportional Metrics are in the panel menu with the East Asian - /// options only (as Mojikumi Set and Top-to-Top Leading are in the Paragraph panel). - #[test] - fn character_alignment_shows_with_the_east_asian_options() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "雅楽"})).unwrap()["id"].clone(); - app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); - let shown = |app: &mut VectorcraftApp| { - let text = crate::tests_labels::painted_text(app, menu); - (text.contains("Character Alignment"), text.contains("Proportional Metrics")) - }; - assert_eq!(shown(&mut app), (false, false)); - app.session.prefs.show_east_asian_options = true; - assert_eq!(shown(&mut app), (true, true)); - } - - /// Tate-chu-yoko is in the panel menu with the East Asian options, and choosing it marks the - /// selected characters. - #[test] - fn tate_chu_yoko_menu_item_shows_with_east_asian_options_and_toggles() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "2026"})).unwrap()["id"].as_u64().unwrap(); - app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); - let ctx = egui::Context::default(); - let painted = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, menu); - assert!(!painted(&mut app).contains("Tate-chu-yoko")); - app.session.prefs.show_east_asian_options = true; - let on = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.runs.iter().all(|r| r.style.tate_chu_yoko), - _ => panic!("text"), - }; - assert!(!on(&app)); - let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], menu); - let at = texts.iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == "Tate-chu-yoko").unwrap_or_else(|| panic!("{texts:?}")).1; - super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); - assert!(on(&app)); - assert!(painted(&mut app).contains("✓ Tate-chu-yoko")); - let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], menu); - let at = texts.iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == "Tate-chu-yoko").unwrap().1; - super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); - assert!(!on(&app)); - } - - /// Digits is in the panel menu with the Middle Eastern (Indic) options only, as Paragraph - /// Direction is in the Paragraph panel. - #[test] - fn digits_show_with_the_indic_options() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "صفحة 3"})).unwrap()["id"].clone(); - app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); - let shown = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, menu).contains("Digits"); - assert!(!shown(&mut app)); - app.session.prefs.show_indic_options = true; - assert!(shown(&mut app)); - } - - /// Character Direction is in the panel menu with the Middle Eastern (Indic) options, beside - /// Digits. Its submenu checks the direction the characters the panel acts on share: the - /// selected type's, or the range the Type tool has selected. It checks no item when they differ. - #[test] - fn character_direction_checks_the_direction_of_the_selection() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "שלום 21 10 2026"})).unwrap()["id"].as_u64().unwrap(); - app.run("select.set", json!({"ids": [id]})).unwrap(); - let shown = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, menu).contains("Character Direction"); - assert!(!shown(&mut app)); - app.session.prefs.show_indic_options = true; - assert!(shown(&mut app)); - let checked = |app: &mut VectorcraftApp| { - let text = crate::tests_labels::painted_text(app, char_direction_items); - text.lines().filter_map(|l| l.strip_prefix("✓ ")).map(str::to_string).collect::>() - }; - assert_eq!(checked(&mut app), ["Default"]); - app.run("text.setRangeStyle", json!({"id": id, "start": 9, "end": 19, "charDirection": "leftToRight"})).unwrap(); - // The object's characters differ: nothing is checked. - assert!(checked(&mut app).is_empty()); - // The Type tool's selection: the date, then the Hebrew word. - app.select_tool("type"); - app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); - app.session.set_tool_option("select", &json!({"start": 9, "end": 19})); - assert_eq!(checked(&mut app), ["Left to Right"]); - app.session.set_tool_option("select", &json!({"start": 0, "end": 8})); - assert_eq!(checked(&mut app), ["Default"]); - } - - /// Small Caps, Superscript and Subscript buttons and menu items stay off when the selection mixes them. - #[test] - fn character_mixed_small_caps_and_position_show_off() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - let a = app.run("text.create", json!({"x": 20, "y": 50, "text": "A"})).unwrap()["id"].clone(); - let b = app.run("text.create", json!({"x": 20, "y": 90, "text": "B"})).unwrap()["id"].clone(); - app.run("text.setRangeStyle", json!({"id": a, "start": 0, "end": 1, "smallCaps": true, "position": "superscript"})).unwrap(); - app.run("text.setRangeStyle", json!({"id": b, "start": 0, "end": 1, "smallCaps": false, "position": "subscript"})).unwrap(); - app.run("select.set", json!({"ids": [a, b]})).unwrap(); - assert_eq!(shared(&app, |s| s.small_caps.is_some()), None); - assert_eq!(shared(&app, |s| s.position), None); - let ctx = egui::Context::default(); - assert_eq!(script_buttons(&ctx, &mut app).1, [false, false, false]); - let checked = |app: &mut VectorcraftApp| { - let texts = super::super::tests_appearance::frame_events(&ctx, app, vec![], menu); - ["Small Caps", "Superscript", "Subscript"].map(|l| texts.iter().any(|(t, _)| *t == format!("✓ {l}"))) - }; - assert_eq!(checked(&mut app), [false, false, false]); - } - - /// At a caret in text the Type tool edits, with no characters selected, the Character - /// Direction items are disabled: a click on one leaves the direction of every character as it was. - #[test] - fn character_direction_does_nothing_at_an_empty_caret() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "שלום 21 10 2026"})).unwrap()["id"].as_u64().unwrap(); - app.run("select.set", json!({"ids": [id]})).unwrap(); - app.select_tool("type"); - app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); - app.session.set_tool_option("select", &json!({"start": 3, "end": 3})); - let directions = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.runs.iter().map(|r| (r.text.clone(), r.style.char_direction)).collect::>(), - _ => panic!("text"), - }; - let before = directions(&app); - let ctx = egui::Context::default(); - let click_rtl = |app: &mut VectorcraftApp| { - let texts = super::super::tests_appearance::frame_events(&ctx, app, vec![], char_direction_items); - let at = texts.iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == "Right to Left").unwrap_or_else(|| panic!("{texts:?}")).1; - super::super::tests_appearance::click(&ctx, app, at.center(), char_direction_items); - }; - click_rtl(&mut app); - assert_eq!(directions(&app), before); - // With characters selected, the same click sets their direction. - app.session.set_tool_option("select", &json!({"start": 0, "end": 4})); - click_rtl(&mut app); - assert_ne!(directions(&app), before); - } - - /// No Break is in the panel menu, and choosing it marks the selected characters. - #[test] - fn no_break_menu_item_toggles_the_selected_characters() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "Hello"})).unwrap()["id"].as_u64().unwrap(); - app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); - let ctx = egui::Context::default(); - let painted = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, menu); - assert!(painted(&mut app).contains("No Break")); - assert!(!painted(&mut app).contains("✓ No Break")); - let on = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.runs.iter().all(|r| r.style.no_break), - _ => panic!("text"), - }; - assert!(!on(&app)); - let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], menu); - let at = texts.iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == "No Break").unwrap_or_else(|| panic!("{texts:?}")).1; - super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); - assert!(on(&app)); - assert!(painted(&mut app).contains("✓ No Break")); - let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], menu); - let at = texts.iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == "No Break").unwrap().1; - super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); - assert!(!on(&app)); - } - - /// Reset Panel sets every character attribute the panel shows, except the font family, style - /// and size, back to its default (Small Caps, the position, Character Alignment, Proportional - /// Metrics, Tate-chu-yoko, No Break, Character Direction and Digits included), in the selected - /// type and in the text the Type tool has selected while it edits. - #[test] - fn reset_panel_resets_every_attribute_but_the_font() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); - let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "One 12"})).unwrap()["id"].as_u64().unwrap(); - let runs = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { - NodeKind::Text(t) => t.runs.iter().map(|r| (r.text.clone(), r.style.clone())).collect::>(), - _ => panic!("text"), - }; - let differs = |s: &CharStyle, d: &CharStyle| { - [ - s.tracking != d.tracking, - s.leading != d.leading, - s.kerning != d.kerning, - s.baseline_shift != d.baseline_shift, - s.h_scale != d.h_scale, - s.v_scale != d.v_scale, - s.rotation != d.rotation, - s.underline != d.underline, - s.strikethrough != d.strikethrough, - s.all_caps != d.all_caps, - s.small_caps != d.small_caps, - s.position != d.position, - s.char_align != d.char_align, - s.proportional_metrics != d.proportional_metrics, - s.tate_chu_yoko != d.tate_chu_yoko, - s.no_break != d.no_break, - s.char_direction != d.char_direction, - s.digits != d.digits, - ] - }; - let custom = |app: &mut VectorcraftApp| { - app.run( - "text.setRangeStyle", - json!({ - "id": id, "tracking": 50, "leading": 30, "kerning": 20, "baselineShift": 3, "hScale": 120, "vScale": 90, "rotation": 15, - "underline": true, "strikethrough": true, "allCaps": true, "smallCaps": true, "position": "superscript", - "charAlign": "emBoxCenter", "proportionalMetrics": true, "tateChuYoko": true, "noBreak": true, - "charDirection": "rightToLeft", "digits": "hindi" - }), - ) - .unwrap(); - }; - let plain = runs(&app)[0].1.clone(); - assert_eq!(differs(&plain, &CharStyle::default()), [false; 18]); - custom(&mut app); - let styled = runs(&app)[0].1.clone(); - assert_eq!(differs(&styled, &CharStyle::default()), [true; 18]); - app.run("select.set", json!({"ids": [id]})).unwrap(); - reset_panel(&mut app); - assert_eq!(runs(&app), [("One 12".to_string(), plain.clone())]); - // While the Type tool edits text, Reset Panel resets only its selection. - custom(&mut app); - app.select_tool("type"); - app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); - app.session.set_tool_option("select", &json!({"start": 4, "end": 6})); - assert!(text_editing(&app).is_some()); - reset_panel(&mut app); - assert_eq!(runs(&app), [("One ".to_string(), styled), ("12".to_string(), plain)]); - } -} +mod tests; diff --git a/crates/ui-egui/src/panels/character/tests.rs b/crates/ui-egui/src/panels/character/tests.rs new file mode 100644 index 000000000..b823b87d1 --- /dev/null +++ b/crates/ui-egui/src/panels/character/tests.rs @@ -0,0 +1,632 @@ +use super::*; +use vectorcraft_engine::Session; + +#[test] +fn scaled_dimensions_do_not_show_false_mixed_values_from_roundoff() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let a = app.run("text.create", json!({"x": 20, "y": 50, "text": "Axis", "size": 12})).unwrap()["id"].clone(); + app.run("object.scale", json!({"sx": 120, "sy": 110})).unwrap(); + let b = app.run("text.create", json!({"x": 20, "y": 90, "text": "Label", "size": 24})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + app.run("text.setStyle", json!({"size": 7, "leading": 9})).unwrap(); + app.run("text.setFormat", json!({"hScale": 130, "baselineShift": 3})).unwrap(); + for (actual, expected) in [ + (shared_effective(&app, |s| s.size), 7.0), + (shared_effective(&app, CharStyle::effective_leading), 9.0), + (shared_effective(&app, |s| s.h_scale), 130.0), + (shared_effective(&app, |s| s.baseline_shift), 3.0), + ] { + assert!((actual.expect("equal dimensions must not be mixed") - expected).abs() < 1e-8); + } +} + +/// A final empty paragraph counts: with the object selected, its Auto Leading differing from +/// the paragraph before it shows Leading as mixed. +#[test] +fn leading_field_counts_the_final_empty_paragraph() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "One\n", "size": 10})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + assert!((shared_leading(&app).unwrap() - 12.0).abs() < 1e-9); + app.run("text.setFormat", json!({"autoLeading": 200, "start": 4, "end": 4})).unwrap(); + assert_eq!(shared_leading(&app), None, "the empty last paragraph differs"); +} + +/// Auto leading shows the leading the paragraph's Justification › Auto Leading sets. +#[test] +fn leading_field_shows_the_paragraph_auto_leading() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "One\nTwo", "size": 10})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + assert!((shared_leading(&app).unwrap() - 12.0).abs() < 1e-9); + app.run("text.setFormat", json!({"autoLeading": 200, "start": 5, "end": 5})).unwrap(); + assert_eq!(shared_leading(&app), None, "the paragraphs differ"); + assert_eq!(shared(&app, |s| s.leading.is_none()), Some(true), "still Auto"); + app.run("text.setFormat", json!({"autoLeading": 200})).unwrap(); + assert!((shared_leading(&app).unwrap() - 20.0).abs() < 1e-9); + app.run("text.setStyle", json!({"leading": 15})).unwrap(); + assert!((shared_leading(&app).unwrap() - 15.0).abs() < 1e-9, "a set leading is its own"); +} + +#[test] +fn scaled_collapsed_text_keeps_nondimensional_controls_available() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + app.run("text.create", json!({"x": 20, "y": 50, "text": "Axis", "size": 12})).unwrap(); + app.run("object.transform", json!({"matrix": [0, 0, 0, 0, 0, 0]})).unwrap(); + assert!(text_style(&app).is_some()); + assert_eq!(shared(&app, |s| s.tracking), Some(0.0)); + assert_eq!(shared_effective(&app, |s| s.size), None); + app.run("text.setStyle", json!({"tracking": 40})).unwrap(); + app.run("text.setFormat", json!({"leftIndent": 4})).unwrap(); + assert_eq!(shared(&app, |s| s.tracking), Some(40.0)); + assert_eq!(text_style(&app).unwrap().1.left_indent, 4.0); + let ctx = egui::Context::default(); + let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], show); + assert!(!texts.iter().any(|(s, _)| s == "No text selected")); +} + +#[test] +fn scaled_character_fields_show_document_sizes_and_mixed_group_values() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let a = app.run("text.create", json!({"x": 20, "y": 50, "text": "Axis", "size": 12})).unwrap()["id"].clone(); + app.run("object.scale", json!({"sx": 200})).unwrap(); + assert_eq!(shared_effective(&app, |s| s.size), Some(24.0)); + assert_eq!(text_style(&app).unwrap().0.size, 12.0, "style definitions keep stored attributes"); + let b = app.run("text.create", json!({"x": 20, "y": 90, "text": "Label", "size": 24})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + app.run("object.group", json!({})).unwrap(); + assert_eq!(shared_effective(&app, |s| s.size), Some(24.0), "different local sizes, same displayed size"); + app.run("object.scale", json!({"sx": 200, "sy": 100})).unwrap(); + assert_eq!(shared_effective(&app, |s| s.h_scale), Some(200.0)); + app.run("text.setStyle", json!({"size": 10})).unwrap(); + assert_eq!(shared_effective(&app, |s| s.size), Some(10.0)); + app.run("text.setFormat", json!({"hScale": 100})).unwrap(); + assert_eq!(shared_effective(&app, |s| s.h_scale), Some(100.0)); +} + +#[test] +fn scaled_range_fields_follow_the_type_tool_selection() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + app.session.select_tool("type", Default::default()).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "Axis label", "size": 12})).unwrap()["id"].clone(); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + assert!(text_editing(&app).is_some()); + app.run("object.scale", json!({"sx": 200})).unwrap(); + app.run("text.setRangeStyle", json!({"id": id, "start": 0, "end": 4, "size": 10})).unwrap(); + app.session.set_tool_option("selectAll", &json!(true)); + assert_eq!(shared_effective(&app, |s| s.size), None); + app.session.set_tool_option("select", &json!({"start": 0, "end": 4})); + assert_eq!(shared_effective(&app, |s| s.size), Some(10.0)); + assert_eq!(text_style(&app).unwrap().0.size, 5.0); +} + +/// The Kerning field at a caret kerns that pair, not the whole text (#2217). +#[test] +fn kerning_at_a_caret_sets_the_pair_not_the_whole_text() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + app.session.select_tool("type", Default::default()).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "AVAX", "size": 12})).unwrap()["id"].clone(); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + app.session.set_tool_option("select", &json!({"start": 2, "end": 2})); + set_kerning(&mut app, json!(-50)); + let kerning = |app: &VectorcraftApp, at: usize| { + let st = app.session.active().unwrap(); + let Some(NodeKind::Text(t)) = st.doc.node(NodeId(id.as_u64().unwrap())).map(|n| &n.kind) else { panic!("text") }; + vectorcraft_text::edit::style_at(&t.runs, at + 1).kerning // the character at `at` + }; + assert_eq!(kerning(&app, 1), Some(-50.0), "the character before the caret"); + assert_eq!(kerning(&app, 3), None, "the rest is untouched"); + assert_eq!(kerning(&app, 0), None); + // A selected range still takes it whole. + app.session.set_tool_option("select", &json!({"start": 2, "end": 4})); + set_kerning(&mut app, json!(30)); + assert_eq!((kerning(&app, 1), kerning(&app, 2), kerning(&app, 3)), (Some(-50.0), Some(30.0), Some(30.0))); +} + +/// A variable font gets the Variable Font button, which opens one slider per axis named as the +/// font names it; a drag along one sets that axis of the selected type in one undo step. +#[test] +fn variable_fonts_get_a_slider_per_axis() { + use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font}; + let db = vectorcraft_text::FontDb::global(); + db.add_font(two_axis_font().unwrap()); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let plain = app.run("text.create", json!({"x": 20, "y": 50, "text": "Static"})).unwrap()["id"].clone(); + let id = app.run("text.create", json!({"x": 20, "y": 120, "text": "lIn", "font": TWO_AXIS_FAMILY})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [plain]})).unwrap(); + assert!(!crate::tests_labels::painted_text(&mut app, show).contains("Variable Font"), "a static font"); + app.run("select.set", json!({"ids": [id]})).unwrap(); + assert!(crate::tests_labels::painted_text(&mut app, show).contains("Variable Font")); + let face = db.face(TWO_AXIS_FAMILY, "Regular").unwrap(); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| axis_sliders(app, ui, &face)); + assert!(text.contains("Weight\n") && text.contains("Width\n") && text.contains("400") && text.contains("100"), "{text}"); + // The labels are the font's names in every interface language, not catalog words (ja + // translates the interface's Weight as the stroke weight). + let ja = crate::i18n::Lang::from_code("ja").unwrap(); + crate::i18n::set_current(ja); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| axis_sliders(app, ui, &face)); + crate::i18n::set_current(crate::i18n::Lang::EN); + let weight = crate::i18n::tr(ja, "Weight"); + assert!(text.contains("Weight\n") && text.contains("Width\n") && (weight == "Weight" || !text.contains(weight)), "{text}"); + // Screen readers name each slider by its axis. + let tree = crate::widgets::accessibility_tests::tree(|ui| axis_sliders(&mut app, ui, &face)); + for name in ["Weight", "Width"] { + assert!(tree.iter().any(|n| n.role() == egui::accesskit::Role::Slider && n.label() == Some(name)), "{name}"); + } + // ...with the number its field shows, on the axis's range. + use crate::widgets::accessibility_tests::slider_value; + let wght = face.axes().into_iter().find(|a| &a.tag == b"wght").unwrap(); + let range = (Some(f64::from(wght.min)), Some(f64::from(wght.max))); + assert_eq!(slider_value(&tree, "Weight"), (Some(400.0), range.0, range.1, Some("400".into()))); + // Drag the Weight slider from its start to past its end. + let ctx = egui::Context::default(); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut slider = None; + let input = egui::RawInput { events, ..Default::default() }; + ctx.run_ui(input, |ui| { + axis_sliders(app, ui, &face); + slider = ctx.read_response(ui.scope_id().with(("ch-axis", *b"wght"))).map(|r| r.rect); + }) + .textures_delta + .clear(); + slider + }; + frame(&mut app, vec![]); + let r = frame(&mut app, vec![]).expect("the Weight slider is drawn"); + let none = egui::Modifiers::NONE; + let (from, to) = (egui::pos2(r.left() + 4.0, r.center().y), egui::pos2(r.right() + 20.0, r.center().y)); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: none }; + frame(&mut app, vec![egui::Event::PointerMoved(from), button(from, true)]); + for i in 1..=4 { + frame(&mut app, vec![egui::Event::PointerMoved(from + (to - from) * i as f32 / 4.0)]); + } + frame(&mut app, vec![button(to, false)]); + frame(&mut app, vec![]); + let axes = |app: &VectorcraftApp| shared(app, |s| s.font_axes.clone()).unwrap(); + assert_eq!(axes(&app).into_iter().collect::>(), [("wght".to_string(), 700.0)]); + app.run("edit.undo", json!({})).unwrap(); + assert!(axes(&app).is_empty(), "one undo step"); + // Regular and Bold selected: the weights differ and the field is blank; the widths agree. + let bold = app.run("text.create", json!({"x": 20, "y": 160, "text": "lIn", "font": TWO_AXIS_FAMILY, "style": "Bold"})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [id, bold]})).unwrap(); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| axis_sliders(app, ui, &face)); + assert!(!text.contains("400") && !text.contains("700") && text.contains("100"), "{text}"); + let tree = crate::widgets::accessibility_tests::tree(|ui| axis_sliders(&mut app, ui, &face)); + assert_eq!(slider_value(&tree, "Weight"), (None, None, None, None), "a blank field: no value"); + assert_eq!(slider_value(&tree, "Width").0, Some(100.0)); + // Reset Panel leaves the settings, as it leaves the font and style. + app.run("select.set", json!({"ids": [id]})).unwrap(); + app.run("text.setFormat", json!({"fontAxes": {"wght": 650}})).unwrap(); + reset_panel(&mut app); + assert_eq!(axes(&app).into_iter().collect::>(), [("wght".to_string(), 650.0)]); +} + +/// Bold and Italic pick the family's own faces by name, and toggle back (#724). +#[test] +fn bold_and_italic_pick_the_familys_own_faces() { + let styles: Vec = + ["Light", "Narrow", "Regular", "Italic", "Narrow Italic", "SemiBold", "Bold", "Bold Condensed", "Narrow Bold", "Bold Italic", "Black"] + .iter() + .map(|s| s.to_string()) + .collect(); + assert_eq!(pick_face(&styles, true, false, "Regular"), Some("Bold"), "not Bold Condensed, Narrow Bold or SemiBold"); + assert_eq!(pick_face(&styles, true, true, "Italic"), Some("Bold Italic")); + assert_eq!(pick_face(&styles, false, true, "Regular"), Some("Italic")); + assert_eq!(pick_face(&styles, false, false, "Italic"), Some("Regular"), "upright again, not Narrow"); + assert_eq!(pick_face(&styles, true, false, "Narrow"), Some("Narrow Bold"), "a Narrow face stays Narrow"); + assert_eq!(pick_face(&styles, false, true, "Narrow"), Some("Narrow Italic")); + let oblique: Vec = ["Book", "Oblique", "Bold-Oblique"].iter().map(|s| s.to_string()).collect(); + assert_eq!(pick_face(&oblique, true, true, "Oblique"), Some("Bold-Oblique")); + assert_eq!(pick_face(&oblique, false, false, "Oblique"), None, "Book has a weight word: no regular face"); + assert_eq!(face_of("Bold Italic"), (true, false, true, vec![])); + assert_eq!(face_of("SemiBold"), (false, false, false, vec![])); + assert_eq!(face_of("Narrow Italic"), (false, true, true, vec!["narrow".to_string()])); + // No type selected: a message, nothing changed. + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + assert!(toggle_face(&mut app, Face::Bold).is_err()); + assert!(!crate::menus::enabled(&app, "type.bold")); +} + +/// From one frame of the panel: a point inside each of the Small Caps, Superscript and +/// Subscript buttons, and which of them are drawn on. The two buttons with a "1" are +/// Superscript (the "1" up) and Subscript; Small Caps is the button before them. +fn script_buttons(ctx: &egui::Context, app: &mut VectorcraftApp) -> ([egui::Pos2; 3], [bool; 3]) { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui)); + out.textures_delta.clear(); + fn walk(s: &egui::Shape, on: egui::Color32, ones: &mut Vec, lit: &mut Vec) { + match s { + egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, on, ones, lit)), + egui::Shape::Text(t) if t.galley.text() == "1" => ones.push(t.visual_bounding_rect().center()), + egui::Shape::Rect(r) if r.fill == on && r.rect.size() == vec2(30.0, 26.0) => lit.push(r.rect), + _ => {} + } + } + let (mut ones, mut lit) = (vec![], vec![]); + for c in &out.shapes { + walk(&c.shape, Tokens::get(ctx).tool_active, &mut ones, &mut lit); + } + let [sup, sub] = ones[..] else { panic!("two script buttons: {ones:?}") }; + assert!(sup.x < sub.x && sup.y < sub.y, "Superscript, then Subscript: {ones:?}"); + // Each "1" is drawn 6 points right of its button's center and 5 points above or below it. + let y = (sup.y + sub.y) / 2.0; + let (sup, sub) = (egui::pos2(sup.x - 6.0, y), egui::pos2(sub.x - 6.0, y)); + let points = [sup - vec2(sub.x - sup.x, 0.0), sup, sub]; + (points, points.map(|p| lit.iter().any(|r| r.contains(p)))) +} + +/// Each run's (small caps, position). +fn scripts(app: &VectorcraftApp, id: &Value) -> Vec<(Option, &'static str)> { + match &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind { + NodeKind::Text(t) => t.runs.iter().map(|r| (r.style.small_caps, r.style.position.id())).collect(), + _ => panic!("text"), + } +} + +/// The Small Caps, Superscript and Subscript buttons set the selected type's small caps and +/// position, with one undo step for each click, and show them; Superscript and Subscript turn +/// each other off. While the Type tool edits, they set and show the selected characters. +#[test] +fn small_caps_superscript_and_subscript_buttons_set_and_show_the_text() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + app.session.select_tool("type", Default::default()).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "E=mc2", "size": 12})).unwrap()["id"].clone(); + let ctx = egui::Context::default(); + let (at, lit) = script_buttons(&ctx, &mut app); + assert_eq!(lit, [false; 3]); + let small = Some(app.session.active().unwrap().doc.setup.small_caps_size); + for (button, runs, shown) in [ + (0, (small, "normal"), [true, false, false]), + (1, (small, "superscript"), [true, true, false]), + (2, (small, "subscript"), [true, false, true]), + (2, (small, "normal"), [true, false, false]), + (1, (small, "superscript"), [true, true, false]), + (1, (small, "normal"), [true, false, false]), + (0, (None, "normal"), [false; 3]), + ] { + let undo = app.session.active().unwrap().history.undo.len(); + super::super::tests_appearance::click(&ctx, &mut app, at[button], show); + assert_eq!(scripts(&app, &id), [runs], "button {button}"); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1, "button {button}"); + assert_eq!(script_buttons(&ctx, &mut app).1, shown, "button {button}"); + } + // The Type tool's selection: "2" alone. + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + app.session.set_tool_option("select", &json!({"start": 4, "end": 5})); + assert!(text_editing(&app).is_some()); + super::super::tests_appearance::click(&ctx, &mut app, at[1], show); + assert_eq!(scripts(&app, &id), [(None, "normal"), (None, "superscript")]); + assert_eq!(script_buttons(&ctx, &mut app).1, [false, true, false]); + app.session.set_tool_option("select", &json!({"start": 0, "end": 4})); + assert_eq!(script_buttons(&ctx, &mut app).1, [false; 3]); +} + +/// The panel menu's Small Caps, Superscript and Subscript set the selected type's small caps +/// and position and show a check mark when on; Superscript and Subscript turn each other off. +#[test] +fn small_caps_superscript_and_subscript_menu_items_set_and_check_the_text() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "E=mc2", "size": 12})).unwrap()["id"].clone(); + let ctx = egui::Context::default(); + let checked = |app: &mut VectorcraftApp| { + let texts = super::super::tests_appearance::frame_events(&ctx, app, vec![], menu); + ["Small Caps", "Superscript", "Subscript"].map(|l| texts.iter().any(|(t, _)| *t == format!("✓ {l}"))) + }; + assert_eq!(checked(&mut app), [false; 3]); + let small = Some(app.session.active().unwrap().doc.setup.small_caps_size); + for (item, runs, shown) in [ + ("Small Caps", (small, "normal"), [true, false, false]), + ("Superscript", (small, "superscript"), [true, true, false]), + ("Subscript", (small, "subscript"), [true, false, true]), + ("Subscript", (small, "normal"), [true, false, false]), + ("Small Caps", (None, "normal"), [false; 3]), + ] { + let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], menu); + let at = texts.iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == item).unwrap_or_else(|| panic!("{item}: {texts:?}")).1; + super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); + assert_eq!(scripts(&app, &id), [runs], "{item}"); + assert_eq!(checked(&mut app), shown, "{item}"); + } +} + +/// Character panel menu › System Layout is off for a new document, and a click turns whole-point +/// spacing on and back off for every text object. +#[test] +fn system_layout_menu_item_toggles_the_document() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "Hi", "size": 13})).unwrap()["id"].as_u64().unwrap(); + let ctx = egui::Context::default(); + let on = |app: &VectorcraftApp| { + let st = app.session.active().unwrap(); + let object = match &st.doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.system_layout, + _ => panic!("text"), + }; + (st.doc.setup.system_layout, object) + }; + let row = |app: &mut VectorcraftApp| { + let texts = super::super::tests_appearance::frame_events(&ctx, app, vec![], menu); + texts.into_iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == "System Layout") + }; + assert_eq!(on(&app), (false, false)); + assert!(row(&mut app).is_some_and(|(t, _)| !t.starts_with('✓'))); + let at = row(&mut app).unwrap().1; + super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); + assert_eq!(on(&app), (true, true)); + assert!(row(&mut app).is_some_and(|(t, _)| t.starts_with('✓'))); + let at = row(&mut app).unwrap().1; + super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); + assert_eq!(on(&app), (false, false)); + assert!(row(&mut app).is_some_and(|(t, _)| !t.starts_with('✓'))); +} + +/// Character panel menu › Fractional Widths is on for new type, and a click turns it off and +/// back on for the whole type object. +#[test] +fn fractional_widths_menu_item_toggles_the_type_object() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "Hi", "size": 13})).unwrap()["id"].as_u64().unwrap(); + let ctx = egui::Context::default(); + let flag = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.fractional_widths, + _ => panic!("text"), + }; + let row = |app: &mut VectorcraftApp| { + let texts = super::super::tests_appearance::frame_events(&ctx, app, vec![], menu); + texts.into_iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == "Fractional Widths") + }; + assert!(flag(&app)); + assert!(row(&mut app).is_some_and(|(t, _)| t.starts_with('✓'))); + let at = row(&mut app).unwrap().1; + super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); + assert!(!flag(&app)); + assert!(row(&mut app).is_some_and(|(t, _)| !t.starts_with('✓'))); + let at = row(&mut app).unwrap().1; + super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); + assert!(flag(&app)); + assert!(row(&mut app).is_some_and(|(t, _)| t.starts_with('✓'))); +} + +/// Character Alignment and Proportional Metrics are in the panel menu with the East Asian +/// options only (as Mojikumi Set and Top-to-Top Leading are in the Paragraph panel). +#[test] +fn character_alignment_shows_with_the_east_asian_options() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "雅楽"})).unwrap()["id"].clone(); + app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); + let shown = |app: &mut VectorcraftApp| { + let text = crate::tests_labels::painted_text(app, menu); + (text.contains("Character Alignment"), text.contains("Proportional Metrics")) + }; + assert_eq!(shown(&mut app), (false, false)); + app.session.prefs.show_east_asian_options = true; + assert_eq!(shown(&mut app), (true, true)); +} + +/// Tate-chu-yoko is in the panel menu with the East Asian options, and choosing it marks the +/// selected characters. +#[test] +fn tate_chu_yoko_menu_item_shows_with_east_asian_options_and_toggles() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "2026"})).unwrap()["id"].as_u64().unwrap(); + app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); + let ctx = egui::Context::default(); + let painted = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, menu); + assert!(!painted(&mut app).contains("Tate-chu-yoko")); + app.session.prefs.show_east_asian_options = true; + let on = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.iter().all(|r| r.style.tate_chu_yoko), + _ => panic!("text"), + }; + assert!(!on(&app)); + let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], menu); + let at = texts.iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == "Tate-chu-yoko").unwrap_or_else(|| panic!("{texts:?}")).1; + super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); + assert!(on(&app)); + assert!(painted(&mut app).contains("✓ Tate-chu-yoko")); + let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], menu); + let at = texts.iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == "Tate-chu-yoko").unwrap().1; + super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); + assert!(!on(&app)); +} + +/// Digits is in the panel menu with the Middle Eastern (Indic) options only, as Paragraph +/// Direction is in the Paragraph panel. +#[test] +fn digits_show_with_the_indic_options() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "صفحة 3"})).unwrap()["id"].clone(); + app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); + let shown = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, menu).contains("Digits"); + assert!(!shown(&mut app)); + app.session.prefs.show_indic_options = true; + assert!(shown(&mut app)); +} + +/// Character Direction is in the panel menu with the Middle Eastern (Indic) options, beside +/// Digits. Its submenu checks the direction the characters the panel acts on share: the +/// selected type's, or the range the Type tool has selected. It checks no item when they differ. +#[test] +fn character_direction_checks_the_direction_of_the_selection() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "שלום 21 10 2026"})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + let shown = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, menu).contains("Character Direction"); + assert!(!shown(&mut app)); + app.session.prefs.show_indic_options = true; + assert!(shown(&mut app)); + let checked = |app: &mut VectorcraftApp| { + let text = crate::tests_labels::painted_text(app, char_direction_items); + text.lines().filter_map(|l| l.strip_prefix("✓ ")).map(str::to_string).collect::>() + }; + assert_eq!(checked(&mut app), ["Default"]); + app.run("text.setRangeStyle", json!({"id": id, "start": 9, "end": 19, "charDirection": "leftToRight"})).unwrap(); + // The object's characters differ: nothing is checked. + assert!(checked(&mut app).is_empty()); + // The Type tool's selection: the date, then the Hebrew word. + app.select_tool("type"); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + app.session.set_tool_option("select", &json!({"start": 9, "end": 19})); + assert_eq!(checked(&mut app), ["Left to Right"]); + app.session.set_tool_option("select", &json!({"start": 0, "end": 8})); + assert_eq!(checked(&mut app), ["Default"]); +} + +/// Small Caps, Superscript and Subscript buttons and menu items stay off when the selection mixes them. +#[test] +fn character_mixed_small_caps_and_position_show_off() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let a = app.run("text.create", json!({"x": 20, "y": 50, "text": "A"})).unwrap()["id"].clone(); + let b = app.run("text.create", json!({"x": 20, "y": 90, "text": "B"})).unwrap()["id"].clone(); + app.run("text.setRangeStyle", json!({"id": a, "start": 0, "end": 1, "smallCaps": true, "position": "superscript"})).unwrap(); + app.run("text.setRangeStyle", json!({"id": b, "start": 0, "end": 1, "smallCaps": false, "position": "subscript"})).unwrap(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + assert_eq!(shared(&app, |s| s.small_caps.is_some()), None); + assert_eq!(shared(&app, |s| s.position), None); + let ctx = egui::Context::default(); + assert_eq!(script_buttons(&ctx, &mut app).1, [false, false, false]); + let checked = |app: &mut VectorcraftApp| { + let texts = super::super::tests_appearance::frame_events(&ctx, app, vec![], menu); + ["Small Caps", "Superscript", "Subscript"].map(|l| texts.iter().any(|(t, _)| *t == format!("✓ {l}"))) + }; + assert_eq!(checked(&mut app), [false, false, false]); +} + +/// At a caret in text the Type tool edits, with no characters selected, the Character +/// Direction items are disabled: a click on one leaves the direction of every character as it was. +#[test] +fn character_direction_does_nothing_at_an_empty_caret() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "שלום 21 10 2026"})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + app.select_tool("type"); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + app.session.set_tool_option("select", &json!({"start": 3, "end": 3})); + let directions = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.iter().map(|r| (r.text.clone(), r.style.char_direction)).collect::>(), + _ => panic!("text"), + }; + let before = directions(&app); + let ctx = egui::Context::default(); + let click_rtl = |app: &mut VectorcraftApp| { + let texts = super::super::tests_appearance::frame_events(&ctx, app, vec![], char_direction_items); + let at = texts.iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == "Right to Left").unwrap_or_else(|| panic!("{texts:?}")).1; + super::super::tests_appearance::click(&ctx, app, at.center(), char_direction_items); + }; + click_rtl(&mut app); + assert_eq!(directions(&app), before); + // With characters selected, the same click sets their direction. + app.session.set_tool_option("select", &json!({"start": 0, "end": 4})); + click_rtl(&mut app); + assert_ne!(directions(&app), before); +} + +/// No Break is in the panel menu, and choosing it marks the selected characters. +#[test] +fn no_break_menu_item_toggles_the_selected_characters() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "Hello"})).unwrap()["id"].as_u64().unwrap(); + app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); + let ctx = egui::Context::default(); + let painted = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, menu); + assert!(painted(&mut app).contains("No Break")); + assert!(!painted(&mut app).contains("✓ No Break")); + let on = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.iter().all(|r| r.style.no_break), + _ => panic!("text"), + }; + assert!(!on(&app)); + let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], menu); + let at = texts.iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == "No Break").unwrap_or_else(|| panic!("{texts:?}")).1; + super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); + assert!(on(&app)); + assert!(painted(&mut app).contains("✓ No Break")); + let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], menu); + let at = texts.iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == "No Break").unwrap().1; + super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); + assert!(!on(&app)); +} + +/// Reset Panel sets every character attribute the panel shows, except the font family, style +/// and size, back to its default (Small Caps, the position, Character Alignment, Proportional +/// Metrics, Tate-chu-yoko, No Break, Character Direction and Digits included), in the selected +/// type and in the text the Type tool has selected while it edits. +#[test] +fn reset_panel_resets_every_attribute_but_the_font() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "One 12"})).unwrap()["id"].as_u64().unwrap(); + let runs = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.iter().map(|r| (r.text.clone(), r.style.clone())).collect::>(), + _ => panic!("text"), + }; + let differs = |s: &CharStyle, d: &CharStyle| { + [ + s.tracking != d.tracking, + s.leading != d.leading, + s.kerning != d.kerning, + s.baseline_shift != d.baseline_shift, + s.h_scale != d.h_scale, + s.v_scale != d.v_scale, + s.rotation != d.rotation, + s.underline != d.underline, + s.strikethrough != d.strikethrough, + s.all_caps != d.all_caps, + s.small_caps != d.small_caps, + s.position != d.position, + s.char_align != d.char_align, + s.proportional_metrics != d.proportional_metrics, + s.tate_chu_yoko != d.tate_chu_yoko, + s.no_break != d.no_break, + s.char_direction != d.char_direction, + s.digits != d.digits, + ] + }; + let custom = |app: &mut VectorcraftApp| { + app.run( + "text.setRangeStyle", + json!({ + "id": id, "tracking": 50, "leading": 30, "kerning": 20, "baselineShift": 3, "hScale": 120, "vScale": 90, "rotation": 15, + "underline": true, "strikethrough": true, "allCaps": true, "smallCaps": true, "position": "superscript", + "charAlign": "emBoxCenter", "proportionalMetrics": true, "tateChuYoko": true, "noBreak": true, + "charDirection": "rightToLeft", "digits": "hindi" + }), + ) + .unwrap(); + }; + let plain = runs(&app)[0].1.clone(); + assert_eq!(differs(&plain, &CharStyle::default()), [false; 18]); + custom(&mut app); + let styled = runs(&app)[0].1.clone(); + assert_eq!(differs(&styled, &CharStyle::default()), [true; 18]); + app.run("select.set", json!({"ids": [id]})).unwrap(); + reset_panel(&mut app); + assert_eq!(runs(&app), [("One 12".to_string(), plain.clone())]); + // While the Type tool edits text, Reset Panel resets only its selection. + custom(&mut app); + app.select_tool("type"); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + app.session.set_tool_option("select", &json!({"start": 4, "end": 6})); + assert!(text_editing(&app).is_some()); + reset_panel(&mut app); + assert_eq!(runs(&app), [("One ".to_string(), styled), ("12".to_string(), plain)]); +}