mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
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:
@@ -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 |
|
||||
|
||||
@@ -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(())
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!({}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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", ¶ms)?;
|
||||
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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user