diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 48be3a5..856f7ab 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -17,6 +17,27 @@ pub fn specs() -> Vec { cmd!(noundo "text.exit", "Stop Editing Text", [], None, "{}", has_doc, exit), cmd!("text.insert", "Type Text", [], None, "{text} (\\n = new paragraph, \\u000b = line break)", has_text, insert), cmd!("text.delete", "Delete Text", [], Some("Backspace"), "{dir?: backward|forward|wordBackward|wordForward}", has_text, delete), + // The input method's composition: shown in place, an undo step only once committed. + CommandSpec { + id: "text.compose", + label: "Compose Text", + menu: &[], + shortcut: None, + params: "{text, active?: [start, end]} — an input method's composition (preedit) at the insertion point; empty text cancels it", + enabled: has_text, + run: compose, + journal: false, + undoable: false, + }, + cmd!( + noundo "text.commit", + "Commit Composition", + [], + None, + "{text} — the input method's result replaces its composition, as one undo step", + has_text, + commit + ), cmd!(noundo "text.move", "Move Insertion Point", [], None, "{to: left|right|up|down|wordLeft|wordRight|lineStart|lineEnd|paraStart|paraEnd|start|end, extend?: bool}", has_text, move_caret), cmd!(noundo "text.select", "Select Text", [], None, "{anchor: [p, c], caret: [p, c]}", has_text, select), cmd!(noundo "text.selectAll", "Select All Text", [], None, "{}", has_text, select_all), @@ -144,6 +165,33 @@ pub fn layout_for(st: &DocState, t: &TextSel) -> Option Option<(deckcraft_geom::Point, deckcraft_geom::Point)> { + if t.notes || t.cell.is_some() { + return None; + } + let sh = st.shape(t.shape)?; + let ctx = ctx_for(&st.doc, &st.selection)?; + let x = xfrm_of(&st.doc, &st.selection, sh); + let geo = deckcraft_render::shape_geometry(sh, x.w, x.h); + let mut body = sh.text.clone().unwrap_or_default(); + if body.paragraphs.is_empty() { + body.paragraphs.push(deckcraft_model::text::Paragraph::default()); + } + let l = deckcraft_text::layout( + &ctx, + sh, + &body, + &deckcraft_text::Opts { rect: geo.text_rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false }, + ); + let (cx, top, bot) = l.caret(deckcraft_text::Pos { para: t.caret.0, ch: t.caret.1 })?; + let m = deckcraft_render::text_transform(x.affine(), geo.text_rect, l.rotation); + Some((m * deckcraft_geom::Point::new(cx, top), m * deckcraft_geom::Point::new(cx, bot))) +} + fn pos_param(p: &Value, key: &str) -> Option { let a = p.get(key)?.as_array()?; Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize)) @@ -340,6 +388,112 @@ pub(crate) fn insert_field(s: &mut Session, field: &str, text: &str) -> Result String { + s.chars().filter(|c| !c.is_control()).collect() +} + +/// Is the composition still where it was put (the paragraph holds all of it)? +fn composition_fits(st: &DocState, c: &crate::Composition) -> bool { + let Some(t) = &st.selection.text else { return false }; + let Some(body) = body_of(st, t) else { return false }; + body.paragraphs.get(c.start.0).is_some_and(|p| c.start.1.saturating_add(c.len) <= p.char_len()) +} + +/// `text.compose`: put an input method's preedit at the insertion point (replacing the selection) +/// or replace the composition with the new preedit. An empty preedit cancels: the presentation +/// goes back to how it was before the composition began. +fn compose(s: &mut Session, p: &Value) -> Result { + let text = ime_text(str_param(p, "text").ok_or_else(|| bad("text.compose", "missing `text`"))?); + let n = text.chars().count(); + let active = p.get("active").and_then(Value::as_array).and_then(|a| { + let (x, y) = (a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize); + (x < y).then_some((x.min(n), y.min(n))) + }); + let current = s.doc()?.composition.clone(); + // A composition whose text was changed under it is kept as it is; a new one starts. + let current = match current { + Some(c) if !composition_fits(s.doc()?, &c) => { + s.end_composition(); + None + } + c => c, + }; + match current { + None if text.is_empty() => Ok(json!({"composing": false})), + None => { + let st = s.doc()?; + let (before, before_selection) = (st.doc.clone(), st.selection.clone()); + let (start, props) = edit_text(s, |body, t| { + let (a, b) = t.ordered(); + let at = ed::delete(body, a, b); + let props = if a != b { Some(ed::props_at(body, at)) } else { unlinked_at_link_end(body, at) }; + let c = ed::insert(body, at, &text, props.clone()); + t.anchor = c; + t.caret = c; + (at, props) + })?; + s.doc_mut()?.composition = Some(crate::Composition { start, len: n, active, props, before, before_selection }); + Ok(json!({"composing": true, "start": [start.0, start.1], "len": n})) + } + Some(c) if text.is_empty() => { + let st = s.doc_mut()?; + st.doc = c.before; + st.selection = c.before_selection; + st.composition = None; + st.revision += 1; + Ok(json!({"composing": false})) + } + Some(c) => { + let (start, len, props) = (c.start, c.len, c.props.clone()); + edit_text(s, |body, t| { + ed::delete(body, start, (start.0, start.1 + len)); + let e = ed::insert(body, start, &text, props); + t.anchor = e; + t.caret = e; + })?; + s.doc_mut()?.composition = Some(crate::Composition { len: n, active, ..c }); + Ok(json!({"composing": true, "start": [start.0, start.1], "len": n})) + } + } +} + +/// `text.commit`: the input method's result replaces the composition (or is typed at the +/// insertion point when there is none) and the whole composition becomes one undo step. +fn commit(s: &mut Session, p: &Value) -> Result { + let text = ime_text(str_param(p, "text").ok_or_else(|| bad("text.commit", "missing `text`"))?); + if s.doc()?.composition.is_some() || !text.is_empty() { + compose(s, &json!({"text": text}))?; + } + s.end_composition(); + let caret = s.doc()?.selection.text.as_ref().map(|t| t.caret).unwrap_or_default(); + Ok(json!({"caret": [caret.0, caret.1]})) +} + +/// Underlines under an input method's composition (#101), in slide coordinates: `(from, to, +/// thick)` per line, thick under the part being converted. Empty without a composition or when +/// the edited text isn't laid out on the slide (notes, table cells). +pub fn composition_underlines(st: &DocState) -> Vec<(deckcraft_geom::Point, deckcraft_geom::Point, bool)> { + let (Some(c), Some(t)) = (&st.composition, &st.selection.text) else { return vec![] }; + let Some(sh) = st.shape(t.shape) else { return vec![] }; + let Some(l) = layout_for(st, t) else { return vec![] }; + let x = xfrm_of(&st.doc, &st.selection, sh); + let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect; + let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation); + let pos = |ch: usize| deckcraft_text::Pos { para: c.start.0, ch: c.start.1 + ch }; + let mut out = vec![]; + let mut add = |a: usize, b: usize, thick: bool| { + for (x0, x1, y) in l.composition_underlines(pos(a), pos(b)) { + out.push((m * deckcraft_geom::Point::new(x0, y), m * deckcraft_geom::Point::new(x1, y), thick)); + } + }; + add(0, c.len, false); + if let Some((a, b)) = c.active { + add(a, b.min(c.len), true); + } + out +} + fn insert(s: &mut Session, p: &Value) -> Result { let text = str_param(p, "text").ok_or_else(|| bad("text.insert", "missing `text`"))?.to_string(); insert_text(s, &text, None) diff --git a/crates/engine/src/keys.rs b/crates/engine/src/keys.rs index 966a109..cbfc705 100644 --- a/crates/engine/src/keys.rs +++ b/crates/engine/src/keys.rs @@ -25,6 +25,7 @@ impl Session { /// `ArrowLeft`…, `Home`, `End`, `PageUp`, `PageDown`, `F5`) or a character (`b`, `]`). /// Returns `{handled: bool, …}`. pub fn key(&mut self, key: &str, mods: Mods) -> Result { + self.end_composition(); let editing = self.active().is_some_and(|d| d.selection.text.is_some()); let has_shapes = self.active().is_some_and(|d| !d.selection.shapes.is_empty()); let k = key.to_ascii_lowercase(); @@ -151,6 +152,45 @@ impl Session { self.execute("text.insert", &json!({"text": text})) } + /// An input method's preedit (#101). On a selected shape that isn't being edited, the first + /// one starts editing its text and replaces it, as typing does ([`Session::type_text`]). + /// `active` is the part being converted, in characters of `text`. + pub fn compose_text(&mut self, text: &str, active: Option<(usize, usize)>) -> Result { + if !self.start_typing(text)? { + return Ok(json!({"handled": false})); + } + let mut p = json!({"text": text}); + if let Some((a, b)) = active { + p["active"] = json!([a, b]); + } + self.execute("text.compose", &p) + } + + /// An input method's result (#101): replaces the composition as one undo step. + pub fn commit_text(&mut self, text: &str) -> Result { + if !self.start_typing(text)? { + return Ok(json!({"handled": false})); + } + self.execute("text.commit", &json!({"text": text})) + } + + /// Ready to take typed `text`: editing text already, or one selected shape whose text editing + /// starts with everything selected. False when there is nowhere to type (or nothing to type + /// and nothing being edited). + fn start_typing(&mut self, text: &str) -> Result { + let st = self.doc()?; + if st.selection.text.is_some() { + return Ok(true); + } + let Some(id) = st.selection.shapes.first().copied() else { return Ok(false) }; + if st.selection.shapes.len() != 1 || text.is_empty() { + return Ok(false); + } + self.execute("text.edit", &json!({"id": id}))?; + self.execute("text.selectAll", &json!({}))?; + Ok(true) + } + fn cycle_selection(&mut self, forward: bool) -> Result<()> { let st = self.doc()?; let ids: Vec<_> = st.shapes().iter().filter(|s| !s.hidden).map(|s| s.id).collect(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b1775c2..f6f1598 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -131,6 +131,24 @@ pub struct Interaction { pub selection: Selection, } +/// Text an input method is composing (#101): its preedit sits in the edited text body, at `start` +/// for `len` characters, until it is committed, replaced or cancelled. The presentation from +/// before it began makes the whole composition one undo step. +#[derive(Clone, Debug)] +pub struct Composition { + /// Where the composed text starts: (paragraph, char). + pub start: (usize, usize), + /// Its length in characters (it never spans paragraphs). + pub len: usize, + /// The part the input method is converting, relative to `start` (drawn with a thicker line). + pub active: Option<(usize, usize)>, + /// The formatting the composed text is typed with (as `text.insert` would choose). + pub props: Option, + /// The presentation and selection before the composition began. + pub before: Arc, + pub before_selection: Selection, +} + #[derive(Clone, Debug)] pub struct DocState { pub doc: Arc, @@ -145,6 +163,8 @@ pub struct DocState { /// Fonts the file's text uses that aren't installed, found when it was opened (empty for new /// documents); the app mentions them once. pub missing_fonts: Vec, + /// An input method's composition in progress ([`Composition`]). + pub composition: Option, } static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -163,6 +183,7 @@ impl DocState { uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), title, missing_fonts: Vec::new(), + composition: None, } } pub fn is_dirty(&self) -> bool { @@ -403,6 +424,10 @@ impl Session { fn execute_unguarded(&mut self, id: &str, params: &Value) -> Result { let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?; + // Any other edit (or a click, a key, undo) settles an input method's composition first. + if self.depth == 0 && (spec.journal || spec.undoable) && !matches!(id, "text.compose" | "text.commit") { + self.end_composition(); + } if let Err(e) = (spec.enabled)(self) { let named = (e == cmd::NOTHING_SELECTED || e == cmd::SELECT_TWO) && (params.get("ids").is_some() || params.get("id").is_some()); if !named { @@ -453,6 +478,16 @@ impl Session { Ok(r) } + /// Keep an input method's composition as it stands (#101): its text stays and the whole + /// composition becomes one undo step. Nothing happens without one. + pub fn end_composition(&mut self) { + let Some(st) = self.active_mut() else { return }; + let Some(c) = st.composition.take() else { return }; + if !Arc::ptr_eq(&c.before, &st.doc) { + push_undo(st, HistoryEntry { label: "Type Text".into(), doc: c.before, selection: c.before_selection }); + } + } + /// Change only the selection (no undo step). pub fn select(&mut self, f: impl FnOnce(&Presentation, &mut Selection)) -> Result<()> { let st = self.doc_mut()?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2693853..673f8ec 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -606,3 +606,171 @@ fn shapes_off_the_slide_can_be_selected_and_moved() { s.pointer(ev(PointerKind::Up, 80.0, 420.0)).unwrap(); assert_eq!(s.doc().unwrap().selection.shapes.iter().map(|i| i.0 as u64).collect::>(), vec![half]); } + +fn title_text(s: &Session) -> String { + let sh = &s.doc().unwrap().current_slide().unwrap().shapes[0]; + sh.text.as_ref().map(|t| t.paragraphs.iter().map(|p| p.text()).collect::>().join("\n")).unwrap_or_default() +} + +/// #101: Korean typed through an input method arrives as preedit updates (ㅎ → 하 → 한 → 한ㄱ → +/// 한그 → 한글) and a commit. The preedit is shown in place and replaced on each update; the +/// committed text is one undo step, and nothing on the way is an undo step of its own. +#[test] +fn ime_composition_replaces_its_preedit_and_commits_as_one_undo_step() { + let mut s = session(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + s.execute("text.edit", &json!({"id": title})).unwrap(); + s.execute("text.insert", &json!({"text": "A "})).unwrap(); + let undo0 = s.doc().unwrap().history.undo.len(); + for pre in ["ㅎ", "하", "한", "한ㄱ", "한그", "한글"] { + s.compose_text(pre, None).unwrap(); + assert_eq!(title_text(&s), format!("A {pre}"), "the preedit is shown in place"); + let c = s.doc().unwrap().composition.clone().unwrap(); + assert_eq!((c.start, c.len), ((0, 2), pre.chars().count())); + assert_eq!(s.doc().unwrap().selection.text.as_ref().unwrap().caret, (0, 2 + pre.chars().count()), "caret after the preedit"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo0, "no undo step while composing"); + } + s.commit_text("한글").unwrap(); + assert_eq!(title_text(&s), "A 한글"); + assert!(s.doc().unwrap().composition.is_none()); + assert_eq!(s.doc().unwrap().history.undo.len(), undo0 + 1, "one undo step for the composition"); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Type Text"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(title_text(&s), "A "); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(title_text(&s), "A 한글"); + + // Windows' Korean IME commits a syllable when the next begins: an empty preedit, the commit, + // then a new preedit. + s.compose_text("ㄴ", None).unwrap(); + s.compose_text("", None).unwrap(); + s.commit_text("ㄴ").unwrap(); + s.compose_text("ㅁ", None).unwrap(); + assert_eq!(title_text(&s), "A 한글ㄴㅁ"); + s.commit_text("마").unwrap(); + assert_eq!(title_text(&s), "A 한글ㄴ마"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(title_text(&s), "A 한글ㄴ"); +} + +/// #101: an empty preedit cancels the composition (Escape, or Backspace over its last jamo): +/// the text and the selection it replaced are as they were, with no undo step. +#[test] +fn ime_cancel_restores_the_text_and_selection() { + let mut s = session(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + s.execute("text.edit", &json!({"id": title})).unwrap(); + s.execute("text.insert", &json!({"text": "Hello world"})).unwrap(); + s.execute("text.select", &json!({"anchor": [0, 6], "caret": [0, 11]})).unwrap(); + let undo0 = s.doc().unwrap().history.undo.len(); + let rev = s.doc().unwrap().revision; + s.compose_text("せかい", Some((0, 3))).unwrap(); + assert_eq!(title_text(&s), "Hello せかい", "the preedit replaces the selection"); + assert_eq!(s.doc().unwrap().composition.as_ref().unwrap().active, Some((0, 3))); + s.compose_text("世界", Some((0, 9))).unwrap(); + assert_eq!(s.doc().unwrap().composition.as_ref().unwrap().active, Some((0, 2)), "clamped to the preedit"); + s.compose_text("", None).unwrap(); + assert_eq!(title_text(&s), "Hello world"); + let t = s.doc().unwrap().selection.text.clone().unwrap(); + assert_eq!((t.anchor, t.caret), ((0, 6), (0, 11)), "the selection is back"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo0); + assert!(s.doc().unwrap().revision > rev, "redrawn"); + // A commit of nothing without a composition is nothing. + s.commit_text("").unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo0); + // A commit without a preedit (some input methods send only the result) is one step. + s.commit_text("世界").unwrap(); + assert_eq!(title_text(&s), "Hello 世界"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo0 + 1); +} + +/// #101: anything else settles the composition first: its text stays and is its own undo step. +#[test] +fn ime_composition_is_kept_by_a_key_a_click_or_another_command() { + let mut s = session(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + s.execute("text.edit", &json!({"id": title})).unwrap(); + let undo0 = s.doc().unwrap().history.undo.len(); + s.compose_text("안", None).unwrap(); + s.key("Right", Mods::default()).unwrap(); + assert!(s.doc().unwrap().composition.is_none()); + assert_eq!(title_text(&s), "안"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo0 + 1); + + s.compose_text("녕", None).unwrap(); + s.pointer(PointerEvent { kind: PointerKind::Move, x: 5.0, y: 5.0, mods: Mods::default(), tol: 4.0 }).unwrap(); + assert!(s.doc().unwrap().composition.is_some(), "hovering keeps composing"); + s.pointer(PointerEvent { kind: PointerKind::Down, x: 5.0, y: 5.0, mods: Mods::default(), tol: 4.0 }).unwrap(); + assert!(s.doc().unwrap().composition.is_none(), "a click settles it"); + assert_eq!(title_text(&s), "안녕"); + + s.execute("text.edit", &json!({"id": title})).unwrap(); + s.compose_text("하", None).unwrap(); + s.execute("text.get", &json!({"id": title})).unwrap(); + assert!(s.doc().unwrap().composition.is_some(), "a query doesn't"); + s.execute("text.insert", &json!({"text": "!"})).unwrap(); + assert_eq!(title_text(&s), "안녕하!"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(title_text(&s), "안녕하"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(title_text(&s), "안녕"); + + // Text changed under the composition (here behind its back): a new one starts, no panic. + s.compose_text("ㄱ", None).unwrap(); + s.doc_mut().unwrap().composition.as_mut().unwrap().len = 99; + s.compose_text("가", None).unwrap(); + assert!(title_text(&s).ends_with('가')); + // Control characters (an Enter sent as text) never split the paragraph. + s.commit_text("가\n\r\u{b}").unwrap(); + assert_eq!(s.doc().unwrap().current_slide().unwrap().shapes[0].text.as_ref().unwrap().paragraphs.len(), 1); +} + +/// #101: the first preedit on a selected shape starts editing it and replaces its text, as typing +/// does; with nothing selected there is nowhere to compose. +#[test] +fn ime_on_a_selected_shape_replaces_its_text() { + let mut s = session(); + let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100], "text": "Old"})).unwrap(); + let id = r["id"].as_u64().unwrap(); + s.select(|_, sel| { + sel.text = None; + sel.shapes = vec![ShapeId(id as u32)]; + }) + .unwrap(); + assert_eq!(s.compose_text("", None).unwrap()["handled"], false, "an empty preedit doesn't start editing"); + s.compose_text("新", None).unwrap(); + s.commit_text("新しい").unwrap(); + let v = s.execute("text.get", &json!({"id": id})).unwrap(); + assert_eq!(v["text"], "新しい"); + s.select(|_, sel| { + sel.text = None; + sel.shapes.clear(); + }) + .unwrap(); + assert_eq!(s.compose_text("x", None).unwrap()["handled"], false); + assert_eq!(s.commit_text("x").unwrap()["handled"], false); +} + +/// #101: the composition is underlined in place, under the composed characters, with a thicker +/// line under the part being converted. +#[test] +fn ime_composition_underlines_follow_the_text() { + let mut s = session(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + s.execute("text.edit", &json!({"id": title})).unwrap(); + assert!(cmd::text::composition_underlines(s.doc().unwrap()).is_empty()); + s.compose_text("にほんご", Some((2, 4))).unwrap(); + let u = cmd::text::composition_underlines(s.doc().unwrap()); + let thin: Vec<_> = u.iter().filter(|x| !x.2).collect(); + let thick: Vec<_> = u.iter().filter(|x| x.2).collect(); + assert_eq!((thin.len(), thick.len()), (1, 1), "{u:?}"); + let sh = s.doc().unwrap().current_slide().unwrap().shapes[0].clone(); + let x = cmd::xfrm_of(&s.doc().unwrap().doc, &s.doc().unwrap().selection, &sh); + for (a, b, _) in &u { + assert!(b.x > a.x && (a.y - b.y).abs() < 1e-9, "a horizontal line: {a:?} {b:?}"); + assert!(a.x >= x.x - 1.0 && b.x <= x.x + x.w + 1.0 && a.y > x.y && a.y < x.y + x.h, "inside the title box"); + } + assert!(thick[0].0.x > thin[0].0.x && (thick[0].1.x - thin[0].1.x).abs() < 1e-6, "the converted part is the end"); + s.commit_text("日本語").unwrap(); + assert!(cmd::text::composition_underlines(s.doc().unwrap()).is_empty()); +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 9a33933..876ea6e 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -265,6 +265,10 @@ impl Session { if !(p.x.is_finite() && p.y.is_finite()) { return Ok(Value::Null); } + // A press (not a hover) settles an input method's composition, as clicking does in PowerPoint. + if !matches!(ev.kind, PointerKind::Move) { + self.end_composition(); + } match ev.kind { PointerKind::Move => { if let Some(Drag::Freeform { points, mode, .. }) = &self.tool.drag { diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 69e6599..791f257 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -1831,6 +1831,33 @@ impl TextLayout { } out } + /// Underlines for text an input method is composing, from `a` to `b` (#101): `(x0, x1, y)` per + /// visual span, a little below each line's baseline (PowerPoint underlines the composition + /// in place). A composition that wraps gets one underline per line. + pub fn composition_underlines(&self, a: Pos, b: Pos) -> Vec<(f64, f64, f64)> { + let (s, e) = if a <= b { (a, b) } else { (b, a) }; + let mut out = vec![]; + if s == e { + return out; + } + for l in &self.lines { + let lp = Pos { para: l.para, ch: l.start }; + let le = Pos { para: l.para, ch: l.end }; + if le <= s || lp >= e { + continue; + } + let from = if s > lp { s.ch } else { l.start }; + let to = if e < le { e.ch } else { l.end }; + // Under the baseline, in the descent, never below the line. + let y = (l.baseline + (l.bottom - l.baseline) * 0.35).min(l.bottom); + for (x0, x1) in l.spans(from, to) { + if x1 > x0 { + out.push((x0, x1, y)); + } + } + } + out + } /// Vertical caret movement: the position on the line above/below closest to `x`. pub fn vertical(&self, pos: Pos, down: bool, x: f64) -> Pos { let Some(li) = self.line_of(pos) else { return pos }; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 724e63b..f3dd013 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -151,6 +151,36 @@ fn caret_and_hit_roundtrip() { assert_eq!(r.len(), 1); } +/// #101: the text an input method is composing is underlined in place, under the baseline of each +/// line it occupies, and only over the composed characters. +#[test] +fn composition_is_underlined_per_line() { + let (p, sh) = setup("Hello 한국어 world", 600.0, 100.0); + let l = lay(&p, &sh, 600.0, 100.0); + let (a, b) = (Pos { para: 0, ch: 6 }, Pos { para: 0, ch: 9 }); + let u = l.composition_underlines(a, b); + assert_eq!(u.len(), 1, "{u:?}"); + let (x0, x1, y) = u[0]; + let (cx0, ..) = l.caret(a).unwrap(); + let (cx1, ..) = l.caret(b).unwrap(); + assert!((x0 - cx0).abs() < 0.01 && (x1 - cx1).abs() < 0.01, "{u:?} vs carets {cx0} {cx1}"); + let line = &l.lines[0]; + assert!(y > line.baseline && y <= line.bottom, "under the baseline: {y} ({} – {})", line.baseline, line.bottom); + // Reversed ends give the same; an empty composition, nothing. + assert_eq!(l.composition_underlines(b, a), u); + assert!(l.composition_underlines(a, a).is_empty()); + + // Wrapped: one underline per line. + let (p, sh) = setup("가나다라마바사아자차카타파하 가나다라마바사아자차카타파하 가나다라마바사", 120.0, 400.0); + let l = lay(&p, &sh, 120.0, 400.0); + assert!(l.lines.len() >= 2); + let u = l.composition_underlines(Pos { para: 0, ch: 0 }, Pos { para: 0, ch: 40 }); + assert!(u.len() >= 2, "{u:?}"); + assert!(u.windows(2).all(|w| w[1].2 > w[0].2), "each lower than the last: {u:?}"); + // Positions past the text never panic. + let _ = l.composition_underlines(Pos { para: 3, ch: 0 }, Pos { para: 9, ch: 99 }); +} + #[test] fn alignment_center_and_right() { let (p, mut sh) = setup("Hi", 400.0, 100.0);