diff --git a/ROADMAP.md b/ROADMAP.md index efa9a5eb..acd977aa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,6 +135,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| | 2026-10-24 | AutoFormat as you type: a paragraph that starts with `*`, `-`, `--`, `>`, `<>`, `1.`, `1)`, `I.`, `i.`, `A.`, `a.` or `a)` and a space or tab becomes a bulleted or numbered list in that style; Backspace right after puts the typed text back, and Tools ▸ AutoCorrect and `text.autocorrect {lists}` turn it off | +| 2026-10-24 | Tables: while the caret is in a cell, dragging into another cell selects that range of cells (shaded, merged cells taken in whole), and Shift+arrow grows or shrinks it; formatting applies to the selected cells only, and Delete clears their text and keeps the table | | 2026-10-23 | Chart text colour: `chart.options {textColor}` and Font Color on a selected chart colour its title, axes, legend and data labels; the colour is written to the chart part (`c:txPr` on the axes, legend, labels and chart, the title's run defaults) so an imported chart keeps its `txPr` colours through an edit and a save; text without a colour of its own takes PowerPoint's default, the theme's text colour at 65 % plus 35 % (595959 on a light theme, light grey on a dark one) | | 2026-10-23 | Format Background ▸ Apply to All also sets the background of the slide masters the slides use (and clears their layouts' overrides), so new slides keep it (#891) | | 2026-10-22 | Table text takes the master's other-text style (`otherStyle`), shapes and text boxes the presentation's default text style, as PowerPoint for Mac draws them (synthetic probe: 14 pt cells under a 14 pt `otherStyle` and a 26 pt `defaultTextStyle`); a private deck's table had been drawn 20 pt instead of 18 | @@ -279,6 +280,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-24 | trivial | Progress log: selecting a range of table cells by dragging. Scores and milestone names unchanged | | 2026-10-22 | trivial | Progress log: Animation Pane ▸ Play From. Scores unchanged | | 2026-10-23 | trivial | Progress log: scroll bars on the zoomed slide pane (#1157). Scores and milestone names unchanged | | 2026-10-20 | trivial | Explicit empty chart number-format sections hide their values; blank whole-code General fallback is retained. Synthetic formatter regressions; chart-element scores and readiness estimates unchanged | diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index fa13825f..0ee42913 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -123,6 +123,11 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result { s.clipboard = crate::Clipboard { slides, media, plain: plain.clone(), ..Default::default() }; return Ok(plain); } + // Selected table cells copy as their text: a tab between cells and a paragraph per row. + if let Some(plain) = super::table::selected_cells_text(st).filter(|_| p.get("id").is_none() && p.get("ids").is_none()) { + s.clipboard = crate::Clipboard { text: Some(TextBody::from_text(&plain)), plain: plain.clone(), ..Default::default() }; + return Ok(plain); + } let shapes: Vec = st .selection .shapes @@ -587,6 +592,11 @@ fn delete(s: &mut Session, p: &Value) -> Result { if slides_scope(s, p) && ids_param(p, "ids").is_none() { return s.execute("slide.delete", &json!({})); } + // With table cells selected, Delete clears their text and keeps the table, as in PowerPoint. + let sel = &s.doc()?.selection; + if ids_param(p, "ids").is_none() && p.get("id").is_none() && sel.cells.is_some_and(|c| sel.shapes == [c.0]) { + return super::table::clear_selected_cells(s); + } let ids = targets(s, p)?; if ids.is_empty() { return ok(); diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 8ab95549..f512f960 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -363,10 +363,12 @@ fn target_cells(s: &Session, p: &Value) -> Result Vec<(usize, .and_then(Value::as_array) .map(|a| a.iter().take(100_000).filter_map(|c| Some((c.get(0)?.as_u64()? as usize, c.get(1)?.as_u64()? as usize))).collect()); let st = s.doc()?; - let sel = st.selection.cells.map(|(_, a, b)| (a, b)).or_else(|| st.selection.text.as_ref().and_then(|t| t.cell).map(|c| (c, c))); + // A selected range grows over the merged cells it touches; with only a caret, the caret's cell is used. + let sel = st.selection.cells.map(|(_, a, b)| (a, b, true)).or_else(|| st.selection.text.as_ref().and_then(|t| t.cell).map(|c| (c, c, false))); Ok(move |t: &Table| match (&cells, sel) { (Some(c), _) => c.clone(), - (None, Some((a, b))) => { + (None, Some((a, b, range))) => { + let (a, b) = if range { cell_block(t, a, b) } else { (a, b) }; // The selection can name cells outside the table; only walk the ones that exist. let (r1, c1) = (a.0.max(b.0).min(t.n_rows().saturating_sub(1)), a.1.max(b.1).min(t.n_cols().saturating_sub(1))); (a.0.min(b.0)..=r1).flat_map(|r| (a.1.min(b.1)..=c1).map(move |c| (r, c))).collect() @@ -585,6 +587,110 @@ fn select_cells(s: &mut Session, p: &Value) -> Result { ok() } +/// The cell range from `a` to `b` in `t`, grown until every merged cell it touches lies inside +/// it, as (the corner on `a`'s side, the corner on `b`'s side). Cells past the table's edge are +/// clamped to it. +pub fn cell_block(t: &Table, a: (usize, usize), b: (usize, usize)) -> ((usize, usize), (usize, usize)) { + let (nr, nc) = (t.n_rows().max(1), t.n_cols().max(1)); + let (a, b) = ((a.0.min(nr - 1), a.1.min(nc - 1)), (b.0.min(nr - 1), b.1.min(nc - 1))); + let (mut r0, mut r1, mut c0, mut c1) = (a.0.min(b.0), a.0.max(b.0), a.1.min(b.1), a.1.max(b.1)); + // Each pass takes in the merged cells that overlap the block; stop when one adds nothing. + for _ in 0..nr + nc { + let mut grown = false; + for (r, row) in t.rows.iter().enumerate() { + for (c, cell) in row.cells.iter().enumerate() { + if cell.is_covered() { + continue; + } + // Spans come from the file; the additions saturate so that they cannot overflow on 32-bit targets. + let end = |at: usize, span: u32, n: usize| at.saturating_add((span.max(1) - 1) as usize).min(n - 1); + let (er, ec) = (end(r, cell.row_span, nr), end(c, cell.grid_span, nc)); + let overlaps = r <= r1 && er >= r0 && c <= c1 && ec >= c0; + if overlaps && (r < r0 || er > r1 || c < c0 || ec > c1) { + (r0, r1, c0, c1) = (r0.min(r), r1.max(er), c0.min(c), c1.max(ec)); + grown = true; + } + } + } + if !grown { + break; + } + } + let pick = |lo: usize, hi: usize, first: bool| if first { lo } else { hi }; + let from = (pick(r0, r1, a.0 <= b.0), pick(c0, c1, a.1 <= b.1)); + let to = (pick(r0, r1, a.0 > b.0), pick(c0, c1, a.1 > b.1)); + (from, to) +} + +/// The selected cell range of table `id` (`t`), grown over merged cells, as its top-left and +/// bottom-right cells. `None` when no range of that table is selected. +pub fn selected_range( + cells: Option<(deckcraft_model::ShapeId, (usize, usize), (usize, usize))>, + id: deckcraft_model::ShapeId, + t: &Table, +) -> Option<((usize, usize), (usize, usize))> { + let (_, a, b) = cells.filter(|c| c.0 == id)?; + let (x, y) = cell_block(t, a, b); + Some(((x.0.min(y.0), x.1.min(y.1)), (x.0.max(y.0), x.1.max(y.1)))) +} + +/// Whether cell (`r`, `c`) lies in `range` (from [`selected_range`]); without a range every cell does. +pub fn in_range(range: Option<((usize, usize), (usize, usize))>, r: usize, c: usize) -> bool { + range.is_none_or(|((r0, c0), (r1, c1))| (r0..=r1).contains(&r) && (c0..=c1).contains(&c)) +} + +/// Shift+arrow with a cell range selected: the cell the range was extended to moves by (`dr`, +/// `dc`), on past a merged cell until the range changes. The cell it started from stays. Returns +/// false when no cell range of the selected table is selected. +pub(crate) fn extend_selected_cells(s: &mut Session, dr: isize, dc: isize) -> Result { + let st = s.doc()?; + let Some((id, from, to)) = st.selection.cells.filter(|c| st.selection.shapes == [c.0]) else { return Ok(false) }; + let Some(ShapeKind::Table(t)) = st.shape(id).map(|sh| &sh.kind) else { return Ok(false) }; + let (nr, nc) = (t.n_rows(), t.n_cols()); + let old = cell_block(t, from, to); + let mut end = to; + while cell_block(t, from, end) == old { + let r = end.0.checked_add_signed(dr).filter(|r| *r < nr); + let c = end.1.checked_add_signed(dc).filter(|c| *c < nc); + let (Some(r), Some(c)) = (r, c) else { break }; + end = (r, c); + } + s.select(|_, sel| sel.cells = Some((id, from, end)))?; + Ok(true) +} + +/// The text of the selected range of cells, for Copy and Cut: a tab between the cells of a row +/// and a line per row (a merged cell once, where it starts). `None` without a cell range of the +/// selected table. +pub(crate) fn selected_cells_text(st: &DocState) -> Option { + let id = st.selection.cells.filter(|c| st.selection.shapes == [c.0])?.0; + let ShapeKind::Table(t) = &st.shape(id)?.kind else { return None }; + let ((r0, c0), (r1, c1)) = selected_range(st.selection.cells, id, t)?; + let rows = (r0..=r1).map(|r| { + let cells = (c0..=c1).filter_map(|c| t.cell(r, c).filter(|x| !x.is_covered()).map(|x| x.text.text())); + cells.collect::>().join("\t") + }); + Some(rows.collect::>().join("\n")) +} + +/// Delete with a cell range selected: the text in those cells goes and the cells stay, each +/// keeping its first paragraph's formatting. +pub(crate) fn clear_selected_cells(s: &mut Session) -> Result { + let Some((id, ..)) = s.doc()?.selection.cells else { return ok() }; + let cells = s.doc()?.selection.cells; + with_table(s, &json!({"id": id}), "edit.delete", move |t| { + let range = selected_range(cells, id, t); + for (r, row) in t.rows.iter_mut().enumerate() { + for (c, cell) in row.cells.iter_mut().enumerate() { + if in_range(range, r, c) { + deckcraft_model::edit::delete(&mut cell.text, (0, 0), (usize::MAX, usize::MAX)); + } + } + } + Ok(()) + }) +} + fn point_param(p: &Value, key: &str) -> Option<(f64, f64)> { let a = p.get(key)?; let (x, y) = (a.get(0)?.as_f64()?, a.get(1)?.as_f64()?); diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index a0c3474f..09229f4b 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -1387,12 +1387,18 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy if ids.is_empty() { return Err(bad("format", SELECT_TEXT_OR_SHAPE)); } + let cells = st.selection.cells; edit_shapes(s, &json!({"ids": ids}), "format", |sh| { + let id = sh.id; match &mut sh.kind { ShapeKind::Table(t) => { - for row in &mut t.rows { - for c in &mut row.cells { - ed::format_all(&mut c.text, f); + // Only the selected cells, when a range of them is selected. + let range = super::table::selected_range(cells, id, t); + for (r, row) in t.rows.iter_mut().enumerate() { + for (c, cell) in row.cells.iter_mut().enumerate() { + if super::table::in_range(range, r, c) { + ed::format_all(&mut cell.text, f); + } } } } @@ -1478,12 +1484,17 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex if ids.is_empty() { return Err(bad("format", SELECT_TEXT_OR_SHAPE)); } + let cells = st.selection.cells; edit_shapes(s, &json!({"ids": ids}), "format", |sh| { + let id = sh.id; match &mut sh.kind { ShapeKind::Table(t) => { - for row in &mut t.rows { - for c in &mut row.cells { - c.text.paragraphs.iter_mut().for_each(f); + let range = super::table::selected_range(cells, id, t); + for (r, row) in t.rows.iter_mut().enumerate() { + for (c, cell) in row.cells.iter_mut().enumerate() { + if super::table::in_range(range, r, c) { + cell.text.paragraphs.iter_mut().for_each(f); + } } } } diff --git a/crates/engine/src/keys.rs b/crates/engine/src/keys.rs index ebc2eae2..8f2932bf 100644 --- a/crates/engine/src/keys.rs +++ b/crates/engine/src/keys.rs @@ -90,6 +90,21 @@ impl Session { return Ok(json!({"handled": true})); } } + if !editing && has_shapes && !mods.cmd && mods.shift && !mods.alt { + // Shift+arrow grows or shrinks a selected range of table cells. + let d = match k { + "left" => Some((0, -1)), + "right" => Some((0, 1)), + "up" => Some((-1, 0)), + "down" => Some((1, 0)), + _ => None, + }; + if let Some((dr, dc)) = d + && crate::cmd::table::extend_selected_cells(self, dr, dc)? + { + return Ok(json!({"handled": true})); + } + } if !editing && has_shapes && !mods.cmd { let step = if mods.alt { 1.0 } else { 6.0 }; let d = match k { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 54ab24df..4dd50936 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -842,6 +842,92 @@ fn clicks_while_editing_a_cell_move_the_caret_between_cells() { assert!(text_sel(&s).is_none()); } +#[test] +fn dragging_across_cells_selects_a_range_of_cells() { + let mut s = session(); + let id = table_at(&mut s, &[100.0, 100.0, 100.0], json!([["a", "b", "c"], ["d", "e", "f"], ["g", "h", "i"]])); + // The middle of cell (r, c): the table's top-left is (100, 100), rows are 40 pt high. + let mid = |r: usize, c: usize| (150.0 + 100.0 * c as f64, 120.0 + 40.0 * r as f64); + let cells = |s: &Session| s.doc().unwrap().selection.cells; + let drag = |s: &mut Session, kind, (x, y): (f64, f64)| s.pointer(tev(kind, x, y)).unwrap(); + s.execute("text.edit", &json!({"id": id, "cell": [0, 0]})).unwrap(); + drag(&mut s, PointerKind::Down, mid(0, 0)); + drag(&mut s, PointerKind::Drag, mid(1, 1)); + assert_eq!(cells(&s), Some((id, (0, 0), (1, 1))), "leaving the cell selects whole cells"); + assert!(text_sel(&s).is_none()); + assert_eq!(s.doc().unwrap().selection.shapes, vec![id]); + // Back in the cell the drag started in, it selects text there again. + drag(&mut s, PointerKind::Drag, mid(0, 0)); + assert_eq!((cells(&s), text_sel(&s).and_then(|t| t.cell)), (None, Some((0, 0)))); + drag(&mut s, PointerKind::Drag, mid(2, 1)); + drag(&mut s, PointerKind::Up, mid(2, 1)); + assert_eq!(cells(&s), Some((id, (0, 0), (2, 1)))); + // Shift+arrow moves the range's far corner. + let shift = Mods { shift: true, ..Default::default() }; + s.key("ArrowRight", shift).unwrap(); + assert_eq!(cells(&s), Some((id, (0, 0), (2, 2)))); + s.key("ArrowUp", shift).unwrap(); + assert_eq!(cells(&s), Some((id, (0, 0), (1, 2)))); + let x = s.doc().unwrap().shape(id).unwrap().xfrm.unwrap(); + assert_eq!((x.x, x.y), (100.0, 100.0), "Shift+arrow doesn't move the table"); + // Formatting applies to the selected cells only. + s.execute("format.bold", &json!({})).unwrap(); + s.execute("format.alignCenter", &json!({})).unwrap(); + let t = table_of(&s, id); + let bold = |r: usize, c: usize| t.cell(r, c).unwrap().text.paragraphs[0].runs[0].props.bold == Some(true); + assert!(bold(0, 0) && bold(1, 2) && !bold(2, 0) && !bold(2, 2), "{t:?}"); + let centered = |r: usize, c: usize| t.cell(r, c).unwrap().text.paragraphs[0].props.align.is_some(); + assert!(centered(1, 1) && !centered(2, 1)); + // Delete clears their text and keeps the table and the other cells. + s.key("Delete", Mods::default()).unwrap(); + let t = table_of(&s, id); + let texts: Vec = (0..3).flat_map(|r| (0..3).map(move |c| (r, c))).map(|(r, c)| t.cell(r, c).unwrap().text.text()).collect(); + assert_eq!(texts, ["", "", "", "", "", "", "g", "h", "i"]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(table_of(&s, id).cell(0, 0).unwrap().text.text(), "a"); + // Cut puts the cells' text on the clipboard, a tab between cells and a line per row, and + // clears them. + let r = s.execute("edit.cut", &json!({})).unwrap(); + assert_eq!(r["text"], "a\tb\tc\nd\te\tf"); + assert!(s.clipboard.shapes.is_empty()); + assert_eq!(s.clipboard.text.as_ref().map(|b| b.paragraphs.len()), Some(2)); + assert_eq!(table_of(&s, id).cell(1, 1).unwrap().text.text(), ""); + assert_eq!(table_of(&s, id).cell(2, 1).unwrap().text.text(), "h"); + s.execute("edit.undo", &json!({})).unwrap(); + // A click in the table puts the caret in the clicked cell and ends the range. + drag(&mut s, PointerKind::Down, mid(2, 2)); + drag(&mut s, PointerKind::Up, mid(2, 2)); + assert_eq!((cells(&s), text_sel(&s).and_then(|t| t.cell)), (None, Some((2, 2)))); + // A range that reaches into a merged cell takes all of it. + s.execute("table.merge", &json!({"id": id, "from": [0, 1], "to": [0, 2]})).unwrap(); + s.execute("text.edit", &json!({"id": id, "cell": [1, 0]})).unwrap(); + drag(&mut s, PointerKind::Down, mid(1, 0)); + drag(&mut s, PointerKind::Drag, mid(0, 1)); + drag(&mut s, PointerKind::Up, mid(0, 1)); + assert_eq!(cells(&s), Some((id, (1, 0), (0, 1)))); + let range = |s: &Session| cmd::table::selected_range(cells(s), id, &table_of(s, id)); + assert_eq!(range(&s), Some(((0, 0), (1, 2)))); + // Shift+arrow keeps the cell the range started from: Shift+Down moves the end to row 1, so the + // range no longer includes the merged cell in row 0. + s.key("ArrowDown", shift).unwrap(); + assert_eq!(range(&s), Some(((1, 0), (1, 1)))); + // Cut of a cell a merge covers takes the whole merged cell, and clears what it copied. + let merged = table_of(&s, id).cell(0, 1).unwrap().text.text(); + assert!(!merged.is_empty()); + s.execute("table.selectCells", &json!({"id": id, "from": [0, 2], "to": [0, 2]})).unwrap(); + let r = s.execute("edit.cut", &json!({})).unwrap(); + assert_eq!(r["text"].as_str(), Some(merged.as_str())); + assert_eq!(table_of(&s, id).cell(0, 1).unwrap().text.text(), ""); + // Delete naming a shape deletes that shape, cells selected or not. + s.execute("table.selectCells", &json!({"id": id, "from": [1, 1], "to": [2, 2]})).unwrap(); + s.execute("edit.delete", &json!({"id": id})).unwrap(); + assert!(s.doc().unwrap().shape(id).is_none()); + // A span past the table's edge (from a file) stops at the edge. + let mut t = deckcraft_model::Table::new(3, 3, 300.0, 40.0); + (t.rows[0].cells[0].row_span, t.rows[0].cells[0].grid_span) = (u32::MAX, u32::MAX); + assert_eq!(cmd::table::cell_block(&t, (1, 1), (2, 2)), ((0, 0), (2, 2))); +} + #[test] fn a_click_in_a_selected_table_enters_the_clicked_cell() { let mut s = session(); diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 50853838..c2ed729b 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -196,6 +196,9 @@ enum Drag { shape: ShapeId, /// The text selection before the press, to tell whether the drag changed it. before: Option, + /// The text selection the press made. In a table, a drag that leaves its cell selects + /// whole cells, and coming back to the cell restores this. + press: Option, }, /// 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 @@ -589,7 +592,8 @@ impl Session { // Clicking inside the text being edited moves the caret / starts a text selection. let before = self.text_selection(); if let Some((shape, pos)) = self.place_caret(p, tol, mods.shift)? { - self.tool.drag = Some(Drag::TextSelect { shape, before }); + let press = self.doc()?.selection.text.clone(); + self.tool.drag = Some(Drag::TextSelect { shape, before, press }); return Ok(json!({"caret": [pos.0, pos.1]})); } let hit = self.hit_test(p, tol); @@ -674,6 +678,18 @@ impl Session { self.tool.drag = Some(Drag::Warp { id, index, name, base, orig }); } } + Some(Hit::Shape { id, .. }) + if !(mods.shift || mods.cmd) && st.selection.cells.is_some_and(|c| c.0 == id && st.selection.shapes == [id]) => + { + // A press in a table whose cells are selected starts again in the cell under the + // pointer: a click puts the caret there and a drag selects another range. + let before = self.text_selection(); + if self.edit_text_at(id, p)? { + self.select(|_, sel| sel.cells = None)?; + let press = self.doc()?.selection.text.clone(); + self.tool.drag = Some(Drag::TextSelect { shape: id, before, press }); + } + } Some(Hit::Shape { id, text }) => { let selected = st.selection.shapes.contains(&id); let toggle = mods.shift || mods.cmd; @@ -950,8 +966,29 @@ impl Session { }; self.tool.preview = Some(x); } - Drag::TextSelect { shape, .. } => { - // The selection stays in the shape or table cell where it started. + Drag::TextSelect { shape, press, .. } => { + // In a table, leaving the cell the drag started in selects whole cells, as in + // PowerPoint; coming back to that cell selects text in it again. + let st = self.doc()?; + if let Some(start) = press.as_ref().and_then(|t| t.cell) + && let Some(f) = st.shape(shape).and_then(|sh| cmd::table::table_frame(st, sh)) + && let Some(cell) = f.cell_at(f.local(p)) + { + if cell != start { + self.select(|_, sel| { + sel.text = None; + sel.shapes = vec![shape]; + sel.cells = Some((shape, start, cell)); + })?; + return Ok(Value::Null); + } + if st.selection.text.is_none() { + self.select(|_, sel| { + sel.cells = None; + sel.text = press; + })?; + } + } let st = self.doc()?; if let Some(t) = st.selection.text.as_ref().filter(|t| t.shape == shape) { let pos = cmd::text::text_pos_at(st, t, p); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index c6320dff..9026bd86 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1061,6 +1061,20 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To .map(|q| xf.to_screen(f.affine * q)); poly(painter, pts, Stroke::new(2.0, t.accent)); } + // Shade the selected table cells in the text-selection color, each merged cell as it is drawn. + if let Some((id, ..)) = st.selection.cells + && st.selection.text.is_none() + && let Some(sh) = st.shape(id) + && let deckcraft_model::ShapeKind::Table(table) = &sh.kind + && let Some(f) = deckcraft_engine::cmd::table::table_frame(st, sh) + { + use deckcraft_engine::cmd::table::{in_range, selected_range}; + let range = selected_range(st.selection.cells, id, table); + for (_, _, cr) in f.cells.iter().filter(|(r, c, _)| range.is_some() && in_range(range, *r, *c)) { + 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)]; + painter.add(egui::Shape::convex_polygon(pts.iter().map(|q| xf.to_screen(f.affine * *q)).collect(), t.text_selection, Stroke::NONE)); + } + } // Smart guides, marquee, previews. for g in &app.session.tool.guides { let (a, b) = diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 27b2780f..b0641930 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -95,7 +95,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | 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 | | Click the frame of a text box or placeholder to select it whole (then move, duplicate or delete it) | yes | yes; a click inside takes the caret | -| 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 | +| Table cells: caret and selection in the cell, click into another cell, drag row and column borders, drag across cells to select a range | yes | yes: a drag from the cell being edited into another cell selects the range (merged cells whole), Shift+arrow grows or shrinks it, formatting and Delete act on the selected cells | | Context menus on shapes, slides, text, thumbnails | yes | yes, shorter; a right click selects the shape under the pointer first | | Group fill and outline | formats the members | Shape Fill and Shape Outline reach nested members in one undo step; a transparency-only fill edit retains each member's colour (#1079) | | WordArt transform | Transform gallery | Shape Format ▸ Text Effects ▸ Transform (`format.textWarp`): No Transform, Follow Path and the other 36 presets. Adjust handles: see WordArt adjust handles | @@ -169,6 +169,7 @@ consistency is open (#73, #56). | Date | Change | Summary | |---|---|---| +| 2026-10-24 | trivial | Table cells: dragging across cells selects a range of cells | | 2026-10-23 | trivial | Scroll bars on the zoomed slide pane (#1157) | | 2026-10-23 | minor | Shape Fill and Shape Outline (Home and Shape Format): the button applies the last colour picked from its menu, Accent 1 and Text 1 until one is picked. Line weight and dashes are not changed by the button; Font Color and Highlight stay as they were (#1080) | | 2026-10-22 | trivial | Group fill and outline commands reach members, including nested groups; selection and single-step undo are preserved (#1079). The existing Arrange / Group checklist score stays D |