Merge PR #230 from pierrevbc: Tables: caret in the edited cell, click-to-place, drag borders to resize

# Conflicts:
#	crates/engine/src/cmd/text.rs
#	crates/engine/src/tests.rs
#	crates/engine/src/tools.rs
#	crates/render/src/lib.rs
#	crates/ui-egui/src/canvas.rs
This commit is contained in:
Brandon Thomas
2026-10-14 14:41:34 -04:00
8 changed files with 815 additions and 123 deletions
+3
View File
@@ -35,6 +35,7 @@
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: chart trendlines are drawn; previous: trivial, chart trendlines round-trip; hours to ~95% per audience added; ready ~55%; stage stays alpha; full re-measure against PowerPoint for Mac 16.113.4) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text box frame drag while editing; previous: 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 for 2026-10-12; previous: minor, hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; before that: 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
@@ -221,6 +222,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-12 | Canvas: a right click selects the shape under the pointer before the context menu opens (`contextclick` pointer kind for the control channel and MCP); in edited text the menu offers text items |
| 2026-10-12 | Canvas: dragging the frame of a text box being edited moves it (move cursor on the frame, one undo step); the caret zone follows rotated boxes |
| 2026-10-13 | Start screen: a missing Recent file is marked and its click shows the error there; entries can be removed from the list (#172) |
| 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 |
@@ -282,6 +284,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-14 | trivial | Progress log: Trim Media dialog follows the selection |
| 2026-10-13 | trivial | Progress log: wild text cases |
| 2026-10-13 | trivial | Progress log: slide show for interactive decks |
| 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 |
+130 -5
View File
@@ -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<CommandSpec> {
vec![
@@ -94,12 +95,118 @@ pub fn specs() -> Vec<CommandSpec> {
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 {
/// Table-local (points from the table's top-left corner) → slide space, as the table is drawn.
pub affine: Affine,
pub cols: Vec<f64>,
/// Row heights as drawn: a stored height is a minimum, rows grow to fit their text.
pub heights: Vec<f64>,
/// Visible cells (merges honoured): (row, col, table-local rect).
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<TableFrame> {
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 {
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
}
/// Table-local rect of visible cell (`r`, `c`).
pub fn cell_rect(&self, r: usize, c: usize) -> Option<Rect> {
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))
}
/// 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<TableBorder> {
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)> {
let a = p.get(key)?.as_array()?;
Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize))
@@ -408,9 +515,27 @@ fn distribute(s: &mut Session, p: &Value, rows: bool) -> Result<Value> {
fn column_width(s: &mut Session, p: &Value) -> Result<Value> {
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(())
})
+35 -10
View File
@@ -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::*;
@@ -174,20 +175,44 @@ pub(crate) fn ctx_for<'a>(doc: &'a Presentation, sel: &Selection) -> Option<Ctx<
/// Lay out the text being edited (for vertical caret movement and hit testing).
pub fn layout_for(st: &DocState, t: &TextSel) -> Option<deckcraft_text::TextLayout> {
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 placed = cell_on_slide(st, t.shape, r, c)?;
return Some((placed.layout, placed.to_slide));
}
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
+15 -8
View File
@@ -290,15 +290,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!({}))
}
}
+347
View File
@@ -483,6 +483,300 @@ 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 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<TextSel> {
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 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();
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 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::<f64>();
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();
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 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();
@@ -2481,3 +2775,56 @@ fn what_a_command_acts_on_names_only_keys_it_takes() {
assert_eq!(acts("format.size").as_deref(), Some("Acts on the selected text or shapes."));
assert_eq!(acts("slide.new"), None);
}
/// 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)));
}
+166 -76
View File
@@ -106,6 +106,16 @@ pub enum Hit {
Frame {
id: ShapeId,
},
/// 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)]
@@ -161,6 +171,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)>,
},
@@ -429,25 +451,9 @@ impl Session {
}
ToolKind::Select => {}
}
let st = self.doc()?;
// Clicking inside the text being edited moves the caret / starts a text selection. Its
// handles and its frame (see `Hit::Frame`) win: they size, rotate or move the box.
if let Some(e) = edited_box(st)
&& edit_zone(&e.xfrm, p, tol) != EditZone::Outside
&& !matches!(self.hit_test(p, tol), Some(Hit::Resize { .. } | Hit::Rotate { .. } | Hit::Adjust { .. } | Hit::Frame { .. }))
&& let Some(sh) = st.shape(e.id)
{
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: e.id });
// Clicking inside the text being edited moves the caret / starts a text selection.
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);
@@ -482,6 +488,8 @@ impl Session {
}
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");
@@ -538,8 +546,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;
@@ -749,9 +759,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;
@@ -759,6 +770,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) {
@@ -802,11 +833,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});
}
}
@@ -925,8 +953,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) {
@@ -934,18 +966,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 { .. } => {
@@ -1018,6 +1044,95 @@ 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<Option<(ShapeId, (usize, usize))>> {
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) };
// A text box: in its own (rotated) frame, short of the band that moves it (`Hit::Frame`).
// A table cell: anywhere over the table.
let inside = match t.cell {
None => edited_box(st).is_some_and(|e| edit_zone(&e.xfrm, p, tol) != EditZone::Outside),
Some(_) => xfrm_of(&st.doc, &st.selection, sh).bounds().inflate(tol, tol).contains(p),
};
if !inside
|| matches!(
self.hit_test(p, tol),
Some(
Hit::Resize { .. }
| Hit::Rotate { .. }
| Hit::Adjust { .. }
| Hit::Frame { .. }
| Hit::TableColumn { .. }
| Hit::TableRow { .. }
)
)
{
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<bool> {
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", &params)?;
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(_)))
}
@@ -1475,6 +1590,15 @@ fn hit_shape(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) -> Option<Hit
let x = xfrm_of(&st.doc, &st.selection, sh);
let inv = x.affine().inverse();
let local = inv * p;
// Table borders resize rows and columns.
let near = local.x >= -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.
@@ -1543,18 +1667,7 @@ fn hit_shape(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) -> Option<Hit
/// Text position under slide point `p` in shape `sh`.
pub fn text_pos(st: &crate::DocState, sh: &Shape, p: Point) -> (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.
@@ -1562,29 +1675,6 @@ pub fn table_rows(st: &crate::DocState, t: &deckcraft_model::Table) -> Vec<f64>
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::*;
+116 -24
View File
@@ -472,6 +472,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()) {
@@ -583,21 +585,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) {
@@ -612,18 +603,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));
// The composition is in the cell text; underline it where that text is drawn (#101).
for (a, b, thick) in deckcraft_engine::cmd::text::composition_underlines(st) {
@@ -696,6 +683,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<TextMarks> {
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 {
@@ -884,6 +889,7 @@ mod tests {
use egui::{Event, Modifiers, MouseWheelUnit, Pos2, TouchPhase, pos2, vec2};
use egui_kittest::Harness;
use super::*;
use crate::{Services, SlideApp};
fn harness() -> Harness<'static, SlideApp> {
@@ -967,4 +973,90 @@ mod tests {
h.run_steps(2);
assert_near(mid, slide_point(&h, c), "zoom out");
}
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, where clicked.
#[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)");
}
// 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)");
}
/// 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);
}
}
+3
View File
@@ -21,6 +21,7 @@
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Master Preserve and Title/Footers placeholders; previous: File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (Trim Media dialog follows the selection; previous: File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (the frame of a text box being edited moves it; previous: minor, File tab / Backstage, #76) · **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-14 · **Change:** minor (File › New starter decks; previous: minor, text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Sorter sections: add, rename, collapse, drag a whole section; previous: text editing keys, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
@@ -126,6 +127,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`).
| Text caret, selection, double/triple click, drag-select | yes | yes; Tab in text boxes ends editing (#78) |
| Exact size/position, lock aspect | yes | yes — Size and Position dialog (height, width, rotation, scale %, lock aspect, position from the top-left), one undo step; ribbon spinners still resize one side at a time |
| Drag the frame of a text box being edited to move it | yes | yes |
| 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; a right click selects the shape under the pointer first |
@@ -224,6 +226,7 @@ on the slide as shapes. No pixel comparison against PowerPoint screenshots has b
| 2026-10-14 | minor | Slide Master: Preserve, and Title and Footers placeholders, on the open master or layout |
| 2026-10-14 | trivial | Trim Media dialog: start, end and fades follow the selected clip |
| 2026-10-12 | minor | Dragging the frame of a text box being edited moves it, as in PowerPoint |
| 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 |