Merge PR #618 from NikitkaRa: Text: underline an IME composition in a table cell

# Conflicts:
#	crates/render/src/lib.rs
This commit is contained in:
Brandon Thomas
2026-10-14 13:23:32 -04:00
8 changed files with 116 additions and 12 deletions
+57 -7
View File
@@ -181,12 +181,18 @@ pub fn layout_for(st: &DocState, t: &TextSel) -> Option<deckcraft_text::TextLayo
/// The insertion point on the slide: its line from top to bottom, in slide coordinates (flips and
/// vertical text as drawn). Text with no paragraph yet (a placeholder about to be typed in) is
/// measured as one empty paragraph, so the caret shows where typing begins. `None` for notes and
/// table cells, which aren't laid out on the slide here.
/// measured as one empty paragraph, so the caret shows where typing begins. `None` for notes,
/// which are an egui text field.
pub fn caret_line(st: &DocState, t: &TextSel) -> Option<(deckcraft_geom::Point, deckcraft_geom::Point)> {
if t.notes || t.cell.is_some() {
if t.notes {
return None;
}
if let Some((row, col)) = t.cell {
let placed = cell_on_slide(st, t.shape, row, col)?;
let aff = if t.at_line_end() { deckcraft_text::Affinity::Upstream } else { deckcraft_text::Affinity::Downstream };
let (cx, top, bot) = placed.layout.caret_drawn(deckcraft_text::Pos { para: t.caret.0, ch: t.caret.1 }, aff)?;
return Some((placed.to_slide * deckcraft_geom::Point::new(cx, top), placed.to_slide * deckcraft_geom::Point::new(cx, bot)));
}
let sh = st.shape(t.shape)?;
let ctx = ctx_for(&st.doc, &st.selection)?;
let x = xfrm_of(&st.doc, &st.selection, sh);
@@ -487,20 +493,35 @@ fn commit(s: &mut Session, p: &Value) -> Result<Value> {
}
/// 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).
/// thick)` per line, thick under the part being converted. Empty without a composition, or for
/// notes (an egui text field draws its own composition).
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![] };
if t.notes {
return vec![];
}
if let Some((row, col)) = t.cell {
let Some(placed) = cell_on_slide(st, t.shape, row, col) else { return vec![] };
return underline_segments(&placed.layout, placed.to_slide, c);
}
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);
underline_segments(&l, m, c)
}
fn underline_segments(
layout: &deckcraft_text::TextLayout,
to_slide: deckcraft_geom::Affine,
c: &crate::Composition,
) -> Vec<(deckcraft_geom::Point, deckcraft_geom::Point, bool)> {
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));
for (x0, x1, y) in layout.composition_underlines(pos(a), pos(b)) {
out.push((to_slide * deckcraft_geom::Point::new(x0, y), to_slide * deckcraft_geom::Point::new(x1, y), thick));
}
};
add(0, c.len, false);
@@ -510,6 +531,35 @@ pub fn composition_underlines(st: &DocState) -> Vec<(deckcraft_geom::Point, deck
out
}
/// A table cell's text laid out the way it is drawn, mapped into slide coordinates.
fn cell_on_slide(st: &DocState, id: ShapeId, row: usize, col: usize) -> Option<CellText> {
let sh = st.shape(id)?;
let ShapeKind::Table(tb) = &sh.kind else { return None };
let cell = tb.cell(row, col)?;
let ctx = ctx_for(&st.doc, &st.selection)?;
let heights = super::table_drawn_heights(&st.doc, &st.selection, tb);
let rect = deckcraft_render::table_cell_rects(tb, &heights).into_iter().find(|(r, c, _)| *r == row && *c == col)?.2;
let mut body = deckcraft_render::table_cell_body(&ctx, tb, row, col, cell);
if body.paragraphs.is_empty() {
body.paragraphs.push(deckcraft_model::text::Paragraph::default());
}
let tmp = deckcraft_model::Shape { text: Some(body.clone()), ..Default::default() };
let layout = deckcraft_text::layout(
&ctx,
&tmp,
&body,
&deckcraft_text::Opts { rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false },
);
let x = xfrm_of(&st.doc, &st.selection, sh);
let to_slide = deckcraft_render::text_transform(x.affine(), rect, layout.rotation);
Some(CellText { layout, to_slide })
}
struct CellText {
layout: deckcraft_text::TextLayout,
to_slide: deckcraft_geom::Affine,
}
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
let text = str_param(p, "text").ok_or_else(|| bad("text.insert", "missing `text`"))?.to_string();
insert_text(s, &text, None)
+35
View File
@@ -875,6 +875,41 @@ fn ime_composition_underlines_follow_the_text() {
assert!(cmd::text::composition_underlines(s.doc().unwrap()).is_empty());
}
/// #101: a composition in a table cell is underlined inside that cell, the same way slide text is.
#[test]
fn ime_composition_in_a_table_cell_is_underlined() {
let mut s = session();
let id = s.execute("insert.table", &json!({"rows": 2, "cols": 2, "rect": [80.0, 90.0, 400.0, 120.0]})).unwrap()["id"].as_u64().unwrap();
s.execute("text.edit", &json!({"id": id, "cell": [0, 0]})).unwrap();
assert!(cmd::text::composition_underlines(s.doc().unwrap()).is_empty());
let ts = s.doc().unwrap().selection.text.clone().unwrap();
assert!(cmd::text::caret_line(s.doc().unwrap(), &ts).is_some(), "the caret is in the cell");
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().shape(deckcraft_model::ShapeId(id as u32)).unwrap().clone();
let x = cmd::xfrm_of(&s.doc().unwrap().doc, &s.doc().unwrap().selection, &sh);
let deckcraft_model::ShapeKind::Table(tb) = &sh.kind else { panic!("not a table") };
let col = tb.cols[0];
let row = cmd::table_drawn_heights(&s.doc().unwrap().doc, &s.doc().unwrap().selection, tb)[0];
for (a, b, _) in &u {
assert!(b.x > a.x && (a.y - b.y).abs() < 1e-6, "a horizontal line: {a:?} {b:?}");
assert!(a.x >= x.x - 1.0 && b.x <= x.x + col + 1.0, "inside the first column");
assert!(a.y > x.y && a.y < x.y + row + 1.0, "inside the first row");
}
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");
let ts = s.doc().unwrap().selection.text.clone().unwrap();
let (top, bot) = cmd::text::caret_line(s.doc().unwrap(), &ts).unwrap();
assert!(top.x >= x.x && top.x <= x.x + col && top.y >= x.y && bot.y <= x.y + row + 1.0, "caret in the cell");
s.commit_text("日本語").unwrap();
assert!(cmd::text::composition_underlines(s.doc().unwrap()).is_empty());
let sh = s.doc().unwrap().shape(deckcraft_model::ShapeId(id as u32)).unwrap().clone();
let deckcraft_model::ShapeKind::Table(tb) = &sh.kind else { panic!("not a table") };
assert_eq!(tb.cell(0, 0).map(|c| c.text.text()), Some("日本語".into()));
}
/// File › Info (#76): the active presentation's name, location, counts and properties.
#[test]
fn file_info_reports_the_presentation() {