From 63b95c8dd3d1fcef92c8576e194327dc969d7b33 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 18:59:34 +0200 Subject: [PATCH] Type: emptying the text while editing keeps its character style (fixes #2119) --- crates/text/src/edit.rs | 6 ++ crates/text/src/tests_typo.rs | 12 ++++ crates/ui-egui/src/canvas.rs | 100 ++++++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+) diff --git a/crates/text/src/edit.rs b/crates/text/src/edit.rs index cde057d59..1dcb40f01 100644 --- a/crates/text/src/edit.rs +++ b/crates/text/src/edit.rs @@ -127,9 +127,15 @@ pub fn replace_range_styled(runs: &mut Vec, start: usize, end: usize, i if a > b { std::mem::swap(&mut a, &mut b); } + // Taken before the splice: deleting all of the text leaves no run for `normalize` to take the + // style from, and the next typed text would get the default one (#2119). + let keep = insertion_style(runs, a, b); let i = split_at(runs, a); let j = split_at(runs, b); runs.splice(i..j, insert.iter().cloned()); + if runs.is_empty() { + runs.push(TextRun { text: String::new(), style: keep, inline: None }); + } normalize(runs); a + insert.iter().map(|r| r.text.len()).sum::() } diff --git a/crates/text/src/tests_typo.rs b/crates/text/src/tests_typo.rs index a4d1ea49f..e44dc156f 100644 --- a/crates/text/src/tests_typo.rs +++ b/crates/text/src/tests_typo.rs @@ -86,6 +86,18 @@ fn replace_range_takes_replaced_style_and_normalizes() { assert_eq!(runs[0].style.size, 12.0); } +/// #2119: deleting all of the text with no runs to insert (Backspace over a full selection, an IME +/// clearing its marked text) keeps the first replaced character's style, not the default one. +#[test] +fn replace_range_styled_with_nothing_keeps_the_style_of_emptied_text() { + let mut runs = vec![run("Chair", 36.0), run(" 123", 48.0)]; + let caret = edit::replace_range_styled(&mut runs, 0, 9, &[]); + assert_eq!(caret, 0); + assert_eq!(runs.len(), 1); + assert!(runs[0].text.is_empty()); + assert_eq!(runs[0].style.size, 36.0); +} + #[test] fn replace_respects_char_boundaries_and_slices() { let mut runs = vec![run("héllo", 12.0)]; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 381148f59..a7664f6ec 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -5292,6 +5292,106 @@ mod tests { assert_eq!((t.len(), t.find('X')), (12, Some(caret as usize)), "{t}"); } + /// #2119: type copied with an Alt-drag, double-clicked, all of its text selected and typed over + /// keeps its font, size and colour: through an IME (the marked text cleared, then committed by + /// Return, as Windows sends them) and with Backspace then typing. Whole app frames, one input + /// event per frame. + fn retype_all_of_a_copied_type(ime: bool) { + use egui::{Event, Key, Modifiers}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let original = app + .session + .execute("text.create", &json!({"x": 60, "y": 100, "text": "Chair 123", "size": 36, "font": "Georgia", "color": "#808080"})) + .unwrap()["id"] + .as_u64() + .unwrap(); + app.session.execute("select.none", &json!({})).unwrap(); + app.select_tool("selection"); + let ctx = egui::Context::default(); + let time = std::cell::Cell::new(0.0); + let frame = |app: &mut VectorcraftApp, events: Vec| { + time.set(time.get() + 0.03); + let screen = egui::Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 850.0)); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(time.get()), events, ..Default::default() }, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + }; + for _ in 0..3 { + frame(&mut app, vec![]); + } + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let node = |app: &VectorcraftApp, id: u64| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().clone(); + let styles = |app: &VectorcraftApp, id: u64| match &node(app, id).kind { + NodeKind::Text(t) => { + t.runs.iter().map(|r| (r.text.clone(), r.style.font_family.clone(), r.style.size, r.style.fill.clone())).collect::>() + } + _ => panic!("not text"), + }; + let button = |pos, pressed, modifiers| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }; + let key = |key, pressed, modifiers| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }; + // 1. Alt-drag the type: a copy, selected. + let from = xf.to_screen(node(&app, original).geometric_bounds().unwrap().center()); + let to = from + vec2(0.0, 120.0 * xf.zoom as f32); + frame(&mut app, vec![Event::PointerMoved(from), Event::ModifiersChanged(Modifiers::ALT)]); + frame(&mut app, vec![button(from, true, Modifiers::ALT)]); + for k in 1..=6 { + frame(&mut app, vec![Event::PointerMoved(from + (to - from) * (k as f32 / 6.0))]); + } + frame(&mut app, vec![button(to, false, Modifiers::ALT)]); + frame(&mut app, vec![Event::ModifiersChanged(Modifiers::NONE)]); + let copy = app.session.active().unwrap().selection.objects[0].0; + assert_ne!(copy, original); + let style = styles(&app, original)[0].clone(); + // 2. Double-click it at once: the Type tool edits it. + let at = xf.to_screen(node(&app, copy).geometric_bounds().unwrap().center()); + frame(&mut app, vec![Event::PointerMoved(at)]); + for pressed in [true, false, true, false] { + frame(&mut app, vec![button(at, pressed, Modifiers::NONE)]); + } + frame(&mut app, vec![]); + assert_eq!(app.session.tool_options()["editing"], json!(copy)); + // 3. Select all of its text and type over it. + frame(&mut app, vec![key(Key::A, true, Modifiers::COMMAND)]); + frame(&mut app, vec![key(Key::A, false, Modifiers::COMMAND)]); + if ime { + frame(&mut app, vec![preedit("t", 1)]); + frame(&mut app, vec![preedit("table", 5)]); + let marked = styles(&app, copy); + assert_eq!(marked.iter().map(|r| (&r.1, r.2, &r.3)).collect::>(), vec![(&style.1, style.2, &style.3)], "composing: {marked:?}"); + // Return commits the composition. + frame(&mut app, vec![preedit("", 0), Event::Ime(egui::ImeEvent::Commit("table".into()))]); + } else { + frame(&mut app, vec![key(Key::Backspace, true, Modifiers::NONE)]); + frame(&mut app, vec![key(Key::Backspace, false, Modifiers::NONE)]); + frame(&mut app, vec![Event::Text("table".into())]); + frame(&mut app, vec![key(Key::Enter, true, Modifiers::NONE)]); + frame(&mut app, vec![key(Key::Enter, false, Modifiers::NONE)]); + } + frame(&mut app, vec![]); + let after = styles(&app, copy); + assert!(after.iter().any(|r| r.0.starts_with("table")), "ime={ime}: typed over: {after:?}"); + assert!( + after.iter().all(|r| (&r.1, r.2, &r.3) == (&style.1, style.2, &style.3)), + "ime={ime}: the copy keeps {:?} {} {:?}, got {after:?}", + style.1, + style.2, + style.3 + ); + } + + #[test] + fn retyping_all_of_a_copied_type_through_an_ime_keeps_its_style() { + retype_all_of_a_copied_type(true); + } + + #[test] + fn retyping_all_of_a_copied_type_after_backspace_keeps_its_style() { + retype_all_of_a_copied_type(false); + } + #[test] fn double_clicking_type_with_the_direct_selection_tool_puts_the_caret_there() { let mut app = VectorcraftApp::new(Session::new(), Default::default());