diff --git a/ROADMAP.md b/ROADMAP.md index c1d16dd7e..5546b77cf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,6 +128,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · With the Type tool, dragging a table's column or row border resizes it (the double-arrow cursor shows over a border): later columns and rows move along, Shift keeps the table size, and Shift on the right or bottom edge scales every column or row. A dragged row becomes At Least; one undo step per drag, Esc cancels; works in rotated frames and turned spreads. Scripts use `table.resizeBorder`. No readiness percentage change. - **2026-10-12** · The Tools panel's Fill/Stroke keys work: D (default fill and stroke), X (fill or stroke in front), Shift+X (swap per object, tints included) and / (Apply None), as `color.*` commands; swap and default are one undo step (`object.swapFillStroke`, `object.defaultFillStroke`); the Color panel follows the proxy's fill/stroke choice. No readiness percentage change. - **2026-10-12** · With a placed graphic selected (Direct Selection), the Transform values act on the graphic inside its frame, as in InDesign: a tilted frame's image can be straightened to 0°. No readiness percentage change. - **2026-10-11** · Font menu laid out like InDesign's: filters (classification, favourites, diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 113d5ef32..ef44533e6 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -49,7 +49,7 @@ use crate::notes::Notes; pub use crate::notes::{find_note, hit_note, hit_note_with, note_caret}; use crate::parabox::ParaBox; use crate::shape::{Glyph, StyleTable, SubstCtx}; -pub use crate::table::{PlacedCell, StrokeSeg, TableFrag, cell_caret, find_cell, hit_cell, hit_cell_with}; +pub use crate::table::{BorderHit, PlacedCell, StrokeSeg, TableFrag, cell_caret, find_cell, hit_border, hit_cell, hit_cell_with}; /// Character appearance shared by many glyphs (indexed from [`PlacedGlyph::style`]). #[derive(Clone, Debug, PartialEq)] diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs index 11110350b..56d3deaef 100644 --- a/crates/compose/src/table.rs +++ b/crates/compose/src/table.rs @@ -719,6 +719,56 @@ pub fn hit_cell_with( None } +/// A row or column border under the pointer, found by [`hit_border`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BorderHit { + pub table: u64, + /// `true`: a column border (dragged horizontally); `false`: a row border. + pub column: bool, + /// The row above / logical column before the border. The last index is the table's + /// bottom or end edge. + pub index: usize, + /// The column border is the physical left edge of its column (a right-to-left table), so + /// dragging left widens it. + pub mirrored: bool, +} + +/// The row or column border within `tol` of `p` (frame inner space) in frame `fi`, nearest +/// first. Only borders that resize something count: inner borders and the bottom and end +/// edges. `tables` holds the story's tables (for cell spans). +pub fn hit_border(cs: &ComposedStory, fi: usize, tables: &std::collections::BTreeMap>, p: Point, tol: f64) -> Option { + if !(tol.is_finite() && p.x.is_finite() && p.y.is_finite()) { + return None; + } + let ft = cs.frames.get(fi)?; + let mut best: Option<(f64, BorderHit)> = None; + let mut offer = |d: f64, hit: BorderHit| { + if d <= tol && best.is_none_or(|(bd, _)| d < bd) { + best = Some((d, hit)); + } + }; + for t in &ft.tables { + if !t.rect.inflate(tol, tol).contains(p) { + continue; + } + let Some(table) = tables.get(&t.table) else { continue }; + let rtl = table.options.direction == designcraft_doc::TextDirection::RightToLeft; + for c in &t.cells { + let Some(cell) = table.cell(c.row, c.col) else { continue }; + let last_col = c.col.saturating_add((cell.col_span.max(1) - 1) as usize); + let last_row = c.row.saturating_add((cell.row_span.max(1) - 1) as usize); + if (c.rect.y0..=c.rect.y1).contains(&p.y) { + let x = if rtl { c.rect.x0 } else { c.rect.x1 }; + offer((p.x - x).abs(), BorderHit { table: t.table, column: true, index: last_col, mirrored: rtl }); + } + if (c.rect.x0..=c.rect.x1).contains(&p.x) { + offer((p.y - c.rect.y1).abs(), BorderHit { table: t.table, column: false, index: last_row, mirrored: false }); + } + } + } + best.map(|(_, h)| h) +} + /// Frame index and placed cell for a cell address (the first, non-repeated occurrence). pub fn find_cell(cs: &ComposedStory, table: u64, row: usize, col: usize) -> Option<(usize, &TableFrag, &PlacedCell)> { let mut found = None; diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index fc2f60398..c1c313554 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -211,6 +211,15 @@ fn table_specs() -> Vec { ), cmd!("table.setRowHeight", "Row Height", [], None, "{height, mode?: atLeast|exactly}", in_table, set_row_height), cmd!("table.setColumnWidth", "Column Width", [], None, "{width}", in_table, set_col_width), + cmd!( + "table.resizeBorder", + "Resize Row or Column", + [], + None, + "{story?, table?, frame?, axis: column|row, index, delta? | size?, keepTableSize?: bool} — moves the border after column/row `index` by `delta` (or makes that column/row `size` wide/high); later columns/rows move along. The last index is the table's end/bottom edge. keepTableSize: on an inner border the next column/row gives or takes the change; on the edge all columns/rows scale proportionally. A dragged row becomes At Least. Refuses a locked `frame`", + in_table_or_ids, + resize_border + ), cmd!("table.distributeColumns", "Distribute Columns Evenly", ["Table"], None, "{}", in_table, distribute_cols), cmd!( "table.distributeRows", @@ -1029,6 +1038,114 @@ fn set_col_width(s: &mut Session, p: &Value) -> Result { }) } +/// Smallest column width and row height a border drag leaves. +const MIN_TRACK: f64 = 3.0; +const MAX_TRACK: f64 = 10000.0; + +/// Composed height of `row` (laid out from cells that span only that row), else its model height. +fn composed_row_height(cs: &compose::ComposedStory, t: &Table, row: usize) -> Option { + let laid_out = cs + .frames + .iter() + .flat_map(|f| &f.tables) + .filter(|f| f.table == t.id) + .flat_map(|f| &f.cells) + .filter(|c| c.row == row && t.cell(c.row, c.col).is_some_and(|x| x.row_span <= 1)) + .map(|c| c.rect.height()) + .filter(|h| h.is_finite() && *h > 0.0) + .reduce(f64::max); + laid_out.or_else(|| t.rows.get(row).map(|r| r.height)) +} + +/// Move the border after `index` among `sizes` by `delta`. Returns the new sizes. +fn resize_track(sizes: &[f64], index: usize, delta: f64, keep: bool) -> Option> { + let mut out = sizes.to_vec(); + let cur = *sizes.get(index)?; + let last = index + 1 == sizes.len(); + if keep && last { + // Scale every track so the table edge moves by `delta`. + let total: f64 = sizes.iter().sum(); + let smallest = sizes.iter().copied().fold(f64::INFINITY, f64::min); + let largest = sizes.iter().copied().fold(0.0, f64::max); + if !(total > 0.0 && smallest > 0.0 && largest > 0.0) { + return None; + } + let lo = (MIN_TRACK / smallest).min(1.0); + let hi = (MAX_TRACK / largest).max(1.0); + let f = ((total + delta) / total).clamp(lo, hi); + out.iter_mut().for_each(|v| *v *= f); + } else if keep { + let next = *sizes.get(index + 1)?; + let pair = cur + next; + let a = (cur + delta).clamp(MIN_TRACK, (pair - MIN_TRACK).max(MIN_TRACK)); + let [this, after] = out.get_mut(index..index + 2)? else { return None }; + *this = a; + *after = (pair - a).max(MIN_TRACK); + } else { + *out.get_mut(index)? = (cur + delta).clamp(MIN_TRACK, MAX_TRACK); + } + out.iter().all(|v| v.is_finite()).then_some(out) +} + +fn resize_border(s: &mut Session, p: &Value) -> Result { + const ID: &str = "table.resizeBorder"; + let column = match str_param(p, "axis") { + Some("column") => true, + Some("row") => false, + _ => return Err(bad(ID, "axis must be `column` or `row`")), + }; + let index = p.get("index").and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok()).ok_or_else(|| bad(ID, "`index` required"))?; + let keep = p.get("keepTableSize").and_then(Value::as_bool).unwrap_or(false); + let g = target(s, p, ID)?; + let st = s.doc()?; + if let Some(fid) = super::id_param(p, "frame") { + let it = st.doc.item(fid).ok_or_else(|| bad(ID, format!("no frame {}", fid.0)))?; + if it.text_frame().map(|f| f.story) != Some(g.story) { + return Err(bad(ID, "the table is not in that frame's story")); + } + if it.locked || st.doc.layer(it.layer).is_some_and(|l| l.locked) { + return Err(bad(ID, "the frame or its layer is locked")); + } + } + let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad(ID, "no table"))?; + let sizes: Vec = if column { + t.columns.iter().map(|c| c.width).collect() + } else { + let cs = s.cache.get(&st.doc, g.story, None); + (0..t.nrows()).map(|r| composed_row_height(&cs, t, r).unwrap_or(MIN_TRACK)).collect() + }; + let cur = *sizes.get(index).ok_or_else(|| bad(ID, format!("index {index} out of range")))?; + let delta = match (p.get("delta").and_then(Value::as_f64), p.get("size").and_then(Value::as_f64)) { + (Some(d), _) => d, + (None, Some(sz)) => sz - cur, + (None, None) => return Err(bad(ID, "`delta` or `size` required")), + }; + if !delta.is_finite() { + return Err(bad(ID, "`delta`/`size` must be a finite number")); + } + let new = resize_track(&sizes, index, delta, keep).ok_or_else(|| bad(ID, "invalid table geometry"))?; + let size = new.get(index).copied().unwrap_or(cur); + let scaled = keep && index + 1 == sizes.len(); + edit_table(s, &g, ID, |t| { + if column { + for (c, w) in t.columns.iter_mut().zip(&new) { + c.width = *w; + } + } else { + for (i, (r, h)) in t.rows.iter_mut().zip(&new).enumerate() { + // Only the rows the drag changed take the new height. + if scaled || i == index || (keep && i == index + 1) { + r.height = *h; + } + if i == index && !scaled { + r.mode = RowHeightMode::AtLeast; + } + } + } + Ok(json!({"axis": if column { "column" } else { "row" }, "index": index, "size": size})) + }) +} + fn distribute_rows(s: &mut Session, p: &Value) -> Result { const ID: &str = "table.distributeRows"; if !p.is_object() { @@ -1967,3 +2084,97 @@ mod arabic_tests { } } } + +#[cfg(test)] +mod resize_border_tests { + use serde_json::json; + + use crate::Session; + + /// A 3×3 table of 80 pt columns; returns the session, frame id, story and table ids. + fn session() -> (Session, u64, u64, u64) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 472, 600], "content": "text", "text": ""})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + let t = s.execute("table.insert", &json!({"rows": 3, "cols": 3, "width": 240})).unwrap(); + (s, r["id"].as_u64().unwrap(), t["story"].as_u64().unwrap(), t["table"].as_u64().unwrap()) + } + + fn table(s: &Session) -> designcraft_doc::Table { + (**s.doc().unwrap().doc.stories.values().flat_map(|st| st.tables.values()).next().unwrap()).clone() + } + + fn widths(s: &Session) -> Vec { + table(s).columns.iter().map(|c| c.width).collect() + } + + /// Composed cell rects of column 0, top to bottom. + fn rows(s: &Session) -> Vec { + let st = s.doc().unwrap(); + let sid = st.doc.stories.values().find(|x| !x.tables.is_empty()).unwrap().id; + let cs = s.cache.get(&st.doc, sid, None); + cs.frames.iter().flat_map(|f| &f.tables).flat_map(|f| &f.cells).filter(|c| c.col == 0).map(|c| c.rect).collect() + } + + fn resize(s: &mut Session, ids: (u64, u64, u64), mut p: serde_json::Value) { + p["frame"] = json!(ids.0); + p["story"] = json!(ids.1); + p["table"] = json!(ids.2); + s.execute("table.resizeBorder", &p).unwrap(); + } + + #[test] + fn column_border_drags_widen_the_table_or_keep_its_width() { + let (mut s, f, st, t) = session(); + resize(&mut s, (f, st, t), json!({"axis": "column", "index": 0, "delta": 20})); + assert_eq!(widths(&s), [100.0, 80.0, 80.0]); + resize(&mut s, (f, st, t), json!({"axis": "column", "index": 1, "delta": 30, "keepTableSize": true})); + assert_eq!(widths(&s), [100.0, 110.0, 50.0]); + // Clamped at the minimum; the neighbour takes only what is left. + resize(&mut s, (f, st, t), json!({"axis": "column", "index": 1, "delta": 500, "keepTableSize": true})); + assert_eq!(widths(&s), [100.0, 157.0, 3.0]); + resize(&mut s, (f, st, t), json!({"axis": "column", "index": 0, "delta": -1000})); + assert_eq!(widths(&s), [3.0, 157.0, 3.0]); + // Shift on the end edge scales every column. + resize(&mut s, (f, st, t), json!({"axis": "column", "index": 2, "size": 166, "keepTableSize": true})); + let w = widths(&s); + assert!((w.iter().sum::() - 326.0).abs() < 1e-9, "{w:?}"); + assert!((w[1] / w[0] - 157.0 / 3.0).abs() < 1e-9, "{w:?}"); + assert!(s.execute("table.resizeBorder", &json!({"table": t, "axis": "column", "index": 3, "delta": 1})).is_err()); + assert!(s.execute("table.resizeBorder", &json!({"table": t, "axis": "column", "index": 0})).is_err()); + } + + #[test] + fn row_drag_sets_at_least_and_moves_later_rows() { + let (mut s, f, st, t) = session(); + s.execute("table.setRowHeight", &json!({"table": t, "row": 0, "height": 30, "mode": "exactly"})).unwrap(); + let before = rows(&s); + resize(&mut s, (f, st, t), json!({"axis": "row", "index": 0, "delta": 12})); + let row = &table(&s).rows[0]; + assert_eq!((row.height, row.mode), (42.0, designcraft_doc::RowHeightMode::AtLeast)); + let after = rows(&s); + assert!((after[0].height() - 42.0).abs() < 1e-9); + assert!((after[1].y0 - before[1].y0 - 12.0).abs() < 1e-9 && (after[1].height() - before[1].height()).abs() < 1e-9); + // Below the minimum: clamped, never negative. + resize(&mut s, (f, st, t), json!({"axis": "row", "index": 0, "delta": -1000})); + assert_eq!(table(&s).rows[0].height, 3.0); + } + + #[test] + fn a_drag_is_one_undo_step_and_locked_frames_refuse() { + let (mut s, f, st, t) = session(); + let before = table(&s); + s.begin_interaction("Resize Column").unwrap(); + for d in [5.0, 15.0, 25.0] { + let p = json!({"frame": f, "story": st, "table": t, "axis": "column", "index": 0, "delta": d}); + s.preview("table.resizeBorder", p).unwrap(); + } + s.commit_interaction().unwrap(); + assert_eq!(widths(&s), [105.0, 80.0, 80.0]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(table(&s), before); + s.execute("object.setFlags", &json!({"ids": [f], "locked": true})).unwrap(); + assert!(s.execute("table.resizeBorder", &json!({"frame": f, "table": t, "axis": "column", "index": 0, "delta": 5})).is_err()); + } +} diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index e5a860704..6aaa12ce6 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -1331,3 +1331,57 @@ fn anchor_tools_show_pen_state_cursors() { assert_eq!(cursor("deleteAnchor"), Cursor::PenDelete); assert_eq!(cursor("convertDirection"), Cursor::PenConvert); } + +/// A text frame holding a 3×3 table of 80 pt columns, optionally rotated about its centre. +fn table_frame(degrees: f64) -> (Document, ItemId) { + let mut d = Document::new(&NewDocument::default()); + let layer = d.default_layer(); + let (id, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 400.0, 400.0), layer, "", ParaFormat::default()).unwrap(); + let tid = d.alloc(); + d.story_mut(sid).unwrap().insert_table(0, designcraft_doc::Table::new(tid, 3, 3, 0, 0, 240.0)); + d.item_mut(id).unwrap().xf = designcraft_geom::Affine::rotate_about(degrees.to_radians(), Point::new(250.0, 250.0)); + (d, id) +} + +#[test] +fn type_tool_drags_a_table_column_border() { + for degrees in [0.0, 90.0] { + let (d, id) = table_frame(degrees); + let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let it = d.item(id).unwrap(); + let sid = it.text_frame().unwrap().story; + let cs = c.get(&d, sid, None); + let cell = cs.frames[0].tables[0].cell(1, 0).unwrap(); + let xf = cx.item_canvas_xf(id).unwrap() * d.text_xf(it); + let on_border = xf * Point::new(cell.rect.x1 + 1.0, (cell.rect.y0 + cell.rect.y1) / 2.0); + let dragged = xf * Point::new(cell.rect.x1 + 21.0, (cell.rect.y0 + cell.rect.y1) / 2.0); + let mut t = create("type"); + let expected = if degrees == 0.0 { Cursor::ResizeH } else { Cursor::ResizeV }; + assert_eq!(t.cursor(&cx, on_border, Mods::default()), expected); + // A click with a 1 px wobble on the border changes nothing. + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, on_border.x, on_border.y)).is_empty()); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, on_border.x + 1.0, on_border.y)).is_empty()); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, on_border.x + 1.0, on_border.y)).is_empty()); + // A press on the border starts a resize, not a text selection. + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, on_border.x, on_border.y)).is_empty()); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, dragged.x, dragged.y)); + assert_eq!(a[0], Action::Begin("Resize Column".into())); + let Action::Preview(cmd, p) = &a[1] else { panic!("{a:?}") }; + assert_eq!( + (cmd.as_str(), &p["axis"], &p["index"], &p["frame"]), + ("table.resizeBorder", &serde_json::json!("column"), &serde_json::json!(0), &serde_json::json!(id.0)) + ); + assert!((p["delta"].as_f64().unwrap() - 20.0).abs() < 1e-6, "{p}"); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, dragged.x, dragged.y)), vec![Action::Commit]); + // Esc cancels a drag in progress. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, on_border.x, on_border.y)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, dragged.x, dragged.y)); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel]); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, dragged.x, dragged.y)).is_empty()); + // Inside the cell the Type tool places the caret as usual. + let inside = xf * Point::new(cell.rect.x0 + 20.0, (cell.rect.y0 + cell.rect.y1) / 2.0); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, inside.x, inside.y)); + assert!(matches!(&a[..], [Action::Exec(cmd, _)] if cmd == "text.placeCaret"), "{a:?}"); + } +} diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index 625d998f3..d495d7cf3 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -1,6 +1,7 @@ //! Type tool (T): click in a frame to place the caret, drag in a frame to select text, drag on //! empty canvas to draw a new text frame. Keys move the caret / delete; typing goes through -//! `text.insert` (sent by the UI as text input). +//! `text.insert` (sent by the UI as text input). Dragging a table's row or column border resizes +//! it (`table.resizeBorder`). //! //! IME: the marked text of a composition is a preview of `text.insert` at the caret (so it lays //! out in place like typed text), underlined, until the IME commits it (one `text.insert`, one @@ -38,6 +39,86 @@ pub struct TypeTool { /// The press selected a word, line, paragraph or story (several clicks): drags keep it. unit_selected: bool, preedit: Option, + /// A press on a table row or column border: a drag resizes it. + border: Option, +} + +/// A table border under the pointer, and the frame's canvas → inner-space mapping. +#[derive(Clone, Copy)] +struct BorderDrag { + frame: u64, + story: u64, + hit: compose::BorderHit, + /// Canvas → frame inner space (through rotations, scaling and turned spreads). + inv: Affine, + /// Press point in frame inner space. + start: Point, + begun: bool, + /// The border runs vertically on the canvas (column borders on an unrotated frame). + horizontal_drag: bool, +} + +/// The row or column border of a table near canvas point `pos`, in an unlocked text frame. +fn border_at(cx: &ToolContext, pos: Point) -> Option { + let (_, id) = cx.hit(pos)?; + let it = cx.doc.item(id)?; + if it.locked { + return None; + } + let sid = it.text_frame()?.story; + let story = cx.doc.story(sid)?; + if story.tables.is_empty() { + return None; + } + let xf = cx.item_canvas_xf(id)? * cx.doc.text_xf(it); + let det = xf.determinant().abs(); + if !(det.is_finite() && det > 1e-12) { + return None; + } + let inv = xf.inverse(); + let start = inv * pos; + let cs = cx.cache.get(cx.doc, sid, None); + let fi = cs.frames.iter().position(|f| f.frame == id)?; + let hit = compose::hit_border(&cs, fi, &story.tables, start, cx.tol(4.0) / det.sqrt())?; + let axis = if hit.column { Point::new(1.0, 0.0) } else { Point::new(0.0, 1.0) }; + let v = (xf * axis) - (xf * Point::ORIGIN); + Some(BorderDrag { frame: id.0, story: sid.0, hit, inv, start, begun: false, horizontal_drag: v.x.abs() >= v.y.abs() }) +} + +impl BorderDrag { + fn cursor(&self) -> Cursor { + if self.horizontal_drag { Cursor::ResizeH } else { Cursor::ResizeV } + } + + /// Begin (once) and preview the resize for the pointer at canvas `pos`. + fn drag(&mut self, pos: Point, keep: bool) -> Vec { + let d = (self.inv * pos) - self.start; + let mut delta = if self.hit.column { d.x } else { d.y }; + if self.hit.mirrored { + delta = -delta; + } + if !delta.is_finite() { + return vec![]; + } + let mut out = vec![]; + if !self.begun { + self.begun = true; + out.push(Action::Begin(if self.hit.column { "Resize Column" } else { "Resize Row" }.into())); + } + out.push(Action::Preview( + "table.resizeBorder".into(), + json!({ + "frame": self.frame, + "story": self.story, + "table": self.hit.table, + "axis": if self.hit.column { "column" } else { "row" }, + "index": self.hit.index, + "delta": delta, + "keepTableSize": keep, + }), + )); + out + } } /// Are whole rows or whole columns of a table selected? @@ -139,6 +220,13 @@ impl Tool for TypeTool { self.selecting = None; self.cell_drag = None; self.unit_selected = false; + self.border = None; + if ev.kind == PointerKind::Down + && let Some(b) = border_at(cx, ev.pos) + { + self.border = Some(b); + return vec![]; + } if let Some((_, id)) = cx.hit(ev.pos) && let Some(it) = cx.doc.item(id) && !matches!(it.content, designcraft_doc::Content::Graphic(_) | designcraft_doc::Content::Group { .. }) @@ -170,6 +258,13 @@ impl Tool for TypeTool { } PointerKind::Drag => { let Some(a) = self.start else { return vec![] }; + if let Some(b) = self.border.as_mut() { + // A click with a little wobble must not resize. + if !b.begun && (ev.pos - a).hypot() < cx.tol(3.0) { + return vec![]; + } + return b.drag(ev.pos, ev.mods.shift); + } if self.cell_drag.is_some() || self.unit_selected { return vec![]; } @@ -199,6 +294,9 @@ impl Tool for TypeTool { PointerKind::Up => { self.guides.clear(); let start = self.start.take(); + if let Some(b) = self.border.take() { + return if b.begun { vec![Action::Commit] } else { vec![] }; + } if let Some((fid, from)) = self.cell_drag.take() { let sp = cx.layout.spread_at(ev.pos).map(|(_, p)| p).unwrap_or(ev.pos); let moved = start.is_some_and(|a| (ev.pos - a).hypot() >= cx.tol(3.0)); @@ -228,6 +326,12 @@ impl Tool for TypeTool { } fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec { + if key == ToolKey::Escape + && let Some(b) = self.border.take() + { + self.start = None; + return if b.begun { vec![Action::Cancel] } else { vec![] }; + } // The IME owns the keys while it composes (the UI holds them back; this is a guard). if cx.selection.text.is_none() || self.preedit.is_some() { return vec![]; @@ -277,14 +381,14 @@ impl Tool for TypeTool { } fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { - match cx.hit(p) { - Some((_, id)) if cx.doc.item(id).is_some_and(|i| i.is_text_frame()) => Cursor::Text, - _ => Cursor::Text, + if let Some(b) = self.border.as_ref().copied().or_else(|| if self.start.is_none() { border_at(cx, p) } else { None }) { + return b.cursor(); } + Cursor::Text } fn busy(&self) -> bool { - self.drawing + self.drawing || self.border.is_some() } fn wants_text(&self, cx: &ToolContext) -> bool { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9c838827b..1b4582215 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -273,7 +273,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom. - PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. File › Export PDF… now opens an "Export PDF" options dialog (General / Compression / Marks and Bleeds / Advanced) and remembers the last-used settings across exports and sessions. - IDML interchange (`designcraft-idml`): export and import of swatches, styles, fonts, preferences, parent spreads, spreads/pages, frames, groups, images (embedded or linked), formatted threaded stories; attributes a file leaves unset take InDesign's defaults (Keep Lines Together at start/end of paragraph, 12 pt corners, paragraph rules in Text Color, auto-sizing from the centre, numbered lists restarting in each story, alternating table fills Black 20 % / None; a tint of −1 is the colour's own; frames take the fill, stroke, corners and Text Frame Options they don't write from their object style chain, then [None]) — opens in InDesign 2026 and round-trips InDesign-exported files (`file.exportIdml`, `file.openIdml`, File → Export IDML…, CLI `--in x.idml` / `--export x.idml`). -- Tables (M8): tables anchored in stories (header/footer/body rows, merged cells, per-edge strokes, fills + alternating fills, insets, vertical justification, at-least/exact row heights); composed into the text column (columns scale to fit, rows break across columns/frames with repeating headers/footers, overset); rendered, exported to PDF (real text) and IDML (export + import); Table menu, Table panel, Create Table dialog, caret/typing/Tab navigation and cell selection in cells; `table.*` commands. +- Tables (M8): tables anchored in stories (header/footer/body rows, merged cells, per-edge strokes, fills + alternating fills, insets, vertical justification, at-least/exact row heights); composed into the text column (columns scale to fit, rows break across columns/frames with repeating headers/footers, overset); rendered, exported to PDF (real text) and IDML (export + import); Table menu, Table panel, Create Table dialog, caret/typing/Tab navigation and cell selection in cells; Type tool drags of row and column borders (Shift keeps the table size, or scales every row/column on the outer edge; `table.resizeBorder`); `table.*` commands. - Hyperlinks (text or frames → URL / e-mail / page) and bookmarks, exported as PDF link annotations and outline. - EPUB 3 (reflowable) export: stories in reading order, CSS from paragraph/character styles, images, navigation (`file.exportEpub`, CLI `--export x.epub`). - Data Merge (one linked CSV, TSV, text, or Excel file; text, image, QR, and hyperlink placeholders; preview; a new document with one record per page or records tiled on a single page), spell checking (public-domain Moby list + document dictionary, suggestions), Step and Repeat (count or grid, with a previewing dialog), snippets, object styles, Numbering & Section Options.