From b5a864241733b4a7cc29fe50b029c2146254c72b Mon Sep 17 00:00:00 2001 From: pierrevbc <101216765+pierrevbc@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:19:41 +0200 Subject: [PATCH 1/6] Tables: show the caret and text selection in the cell being edited Editing a table cell outlined the cell but drew no caret or selection, and Up/Down/Home/End fell back to paragraph moves, because the text layout used for drawing, hit testing and caret movement only existed for shapes. text::text_frame now lays out the edited text as it is drawn and gives the transform to the slide, for shapes and cells alike: a cell's text is laid out in its rect (merges honoured) with the cell's margins, anchor and the table style's look, through the renderer's own cell_body and cell_rects. layout_for and tools::text_pos use it, and the canvas draws the caret and selection from it. The cell outline follows merged cells. --- crates/engine/src/cmd/table.rs | 39 ++++++++++- crates/engine/src/cmd/text.rs | 67 ++++++++++++++----- crates/engine/src/tests.rs | 65 +++++++++++++++++++ crates/engine/src/tools.rs | 13 +--- crates/render/src/lib.rs | 2 +- crates/render/src/table.rs | 2 +- crates/ui-egui/src/canvas.rs | 115 ++++++++++++++++++++++++++------- 7 files changed, 247 insertions(+), 56 deletions(-) diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index e9f6f164..a8476e6b 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -1,10 +1,11 @@ //! Table Design and Table Layout tabs. -use deckcraft_model::{ShapeKind, Table}; +use deckcraft_geom::{Affine, Point, Rect}; +use deckcraft_model::{Shape, ShapeKind, Table}; use serde_json::{Value, json}; use super::*; -use crate::{Result, Session}; +use crate::{DocState, Result, Session}; pub fn specs() -> Vec { vec![ @@ -65,6 +66,40 @@ pub fn specs() -> Vec { ] } +/// A table as drawn on the slide: where it is, its drawn row heights and its visible cells. +#[derive(Clone, Debug)] +pub struct TableFrame { + /// Table-local (points from the table's top-left corner) → slide space, as the table is drawn. + pub affine: Affine, + pub cols: Vec, + /// Row heights as drawn: a stored height is a minimum, rows grow to fit their text. + pub heights: Vec, + /// Visible cells (merges honoured): (row, col, table-local rect). + pub cells: Vec<(usize, usize, Rect)>, +} + +/// The drawn geometry of table shape `sh` on the edited slide (`None` if it isn't a table). +pub fn table_frame(st: &DocState, sh: &Shape) -> Option { + let ShapeKind::Table(t) = &sh.kind else { return None }; + // The table is drawn in its own box; rows that grow past it don't move it. + let x = sh.xfrm.unwrap_or_else(|| xfrm_of(&st.doc, &st.selection, sh)); + let heights = table_drawn_heights(&st.doc, &st.selection, t); + let cells = deckcraft_render::table_cell_rects(t, &heights); + Some(TableFrame { affine: x.affine(), cols: t.cols.clone(), heights, cells }) +} + +impl TableFrame { + /// Table-local point for slide point `p`. + pub fn local(&self, p: Point) -> Point { + self.affine.inverse() * p + } + + /// Table-local rect of visible cell (`r`, `c`). + pub fn cell_rect(&self, r: usize, c: usize) -> Option { + self.cells.iter().find(|(cr, cc, _)| (*cr, *cc) == (r, c)).map(|x| x.2) + } +} + fn cell_param(p: &Value, key: &str) -> Option<(usize, usize)> { let a = p.get(key)?.as_array()?; Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize)) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 1b2cb2d2..cd69adbf 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -2,10 +2,11 @@ use std::sync::Arc; +use deckcraft_geom::{Affine, Point, Rect}; use deckcraft_model::edit::{self as ed, Pos}; use deckcraft_model::resolve::{self, Ctx}; use deckcraft_model::text::{AutoFit, Hyperlink, Run, RunKind, RunProps, TextBody}; -use deckcraft_model::{Presentation, ShapeId, ShapeKind}; +use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind}; use serde_json::{Value, json}; use super::*; @@ -159,20 +160,50 @@ pub(crate) fn ctx_for<'a>(doc: &'a Presentation, sel: &Selection) -> Option Option { - if t.notes || t.cell.is_some() { + text_frame(st, t).map(|f| f.0) +} + +/// The text being edited as it is drawn: its layout, and the transform from layout space to the +/// slide (flips don't mirror text, vertical text is turned). Shapes and table cells alike; the +/// caret, the text selection, hit testing and caret movement all use it. `None` for notes. +pub fn text_frame(st: &DocState, t: &TextSel) -> Option<(deckcraft_text::TextLayout, Affine)> { + if t.notes { return None; } let sh = st.shape(t.shape)?; - let body = sh.text.as_ref()?; let ctx = ctx_for(&st.doc, &st.selection)?; + let lay = |s: &Shape, body: &TextBody, rect: Rect| { + deckcraft_text::layout(&ctx, s, body, &deckcraft_text::Opts { rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false }) + }; + if let Some((r, c)) = t.cell { + // A cell's text is laid out in the cell's rect with the cell's margins, anchor and the + // table style's text look, exactly as the table draws it. + let ShapeKind::Table(tb) = &sh.kind else { return None }; + let frame = super::table::table_frame(st, sh)?; + let rect = frame.cell_rect(r, c)?; + let body = deckcraft_render::table_cell_body(&ctx, tb, r, c, tb.cell(r, c)?); + let tmp = Shape { text: Some(body.clone()), ..Default::default() }; + let l = lay(&tmp, &body, rect); + let m = deckcraft_render::text_transform(frame.affine, rect, l.rotation); + return Some((l, m)); + } + let body = sh.text.as_ref()?; let x = xfrm_of(&st.doc, &st.selection, sh); - let geo = deckcraft_render::shape_geometry(sh, x.w, x.h); - Some(deckcraft_text::layout( - &ctx, - sh, - body, - &deckcraft_text::Opts { rect: geo.text_rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false }, - )) + let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect; + let l = lay(sh, body, tr); + let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation); + Some((l, m)) +} + +/// The text position under slide point `p` in the text being edited (its shape or cell). +pub fn text_pos_at(st: &DocState, t: &TextSel, p: Point) -> Pos { + match text_frame(st, t) { + Some((l, m)) => { + let h = l.hit(m.inverse() * p); + (h.para, h.ch) + } + None => (0, 0), + } } /// The insertion point on the slide: its line from top to bottom, in slide coordinates (flips and @@ -180,9 +211,16 @@ pub fn layout_for(st: &DocState, t: &TextSel) -> Option Option<(deckcraft_geom::Point, deckcraft_geom::Point)> { - if t.notes || t.cell.is_some() { + if t.notes { return None; } + // A table cell: where the cell's text is drawn. + if t.cell.is_some() { + let (l, m) = text_frame(st, t)?; + let aff = if t.at_line_end() { deckcraft_text::Affinity::Upstream } else { deckcraft_text::Affinity::Downstream }; + let (cx, top, bot) = l.caret_drawn(deckcraft_text::Pos { para: t.caret.0, ch: t.caret.1 }, aff)?; + return Some((m * deckcraft_geom::Point::new(cx, top), m * 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,11 +525,8 @@ fn commit(s: &mut Session, p: &Value) -> Result { /// 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); + // Where the text is drawn, in a shape or a table cell. + let Some((l, m)) = text_frame(st, t) else { return vec![] }; 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| { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e83f72ef..10630add 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -401,6 +401,71 @@ fn grown_table_frame_covers_its_drawn_rows() { assert!(matches!(s.hit_test(below_stored, 1.0), Some(tools::Hit::Shape { .. }))); } +/// A table at (100, 100): columns `cols` wide, rows 40 pt tall, cells holding `data`. +fn table_at(s: &mut Session, cols: &[f64], data: serde_json::Value) -> deckcraft_model::ShapeId { + let rows = data.as_array().map_or(1, Vec::len); + let w: f64 = cols.iter().sum(); + let r = + s.execute("insert.table", &json!({"rows": rows, "cols": cols.len(), "rect": [100.0, 100.0, w, 40.0 * rows as f64], "data": data})).unwrap(); + let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32); + for (c, cw) in cols.iter().enumerate() { + s.execute("table.columnWidth", &json!({"id": id, "col": c, "width": cw})).unwrap(); + } + s.execute("edit.deselect", &json!({})).unwrap(); + id +} + +fn text_sel(s: &Session) -> Option { + s.doc().unwrap().selection.text.clone() +} + +/// Slide point of the caret before character `ch` of paragraph `para` in the text being edited. +fn caret_point(s: &Session, para: usize, ch: usize) -> deckcraft_geom::Point { + let st = s.doc().unwrap(); + text_point(st, st.selection.text.as_ref().unwrap(), para, ch) +} + +/// Slide point of the caret before character `ch` of paragraph `para` of text `t`. +fn text_point(st: &DocState, t: &TextSel, para: usize, ch: usize) -> deckcraft_geom::Point { + let (l, m) = cmd::text::text_frame(st, t).unwrap(); + let (x, top, bot) = l.caret(deckcraft_text::Pos { para, ch }).unwrap(); + m * deckcraft_geom::Point::new(x, (top + bot) / 2.0) +} + +#[test] +fn cell_text_is_laid_out_where_the_cell_draws_it() { + // The caret and hit testing of a cell's text use the cell's rect, not the table's. + let mut s = session(); + let id = table_at(&mut s, &[200.0, 200.0], json!([["Hello world", "b"], ["c", ""]])); + s.execute("text.edit", &json!({"id": id, "cell": [0, 1], "at": [0, 0]})).unwrap(); + let c = caret_point(&s, 0, 0); + assert!(c.x > 300.0 && c.x < 320.0 && c.y > 100.0 && c.y < 140.0, "caret of cell (0, 1) at {c:?}"); + // An empty cell still has a caret. + s.execute("text.edit", &json!({"id": id, "cell": [1, 1]})).unwrap(); + let c = caret_point(&s, 0, 0); + assert!(c.x > 300.0 && c.y > 140.0 && c.y < 180.0, "caret of empty cell (1, 1) at {c:?}"); + // Hit testing: the start and the end of "Hello world". + let st = s.doc().unwrap(); + let t = TextSel { shape: id, cell: Some((0, 0)), ..Default::default() }; + assert_eq!(cmd::text::text_pos_at(st, &t, deckcraft_geom::Point::new(101.0, 115.0)), (0, 0)); + assert_eq!(cmd::text::text_pos_at(st, &t, deckcraft_geom::Point::new(295.0, 115.0)), (0, 11)); +} + +#[test] +fn caret_moves_by_line_in_a_wrapped_cell() { + let mut s = session(); + let id = table_at(&mut s, &[90.0, 200.0], json!([["one two three four five six seven", "b"]])); + s.execute("text.edit", &json!({"id": id, "cell": [0, 0], "at": [0, 0]})).unwrap(); + s.execute("text.move", &json!({"to": "down"})).unwrap(); + let down = text_sel(&s).unwrap().caret; + assert!(down.0 == 0 && down.1 > 0, "down goes to the next line of the paragraph: {down:?}"); + s.execute("text.move", &json!({"to": "lineEnd"})).unwrap(); + let end = text_sel(&s).unwrap().caret; + assert!(end.1 > down.1 && end.1 < 33, "end of the second line: {end:?}"); + s.execute("text.move", &json!({"to": "up"})).unwrap(); + assert!(text_sel(&s).unwrap().caret.1 < down.1); +} + #[test] fn right_to_left_paragraphs() { let mut s = session(); diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index bf8b5c31..c684dc68 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1273,18 +1273,7 @@ fn hit_shape(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) -> Option (usize, usize) { - let x = xfrm_of(&st.doc, &st.selection, sh); - let t = TextSel { shape: sh.id, ..Default::default() }; - match cmd::text::layout_for(st, &t) { - Some(l) => { - // The text's own transform: flips don't mirror text, vertical text is turned. - let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect; - let local = deckcraft_render::text_transform(x.affine(), tr, l.rotation).inverse() * p; - let h = l.hit(local); - (h.para, h.ch) - } - None => (0, 0), - } + cmd::text::text_pos_at(st, &TextSel { shape: sh.id, ..Default::default() }, p) } /// Row heights of table `t` as drawn: rows grow to fit their text. diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 47a0f1e2..6b084ecf 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -46,7 +46,7 @@ pub use images::{decode as decode_image, gif_frame}; pub use morph_path::PathMorph; pub use morph_text::TextMorph; pub use placed::{Placed, PlacedLink, PlacedText, place_slide}; -pub use table::row_heights as table_row_heights; +pub use table::{cell_body as table_cell_body, cell_rects as table_cell_rects, row_heights as table_row_heights}; /// Largest pixmap side we render (vello_cpu uses u16 sizes). pub const MAX_SIDE: u32 = 16_000; diff --git a/crates/render/src/table.rs b/crates/render/src/table.rs index 685df6f9..929a87a6 100644 --- a/crates/render/src/table.rs +++ b/crates/render/src/table.rs @@ -224,7 +224,7 @@ fn edges(sizes: &[f64]) -> Vec { } /// A cell's text as drawn: its margins, anchor and direction, plus the table style's text colour and bold. -pub(crate) fn cell_body(rctx: &Ctx, t: &Table, r: usize, c: usize, cell: &Cell) -> TextBody { +pub fn cell_body(rctx: &Ctx, t: &Table, r: usize, c: usize, cell: &Cell) -> TextBody { let lk = look(rctx, t, r, c); let mut body = cell.text.clone(); let [ml, mr, mt, mb] = cell.margins.unwrap_or([7.2, 7.2, 3.6, 3.6]); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 253765bf..e853130c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -486,21 +486,10 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To } } } - // Caret / text selection. - if let Some(ts) = &editing - && !ts.notes - && ts.cell.is_none() - && let Some(sh) = st.shape(ts.shape) - && let Some(l) = deckcraft_engine::cmd::text::layout_for(st, ts) - { - let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh); - // Where the text is drawn: flips don't mirror it, vertical text is turned. - 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 (s0, s1) = ts.ordered(); - for r in l.selection_rects(deckcraft_text::Pos { para: s0.0, ch: s0.1 }, deckcraft_text::Pos { para: s1.0, ch: s1.1 }) { - let pts = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)].map(|q| xf.to_screen(m * q)); - painter.add(egui::Shape::convex_polygon(pts.to_vec(), t.text_selection, Stroke::NONE)); + // Caret / text selection, in a shape or a table cell. + if let Some(ts) = &editing { + for quad in text_marks(st, ts).iter().flat_map(|m| &m.selection) { + painter.add(egui::Shape::convex_polygon(quad.iter().map(|q| xf.to_screen(*q)).collect(), t.text_selection, Stroke::NONE)); } // An input method's composition: underlined in place, thicker under the part being converted. for (a, b, thick) in deckcraft_engine::cmd::text::composition_underlines(st) { @@ -515,18 +504,14 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To } } } - // Table cell editing: outline the cell. + // Table cell editing: outline the cell (merged cells as drawn). if let Some(ts) = &editing && let (Some((r, c)), Some(sh)) = (ts.cell, st.shape(ts.shape)) - && let deckcraft_model::ShapeKind::Table(tb) = &sh.kind + && let Some(f) = deckcraft_engine::cmd::table::table_frame(st, sh) + && let Some(cr) = f.cell_rect(r, c) { - let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh); - let heights = deckcraft_engine::tools::table_rows(st, tb); - let y0: f64 = heights.iter().take(r).sum(); - let x0: f64 = tb.cols.iter().take(c).sum(); - let (w, h) = (tb.cols.get(c).copied().unwrap_or(0.0), heights.get(r).copied().unwrap_or(0.0)); - let a = x.affine(); - let pts = [Point::new(x0, y0), Point::new(x0 + w, y0), Point::new(x0 + w, y0 + h), Point::new(x0, y0 + h)].map(|q| xf.to_screen(a * q)); + let pts = [Point::new(cr.x0, cr.y0), Point::new(cr.x1, cr.y0), Point::new(cr.x1, cr.y1), Point::new(cr.x0, cr.y1)] + .map(|q| xf.to_screen(f.affine * q)); poly(painter, pts, Stroke::new(2.0, t.accent)); } // Smart guides, marquee, previews. @@ -588,6 +573,24 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To } } +/// The text selection (quads) of the text being edited, in slide space: in a shape or a table +/// cell, wherever its text is drawn. The caret is `cmd::text::caret_line`. +pub(crate) struct TextMarks { + pub selection: Vec<[Point; 4]>, +} + +pub(crate) fn text_marks(st: &deckcraft_engine::DocState, ts: &deckcraft_engine::TextSel) -> Option { + let (l, m) = deckcraft_engine::cmd::text::text_frame(st, ts)?; + let pos = |p: (usize, usize)| deckcraft_text::Pos { para: p.0, ch: p.1 }; + let (s0, s1) = ts.ordered(); + let selection = l + .selection_rects(pos(s0), pos(s1)) + .into_iter() + .map(|r| [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)].map(|q| m * q)) + .collect(); + Some(TextMarks { selection }) +} + fn rotate_handle(p: &egui::Painter, c: Pos2, t: &Tokens) { p.circle_filled(c, 6.0, t.handle_fill); p.add(egui::Shape::Path(egui::epaint::PathShape { @@ -697,3 +700,67 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { } }); } + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + + use deckcraft_engine::Session; + + use super::*; + use crate::Services; + + fn app_with_table() -> (egui_kittest::Harness<'static, SlideApp>, deckcraft_model::ShapeId) { + let mut s = Session::with_new(); + let data = json!([["Hello world", "b"], ["c", ""]]); + let r = s.execute("insert.table", &json!({"rows": 2, "cols": 2, "rect": [100.0, 100.0, 400.0, 80.0], "data": data})).unwrap(); + let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32); + s.execute("edit.deselect", &json!({})).unwrap(); + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(s, Services::default()), + ); + // The app keeps requesting repaints, so step frames instead of run(). + harness.run_steps(2); + (harness, id) + } + + /// Screen position of slide point (x, y). + fn screen(app: &SlideApp, x: f64, y: f64) -> Pos2 { + Xf { origin: app.slide_rect.unwrap().min, scale: app.canvas_scale }.to_screen(Point::new(x, y)) + } + + /// Press and release at `p`, 50 ms apart in input time (two clicks make a double-click). + fn click(harness: &mut egui_kittest::Harness<'static, SlideApp>, p: Pos2) { + for pressed in [true, false] { + let t = harness.ctx.input(|i| i.time) + 0.05; + harness.input_mut().time = Some(t); + harness.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + harness.step(); + } + } + + /// Double-clicking a table cell edits it with the caret drawn in that cell. + #[test] + fn double_click_in_a_table_cell_shows_the_caret_there() { + let (mut harness, id) = app_with_table(); + // The empty cell (1, 1) spans x 300..500, y 140..180. + let p = screen(harness.state(), 400.0, 160.0); + harness.hover_at(p); + harness.step(); + click(&mut harness, p); + click(&mut harness, p); + harness.run_steps(2); + let st = harness.state().session.active().unwrap(); + let ts = st.selection.text.clone().expect("editing the cell"); + assert_eq!((ts.shape, ts.cell), (id, Some((1, 1)))); + assert!(text_marks(st, &ts).is_some_and(|m| m.selection.is_empty()), "no range"); + let (top, bottom) = deckcraft_engine::cmd::text::caret_line(st, &ts).expect("the cell's text has a caret"); + for q in [top, bottom] { + assert!(q.x > 300.0 && q.x < 500.0 && q.y >= 140.0 && q.y <= 180.0, "caret {q:?} outside cell (1, 1)"); + } + } +} From 70314fa82822cf37f9b6503de823eee563e9420c Mon Sep 17 00:00:00 2001 From: pierrevbc <101216765+pierrevbc@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:19:42 +0200 Subject: [PATCH 2/6] Tables: clicks place the caret in the clicked cell - A double-click in a cell puts the caret where clicked and selects the word there, as in shapes (it used to put the caret at the end). - While a cell is edited, a click in the same table moves the caret to the clicked cell and position; a drag selects text within the cell and Shift+click extends only within it. - A click in an already selected table enters the clicked cell at the click; dragging the table still moves it. - The release that follows a double-click no longer re-enters the text at the click, which collapsed the word just selected (shapes too). Cells are found from the drawn cell rects, so merged cells and rows that grew to fit their text are hit where they are drawn. --- crates/engine/src/cmd/table.rs | 20 +++++ crates/engine/src/tests.rs | 103 +++++++++++++++++++++++ crates/engine/src/tools.rs | 147 ++++++++++++++++++--------------- crates/ui-egui/src/canvas.rs | 11 ++- 4 files changed, 212 insertions(+), 69 deletions(-) diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index a8476e6b..4c46eb5f 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -89,6 +89,14 @@ pub fn table_frame(st: &DocState, sh: &Shape) -> Option { } impl TableFrame { + pub fn width(&self) -> f64 { + self.cols.iter().map(|w| w.max(0.0)).sum() + } + + pub fn height(&self) -> f64 { + self.heights.iter().map(|h| h.max(0.0)).sum() + } + /// Table-local point for slide point `p`. pub fn local(&self, p: Point) -> Point { self.affine.inverse() * p @@ -98,6 +106,18 @@ impl TableFrame { pub fn cell_rect(&self, r: usize, c: usize) -> Option { self.cells.iter().find(|(cr, cc, _)| (*cr, *cc) == (r, c)).map(|x| x.2) } + + /// The visible cell at table-local point `p` (points outside the table go to the nearest cell). + pub fn cell_at(&self, p: Point) -> Option<(usize, usize)> { + let q = Point::new(p.x.clamp(0.0, self.width().max(0.0)), p.y.clamp(0.0, self.height().max(0.0))); + let inside = |r: &Rect| q.x >= r.x0 && q.x <= r.x1 && q.y >= r.y0 && q.y <= r.y1; + // Edges are shared: prefer the cell the point is strictly inside. + self.cells + .iter() + .find(|(_, _, r)| q.x >= r.x0 && q.x < r.x1 && q.y >= r.y0 && q.y < r.y1) + .or_else(|| self.cells.iter().rev().find(|(_, _, r)| inside(r))) + .map(|(r, c, _)| (*r, *c)) + } } fn cell_param(p: &Value, key: &str) -> Option<(usize, usize)> { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 10630add..69d3bc68 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -415,6 +415,17 @@ fn table_at(s: &mut Session, cols: &[f64], data: serde_json::Value) -> deckcraft id } +fn table_of(s: &Session, id: deckcraft_model::ShapeId) -> deckcraft_model::Table { + match &s.doc().unwrap().shape(id).unwrap().kind { + deckcraft_model::ShapeKind::Table(t) => t.clone(), + k => panic!("not a table: {k:?}"), + } +} + +fn tev(kind: PointerKind, x: f64, y: f64) -> PointerEvent { + PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 } +} + fn text_sel(s: &Session) -> Option { s.doc().unwrap().selection.text.clone() } @@ -451,6 +462,86 @@ fn cell_text_is_laid_out_where_the_cell_draws_it() { assert_eq!(cmd::text::text_pos_at(st, &t, deckcraft_geom::Point::new(295.0, 115.0)), (0, 11)); } +#[test] +fn double_click_in_a_cell_selects_the_word_clicked() { + let mut s = session(); + let id = table_at(&mut s, &[200.0, 200.0], json!([["Hello world", "b"], ["c", "d"]])); + // Where "world" is drawn. + s.execute("text.edit", &json!({"id": id, "cell": [0, 0]})).unwrap(); + let p = caret_point(&s, 0, 8); + s.execute("text.exit", &json!({})).unwrap(); + s.execute("edit.deselect", &json!({})).unwrap(); + // As the UI sends it: press, release, press, double-click (on release), then the release. + s.pointer(tev(PointerKind::Down, p.x, p.y)).unwrap(); + s.pointer(tev(PointerKind::Up, p.x, p.y)).unwrap(); + assert!(text_sel(&s).is_none(), "a click on an unselected table selects it"); + s.pointer(tev(PointerKind::Down, p.x, p.y)).unwrap(); + s.pointer(tev(PointerKind::DoubleClick, p.x, p.y)).unwrap(); + s.pointer(tev(PointerKind::Up, p.x, p.y)).unwrap(); + let t = text_sel(&s).unwrap(); + assert_eq!((t.cell, t.ordered()), (Some((0, 0)), ((0, 6), (0, 11))), "the word under the double-click"); + // The control channel sends the double-click alone. + s.execute("text.exit", &json!({})).unwrap(); + s.execute("edit.deselect", &json!({})).unwrap(); + s.pointer(tev(PointerKind::DoubleClick, p.x, p.y)).unwrap(); + assert_eq!(text_sel(&s).map(|t| t.ordered()), Some(((0, 6), (0, 11)))); +} + +#[test] +fn clicks_while_editing_a_cell_move_the_caret_between_cells() { + let mut s = session(); + let id = table_at(&mut s, &[200.0, 200.0], json!([["Hello world", "Second cell"], ["c", "d"]])); + s.execute("text.edit", &json!({"id": id, "cell": [0, 1], "at": [0, 0]})).unwrap(); + let p6 = caret_point(&s, 0, 6); + let p11 = caret_point(&s, 0, 11); + s.execute("text.edit", &json!({"id": id, "cell": [1, 0]})).unwrap(); + // A click in another cell of the table moves the caret there, at the click. + s.pointer(tev(PointerKind::Down, p6.x, p6.y)).unwrap(); + let t = text_sel(&s).unwrap(); + assert_eq!((t.shape, t.cell, t.caret, t.anchor), (id, Some((0, 1)), (0, 6), (0, 6))); + // Dragging selects within that cell, even past its edge. + s.pointer(tev(PointerKind::Drag, p11.x, p11.y)).unwrap(); + s.pointer(tev(PointerKind::Drag, 600.0, p11.y)).unwrap(); + s.pointer(tev(PointerKind::Up, 600.0, p11.y)).unwrap(); + let t = text_sel(&s).unwrap(); + assert_eq!((t.cell, t.ordered()), (Some((0, 1)), ((0, 6), (0, 11)))); + // Shift+click in another cell starts a new caret there instead of extending across cells. + let shift = Mods { shift: true, ..Default::default() }; + s.pointer(PointerEvent { mods: shift, ..tev(PointerKind::Down, 150.0, 165.0) }).unwrap(); + s.pointer(tev(PointerKind::Up, 150.0, 165.0)).unwrap(); + let t = text_sel(&s).unwrap(); + assert_eq!((t.cell, t.is_range()), (Some((1, 0)), false)); + // Typing goes into the clicked cell. + s.execute("text.insert", &json!({"text": "X"})).unwrap(); + assert_eq!(table_of(&s, id).cell(1, 0).unwrap().text.text(), "cX"); + // A click outside the table stops editing. + s.pointer(tev(PointerKind::Down, 50.0, 400.0)).unwrap(); + s.pointer(tev(PointerKind::Up, 50.0, 400.0)).unwrap(); + assert!(text_sel(&s).is_none()); +} + +#[test] +fn a_click_in_a_selected_table_enters_the_clicked_cell() { + let mut s = session(); + let id = table_at(&mut s, &[200.0, 200.0], json!([["a", "b"], ["c", "Hello world"]])); + s.pointer(tev(PointerKind::Down, 350.0, 160.0)).unwrap(); + s.pointer(tev(PointerKind::Up, 350.0, 160.0)).unwrap(); + assert_eq!((s.doc().unwrap().selection.shapes.clone(), text_sel(&s)), (vec![id], None), "the first click selects the table"); + let st = s.doc().unwrap(); + let p = text_point(st, &TextSel { shape: id, cell: Some((1, 1)), ..Default::default() }, 0, 6); + s.pointer(tev(PointerKind::Down, p.x, p.y)).unwrap(); + s.pointer(tev(PointerKind::Up, p.x, p.y)).unwrap(); + let t = text_sel(&s).unwrap(); + assert_eq!((t.cell, t.caret), (Some((1, 1)), (0, 6)), "caret at the click"); + // Dragging a selected table still moves it. + s.execute("text.exit", &json!({})).unwrap(); + s.pointer(tev(PointerKind::Down, 150.0, 120.0)).unwrap(); + s.pointer(tev(PointerKind::Drag, 170.0, 130.0)).unwrap(); + s.pointer(tev(PointerKind::Up, 170.0, 130.0)).unwrap(); + assert!(text_sel(&s).is_none()); + assert_ne!(s.doc().unwrap().shape(id).unwrap().xfrm.unwrap().x, 100.0); +} + #[test] fn caret_moves_by_line_in_a_wrapped_cell() { let mut s = session(); @@ -466,6 +557,18 @@ fn caret_moves_by_line_in_a_wrapped_cell() { assert!(text_sel(&s).unwrap().caret.1 < down.1); } +#[test] +fn rotated_tables_hit_the_cell_under_the_pointer() { + let mut s = session(); + let id = table_at(&mut s, &[100.0, 100.0], json!([["a", "b"], ["c", "d"]])); + s.execute("shape.rotate", &json!({"ids": [id], "deg": 90})).unwrap(); + let st = s.doc().unwrap(); + let f = cmd::table::table_frame(st, st.shape(id).unwrap()).unwrap(); + let p = f.affine * deckcraft_geom::Point::new(150.0, 60.0); + s.pointer(tev(PointerKind::DoubleClick, p.x, p.y)).unwrap(); + assert_eq!(text_sel(&s).and_then(|t| t.cell), Some((1, 1))); +} + #[test] fn right_to_left_paragraphs() { let mut s = session(); diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index c684dc68..ab051c7a 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -361,30 +361,10 @@ impl Session { } ToolKind::Select => {} } - let st = self.doc()?; // Clicking inside the text being edited moves the caret / starts a text selection. - if let Some(t) = st.selection.text.clone() - && !t.notes - && t.cell.is_none() - && let Some(sh) = st.shape(t.shape) - { - let x = xfrm_of(&st.doc, &st.selection, sh); - if x.bounds().inflate(tol, tol).contains(p) - && !matches!(self.hit_test(p, tol), Some(Hit::Resize { .. } | Hit::Rotate { .. } | Hit::Adjust { .. })) - { - let pos = text_pos(st, sh, p); - let shift = mods.shift; - self.select(|_, sel| { - if let Some(tt) = sel.text.as_mut() { - tt.caret = pos; - if !shift { - tt.anchor = pos; - } - } - })?; - self.tool.drag = Some(Drag::TextSelect { shape: t.shape }); - return Ok(json!({"caret": [pos.0, pos.1]})); - } + if let Some((shape, pos)) = self.place_caret(p, tol, mods.shift)? { + self.tool.drag = Some(Drag::TextSelect { shape }); + return Ok(json!({"caret": [pos.0, pos.1]})); } let hit = self.hit_test(p, tol); let st = self.doc()?; @@ -432,8 +412,10 @@ impl Session { let origs: Vec<(ShapeId, Xfrm)> = ids.iter().filter_map(|i| st.shape(*i).filter(|s| !s.locked).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect(); // A click (no drag) on text puts the caret there; unselected text boxes and - // placeholders take the caret straight away like PowerPoint. - let enter_text = (text && !toggle && (selected || is_text_first(st, id))).then_some((id, p)); + // placeholders take the caret straight away like PowerPoint. A click in a selected + // table puts the caret in the clicked cell. + let table = st.shape(id).is_some_and(|s| matches!(s.kind, ShapeKind::Table(_))); + let enter_text = (!toggle && ((text && (selected || is_text_first(st, id))) || (table && selected))).then_some((id, p)); let ids2 = ids.clone(); self.select(|_, sel| { sel.shapes = ids2; @@ -630,9 +612,10 @@ impl Session { self.tool.preview = Some(x); } Drag::TextSelect { shape } => { + // The selection stays in the shape or table cell where it started. let st = self.doc()?; - if let Some(sh) = st.shape(shape) { - let pos = text_pos(st, sh, p); + if let Some(t) = st.selection.text.as_ref().filter(|t| t.shape == shape) { + let pos = cmd::text::text_pos_at(st, t, p); self.select(|_, sel| { if let Some(t) = sel.text.as_mut() { t.caret = pos; @@ -669,11 +652,8 @@ impl Session { let mut out = Value::Null; match drag { Some(Drag::Move { moved: false, enter_text: Some((id, at)), .. }) => { - // Plain click on text: caret at the click. - let st = self.doc()?; - if let Some(sh) = st.shape(id) { - let pos = text_pos(st, sh, at); - self.execute("text.edit", &json!({"id": id, "at": [pos.0, pos.1]}))?; + // Plain click on text: caret at the click (in a table, in the clicked cell). + if self.edit_text_at(id, at)? { out = json!({"editing": id}); } } @@ -792,8 +772,12 @@ impl Session { self.tool.sticky = false; return Ok(Value::Null); } - let st = self.doc()?; - if st.selection.text.is_some() { + // The double-click's second press started a click (caret on release): this replaces it. + if matches!(self.tool.drag, Some(Drag::Move { moved: false, .. })) { + self.tool.drag = None; + } + // In the text being edited: select the word double-clicked. + if self.place_caret(p, tol, false)?.is_some() { return self.execute("text.selectWord", &json!({})); } match self.hit_test(p, tol) { @@ -801,18 +785,12 @@ impl Session { let st = self.doc()?; let Some(sh) = st.shape(id).cloned() else { return Ok(Value::Null) }; match &sh.kind { - ShapeKind::Table(_) => { - let x = xfrm_of(&st.doc, &st.selection, &sh); - let local = x.affine().inverse() * p; - if let ShapeKind::Table(t) = &sh.kind { - let cell = deckcraft_render_cell(t, &table_rows(st, t), local); - return self.execute("text.edit", &json!({"id": id, "cell": [cell.0, cell.1]})); + ShapeKind::Table(_) | ShapeKind::Shape | ShapeKind::Connector { .. } if !sh.is_line() => { + // Caret at the double-click (in a table, in the clicked cell), then select + // the word there. + if !self.edit_text_at(id, p)? { + return Ok(Value::Null); } - Ok(Value::Null) - } - ShapeKind::Shape | ShapeKind::Connector { .. } if !sh.is_line() => { - let pos = text_pos(st, &sh, p); - self.execute("text.edit", &json!({"id": id, "at": [pos.0, pos.1]}))?; self.execute("text.selectWord", &json!({})) } ShapeKind::Group { .. } => Ok(Value::Null), @@ -826,6 +804,62 @@ impl Session { } } + /// A click at `p` inside the text being edited: the caret goes there (`extend`: the selection + /// grows to it). In a table being edited, a click in another cell moves the caret into that + /// cell. Returns the edited shape and the caret, or `None` when `p` isn't in the edited text + /// (or is on a handle or a table border). + fn place_caret(&mut self, p: Point, tol: f64, extend: bool) -> Result> { + let st = self.doc()?; + let Some(mut t) = st.selection.text.clone().filter(|t| !t.notes) else { return Ok(None) }; + let Some(sh) = st.shape(t.shape) else { return Ok(None) }; + let x = xfrm_of(&st.doc, &st.selection, sh); + if !x.bounds().inflate(tol, tol).contains(p) + || matches!(self.hit_test(p, tol), Some(Hit::Resize { .. } | Hit::Rotate { .. } | Hit::Adjust { .. })) + { + return Ok(None); + } + let old_cell = t.cell; + if old_cell.is_some() + && let Some(f) = cmd::table::table_frame(st, sh) + { + t.cell = f.cell_at(f.local(p)).or(old_cell); + } + let pos = cmd::text::text_pos_at(st, &t, p); + // A selection can't span cells: a click in another cell starts afresh. + let extend = extend && t.cell == old_cell; + let cell = t.cell; + self.select(|_, sel| { + if let Some(tt) = sel.text.as_mut() { + tt.cell = cell; + tt.caret = pos; + if !extend { + tt.anchor = pos; + } + } + })?; + Ok(Some((t.shape, pos))) + } + + /// Start editing shape `id`'s text with the caret at slide point `p`; for a table, in the cell + /// at `p`. Returns whether editing started. + fn edit_text_at(&mut self, id: ShapeId, p: Point) -> Result { + let st = self.doc()?; + let Some(sh) = st.shape(id) else { return Ok(false) }; + let params = match cmd::table::table_frame(st, sh) { + Some(f) => { + let Some((r, c)) = f.cell_at(f.local(p)) else { return Ok(false) }; + let pos = cmd::text::text_pos_at(st, &TextSel { shape: id, cell: Some((r, c)), ..Default::default() }, p); + json!({"id": id, "cell": [r, c], "at": [pos.0, pos.1]}) + } + None => { + let pos = text_pos(st, sh, p); + json!({"id": id, "at": [pos.0, pos.1]}) + } + }; + self.execute("text.edit", ¶ms)?; + Ok(true) + } + fn is_picture(&self, id: ShapeId) -> bool { self.active().and_then(|d| d.shape(id)).is_some_and(|s| matches!(s.kind, ShapeKind::Picture { .. } | ShapeKind::Media(_))) } @@ -1281,29 +1315,6 @@ pub fn table_rows(st: &crate::DocState, t: &deckcraft_model::Table) -> Vec cmd::table_drawn_heights(&st.doc, &st.selection, t) } -/// Which table cell contains a table-local point (`heights` from [`table_rows`]). -fn deckcraft_render_cell(t: &deckcraft_model::Table, heights: &[f64], p: Point) -> (usize, usize) { - let mut y = 0.0; - let mut row = heights.len().saturating_sub(1); - for (i, h) in heights.iter().enumerate() { - if p.y < y + h { - row = i; - break; - } - y += h; - } - let mut x = 0.0; - let mut col = t.cols.len().saturating_sub(1); - for (i, w) in t.cols.iter().enumerate() { - if p.x < x + w { - col = i; - break; - } - x += w; - } - (row, col) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index e853130c..d3181247 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -743,7 +743,7 @@ mod tests { } } - /// Double-clicking a table cell edits it with the caret drawn in that cell. + /// Double-clicking a table cell edits it with the caret drawn in that cell, where clicked. #[test] fn double_click_in_a_table_cell_shows_the_caret_there() { let (mut harness, id) = app_with_table(); @@ -762,5 +762,14 @@ mod tests { for q in [top, bottom] { assert!(q.x > 300.0 && q.x < 500.0 && q.y >= 140.0 && q.y <= 180.0, "caret {q:?} outside cell (1, 1)"); } + // A click in another cell moves the caret there. + let p = screen(harness.state(), 110.0, 120.0); + click(&mut harness, p); + harness.run_steps(1); + let st = harness.state().session.active().unwrap(); + let ts = st.selection.text.clone().unwrap(); + assert_eq!((ts.cell, ts.caret), (Some((0, 0)), (0, 0))); + let (top, _) = deckcraft_engine::cmd::text::caret_line(st, &ts).unwrap(); + assert!(top.x > 100.0 && top.x < 120.0 && top.y >= 100.0 && top.y < 140.0, "caret {top:?} at the start of cell (0, 0)"); } } From 456c5c53739b44faefcbdcbdea3d2055b1d82b0b Mon Sep 17 00:00:00 2001 From: pierrevbc <101216765+pierrevbc@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:19:43 +0200 Subject: [PATCH 3/6] Tables: drag row and column borders to resize them Hovering a table border shows a resize cursor (new hit kinds TableColumn and TableRow) and dragging it resizes, one undo step per drag, as in PowerPoint: - an inner column border moves between its two columns and the table keeps its width; - the right edge widens or narrows the last column and the table; - a row's bottom border (the last one is the table's bottom edge) sets that row's height, which stays a minimum: rows still grow to fit their text. Columns stop at 12 pt. Borders hidden inside merged cells aren't hit; the left and top edges still move the table. A border drag while a cell is edited keeps editing it. The drag goes through table.columnWidth, which takes a new keepTotal option (the next column absorbs the change), and table.rowHeight. Hit testing is done in the table's own frame, so rotated tables resize along their axes. --- crates/engine/src/cmd/table.rs | 76 ++++++++++++++++++++++- crates/engine/src/tests.rs | 107 +++++++++++++++++++++++++++++++++ crates/engine/src/tools.rs | 76 ++++++++++++++++++++++- crates/ui-egui/src/canvas.rs | 25 ++++++++ 4 files changed, 280 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 4c46eb5f..fd39faac 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -60,12 +60,23 @@ pub fn specs() -> Vec { cmd!("table.distributeColumns", "Distribute Columns", ["Table Layout", "Cell Size"], None, "{id?}", has_selection, |s, p| distribute( s, p, false )), - cmd!("table.columnWidth", "Width", ["Table Layout", "Cell Size"], None, "{col, width: pt, id?}", has_selection, column_width), + cmd!( + "table.columnWidth", + "Width", + ["Table Layout", "Cell Size"], + None, + "{col, width: pt, keepTotal?: bool (the next column absorbs the change), id?}", + has_selection, + column_width + ), cmd!("table.rowHeight", "Height", ["Table Layout", "Cell Size"], None, "{row, height: pt, id?}", has_selection, row_height), cmd!(noundo "table.selectCells", "Select Cells", [], None, "{from: [r,c], to: [r,c], id?}", has_selection, select_cells), ] } +/// Narrowest a column gets when its border is dragged (points). +pub const MIN_COLUMN_WIDTH: f64 = 12.0; + /// A table as drawn on the slide: where it is, its drawn row heights and its visible cells. #[derive(Clone, Debug)] pub struct TableFrame { @@ -78,6 +89,15 @@ pub struct TableFrame { pub cells: Vec<(usize, usize, Rect)>, } +/// A border between table rows or columns that can be dragged. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TableBorder { + /// The right edge of this column (the last one is the table's right edge). + Column(usize), + /// The bottom edge of this row (the last one is the table's bottom edge). + Row(usize), +} + /// The drawn geometry of table shape `sh` on the edited slide (`None` if it isn't a table). pub fn table_frame(st: &DocState, sh: &Shape) -> Option { let ShapeKind::Table(t) = &sh.kind else { return None }; @@ -118,6 +138,38 @@ impl TableFrame { .or_else(|| self.cells.iter().rev().find(|(_, _, r)| inside(r))) .map(|(r, c, _)| (*r, *c)) } + + /// The draggable border within `tol` of table-local point `p`: the inner borders and the right + /// and bottom edges (the left and top edges move the table). Borders hidden inside merged + /// cells don't count. + pub fn border_at(&self, p: Point, tol: f64) -> Option { + let (w, h) = (self.width(), self.height()); + if !(p.x >= -tol && p.y >= -tol && p.x <= w + tol && p.y <= h + tol) { + return None; + } + let q = Point::new(p.x.clamp(0.0, w), p.y.clamp(0.0, h)); + let mut best: Option<(f64, TableBorder)> = None; + let mut x = 0.0; + for (i, cw) in self.cols.iter().enumerate() { + x += cw.max(0.0); + let d = (p.x - x).abs(); + // A cell ending at this edge at this height shows the border. + let shown = self.cells.iter().any(|(_, _, r)| (r.x1 - x).abs() < 1e-6 && q.y >= r.y0 && q.y <= r.y1); + if d <= tol && shown && best.is_none_or(|b| d < b.0) { + best = Some((d, TableBorder::Column(i))); + } + } + let mut y = 0.0; + for (i, rh) in self.heights.iter().enumerate() { + y += rh.max(0.0); + let d = (p.y - y).abs(); + let shown = self.cells.iter().any(|(_, _, r)| (r.y1 - y).abs() < 1e-6 && q.x >= r.x0 && q.x <= r.x1); + if d <= tol && shown && best.is_none_or(|b| d < b.0) { + best = Some((d, TableBorder::Row(i))); + } + } + best.map(|b| b.1) + } } fn cell_param(p: &Value, key: &str) -> Option<(usize, usize)> { @@ -288,9 +340,27 @@ fn distribute(s: &mut Session, p: &Value, rows: bool) -> Result { fn column_width(s: &mut Session, p: &Value) -> Result { let (_, c) = at(s, p); let w = f64_param(p, "width").ok_or_else(|| bad("table.columnWidth", "missing `width`"))?.clamp(4.0, 4000.0); + let keep_total = bool_or(p, "keepTotal", false); with_table(s, p, "table.columnWidth", move |t| { - if let Some(x) = t.cols.get_mut(c) { - *x = w; + // Keeping the table's width, the column to the right gives or takes the difference (dragging + // an inner border). The last column has none: the table gets wider or narrower. + let pair = t.cols.get(c).zip(t.cols.get(c + 1)).map(|(a, b)| a.max(0.0) + b.max(0.0)).filter(|x| x.is_finite()); + match pair { + Some(pair) if keep_total => { + let min = MIN_COLUMN_WIDTH.min(pair / 2.0); + let w = w.max(min).min(pair - min); + if let Some(x) = t.cols.get_mut(c) { + *x = w; + } + if let Some(x) = t.cols.get_mut(c + 1) { + *x = pair - w; + } + } + _ => { + if let Some(x) = t.cols.get_mut(c) { + *x = w; + } + } } Ok(()) }) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 69d3bc68..772a42d0 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -557,6 +557,113 @@ fn caret_moves_by_line_in_a_wrapped_cell() { assert!(text_sel(&s).unwrap().caret.1 < down.1); } +#[test] +fn table_borders_are_hit_where_they_are_drawn() { + let mut s = session(); + let id = table_at(&mut s, &[100.0, 100.0, 100.0], json!([["a", "b", "c"], ["d", "e", "f"]])); + let hit = |s: &Session, x: f64, y: f64| s.hit_test(deckcraft_geom::Point::new(x, y), 3.0); + assert_eq!(hit(&s, 201.0, 125.0), Some(tools::Hit::TableColumn { id, col: 0 })); + assert_eq!(hit(&s, 400.0, 125.0), Some(tools::Hit::TableColumn { id, col: 2 }), "the right edge"); + assert_eq!(hit(&s, 150.0, 139.0), Some(tools::Hit::TableRow { id, row: 0 })); + assert_eq!(hit(&s, 150.0, 181.0), Some(tools::Hit::TableRow { id, row: 1 }), "the bottom edge"); + assert!(matches!(hit(&s, 100.0, 125.0), Some(tools::Hit::Shape { .. })), "the left edge moves the table"); + assert!(matches!(hit(&s, 150.0, 125.0), Some(tools::Hit::Shape { .. }))); + // Hovering a border shows it (the UI picks the resize cursor from the hover). + s.pointer(tev(PointerKind::Move, 300.0, 170.0)).unwrap(); + assert_eq!(s.tool.hover, Some(tools::Hit::TableColumn { id, col: 1 })); + // A border inside a merged cell isn't there. + s.execute("table.merge", &json!({"id": id, "from": [0, 0], "to": [0, 1]})).unwrap(); + assert!(matches!(hit(&s, 200.0, 120.0), Some(tools::Hit::Shape { .. }))); + assert_eq!(hit(&s, 200.0, 160.0), Some(tools::Hit::TableColumn { id, col: 0 })); +} + +#[test] +fn dragging_column_borders_resizes_columns() { + let mut s = session(); + let id = table_at(&mut s, &[100.0, 100.0, 100.0], json!([["a", "b", "c"], ["d", "e", "f"]])); + let undo_depth = s.doc().unwrap().history.undo.len(); + // An inner border: the two columns share the change, the table keeps its width. + s.pointer(tev(PointerKind::Down, 200.0, 125.0)).unwrap(); + s.pointer(tev(PointerKind::Drag, 215.0, 125.0)).unwrap(); + s.pointer(tev(PointerKind::Drag, 230.0, 128.0)).unwrap(); + s.pointer(tev(PointerKind::Up, 230.0, 128.0)).unwrap(); + assert_eq!(table_of(&s, id).cols, vec![130.0, 70.0, 100.0]); + assert_eq!(s.doc().unwrap().shape(id).unwrap().xfrm.unwrap().w, 300.0); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_depth + 1, "one undo step per drag"); + // Columns don't collapse. + s.pointer(tev(PointerKind::Down, 230.0, 125.0)).unwrap(); + s.pointer(tev(PointerKind::Drag, 500.0, 125.0)).unwrap(); + s.pointer(tev(PointerKind::Up, 500.0, 125.0)).unwrap(); + assert_eq!(table_of(&s, id).cols, vec![200.0 - cmd::table::MIN_COLUMN_WIDTH, cmd::table::MIN_COLUMN_WIDTH, 100.0]); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(table_of(&s, id).cols, vec![100.0, 100.0, 100.0]); + // The right edge: the last column and the table get wider. + s.pointer(tev(PointerKind::Down, 400.0, 120.0)).unwrap(); + s.pointer(tev(PointerKind::Drag, 450.0, 120.0)).unwrap(); + s.pointer(tev(PointerKind::Up, 450.0, 120.0)).unwrap(); + assert_eq!(table_of(&s, id).cols, vec![100.0, 100.0, 150.0]); + assert_eq!(s.doc().unwrap().shape(id).unwrap().xfrm.unwrap().w, 350.0); + // A click on a border without dragging changes nothing. + let before = s.doc().unwrap().doc.clone(); + s.pointer(tev(PointerKind::Down, 200.0, 125.0)).unwrap(); + s.pointer(tev(PointerKind::Up, 200.0, 125.0)).unwrap(); + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc)); +} + +#[test] +fn dragging_a_row_border_sets_the_row_height() { + let mut s = session(); + let id = table_at(&mut s, &[200.0, 200.0], json!([["a", "b"], ["c", "d"]])); + let drawn = |s: &Session| { + let st = s.doc().unwrap(); + tools::table_rows(st, &table_of(s, id)) + }; + s.pointer(tev(PointerKind::Down, 150.0, 140.0)).unwrap(); + s.pointer(tev(PointerKind::Drag, 150.0, 170.0)).unwrap(); + s.pointer(tev(PointerKind::Up, 150.0, 170.0)).unwrap(); + assert_eq!(drawn(&s), vec![70.0, 40.0]); + let st = s.doc().unwrap(); + assert_eq!(cmd::xfrm_of(&st.doc, &st.selection, st.shape(id).unwrap()).h, 110.0, "the table grows"); + // Rows still fit their text: dragging the border up stops at the text. + s.pointer(tev(PointerKind::Down, 150.0, 170.0)).unwrap(); + s.pointer(tev(PointerKind::Drag, 150.0, 60.0)).unwrap(); + s.pointer(tev(PointerKind::Up, 150.0, 60.0)).unwrap(); + let h = drawn(&s)[0]; + assert!(h > 10.0 && h < 40.0, "row 0 shrinks to its text: {h}"); + // The bottom edge sizes the last row. + let bottom = 100.0 + drawn(&s).iter().sum::(); + s.pointer(tev(PointerKind::Down, 150.0, bottom)).unwrap(); + s.pointer(tev(PointerKind::Drag, 150.0, bottom + 20.0)).unwrap(); + s.pointer(tev(PointerKind::Up, 150.0, bottom + 20.0)).unwrap(); + assert_eq!(drawn(&s)[1], 60.0); +} + +#[test] +fn dragging_a_border_while_editing_a_cell_keeps_editing() { + let mut s = session(); + let id = table_at(&mut s, &[100.0, 100.0], json!([["a", "b"]])); + s.execute("text.edit", &json!({"id": id, "cell": [0, 1]})).unwrap(); + s.pointer(tev(PointerKind::Down, 200.0, 120.0)).unwrap(); + s.pointer(tev(PointerKind::Drag, 180.0, 120.0)).unwrap(); + s.pointer(tev(PointerKind::Up, 180.0, 120.0)).unwrap(); + assert_eq!(table_of(&s, id).cols, vec![80.0, 120.0]); + assert_eq!(text_sel(&s).and_then(|t| t.cell), Some((0, 1))); +} + +#[test] +fn column_width_can_keep_the_table_width() { + let mut s = session(); + let id = table_at(&mut s, &[100.0, 100.0, 100.0], json!([["a", "b", "c"]])); + s.execute("table.columnWidth", &json!({"id": id, "col": 1, "width": 150.0, "keepTotal": true})).unwrap(); + assert_eq!(table_of(&s, id).cols, vec![100.0, 150.0, 50.0]); + s.execute("table.columnWidth", &json!({"id": id, "col": 1, "width": 1000.0, "keepTotal": true})).unwrap(); + assert_eq!(table_of(&s, id).cols, vec![100.0, 200.0 - cmd::table::MIN_COLUMN_WIDTH, cmd::table::MIN_COLUMN_WIDTH]); + // The last column has no neighbour: the table changes width. + s.execute("table.columnWidth", &json!({"id": id, "col": 2, "width": 80.0, "keepTotal": true})).unwrap(); + assert_eq!(s.doc().unwrap().shape(id).unwrap().xfrm.unwrap().w, 100.0 + (200.0 - cmd::table::MIN_COLUMN_WIDTH) + 80.0); +} + #[test] fn rotated_tables_hit_the_cell_under_the_pointer() { let mut s = session(); diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index ab051c7a..7e062667 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -85,6 +85,16 @@ pub enum Hit { id: ShapeId, text: bool, }, + /// The right edge of table column `col` (dragging it resizes the column). + TableColumn { + id: ShapeId, + col: usize, + }, + /// The bottom edge of table row `row` (dragging it resizes the row). + TableRow { + id: ShapeId, + row: usize, + }, } #[derive(Clone, Debug)] @@ -129,6 +139,18 @@ enum Drag { TextSelect { shape: ShapeId, }, + /// Dragging a table border: the right edge of column `index`, or the bottom edge of row + /// `index`. `start` is the table-local press point, `size` the column's width or the row's + /// drawn height then, `frame` where the table was drawn. + TableBorder { + id: ShapeId, + column: bool, + index: usize, + start: Point, + size: f64, + frame: Affine, + moved: bool, + }, Ink { points: Vec<(f64, f64, f32)>, }, @@ -369,6 +391,8 @@ impl Session { let hit = self.hit_test(p, tol); let st = self.doc()?; match hit { + Some(Hit::TableColumn { id, col }) => self.begin_table_border(id, true, col, p)?, + Some(Hit::TableRow { id, row }) => self.begin_table_border(id, false, row, p)?, Some(Hit::Resize { id, handle }) => { let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default(); self.begin("Size"); @@ -623,6 +647,26 @@ impl Session { })?; } } + Drag::TableBorder { id, column, index, start, size, frame, moved } => { + let local = frame.inverse() * p; + let d = if column { local.x - start.x } else { local.y - start.y }; + if !moved && d.abs() < tol.max(1.0) { + return Ok(Value::Null); + } + if !moved { + self.begin(if column { "Column Width" } else { "Row Height" }); + } + // An inner column border moves between its two columns (the table keeps its + // width); the right edge widens the last column and the table. A row's height is + // a minimum: rows still grow to fit their text. + if column { + let width = (size + d).max(cmd::table::MIN_COLUMN_WIDTH); + self.execute("table.columnWidth", &json!({"id": id, "col": index, "width": width, "keepTotal": true}))?; + } else { + self.execute("table.rowHeight", &json!({"id": id, "row": index, "height": (size + d).max(1.0)}))?; + } + self.tool.drag = Some(Drag::TableBorder { id, column, index, start, size, frame, moved: true }); + } Drag::Freeform { mode, mut points, pressed } => { // Dragging scribbles (except for curves, which only take clicked points). if pressed && mode != "curve" && points.last().is_none_or(|l| (*l - p).hypot() >= 1.5) { @@ -814,7 +858,10 @@ impl Session { let Some(sh) = st.shape(t.shape) else { return Ok(None) }; let x = xfrm_of(&st.doc, &st.selection, sh); if !x.bounds().inflate(tol, tol).contains(p) - || matches!(self.hit_test(p, tol), Some(Hit::Resize { .. } | Hit::Rotate { .. } | Hit::Adjust { .. })) + || matches!( + self.hit_test(p, tol), + Some(Hit::Resize { .. } | Hit::Rotate { .. } | Hit::Adjust { .. } | Hit::TableColumn { .. } | Hit::TableRow { .. }) + ) { return Ok(None); } @@ -860,6 +907,24 @@ impl Session { Ok(true) } + /// Press on a table border: dragging it resizes the column or row. Editing a cell of the + /// table goes on; otherwise the table becomes the selection. + fn begin_table_border(&mut self, id: ShapeId, column: bool, index: usize, p: Point) -> Result<()> { + let st = self.doc()?; + let Some(f) = st.shape(id).and_then(|sh| cmd::table::table_frame(st, sh)) else { return Ok(()) }; + let size = if column { f.cols.get(index) } else { f.heights.get(index) }.copied().unwrap_or(0.0); + let (start, frame) = (f.local(p), f.affine); + if !st.selection.text.as_ref().is_some_and(|t| t.shape == id) { + self.select(|_, sel| { + sel.shapes = vec![id]; + sel.text = None; + sel.cells = None; + })?; + } + self.tool.drag = Some(Drag::TableBorder { id, column, index, start, size, frame, moved: false }); + Ok(()) + } + fn is_picture(&self, id: ShapeId) -> bool { self.active().and_then(|d| d.shape(id)).is_some_and(|s| matches!(s.kind, ShapeKind::Picture { .. } | ShapeKind::Media(_))) } @@ -1244,6 +1309,15 @@ fn hit_shape(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) -> Option= -tol && local.y >= -tol && local.x <= x.w + tol && local.y <= x.h + tol; + if near && let Some(f) = cmd::table::table_frame(st, sh) { + match f.border_at(f.local(p), tol) { + Some(cmd::table::TableBorder::Column(col)) => return Some(Hit::TableColumn { id: sh.id, col }), + Some(cmd::table::TableBorder::Row(row)) => return Some(Hit::TableRow { id: sh.id, row }), + None => {} + } + } match &sh.kind { ShapeKind::Group { children, child } => { // A group is hit when any child is hit; the group is what gets selected. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d3181247..bdce269b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -404,6 +404,8 @@ fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) { _ => egui::CursorIcon::ResizeHorizontal, }, (_, Some(Hit::Rotate { .. })) => egui::CursorIcon::Alias, + (_, Some(Hit::TableColumn { .. })) => egui::CursorIcon::ResizeColumn, + (_, Some(Hit::TableRow { .. })) => egui::CursorIcon::ResizeRow, (_, Some(Hit::Adjust { .. } | Hit::LineEnd { .. })) => egui::CursorIcon::PointingHand, (_, Some(Hit::Shape { text: true, .. })) => { if app.session.active().is_some_and(|d| d.selection.text.is_some()) { @@ -772,4 +774,27 @@ mod tests { let (top, _) = deckcraft_engine::cmd::text::caret_line(st, &ts).unwrap(); assert!(top.x > 100.0 && top.x < 120.0 && top.y >= 100.0 && top.y < 140.0, "caret {top:?} at the start of cell (0, 0)"); } + + /// Table borders show a resize cursor and drag to resize columns. + #[test] + fn table_borders_resize_with_the_pointer() { + let (mut harness, id) = app_with_table(); + let p = screen(harness.state(), 300.0, 120.0); + harness.hover_at(p); + harness.step(); + harness.step(); + assert_eq!(harness.output().platform_output.cursor_icon, egui::CursorIcon::ResizeColumn); + let q = screen(harness.state(), 340.0, 120.0); + harness.drag_at(p); + harness.step(); + for k in 1..=4 { + harness.hover_at(p + (q - p) * k as f32 / 4.0); + harness.step(); + } + harness.drop_at(q); + harness.run_steps(2); + let st = harness.state().session.active().unwrap(); + let deckcraft_model::ShapeKind::Table(t) = &st.shape(id).unwrap().kind else { panic!("not a table") }; + assert!((t.cols[0] - 240.0).abs() < 0.5 && (t.cols[1] - 160.0).abs() < 0.5, "{:?}", t.cols); + } } From d63d250d584882c9c1900d9e8c8a3a9554f8ac99 Mon Sep 17 00:00:00 2001 From: pierrevbc <101216765+pierrevbc@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:19:43 +0200 Subject: [PATCH 4/6] Tables: Tab and Shift+Tab skip cells hidden under a merge Tab moved into the cells covered by a merged cell, editing text that is never drawn. It now goes to the next visible cell; Tab in the last visible cell still adds a row, and Shift+Tab in the first cell stays. --- crates/engine/src/keys.rs | 23 +++++++++++++++-------- crates/engine/src/tests.rs | 19 +++++++++++++++++++ 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/keys.rs b/crates/engine/src/keys.rs index 665cc22e..2582dd97 100644 --- a/crates/engine/src/keys.rs +++ b/crates/engine/src/keys.rs @@ -285,15 +285,22 @@ impl Session { let Some(t) = st.selection.text.clone() else { return Ok(Value::Null) }; let Some((r, c)) = t.cell else { return Ok(Value::Null) }; let Some(deckcraft_model::ShapeKind::Table(tb)) = st.shape(t.shape).map(|s| s.kind.clone()) else { return Ok(Value::Null) }; - let (nr, nc) = (tb.n_rows(), tb.n_cols()); + let (nr, nc) = (tb.n_rows(), tb.n_cols().max(1)); let idx = r * nc + c; - if !back && idx + 1 >= nr * nc { - // Tab in the last cell adds a row. - self.execute("table.insertRowBelow", &json!({"id": t.shape, "row": r}))?; - return self.execute("text.edit", &json!({"id": t.shape, "cell": [r + 1, 0]})); - } - let ni = if back { idx.saturating_sub(1) } else { idx + 1 }; - self.execute("text.edit", &json!({"id": t.shape, "cell": [ni / nc.max(1), ni % nc.max(1)]}))?; + // Cells hidden under a merged cell are skipped. + let visible = |i: &usize| tb.cell(i / nc, i % nc).is_some_and(|x| !x.is_covered()); + let next = if back { (0..idx).rev().find(visible) } else { (idx + 1..nr * nc).find(visible) }; + let ni = match next { + Some(ni) => ni, + None if !back => { + // Tab in the last cell adds a row. + let last = nr.saturating_sub(1); + self.execute("table.insertRowBelow", &json!({"id": t.shape, "row": last}))?; + return self.execute("text.edit", &json!({"id": t.shape, "cell": [last + 1, 0]})); + } + None => idx, + }; + self.execute("text.edit", &json!({"id": t.shape, "cell": [ni / nc, ni % nc]}))?; self.execute("text.selectAll", &json!({})) } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 772a42d0..e252f2eb 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -676,6 +676,25 @@ fn rotated_tables_hit_the_cell_under_the_pointer() { assert_eq!(text_sel(&s).and_then(|t| t.cell), Some((1, 1))); } +#[test] +fn tab_moves_between_cells_skipping_merged_ones() { + let mut s = session(); + let id = table_at(&mut s, &[100.0, 100.0, 100.0], json!([["a", "b", "c"], ["d", "e", "f"]])); + s.execute("table.merge", &json!({"id": id, "from": [0, 0], "to": [0, 1]})).unwrap(); + s.execute("text.edit", &json!({"id": id, "cell": [0, 0]})).unwrap(); + let cell = |s: &Session| text_sel(s).and_then(|t| t.cell); + let shift = Mods { shift: true, ..Default::default() }; + s.key("Tab", Mods::default()).unwrap(); + assert_eq!(cell(&s), Some((0, 2)), "the cell under the merge is skipped"); + s.key("Tab", shift).unwrap(); + assert_eq!(cell(&s), Some((0, 0))); + s.key("Tab", shift).unwrap(); + assert_eq!(cell(&s), Some((0, 0)), "Shift+Tab in the first cell stays"); + s.execute("text.edit", &json!({"id": id, "cell": [1, 2]})).unwrap(); + s.key("Tab", Mods::default()).unwrap(); + assert_eq!((cell(&s), table_of(&s, id).n_rows()), (Some((2, 0)), 3), "Tab in the last cell adds a row"); +} + #[test] fn right_to_left_paragraphs() { let mut s = session(); From 8392081976aca3fe485c1d6046da971d4fd19104 Mon Sep 17 00:00:00 2001 From: pierrevbc <101216765+pierrevbc@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:19:44 +0200 Subject: [PATCH 5/6] Progress docs: table cell editing and border resizing --- ROADMAP.md | 4 +++- docs/ui-parity.md | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2dba0e27..347a3d34 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (progress log: table cell editing and border resizing; previous: minor, hours to ~95% per audience added; ready 56% → 55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere @@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-12 | Tables: the caret and text selection show in the cell being edited; clicks and double-clicks place the caret in the clicked cell (and select the word); dragging inner column borders, the right edge and row borders resizes them in one undo step; Tab skips cells hidden under a merge | | 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) | | 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) | | 2026-10-12 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes | @@ -147,6 +148,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Progress log: table cell editing and border resizing | | 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) | | 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) | | 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for full, mainstream and essentials | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c403f05e..1451e6d2 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (File tab / Backstage, #76; previous: off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (table cells: caret, clicks between cells, border resizing; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -81,6 +81,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Edit points on freeforms | yes | **no** | | Crop handles, crop to shape, aspect ratio | yes | partial | | Text caret, selection, double/triple click, drag-select | yes | yes; Tab in text boxes ends editing (#78) | +| Table cells: caret and selection in the cell, click into another cell, drag row and column borders | yes | yes; no cell-range selection by dragging across cells | | Exact size/position, lock aspect | yes | yes | | Rulers with indent markers and tab stops | yes | rulers yes; tab stops partial | | Context menus on shapes, slides, text, thumbnails | yes | yes, shorter | @@ -148,6 +149,7 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Table cells: caret and selection shown in the edited cell, clicks place the caret in the clicked cell, row and column borders drag to resize | | 2026-10-12 | minor | File tab (Backstage) observed in PowerPoint for Windows and matched (#76); the start screen is File › Home | | 2026-10-12 | minor | Off-slide objects drawn and editable around the slide in Normal view (#270) | | 2026-10-12 | minor | Selection Pane matched to PowerPoint (#133): drag and drop, arrows, groups, eye, Lock | From 44a032d3de345351aef912b231bda505c663dae9 Mon Sep 17 00:00:00 2001 From: pierrevbc <101216765+pierrevbc@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:52:59 +0200 Subject: [PATCH 6/6] Tables: editing tests for tables with a file-defined style A table whose style the file defines (its parts and background drawn since #292 and #294) edits like the others: the caret is laid out with the file style's lettering (a regular header where the built-in style bolds it), a double-click selects the word drawn there, a click moves the caret to the clicked cell and a border drag resizes the columns while the cell stays edited. --- crates/engine/src/tests.rs | 53 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e252f2eb..600520b7 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1129,3 +1129,56 @@ fn slide_ranges_parse_like_powerpoints_print_box() { let opts = cmd::file::pdf_options(&json!({"slides": [0, 7, 1]}), 2).unwrap(); assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped"); } + +/// Tables whose style the file defines (#292, #294: its look and background drawn) edit like the +/// others: the caret is laid out with the file style's lettering, clicks and double-clicks land +/// in the cell where it is drawn, and borders still resize. +#[test] +fn tables_with_a_file_defined_style_edit_and_resize() { + use deckcraft_model::style::{ColorRef, Fill}; + use deckcraft_model::table::{TablePart, TablePartStyle, TableStyleDef}; + let mut s = session(); + let id = table_at(&mut s, &[200.0, 200.0], json!([["Hello world", "b"], ["c", "d"]])); + s.execute("text.edit", &json!({"id": id, "cell": [0, 0]})).unwrap(); + let built_in = caret_point(&s, 0, 8); + s.execute("text.exit", &json!({})).unwrap(); + // The table's style, defined by the file: a plain (not bold) header, a filled body, a + // background. + { + let style = table_of(&s, id).style; + let d = s.doc_mut().unwrap(); + let doc = std::sync::Arc::make_mut(&mut d.doc); + let solid = |r, g, b| Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(r, g, b)))); + doc.table_styles.push(TableStyleDef { + id: style, + name: "File style".into(), + parts: vec![ + (TablePart::WholeTbl, TablePartStyle { fill: solid(230, 240, 250), ..Default::default() }), + (TablePart::FirstRow, TablePartStyle { fill: solid(40, 60, 90), bold: Some(false), ..Default::default() }), + ], + background: Some(TablePartStyle { fill: solid(200, 200, 200), ..Default::default() }), + raw: String::new(), + }); + } + s.execute("edit.deselect", &json!({})).unwrap(); + s.execute("text.edit", &json!({"id": id, "cell": [0, 0]})).unwrap(); + let p = caret_point(&s, 0, 8); + assert!((p.x - built_in.x).abs() > 0.5, "the header is laid out in the file style's (regular) weight: {p:?} vs {built_in:?}"); + s.execute("text.exit", &json!({})).unwrap(); + s.execute("edit.deselect", &json!({})).unwrap(); + // A double-click selects the word drawn there. + s.pointer(tev(PointerKind::DoubleClick, p.x, p.y)).unwrap(); + let t = text_sel(&s).unwrap(); + assert_eq!((t.cell, t.ordered()), (Some((0, 0)), ((0, 6), (0, 11)))); + // A click in another cell moves the caret there. + s.pointer(tev(PointerKind::Down, 400.0, 160.0)).unwrap(); + s.pointer(tev(PointerKind::Up, 400.0, 160.0)).unwrap(); + assert_eq!(text_sel(&s).unwrap().cell, Some((1, 1))); + // Border drag, while editing: the columns resize, editing goes on. + s.pointer(tev(PointerKind::Down, 300.0, 120.0)).unwrap(); + s.pointer(tev(PointerKind::Drag, 320.0, 120.0)).unwrap(); + s.pointer(tev(PointerKind::Drag, 330.0, 121.0)).unwrap(); + s.pointer(tev(PointerKind::Up, 330.0, 121.0)).unwrap(); + assert_eq!(table_of(&s, id).cols, vec![230.0, 170.0]); + assert_eq!(text_sel(&s).unwrap().cell, Some((1, 1))); +}