Type: emptying the text while editing keeps its character style (fixes #2119)

This commit is contained in:
Marco Arnulfo
2026-10-17 14:43:13 +07:00
committed by bflatastic
parent b275eb04d0
commit 63b95c8dd3
3 changed files with 118 additions and 0 deletions
+6
View File
@@ -127,9 +127,15 @@ pub fn replace_range_styled(runs: &mut Vec<TextRun>, 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::<usize>()
}
+12
View File
@@ -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)];
+100
View File
@@ -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<Event>| {
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::<Vec<_>>()
}
_ => 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<_>>(), 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());