// ============================================================================ // makepad-table: Notion-style manipulable table widget // ============================================================================ // // All six phases of the widget are implemented. See README.md for the status // table and for the cell-kind and solid-spec syntax. // // Phase 1: static rendering // - Background, header, grid lines, cell text via DrawColor + DrawText // - Same immediate-mode begin/end pattern as widgets/src/chart.rs // // Phase 2: hover + cell editing // - Hover row → highlight + green left-handle pill (matches video) // - Click/tap cell → overlays a TextInput at the cell rect, focus + cursor // - Enter/Escape/Tab keyboard flow for commit / cancel / move // // Phase 3: row and column context menus // - Own overlay drawn after end_turtle so it is not clipped to the table // - Hover tracking, Escape to close, click-outside to dismiss // - 6 row actions and 9 column actions, applied through the mutator API // // Phase 4: drag-reorder columns // - A header press arms a drag; it only becomes one past DRAG_THRESHOLD, // so a click still opens the menu // - Ghost over the carried column, insert marker at the target boundary // - Emits TableAction::ColumnMoved only when the index actually changed // // Phase 5: LaTeX cells // - CellKind::Latex on a column routes its cells through MathView // // Phase 6: 3D cells // - CellKind::Solid3d parses a short solid spec and strokes an isometric // wireframe with DrawVector // // Two parallel input paths: // Two parallel input paths: // 1. Mouse: Hit::FingerDown/Move/Up/HoverMove via event.hits_with_capture_overload // 2. Touch: raw Event::TouchUpdate with TouchState::{Start,Move,Stop,Stable} // (long-press detection for context menus on touch devices) // // Both paths route through hit_test() → begin_edit() so behaviour stays // consistent. The touch pattern mirrors the cad example's // handle_touch_interaction (orbit camera) but adapted for table interactions. // ============================================================================ use makepad_widgets::*; // TouchState lives at makepad_widgets::event::TouchState (re-exported from // event::finger::TouchState). The glob import above doesn't pull it in, so // we import it explicitly. Confirmed via compiler hint + portal_list.rs:4. use makepad_widgets::event::TouchState; use std::collections::HashMap; // ============================================================================ // Section 1 — Live DSL registration (script_mod!) // ============================================================================ // // Mirrors the structure of widgets/src/chart.rs script_mod! block. // mod.widgets.TableBase registers the Rust type with the script VM. // mod.widgets.Table sets the live DSL defaults (colours, layout, sub-widgets). script_mod! { use mod.prelude.widgets_internal.* use mod.widgets.* mod.widgets.TableBase = #(Table::register_widget(vm)) mod.widgets.Table = set_type_default() do mod.widgets.TableBase{ width: Fill height: Fill // ---- Drawing layers (mirrors ChartView's draw_depth convention) ---- // Layer 0: solid background draw_bg +: { draw_depth: 0.0 color: #xffffff } // Layer 0.5: thin grid lines (cell borders + header divider) draw_grid +: { draw_depth: 0.5 color: #xe6e6e6 } // Layer 0.6: header row tint draw_header_bg +: { draw_depth: 0.6 color: #xf7f7f5 } // Layer 0.7: row hover background draw_row_hover +: { draw_depth: 0.7 color: #xf5f5f5 } // Layer 0.8: green handle pill (Notion-style left edge of hovered row) draw_handle +: { draw_depth: 0.9 color: #x22c55e } // Layer 1.5: selection border around the editing cell draw_select +: { draw_depth: 1.5 color: #x16a34a } // Layer 2.0: column drag-reorder feedback (Phase 4). // `draw_drag_ghost` tints the header of the column being carried; // `draw_drag_marker` is the vertical bar showing where it will land. draw_drag_ghost +: { draw_depth: 2.0 color: #x60a5fa80 } draw_drag_marker +: { draw_depth: 2.1 color: #x2563eb } // Layer 3.0: cell body text draw_text +: { draw_depth: 3.0 color: #x1f2937 text_style: theme.font_regular { font_size: 13.0 } } // Layer 3.1: header text (bold, slightly muted) draw_header_text +: { draw_depth: 3.1 color: #x6b7280 text_style: theme.font_bold { font_size: 12.0 } } // ---- Phase 3: Context menu drawing layers ---- // Layer 4.0: menu background (white with shadow effect via border) draw_menu_bg +: { draw_depth: 4.0 color: #xffffff } // Layer 4.1: menu item hover highlight draw_menu_hover +: { draw_depth: 4.1 color: #xf3f4f6 } // Layer 4.2: menu item text draw_menu_text +: { draw_depth: 4.2 color: #x1f2937 text_style: theme.font_regular { font_size: 13.0 } } // Layer 4.3: menu border (subtle grey) draw_menu_border +: { draw_depth: 4.3 color: #xe5e7eb } // ---- Layout params ---- header_height: 38.0 row_height: 40.0 default_col_width: 180.0 handle_width: 16.0 cell_padding: Inset{left: 10.0, top: 6.0, right: 10.0, bottom: 6.0} // ---- Phase 6: 3D cell wireframe ---- draw_solid +: { draw_depth: 3.0 } draw_attachment_bg +: { draw_depth: 2.5 color: #xeef2ff } draw_attachment_text +: { draw_depth: 3.2 color: #x3730a3 text_style: theme.font_regular { font_size: 11.0 } } draw_solid_error +: { draw_depth: 3.0 color: #xb91c1c text_style: theme.font_regular { font_size: 10.0 } } // ---- Sub-widget: LaTeX cell renderer (Phase 5) ---- // One MathView, repositioned over each Latex-kind cell in turn — the // same pattern as cell_editor. A widget per cell would allocate a // glyph cache per cell; this reuses one. math_cell: mod.widgets.MathView { width: Fit height: Fit font_size: 11.0 color: #x1f2937 } // ---- Sub-widget: cell editor (Phase 2) ---- // A single TextInput instance that we reposition over the editing cell. // Drawn only when self.editing is Some (controlled by the Rust code, // not by a DSL visible flag — TextInput in makepad dev doesn't have one). cell_image: mod.widgets.Image { width: Fill height: Fill fit: Stretch } cell_editor: TextInput { is_read_only: false empty_text: "" width: Fill height: Fill padding: Inset{left: 10.0, top: 6.0, right: 10.0, bottom: 6.0} margin: 0.0 // Every colour state is pinned explicitly. `TextInput` blends // through `color_focus`, `color_hover`, `color_empty` and their // border equivalents, and anything left unset falls back to the // active theme — which is the dark one, since // `theme_desktop_dark::script_mod` is registered after the light // one and wins. Setting only `color` and `color_focus` left the // editor drawing near-white text on a dark inset the moment it // took focus, and the placeholder path (`color_empty`) put a // white background under it when the cell was cleared. // // The rule for the whole block: an editing cell should look // exactly like a non-editing cell with a caret in it. The caret // is the affordance; a colour change is not needed and only // reduces contrast. draw_bg +: { // No border. The 2px green selection rectangle is drawn // separately by `draw_select` in `draw_walk`, directly on top // of this widget's rect. A border here sat inside that and // read as a second, doubled frame. border_size: 0.0 border_color: #x00000000 border_color_hover: #x00000000 border_color_focus: #x00000000 border_color_down: #x00000000 border_color_empty: #x00000000 border_color_disabled: #x00000000 // `border_color_2*` is the gradient partner for each of the // above. Left unset it is a theme bevel, which shows as a // faint grey edge even with `border_size: 0.0` on some // backends. border_color_2: #x00000000 border_color_2_hover: #x00000000 border_color_2_focus: #x00000000 border_color_2_down: #x00000000 border_color_2_empty: #x00000000 border_color_2_disabled: #x00000000 // Plain white in every state, including `color_empty`, which // is what an emptied cell draws. // // `color_down` is the one that matters most and the one that // was missed first time round. The animator sets `down: 1.0` // for as long as the pointer is held on the widget, and // `draw_bg` blends toward `color_down` — so clicking into a // cell flashed the theme's light fill until the pointer moved // away and the state decayed. That is exactly the "goes white // on click, correct once I move the mouse" report. color: #xffffff color_hover: #xffffff color_focus: #xffffff color_down: #xffffff color_empty: #xffffff color_disabled: #xffffff // The `color_2*` family is the gradient partner for each fill // state. The shader only mixes them when `color_2.x > -0.5`, // and the default is a sentinel of -1.0 which disables the // gradient — but the theme sets the *others*, so any state // that turned the gradient on would pull a theme colour in. // Pinning all six keeps that impossible. color_2: #xffffff color_2_hover: #xffffff color_2_focus: #xffffff color_2_down: #xffffff color_2_empty: #xffffff color_2_disabled: #xffffff } draw_text +: { // The same ink as a cell that is not being edited // (`draw_text` below uses #x1f2937), in every state. This is // the fix for white-on-grey while typing: `get_color` mixes // toward `color_focus` on focus and `color_empty*` when the // field is empty, so leaving those unset meant the text // changed colour the moment the caret arrived. color: #x1f2937 color_hover: #x1f2937 color_focus: #x1f2937 color_down: #x1f2937 color_disabled: #x1f2937 color_empty: #x1f2937 color_empty_hover: #x1f2937 color_empty_focus: #x1f2937 text_style: theme.font_regular { font_size: 13.0 } } // The caret is the only signal that a cell is being edited, so it // has to be visible. `theme.color_text_cursor` is picked for the // dark theme's dark inset and nearly disappears on white. draw_cursor +: { color: #x1f2937 } // Selection highlight: a light green wash that matches the // selection rectangle, rather than the theme's blue. Kept pale // deliberately — the text on top of it is dark, and a saturated // highlight would reintroduce the contrast problem this change // exists to fix. draw_selection +: { color: #xbbf7d0 color_hover: #xbbf7d0 color_focus: #xbbf7d0 color_down: #xbbf7d0 color_empty: #xbbf7d0 color_disabled: #xbbf7d0 color_2: #xbbf7d0 color_2_hover: #xbbf7d0 color_2_focus: #xbbf7d0 color_2_down: #xbbf7d0 color_2_empty: #xbbf7d0 color_2_disabled: #xbbf7d0 } } } } // ============================================================================ // Section 2 — Data types // ============================================================================ #[derive(Clone, Debug, Default, PartialEq, Copy)] pub enum CellAlign { #[default] Left, Center, Right, } /// How the cells of a column are interpreted when drawn. /// /// A column property rather than a per-cell one, deliberately. Cells stay /// `String`, so `TableData` built before this existed keeps working and a /// host can still round-trip a table through plain text. A column of formulae /// is also the realistic case — a spreadsheet does not mix prose and LaTeX /// down one column — and it means the decision is made once per column /// instead of being re-derived for every cell on every frame. #[derive(Clone, Debug, Default, PartialEq, Eq, Copy)] pub enum CellKind { /// Plain text, drawn with `draw_text`. #[default] Text, /// LaTeX math, parsed and laid out by `makepad-latex-math` (Phase 5). /// /// Content that fails to parse is not silently blanked; see /// `LatexCell::render` for what is drawn instead. Latex, /// A CSG solid, described by the cell text and drawn as a wireframe /// preview (Phase 6). See [`parse_solid_spec`] for the accepted syntax. /// /// Requires the `csg` feature; without it the cell falls back to text, so /// a table using 3D cells still renders rather than failing to build. Solid3d, } #[derive(Clone, Debug)] pub struct TableColumn { pub id: String, pub title: String, pub width: f64, pub align: CellAlign, /// How this column's cells are interpreted. Defaults to `Text`, so /// existing callers are unaffected. pub kind: CellKind, } impl Default for TableColumn { fn default() -> Self { Self { id: String::new(), title: String::new(), width: 180.0, align: CellAlign::Left, kind: CellKind::Text, } } } #[derive(Clone, Debug, Default)] pub struct TableRow { pub id: String, pub cells: Vec, /// Height set by dragging this row's gutter handle, if any. /// /// `None` means "use the table default, or whatever an attachment needs". /// Storing the override per row rather than mutating a shared /// `row_height` is what makes item 5's resize actually per-row: dragging /// one row used to set the height of every row at once. pub height: Option, } #[derive(Clone, Debug, Default)] pub struct TableData { pub columns: Vec, pub rows: Vec, /// Attachments keyed by `(row, column)` (item 6). /// /// A side-table rather than a field on the cell, so `cells` stays /// `Vec` and every existing caller and serialisation is /// unaffected. A table with no attachments carries an empty map. pub attachments: std::collections::HashMap<(usize, usize), CellAttachment>, } impl TableData { pub fn attachment(&self, row: usize, col: usize) -> Option<&CellAttachment> { self.attachments.get(&(row, col)) } /// Attach something to a cell, replacing anything already there. pub fn set_attachment(&mut self, row: usize, col: usize, item: CellAttachment) { self.attachments.insert((row, col), item); } pub fn clear_attachment(&mut self, row: usize, col: usize) -> Option { self.attachments.remove(&(row, col)) } /// Shift attachment keys to follow a row insertion at `at`. /// /// Keys are positional, so anything that moves rows or columns has to /// move them too. Forgetting this is how an image ends up on the wrong /// row after an insert — the sort of bug that only shows up once someone /// has real data in the table. pub fn shift_attachments_for_row_insert(&mut self, at: usize) { self.attachments = self .attachments .drain() .map(|((r, c), v)| { if r >= at { ((r + 1, c), v) } else { ((r, c), v) } }) .collect(); } /// Drop attachments on a removed row and shift those below it up. pub fn shift_attachments_for_row_remove(&mut self, at: usize) { self.attachments = self .attachments .drain() .filter(|((r, _), _)| *r != at) .map(|((r, c), v)| if r > at { ((r - 1, c), v) } else { ((r, c), v) }) .collect(); } /// Move attachment keys to follow a column moving from `from` to `to`. pub fn shift_attachments_for_col_move(&mut self, from: usize, to: usize) { self.attachments = self .attachments .drain() .map(|((r, c), v)| { let nc = if c == from { to } else if from < c && c <= to { c - 1 } else if to <= c && c < from { c + 1 } else { c }; ((r, nc), v) }) .collect(); } } /// Actions emitted by the table widget. /// /// Listen for these in your app's `MatchEvent::handle_actions` impl: /// ```ignore /// for action in actions { /// if let Some(ta) = action.cast::() { /// match ta { /* ... */ } /// } /// } /// ``` #[derive(Clone, Debug)] pub enum TableAction { /// User clicked/tapped a cell (editor was opened). CellClicked { row: usize, col: usize }, /// User committed an edit (Enter / Tab / focus loss). CellEdited { row: usize, col: usize, new_value: String, }, /// Pointer is hovering over a row (handle area visible). RowHovered(Option), /// Pointer is hovering over a column header. ColHovered(Option), /// Row handle was clicked/tapped; the row context menu is now open. /// /// The widget handles the menu itself — this is a notification, not a /// request for the host to do something. RowMenuRequested(usize), /// Column header was clicked/tapped; the column context menu is now open. ColMenuRequested(usize), /// Long-press fired on a cell (touch devices) — opens cell context menu. LongPress { row: usize, col: usize }, /// A column was dragged to a new index (Phase 4). Emitted only when the /// index actually changed, so a host persisting column order does not /// have to filter no-op drags. ColumnMoved { from: usize, to: usize }, /// A column was resized by dragging its header handle (item 5). ColumnResized(usize), /// A row was resized by dragging its gutter handle (item 5). RowResized(usize), /// A header cell's title was edited (item 7). HeaderEdited { target: HeaderSelection, title: String, }, /// The user asked to copy a cell (item 6). The table has no clipboard; /// the host writes `text` to one. CopyRequested { row: usize, col: usize, text: String, }, /// The user asked to paste into a cell. The host answers with `set_cell`. PasteRequested { row: usize, col: usize }, /// An attachment provider accepted a request and will deliver later. AttachmentRequested { row: usize, col: usize, source: AttachmentSource, }, /// No provider could satisfy the request. Emitted rather than ignored, so /// a menu entry never appears to do nothing. AttachmentUnavailable { row: usize, col: usize, source: AttachmentSource, }, /// An attachment was removed from a cell. AttachmentRemoved { row: usize, col: usize }, /// An attached image was resized by dragging its corner anchor. ImageResized { row: usize, col: usize, height: f64 }, } impl Default for TableAction { fn default() -> Self { TableAction::CellClicked { row: 0, col: 0 } } } // ---- Internal interaction state ---- /// What a hit-test resolved to. Used by both mouse and touch paths. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum HitTarget { Cell(usize, usize), ColHeader(usize), /// The row-number gutter cell for this row (items 1 and 2). Acts as the /// row header: click to select, long-press for the menu. RowHeader(usize), /// The resize grab handle on a selected header (item 5). Checked before /// the header itself, so grabbing the handle never re-selects. ResizeHandle(HeaderSelection), /// The corner anchor of an attached image. Checked before the cell, or /// grabbing it would start an edit instead of a resize. ImageAnchor(usize, usize), /// Inside the table rect but not on any cell (e.g. blank area below rows). Empty, /// Outside the table rect entirely. Outside, } // ---- Phase 3: Context menu state ---- /// Which target a context menu was opened for. #[derive(Clone, Debug, PartialEq)] pub enum MenuTarget { Row(usize), Col(usize), /// A cell's own menu (item 6): copy, paste and attachments. Cell(usize, usize), } /// A single menu item. #[derive(Clone, Debug)] pub struct MenuItem { pub id: LiveId, pub label: String, } /// Tracks an open context menu. #[derive(Clone, Debug)] struct MenuState { target: MenuTarget, /// Top-left position of the menu in absolute screen coords. pos: DVec2, /// Items to display. items: Vec, /// Index of the hovered item (if any). hover: Option, } // ---- Phase 4: column drag-reorder state ---- /// Tracks a column being dragged by its header. /// /// A press on a header is ambiguous: it may open the column menu or begin a /// reorder. It is resolved by distance — the drag only becomes real once the /// pointer has travelled `DRAG_THRESHOLD` from where it went down, and until /// then the press is still a click. Without that, every menu open would jitter /// into a one-pixel drag and the menu would never appear. #[derive(Clone, Debug)] struct ColDrag { /// The column index being carried. Follows the column as it moves, so a /// drag across several positions keeps pointing at the same data. col: usize, /// Absolute position of the initial press, for the threshold test. start_abs: DVec2, /// Latest pointer position, used to place the ghost and the marker. last_abs: DVec2, /// False until the pointer clears `DRAG_THRESHOLD`; a press that never /// clears it is a click on release, not a reorder. active: bool, } /// What the user currently has selected by clicking a header. /// /// Selecting a header is distinct from editing a cell: it shows the resize /// affordance for that row or column and nothing else. Item 5. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum HeaderSelection { Row(usize), Col(usize), } /// An in-progress resize drag started from a selected header's handle. #[derive(Clone, Copy, Debug)] struct ResizeDrag { target: HeaderSelection, /// Pointer position when the drag began. start: f64, /// The row height or column width at that moment. start_size: f64, } impl ResizeDrag { /// Smallest a column or row may be dragged to. Below this the header /// stops being clickable and the user cannot recover it by dragging. const MIN_SIZE: f64 = 24.0; } /// A press in progress, used to fire a long press and to tell a click from a /// drag. /// /// One tracker for mouse and touch alike (item 9). The old code had a /// separate `TouchTracker` with its own long-press logic, so the two paths /// could — and did — diverge: long press opened a menu on touch and did /// nothing with a mouse. #[derive(Clone, Copy, Debug)] struct PressTracker { start_abs: DVec2, start_time: std::time::Instant, target: HitTarget, /// Set once the long press has fired, or once travel has ruled it out. /// Either way the release must not also be treated as a click. fired: bool, } /// How long a press must be held, without moving, to count as a long press. const LONG_PRESS: std::time::Duration = std::time::Duration::from_millis(500); /// Maximum gap between two taps on the same target for the second to count as /// a double tap (item 3, touch side). /// /// A mouse gets `tap_count` from the platform; a finger does not, so the /// interval is measured here. 400ms is the usual platform default and is /// comfortably shorter than `LONG_PRESS`, so a slow double tap becomes two /// separate taps rather than a long press. const DOUBLE_TAP_WINDOW: std::time::Duration = std::time::Duration::from_millis(400); /// Size of the square grab handle drawn at the end of a selected header. const RESIZE_HANDLE: f64 = 10.0; /// New size for a resize drag, clamped so a header can never be dragged /// smaller than it takes to grab it again. /// /// Split out because it is the whole of the resize arithmetic and needs no /// widget: `start_size + (current - start)`, floored. A resize that can /// produce a zero-width column is a resize that loses the column. fn resize_to(start_size: f64, start: f64, current: f64) -> f64 { (start_size + (current - start)).max(ResizeDrag::MIN_SIZE) } impl ColDrag { /// Shared by the mouse and touch paths so a pointer and a finger agree /// on what counts as a drag rather than a tap or a long press. const DRAG_THRESHOLD: f64 = 8.0; } // ============================================================================ // Phase 6 — 3D cells // ============================================================================ // // A cell holds a short textual description of a solid, and the table draws a // wireframe preview of it. Text in, geometry out: the table stays a table of // strings, so a 3D column still saves, loads and round-trips as text like // every other column. // // The preview is a wireframe rather than a shaded render. Shading needs a 3D // pass with its own camera, depth buffer and lighting shader — the CAD // viewport in this repo spends ~2,500 lines on exactly that — and a cell // forty pixels tall gains nothing from it. Edges projected to 2D and stroked // with DrawVector give a readable thumbnail with no pass of its own. /// A solid described by a cell's text. /// /// Deliberately small: these are the three primitives `makepad-csg` exposes /// directly, and a table cell is a thumbnail, not a modelling tool. Boolean /// combinations are representable in CSG but not in this syntax, because a /// cell wide enough to write one is wide enough to deserve a real editor. #[derive(Clone, Debug, PartialEq)] pub enum SolidSpec { Cube { sx: f64, sy: f64, sz: f64 }, Sphere { radius: f64 }, Cylinder { radius: f64, height: f64 }, } /// Why a cell's solid description could not be used. /// /// Carried rather than discarded so the cell can say what is wrong. A 3D cell /// that silently draws nothing is indistinguishable from an empty one, and a /// typo in a dimension would look like a rendering bug. // No `Eq`: the `NonPositive` variant carries an f64, and NaN is one of the // values it exists to report. #[derive(Clone, Debug, PartialEq)] pub enum SolidSpecError { /// Nothing but whitespace. Empty, /// The leading word is not a known primitive. UnknownShape(String), /// Wrong number of arguments for the named shape. WrongArity { shape: String, expected: usize, got: usize, }, /// An argument was not a number. NotANumber(String), /// A dimension was zero, negative, or not finite. A solid with a /// non-positive extent has no meaningful wireframe, and NaN would /// propagate silently through the projection into garbage coordinates. NonPositive(f64), } impl SolidSpecError { /// Short label drawn in the cell when a spec cannot be used. pub fn label(&self) -> String { match self { SolidSpecError::Empty => "[empty]".to_string(), SolidSpecError::UnknownShape(s) => format!("[unknown shape: {s}]"), SolidSpecError::WrongArity { shape, expected, got, } => format!("[{shape} wants {expected} args, got {got}]"), SolidSpecError::NotANumber(s) => format!("[not a number: {s}]"), SolidSpecError::NonPositive(v) => format!("[bad dimension: {v}]"), } } } /// Parse a cell's text into a solid description. /// /// Accepted forms, whitespace or comma separated and case-insensitive: /// /// ```text /// cube 10 20 30 a box with those extents /// cube 10 a uniform cube /// sphere 5 radius /// cylinder 3 12 radius, height /// ``` /// /// Every dimension must be finite and greater than zero. pub fn parse_solid_spec(text: &str) -> Result { let cleaned = text.replace(',', " "); let mut parts = cleaned.split_whitespace(); let Some(shape) = parts.next() else { return Err(SolidSpecError::Empty); }; let shape = shape.to_lowercase(); let mut args = Vec::new(); for raw in parts { let v: f64 = raw .parse() .map_err(|_| SolidSpecError::NotANumber(raw.to_string()))?; if !v.is_finite() || v <= 0.0 { return Err(SolidSpecError::NonPositive(v)); } args.push(v); } let arity = |expected: usize| SolidSpecError::WrongArity { shape: shape.clone(), expected, got: args.len(), }; match shape.as_str() { "cube" | "box" => match args.len() { 1 => Ok(SolidSpec::Cube { sx: args[0], sy: args[0], sz: args[0], }), 3 => Ok(SolidSpec::Cube { sx: args[0], sy: args[1], sz: args[2], }), _ => Err(arity(3)), }, "sphere" => match args.len() { 1 => Ok(SolidSpec::Sphere { radius: args[0] }), _ => Err(arity(1)), }, "cylinder" | "cyl" => match args.len() { 2 => Ok(SolidSpec::Cylinder { radius: args[0], height: args[1], }), _ => Err(arity(2)), }, other => Err(SolidSpecError::UnknownShape(other.to_string())), } } /// A 3D point, independent of whether the `csg` feature is on. /// /// The projection maths is the same either way, and keeping it feature-free /// means it stays under test in a build without `csg`. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Point3 { pub x: f64, pub y: f64, pub z: f64, } /// Unique edges of a solid's wireframe, in model space. /// /// Built directly from the spec rather than from a CSG mesh so the preview /// works without the `csg` feature and so the edge count stays small enough /// to stroke every frame. Sphere and cylinder are drawn as rings rather than /// as their full triangulation: a 40px cell cannot resolve hundreds of /// triangles, and stroking them all would cost more than the rest of the /// table put together. pub fn wireframe_edges(spec: &SolidSpec) -> Vec<(Point3, Point3)> { let p = |x: f64, y: f64, z: f64| Point3 { x, y, z }; match *spec { SolidSpec::Cube { sx, sy, sz } => { let (hx, hy, hz) = (sx * 0.5, sy * 0.5, sz * 0.5); let c = [ p(-hx, -hy, -hz), p(hx, -hy, -hz), p(hx, hy, -hz), p(-hx, hy, -hz), p(-hx, -hy, hz), p(hx, -hy, hz), p(hx, hy, hz), p(-hx, hy, hz), ]; // 4 bottom, 4 top, 4 uprights. [ (0, 1), (1, 2), (2, 3), (3, 0), (4, 5), (5, 6), (6, 7), (7, 4), (0, 4), (1, 5), (2, 6), (3, 7), ] .iter() .map(|&(a, b)| (c[a], c[b])) .collect() } SolidSpec::Sphere { radius } => { const SEGMENTS: usize = 16; let mut edges = Vec::with_capacity(SEGMENTS * 3); // Three great circles, one per axis plane. for axis in 0..3 { for i in 0..SEGMENTS { let t0 = (i as f64) / SEGMENTS as f64 * std::f64::consts::TAU; let t1 = ((i + 1) as f64) / SEGMENTS as f64 * std::f64::consts::TAU; let (a, b) = match axis { 0 => ( p(radius * t0.cos(), radius * t0.sin(), 0.0), p(radius * t1.cos(), radius * t1.sin(), 0.0), ), 1 => ( p(radius * t0.cos(), 0.0, radius * t0.sin()), p(radius * t1.cos(), 0.0, radius * t1.sin()), ), _ => ( p(0.0, radius * t0.cos(), radius * t0.sin()), p(0.0, radius * t1.cos(), radius * t1.sin()), ), }; edges.push((a, b)); } } edges } SolidSpec::Cylinder { radius, height } => { const SEGMENTS: usize = 16; let hz = height * 0.5; let mut edges = Vec::with_capacity(SEGMENTS * 3); for i in 0..SEGMENTS { let t0 = (i as f64) / SEGMENTS as f64 * std::f64::consts::TAU; let t1 = ((i + 1) as f64) / SEGMENTS as f64 * std::f64::consts::TAU; let (c0, s0) = (radius * t0.cos(), radius * t0.sin()); let (c1, s1) = (radius * t1.cos(), radius * t1.sin()); edges.push((p(c0, s0, -hz), p(c1, s1, -hz))); edges.push((p(c0, s0, hz), p(c1, s1, hz))); // Four uprights are enough to read the extrusion. if i % (SEGMENTS / 4) == 0 { edges.push((p(c0, s0, -hz), p(c0, s0, hz))); } } edges } } } /// Project model-space edges into a cell, isometrically. /// /// Isometric rather than perspective: a thumbnail wants a stable, readable /// silhouette more than it wants depth cues, and an isometric projection has /// no camera to configure and cannot degenerate. The result is scaled to fit /// `rect` with a small inset and centred, so a cell renders the same shape at /// any dimension and any column width. /// /// Returns an empty vector when the projection has no extent — a zero-size /// cell, or geometry that collapses to a point — rather than dividing by it. pub fn project_isometric( edges: &[(Point3, Point3)], rect: Rect, inset: f64, ) -> Vec<(DVec2, DVec2)> { if edges.is_empty() { return Vec::new(); } // Standard isometric basis: x and y go off at ±30 degrees, z is up. let iso = |p: Point3| { let a = std::f64::consts::FRAC_PI_6.cos(); let b = std::f64::consts::FRAC_PI_6.sin(); dvec2((p.x - p.y) * a, (p.x + p.y) * b - p.z) }; let flat: Vec<(DVec2, DVec2)> = edges.iter().map(|&(a, b)| (iso(a), iso(b))).collect(); let (mut min_x, mut min_y) = (f64::INFINITY, f64::INFINITY); let (mut max_x, mut max_y) = (f64::NEG_INFINITY, f64::NEG_INFINITY); for (a, b) in &flat { for v in [a, b] { min_x = min_x.min(v.x); min_y = min_y.min(v.y); max_x = max_x.max(v.x); max_y = max_y.max(v.y); } } let span_x = max_x - min_x; let span_y = max_y - min_y; let avail_w = rect.size.x - inset * 2.0; let avail_h = rect.size.y - inset * 2.0; if !(span_x.is_finite() && span_y.is_finite()) || avail_w <= 0.0 || avail_h <= 0.0 { return Vec::new(); } // A flat silhouette is legitimate — a cube seen edge-on has zero span in // one axis — so only the axis with extent constrains the scale. let scale_x = if span_x > f64::EPSILON { avail_w / span_x } else { f64::INFINITY }; let scale_y = if span_y > f64::EPSILON { avail_h / span_y } else { f64::INFINITY }; let scale = scale_x.min(scale_y); if !scale.is_finite() { return Vec::new(); } let cx = rect.pos.x + rect.size.x * 0.5; let cy = rect.pos.y + rect.size.y * 0.5; let mid_x = (min_x + max_x) * 0.5; let mid_y = (min_y + max_y) * 0.5; let place = |v: DVec2| dvec2(cx + (v.x - mid_x) * scale, cy + (v.y - mid_y) * scale); flat.iter().map(|&(a, b)| (place(a), place(b))).collect() } /// Something attached to a cell alongside its text (item 6). /// /// Cells stay `String` — the whole table serialises as text, and every /// existing caller builds `TableData` from strings. An attachment is a /// side-table keyed by position instead, so a table with no attachments costs /// nothing and round-trips exactly as it did. /// /// Paths rather than decoded bytes: the table does not own the file, it /// points at one. Decoding a PDF or a 12MP photo into every frame's draw call /// is the wrong place for that work, and the host usually already has the /// file in a cache of its own. #[derive(Clone, Debug, PartialEq)] pub enum CellAttachment { /// An image on disk. `intrinsic` is its pixel size when known, used to /// size the row; `None` means "not measured yet" and falls back to the /// default attachment height. Image { path: std::path::PathBuf, intrinsic: Option<(f64, f64)>, /// How the user has chosen to scale it (drag the corner anchor). sizing: ImageSizing, }, /// A PDF on disk. Shown as a labelled placeholder — rendering a page /// needs a PDF rasteriser, which this widget deliberately does not carry. Pdf { path: std::path::PathBuf, pages: Option, }, /// A geographic location. Location { latitude: f64, longitude: f64, label: String, }, } impl CellAttachment { /// Height this attachment wants, given the width available to it. /// /// An image keeps its aspect ratio up to `max_h`; everything else is a /// fixed-height chip. Returning a height rather than setting one keeps /// the layout decision in one place — `Table::row_height_for`. pub fn preferred_height(&self, available_w: f64, default_h: f64, max_h: f64) -> f64 { match self { CellAttachment::Image { intrinsic, sizing, .. } => { // The row grows to whatever the image will actually draw at, // so the two cannot disagree. `default_h` is the floor for an // unmeasured one. image_display_height(*sizing, *intrinsic, available_w, max_h).max(default_h) } CellAttachment::Pdf { .. } => default_h, // A location is a one-line chip; it never needs more than a row. CellAttachment::Location { .. } => default_h, } } /// Short label drawn on the attachment chip. pub fn label(&self) -> String { match self { CellAttachment::Image { path, .. } => file_label(path, "image"), CellAttachment::Pdf { path, pages } => match pages { Some(n) => format!("{} ({n}p)", file_label(path, "pdf")), None => file_label(path, "pdf"), }, CellAttachment::Location { latitude, longitude, label, } => { if label.trim().is_empty() { format!("{latitude:.4}, {longitude:.4}") } else { label.clone() } } } } } /// File name for an attachment chip, falling back to a kind when the path has /// no final component. fn file_label(path: &std::path::Path, fallback: &str) -> String { path.file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| fallback.to_string()) } /// How an attached image is scaled inside its cell. /// /// The user changes this by dragging the image's corner anchor. It is a /// display property, so it lives with the attachment rather than in widget /// state — reordering a column must carry the chosen size with it. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub enum ImageSizing { /// Fill the cell width, height following the aspect ratio. #[default] FitWidth, /// An explicit height in pixels, set by dragging the anchor. Fixed(f64), } /// Smallest an image may be dragged to, and the size of its corner anchor. /// /// The anchor has to stay reachable: an image dragged smaller than its own /// grab handle cannot be grabbed again, and the user would have to remove the /// attachment to recover. pub const IMAGE_MIN_HEIGHT: f64 = 32.0; pub const IMAGE_ANCHOR: f64 = 12.0; // The floor has to leave the anchor grabbable. Checked at compile time // because both are constants; a runtime assertion on them is pointless. const _: () = assert!(IMAGE_MIN_HEIGHT >= IMAGE_ANCHOR); /// Height an image should draw at, given its sizing mode and the room it has. /// /// Pulled out of the widget because it is the whole of the resize /// arithmetic and needs no `Cx`: an aspect-ratio fit, a user-chosen height, /// and a floor that keeps the anchor grabbable. pub fn image_display_height( sizing: ImageSizing, intrinsic: Option<(f64, f64)>, available_w: f64, max_h: f64, ) -> f64 { match sizing { ImageSizing::Fixed(h) => h.clamp(IMAGE_MIN_HEIGHT, max_h), ImageSizing::FitWidth => match intrinsic { Some((iw, ih)) if iw > 0.0 && ih > 0.0 => { (available_w * (ih / iw)).clamp(IMAGE_MIN_HEIGHT, max_h) } // Unmeasured: a square-ish default rather than a guess at the // real ratio, which would make the row jump once it loads. _ => IMAGE_MIN_HEIGHT.max(available_w * 0.5).min(max_h), }, } } /// An in-progress drag of an image's corner anchor. #[derive(Clone, Copy, Debug)] struct ImageDrag { row: usize, col: usize, /// Pointer y when the drag began. start_y: f64, /// The image's drawn height at that moment. start_h: f64, } /// Which capability a cell-menu attachment entry asked for. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum AttachmentSource { File, Camera, Location, } impl AttachmentSource { /// Human-readable name, for a host reporting that it is unavailable. pub fn label(&self) -> &'static str { match self { AttachmentSource::File => "file picker", AttachmentSource::Camera => "camera", AttachmentSource::Location => "location", } } } /// What a host can offer when the user picks "add" from a cell menu. /// /// The table cannot reach a camera, a GPS or a file dialog on its own without /// dragging `nigig-uikit` — and through it `nigig-core`, tokio, reqwest and /// matrix — into a widget that currently depends on `makepad-widgets` alone. /// So it names what it needs and the host supplies it. /// /// Every method returns `bool`: `true` means the provider has taken the /// request and will deliver the result later by calling /// `TableRef::set_attachment`. `false` means it cannot, and the table says so /// rather than appearing to work. pub trait CellAttachmentProvider { /// Choose an image or PDF from the file system. fn pick_file(&mut self, _cx: &mut Cx, _row: usize, _col: usize) -> bool { false } /// Capture a photo with the device camera. fn capture_photo(&mut self, _cx: &mut Cx, _row: usize, _col: usize) -> bool { false } /// Attach the current location. fn attach_location(&mut self, _cx: &mut Cx, _row: usize, _col: usize) -> bool { false } } /// Cumulative y-offsets of every row boundary. /// /// The mirror of `ColumnGeometry`, and it exists for the same reason: row /// positions used to be `index * row_height`, which is only correct while /// every row is the same height. Item 6 breaks that — a cell holding an image /// grows to fit it — so positions have to come from a running total instead /// of a multiplication. /// /// Kept as a plain value so the arithmetic is testable without a widget. #[derive(Clone, Debug, Default, PartialEq)] pub struct RowGeometry { /// y of each row boundary. Length is `rows + 1`. pub bounds: Vec, } impl RowGeometry { /// Recompute boundaries from a starting y and the per-row heights. pub fn recompute<'a>(&mut self, start_y: f64, heights: impl Iterator) { self.bounds.clear(); let mut y = start_y; self.bounds.push(y); for h in heights { y += h; self.bounds.push(y); } } pub fn len(&self) -> usize { self.bounds.len().saturating_sub(1) } pub fn is_empty(&self) -> bool { self.len() == 0 } /// Top edge of `row`, or the bottom of the last row when out of range. pub fn top(&self, row: usize) -> f64 { *self .bounds .get(row) .or_else(|| self.bounds.last()) .unwrap_or(&0.0) } /// Height of `row`, or zero when out of range. pub fn height(&self, row: usize) -> f64 { match (self.bounds.get(row), self.bounds.get(row + 1)) { (Some(a), Some(b)) => b - a, _ => 0.0, } } /// Which row contains `abs_y`, or `None` past the last one. /// /// A linear scan rather than a division, which is the whole point: with /// unequal heights there is no divisor. Row counts here are small enough /// that a scan costs less than maintaining a search structure. pub fn row_at(&self, abs_y: f64) -> Option { if self.bounds.len() < 2 || abs_y < self.bounds[0] { return None; } (0..self.len()).find(|&r| abs_y < self.bounds[r + 1]) } } /// Move column `from` into gap `to`, carrying its cells in every row. /// /// Returns the index the column ended up at, or `None` when the drop changes /// nothing. A free function rather than a method so the reorder can be tested /// without constructing a `Table`, which requires a live `Cx`. /// /// Rows shorter than the column list are left alone rather than padded: a /// host can hand us ragged data through `set_data`, and inventing cells to /// make the arithmetic tidy would be inventing data. pub fn reorder_columns(data: &mut TableData, from: usize, to: usize) -> Option { let dest = ColumnGeometry::shift_for_removal(from, to, data.columns.len())?; let col = data.columns.remove(from); data.columns.insert(dest, col); for row in &mut data.rows { if from < row.cells.len() { let cell = row.cells.remove(from); let at = dest.min(row.cells.len()); row.cells.insert(at, cell); } } // Attachment keys are positional and must follow the column (item 6). data.shift_attachments_for_col_move(from, dest); Some(dest) } // ---- Column geometry ---- /// Column boundary positions, split out from the widget so the reorder /// arithmetic can be tested. /// /// `Table` derives `Script` and `Widget`, which means it cannot be built /// without a live `Cx` — there is no `Default`, and constructing one in a unit /// test is not possible. The index maths behind drag-reorder is where the /// bugs live and it depends on nothing but a list of x-positions, so it lives /// here instead of on the widget. `Table` holds one of these and forwards to /// it. #[derive(Clone, Debug, Default, PartialEq)] pub struct ColumnGeometry { /// x-position of each column boundary. Length is `columns.len() + 1`. pub bounds: Vec, } impl ColumnGeometry { /// Recompute boundaries from a starting x and the column widths. pub fn recompute<'a>(&mut self, start_x: f64, widths: impl Iterator) { self.bounds.clear(); let mut x = start_x; self.bounds.push(x); for w in widths { x += w; self.bounds.push(x); } } /// Number of columns described. pub fn len(&self) -> usize { self.bounds.len().saturating_sub(1) } pub fn is_empty(&self) -> bool { self.len() == 0 } /// Which boundary a drag at `abs_x` would drop a column into. /// /// Returns an index in `0..=len()` — a *gap*, not a column, so the /// far-right position is representable. Each column claims the gap /// nearest its own midpoint: past the midpoint of column `i`, the drop /// goes after it. /// /// Deliberately independent of which column is being carried; /// `shift_for_removal` handles the index shift that lifting the source /// out causes. Keeping the two apart is what makes the off-by-one /// testable in isolation, and that off-by-one is the whole difficulty of /// drag-reorder. pub fn drop_index_at(&self, abs_x: f64) -> usize { let n = self.len(); if n == 0 { return 0; } for i in 0..n { let left = self.bounds[i]; let right = self.bounds[i + 1]; if abs_x < (left + right) * 0.5 { return i; } } n } /// Resolve a drop of column `from` into gap `to` to a destination index, /// or `None` when the move would change nothing. /// /// The subtlety: `to` is a gap measured on the table *before* the column /// is lifted out. Once `from` is removed every gap to its right shifts /// down by one, so dropping column 1 into gap 3 lands at index 2, and /// dropping into gap 1 or 2 does not move it at all. Getting this wrong /// gives a reorder that is correct dragging left and off by one dragging /// right — the classic symptom. pub fn shift_for_removal(from: usize, to: usize, len: usize) -> Option { if from >= len || to > len { return None; } if to == from || to == from + 1 { return None; } Some(if to > from { to - 1 } else { to }) } } impl MenuState { const ITEM_HEIGHT: f64 = 28.0; const MENU_WIDTH: f64 = 180.0; const PADDING: f64 = 4.0; fn height(&self) -> f64 { self.items.len() as f64 * Self::ITEM_HEIGHT + Self::PADDING * 2.0 } fn item_rect(&self, index: usize) -> Rect { Rect { pos: dvec2( self.pos.x + Self::PADDING, self.pos.y + Self::PADDING + index as f64 * Self::ITEM_HEIGHT, ), size: dvec2(Self::MENU_WIDTH - Self::PADDING * 2.0, Self::ITEM_HEIGHT), } } fn full_rect(&self) -> Rect { Rect { pos: self.pos, size: dvec2(Self::MENU_WIDTH, self.height()), } } fn hit_test(&self, abs: DVec2) -> Option { if !self.full_rect().contains(abs) { return None; } (0..self.items.len()).find(|&i| self.item_rect(i).contains(abs)) } } // ============================================================================ // Section 3 — Table widget struct // ============================================================================ // // Field attributes follow the ChartView convention: // #[uid] — widget UID (mandatory) // #[source] — script source ref (mandatory for script-driven widgets) // #[walk] — Walk (outer layout) // #[layout] — Layout (inner layout) // #[redraw] — area used for self.redraw(cx) // #[live] — settable from Live DSL, hot-reloadable // #[rust] — plain Rust state, not settable from DSL #[derive(Script, ScriptHook, Widget)] pub struct Table { #[uid] uid: WidgetUid, #[source] source: ScriptObjectRef, #[walk] walk: Walk, #[layout] layout: Layout, // ---- Drawing layers (mirror ChartView) ---- #[redraw] #[live] draw_bg: DrawColor, #[live] draw_grid: DrawColor, #[live] draw_header_bg: DrawColor, #[live] draw_row_hover: DrawColor, #[live] draw_handle: DrawColor, #[live] draw_select: DrawColor, // ---- Phase 4: column drag-reorder ---- // // This was declared as `draw_drag: DrawVector` and never used anywhere. // `DrawVector` is a full tessellator — path, vertex and index buffers, // paint state — which is far more machinery than a translucent rectangle // and a vertical bar need, and none of it was ever driven. Two // `DrawColor` layers replace it. #[live] draw_drag_ghost: DrawColor, #[live] draw_drag_marker: DrawColor, #[live] draw_text: DrawText, #[live] draw_header_text: DrawText, // ---- Phase 3: Context menu drawing layers ---- #[live] draw_menu_bg: DrawColor, #[live] draw_menu_hover: DrawColor, #[live] draw_menu_text: DrawText, #[live] draw_menu_border: DrawColor, // ---- Sub-widget for cell editing ---- #[live] cell_editor: TextInput, // ---- Phase 5: LaTeX cell rendering ---- /// Repositioned over each `CellKind::Latex` cell as it is drawn. #[live] math_cell: MathView, // ---- Phase 6: 3D cell rendering ---- /// Strokes the wireframe preview of each `CellKind::Solid3d` cell. #[live] draw_solid: DrawVector, /// Panel behind an attachment chip (item 6). #[live] draw_attachment_bg: DrawColor, /// Label on an attachment chip. #[live] draw_attachment_text: DrawText, /// Draws the `[reason]` marker when a cell's solid spec cannot be parsed. #[live] draw_solid_error: DrawText, // ---- Layout params ---- #[live(38.0)] header_height: f64, #[live(40.0)] row_height: f64, #[live(180.0)] default_col_width: f64, #[live(16.0)] handle_width: f64, #[live] cell_padding: Inset, // ---- Data ---- #[rust] data: TableData, #[rust(false)] initialized: bool, // ---- Interaction state ---- /// Mouse hover target (row index) — drives row hover highlight. #[rust] hover_row: Option, /// Mouse hover target (column index) — drives column header hover. #[rust] hover_col: Option, /// Currently-editing cell. When Some, the cell_editor is positioned /// over this cell's rect and given keyboard focus. #[rust] editing: Option<(usize, usize)>, /// Set by `begin_edit()` — picked up by the next `handle_event` after the /// editor has been drawn at least once (so its `area()` is valid), then /// used to call `cx.set_key_focus(editor_area)`. /// /// This deferred-focus pattern is needed because `set_key_focus` takes an /// `Area` (not a `WidgetUid`), and the editor's area is only populated /// after its first `draw_walk`. #[rust] needs_editor_focus: bool, // ---- Phase 3: Context menu state ---- /// Currently-open context menu (None = no menu visible). #[rust] menu: Option, // ---- Phase 4: column drag-reorder state ---- /// Column currently being dragged by its header (None = not dragging). #[rust] col_drag: Option, // ---- Item 5: header selection and resizing ---- /// Header selected by a single click. Drives the resize affordance. #[rust] header_selection: Option, /// Live resize drag started from a selected header's handle. #[rust] resize_drag: Option, // ---- Items 1 and 2: the row-number gutter ---- /// Width of the leading gutter column that numbers the rows. The gutter /// is not a data column: it has no entry in `data.columns` and cannot be /// reordered, edited or deleted. #[live(44.0)] gutter_width: f64, // ---- Item 7: header editing ---- /// Header cell being edited by long-press, if any. #[rust] editing_header: Option, /// The press currently being tracked, for long-press and click/drag /// discrimination. Shared by the mouse and touch paths. #[rust] press_started: Option, /// When and where the last tap landed, for touch double-tap detection. #[rust] last_tap: Option<(std::time::Instant, HitTarget)>, /// The image widget, repositioned over each attached cell as it is drawn. /// /// One widget rather than a pool, matching `cell_editor` and `math_cell`. /// A pool would let several textures live at once, but it needs runtime /// template instantiation and a reuse policy; with one widget the texture /// is swapped per cell from a cache this widget owns, which is the same /// number of GPU uploads and far less machinery. #[live] cell_image: Image, /// Decoded textures by cell, so a photo is read from disk once rather /// than every frame. `draw_walk` runs at 60Hz; re-decoding a 12MP JPEG /// there would be the slowest thing in the widget by a wide margin. #[rust] image_cache: HashMap<(usize, usize), Option>, /// Live drag of an image's corner anchor. #[rust] image_drag: Option, /// Host-supplied source of camera, location and file attachments (item 6). /// /// `None` by default: the widget works without one, and says so when a /// menu entry cannot be served rather than failing silently. #[rust] attachment_provider: Option>, // ---- Cached layout (recomputed each draw_walk) ---- #[rust] rect: Rect, /// x-position of each column boundary. Length = columns.len() + 1. #[rust] col_x: Vec, /// y-position of each row boundary. Length = rows.len() + 1. /// /// Recomputed with `col_x`. Rows are no longer uniform: a cell holding an /// image grows to fit it (item 6), so a position cannot be derived by /// multiplying an index. #[rust] row_y: Vec, /// Height of an attachment chip, and the floor for an image row. #[live(72.0)] attachment_height: f64, /// Ceiling for a row grown to fit an image, so one tall photo cannot make /// a row taller than the viewport. #[live(240.0)] attachment_max_height: f64, } // ============================================================================ // Section 4 — Widget impl // ============================================================================ impl Widget for Table { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { // ---- 0. Close menu on Escape (Phase 3) ---- if self.menu.is_some() { if let Event::KeyDown(ke) = event { if ke.key_code == KeyCode::Escape { self.menu = None; self.redraw(cx); return; } } } // ---- 1. Pre-filter control keys when editing ---- // We catch Return / Escape / Tab BEFORE forwarding to the cell_editor // so they commit / cancel / move instead of being inserted as text. // (KeyCode names verified against widgets/src/text_input.rs:2434,2473) let mut consumed_key = false; if self.editing.is_some() || self.editing_header.is_some() { if let Event::KeyDown(ke) = event { match ke.key_code { KeyCode::ReturnKey | KeyCode::Escape | KeyCode::Tab => { self.handle_control_key(cx, ke.key_code); consumed_key = true; } _ => {} } } } // ---- 2. Forward to cell editor (typing, IME, click inside editor) ---- if !consumed_key { self.cell_editor.handle_event(cx, event, scope); } // ---- 3. Mouse path (Hit::Finger*) ---- self.handle_mouse_interaction(cx, event); // ---- 4. Touch path (raw Event::TouchUpdate) ---- // Mirrors the cad example's handle_touch_interaction pattern. self.handle_touch_interaction(cx, event); // ---- 4b. Long press (item 4) ---- // Checked on every event, and a frame is requested while a press is // live. Neither a mouse nor a finger sends anything while held still, // so without the self-scheduled frame the long press would only fire // if the user happened to move. self.handle_press_timers(cx); if let Some(press) = self.press_started { if !press.fired { cx.new_next_frame(); } } // ---- 5. Deferred editor focus ---- // If begin_edit() was called since the last draw, the editor now has // a valid area (populated during draw_walk). Set keyboard focus. if self.needs_editor_focus { let editor_area = self.cell_editor.area(); if editor_area != Area::Empty { cx.set_key_focus(editor_area); self.needs_editor_focus = false; // Taking focus changes how the editor draws — the caret // appears and the focused colour states apply — but nothing // here had asked for another frame. The editor therefore kept // whatever it had painted before it was focused until some // unrelated event forced a redraw, which is why the text only // showed up after moving the pointer. self.redraw(cx); } } } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { // ---- Lazy init: seed demo data if empty (matches charts example pattern) ---- if !self.initialized { self.initialized = true; if self.data.columns.is_empty() { self.data = demo_data(); } } // ---- Compute geometry ---- self.rect = cx.walk_turtle(walk); self.compute_layout(); // ---- Layer 0: background ---- self.draw_bg.draw_abs(cx, self.rect); // ---- Begin clipping turtle so cell text doesn't overflow the table ---- cx.begin_turtle( Walk { abs_pos: Some(self.rect.pos), width: Size::Fixed(self.rect.size.x), height: Size::Fixed(self.rect.size.y), margin: Inset::default(), metrics: Default::default(), }, Layout { clip_x: true, clip_y: true, ..Default::default() }, ); // ---- Layer 0.5: grid lines (column dividers + row dividers) ---- self.draw_grid_lines(cx); // ---- Layer 0.6: header background ---- let header_rect = Rect { pos: self.rect.pos, size: dvec2(self.rect.size.x, self.header_height), }; self.draw_header_bg.draw_abs(cx, header_rect); // ---- Layer 0.7 + 0.9: row hover background + green handle pill ---- if let Some(r) = self.hover_row { if r < self.data.rows.len() { let row_rect = self.row_rect(r); self.draw_row_hover.draw_abs(cx, row_rect); let handle_rect = Rect { pos: dvec2(row_rect.pos.x, row_rect.pos.y + 4.0), size: dvec2(4.0, row_rect.size.y - 8.0), }; self.draw_handle.draw_abs(cx, handle_rect); } } // ---- Layer 3.1: header text ---- self.draw_header_text_all(cx); // ---- Items 1 and 2: the row-number gutter ---- self.draw_gutter(cx); // ---- Item 5: selected header outline and its resize handle ---- self.draw_header_selection(cx); // ---- Layer 3.0: cell text (skip the editing cell — editor draws it) ---- self.draw_cells(cx, scope); // ---- Layer 1.5: selection border around editing cell ---- if let Some((r, c)) = self.editing { let cell_rect = self.cell_rect(r, c); // 2px green border drawn as 4 thin rects (DrawColor can't stroke) let border = 2.0; self.draw_select.draw_abs( cx, Rect { pos: dvec2(cell_rect.pos.x, cell_rect.pos.y), size: dvec2(cell_rect.size.x, border), }, ); self.draw_select.draw_abs( cx, Rect { pos: dvec2(cell_rect.pos.x, cell_rect.pos.y + cell_rect.size.y - border), size: dvec2(cell_rect.size.x, border), }, ); self.draw_select.draw_abs( cx, Rect { pos: dvec2(cell_rect.pos.x, cell_rect.pos.y), size: dvec2(border, cell_rect.size.y), }, ); self.draw_select.draw_abs( cx, Rect { pos: dvec2(cell_rect.pos.x + cell_rect.size.x - border, cell_rect.pos.y), size: dvec2(border, cell_rect.size.y), }, ); } // ---- Cell editor overlay (positioned over the editing cell) ---- // // The same editor serves header edits (item 7); only the rect differs. let editor_rect = match (self.editing, self.editing_header) { (Some((r, c)), _) => Some(self.cell_rect(r, c)), (None, Some(HeaderSelection::Col(c))) if c < self.data.columns.len() => { Some(self.header_rect(c)) } _ => None, }; if let Some(cell_rect) = editor_rect { // draw_walk returns a Result<_, ()> that must be used (the unused_must_use // lint is on). Discard the result with `let _ =`. let _ = self.cell_editor.draw_walk( cx, scope, Walk { abs_pos: Some(cell_rect.pos), width: Size::Fixed(cell_rect.size.x), height: Size::Fixed(cell_rect.size.y), margin: Inset::default(), metrics: Default::default(), }, ); } // ---- Phase 3: Context menu overlay (drawn on top of everything) ---- // Drawn AFTER end_turtle so it's not clipped to the table rect. // The menu floats above the table content. cx.end_turtle(); // ---- Phase 4: column drag feedback, under the menu ---- if let Some(drag) = self.col_drag.clone() { if drag.active { self.draw_col_drag(cx, &drag); } } if let Some(menu) = self.menu.clone() { self.draw_menu(cx, &menu); } DrawStep::done() } // NOTE: We intentionally do NOT override `text()`, `set_text()`, or // `widget_uid()` here. The `#[derive(Widget)]` macro provides // `widget_uid()` automatically (using the `#[uid] uid` field), and the // Widget trait's default `text()` / `set_text()` are no-ops which is // fine for Table (we expose a typed API via TableRef instead). } // ============================================================================ // Section 5 — Table impl (geometry helpers) // ============================================================================ impl Table { /// Recompute column boundary x-positions from current data + rect. fn compute_layout(&mut self) { let widths: Vec = self.data.columns.iter().map(|c| c.width).collect(); let mut geometry = ColumnGeometry { bounds: std::mem::take(&mut self.col_x), }; // Data columns start after the gutter. Everything downstream — // hit-testing, cell rects, drop positions — reads `col_x`, so // offsetting the origin here is what keeps the gutter out of the // reorderable set rather than a special case at each site. geometry.recompute(self.rect.pos.x + self.gutter_width, widths.iter()); self.col_x = geometry.bounds; // Rows depend on the column widths just computed: an image is sized // against the width of the cell holding it, so this has to run second. let heights: Vec = (0..self.data.rows.len()) .map(|r| self.row_height_for(r)) .collect(); let mut rows = RowGeometry { bounds: std::mem::take(&mut self.row_y), }; rows.recompute(self.rect.pos.y + self.header_height, heights.iter()); self.row_y = rows.bounds; } /// Height row `r` needs: the base height, or enough for its tallest /// attachment (item 6). fn row_height_for(&self, r: usize) -> f64 { // A height the user dragged to wins outright. Growing a row past what // they chose because it holds an image would undo the drag on the // next layout, which reads as the handle not working. if let Some(h) = self.data.rows.get(r).and_then(|row| row.height) { return h.max(ResizeDrag::MIN_SIZE); } let base = self.row_height; let mut tallest = base; for c in 0..self.data.columns.len() { let Some(att) = self.data.attachment(r, c) else { continue; }; let avail = (self.col_x.get(c + 1).copied().unwrap_or(0.0) - self.col_x.get(c).copied().unwrap_or(0.0) - self.cell_padding.left - self.cell_padding.right) .max(1.0); let want = att.preferred_height(avail, self.attachment_height, self.attachment_max_height); tallest = tallest.max(want); } tallest } /// Geometry of the current rows, as a testable value. fn row_geometry(&self) -> RowGeometry { RowGeometry { bounds: self.row_y.clone(), } } /// Rect of the gutter cell numbering `row`. fn gutter_rect(&self, row: usize) -> Rect { let g = self.row_geometry(); Rect { pos: dvec2(self.rect.pos.x, g.top(row)), size: dvec2(self.gutter_width, g.height(row)), } } /// Rect of the empty corner box above the gutter. fn corner_rect(&self) -> Rect { Rect { pos: self.rect.pos, size: dvec2(self.gutter_width, self.header_height), } } /// Rect of a single cell (row, col) in absolute screen coords. fn cell_rect(&self, row: usize, col: usize) -> Rect { if row >= self.data.rows.len() || col >= self.data.columns.len() { return Rect::default(); } let x0 = self.col_x[col]; let x1 = self.col_x[col + 1]; let g = self.row_geometry(); Rect { pos: dvec2(x0, g.top(row)), size: dvec2(x1 - x0, g.height(row)), } } /// Rect of a full row (spans the table width). fn row_rect(&self, row: usize) -> Rect { if row >= self.data.rows.len() { return Rect::default(); } let g = self.row_geometry(); Rect { pos: dvec2(self.rect.pos.x, g.top(row)), size: dvec2(self.rect.size.x, g.height(row)), } } /// Rect of a column header. fn header_rect(&self, col: usize) -> Rect { if col >= self.data.columns.len() { return Rect::default(); } let x0 = self.col_x[col]; let x1 = self.col_x[col + 1]; Rect { pos: dvec2(x0, self.rect.pos.y), size: dvec2(x1 - x0, self.header_height), } } /// Which boundary a drag at `abs_x` would drop a column into. /// /// Returns an index in `0..=columns.len()` — a *gap*, not a column, so the /// far-right position is representable. Each column claims the gap nearest /// its own midpoint: past the midpoint of column `i`, the drop goes after /// it. /// /// This is deliberately independent of which column is being carried; /// `apply_col_drop` handles the index shift that removing the source /// causes. Keeping the two apart is what makes the off-by-one testable in /// isolation, and that off-by-one is the whole difficulty of drag-reorder. fn drop_index_at(&self, abs_x: f64) -> usize { self.geometry().drop_index_at(abs_x) } /// Current column boundaries as a testable value. fn geometry(&self) -> ColumnGeometry { ColumnGeometry { bounds: self.col_x.clone(), } } /// Move column `from` so that it sits at gap `to`, returning the index it /// ended up at, or `None` if the move is a no-op. /// /// The subtlety: `to` is a gap measured on the table *before* the column is /// lifted out. Once `from` is removed every gap to its right shifts down /// by one, so dropping column 1 into gap 3 lands at index 2, and dropping /// into gap 1 or 2 does not move it at all. Getting this wrong produces a /// reorder that is right when dragging left and off by one when dragging /// right, which is the classic symptom. fn move_column_to_gap(&mut self, from: usize, to: usize) -> Option { reorder_columns(&mut self.data, from, to) } /// Resolve an absolute screen position to a HitTarget. /// Used by both the mouse path and the touch path so behaviour stays uniform. fn hit_test(&self, abs: DVec2) -> HitTarget { if !self.rect.contains(abs) { return HitTarget::Outside; } // The resize handle of a selected header wins over everything. It // overlaps the header it belongs to, and if the header were tested // first the handle would be unreachable. if let Some(sel) = self.header_selection { if self.resize_handle_rect(sel).contains(abs) { return HitTarget::ResizeHandle(sel); } } let in_gutter = abs.x < self.rect.pos.x + self.gutter_width; let in_header_band = abs.y < self.rect.pos.y + self.header_height; // The corner box above the gutter belongs to neither axis. if in_gutter && in_header_band { return HitTarget::Empty; } if in_header_band { for c in 0..self.data.columns.len() { if self.header_rect(c).contains(abs) { return HitTarget::ColHeader(c); } } return HitTarget::Empty; } // Which row? A scan of the boundary table, because with unequal row // heights there is no divisor to use. let Some(row) = self.row_geometry().row_at(abs.y) else { return HitTarget::Empty; }; if row >= self.data.rows.len() { return HitTarget::Empty; } // The gutter is the row header (items 1 and 2). It replaces the old // Notion-style handle strip, which sat inside the first data cell and // stole clicks from it. if in_gutter { return HitTarget::RowHeader(row); } // Which column? for c in 0..self.data.columns.len() { if self.cell_rect(row, c).contains(abs) { // An image's corner anchor sits inside its cell and has to // win, or dragging it would open the editor instead. if let Some(att) = self.data.attachment(row, c) { if matches!(att, CellAttachment::Image { .. }) && self .image_anchor_rect(self.cell_rect(row, c), att) .contains(abs) { return HitTarget::ImageAnchor(row, c); } } return HitTarget::Cell(row, c); } } HitTarget::Empty } /// Rect of the resize grab handle for a selected header. /// /// For a column it sits at the right edge of the header, for a row at the /// bottom edge of the gutter cell — the edge each one is dragged from. fn resize_handle_rect(&self, sel: HeaderSelection) -> Rect { match sel { HeaderSelection::Col(c) => { let hr = self.header_rect(c); Rect { pos: dvec2( hr.pos.x + hr.size.x - RESIZE_HANDLE, hr.pos.y + (hr.size.y - RESIZE_HANDLE) * 0.5, ), size: dvec2(RESIZE_HANDLE, RESIZE_HANDLE), } } HeaderSelection::Row(r) => { let gr = self.gutter_rect(r); Rect { pos: dvec2( gr.pos.x + (gr.size.x - RESIZE_HANDLE) * 0.5, gr.pos.y + gr.size.y - RESIZE_HANDLE, ), size: dvec2(RESIZE_HANDLE, RESIZE_HANDLE), } } } } // ---- Drawing helpers ---- fn draw_grid_lines(&mut self, cx: &mut Cx2d) { // Vertical column dividers. // // Item 1: every boundary is drawn, including the last one, so the // rightmost column is closed rather than bleeding into the empty // space beside it. The old range stopped one short. for i in 1..self.col_x.len() { let x = self.col_x[i]; self.draw_grid.draw_abs( cx, Rect { pos: dvec2(x - 0.5, self.rect.pos.y), size: dvec2(1.0, self.rect.size.y), }, ); } // Horizontal: header bottom divider let y_header_bottom = self.rect.pos.y + self.header_height; self.draw_grid.draw_abs( cx, Rect { pos: dvec2(self.rect.pos.x, y_header_bottom - 0.5), size: dvec2(self.rect.size.x, 1.0), }, ); // Row dividers, from the boundary table rather than a multiple. let row_bounds = self.row_y.clone(); for y in row_bounds { self.draw_grid.draw_abs( cx, Rect { pos: dvec2(self.rect.pos.x, y - 0.5), size: dvec2(self.rect.size.x, 1.0), }, ); } } fn draw_header_text_all(&mut self, cx: &mut Cx2d) { let pad = self.cell_padding; for (c, col) in self.data.columns.iter().enumerate() { let hr = self.header_rect(c); let pos = dvec2(hr.pos.x + pad.left, hr.pos.y + pad.top + 2.0); self.draw_header_text.draw_abs(cx, pos, &col.title); } } /// Draw the leading gutter: a corner box, then a numbered cell per row. /// /// The number is the row's 1-based position, recomputed every frame from /// the index rather than stored, so inserting or reordering rows cannot /// leave it stale. fn draw_gutter(&mut self, cx: &mut Cx2d) { let pad = self.cell_padding; // Corner box above the gutter, styled as a header. let corner = self.corner_rect(); self.draw_header_bg.draw_abs(cx, corner); for r in 0..self.data.rows.len() { let gr = self.gutter_rect(r); self.draw_header_bg.draw_abs(cx, gr); let label = (r + 1).to_string(); let width = self .draw_header_text .layout(cx.cx, 0.0, 0.0, None, false, Align::default(), &label) .size_in_lpxs .width as f64; // Centred, because a row number is a label rather than content. let x = gr.pos.x + (gr.size.x - width) * 0.5; let y = gr.pos.y + pad.top + 2.0; self.draw_header_text.draw_abs(cx, dvec2(x, y), &label); } // Right-hand edge of the gutter, so it reads as a fixed column. self.draw_grid.draw_abs( cx, Rect { pos: dvec2(self.rect.pos.x + self.gutter_width - 0.5, self.rect.pos.y), size: dvec2(1.0, self.rect.size.y), }, ); } /// Outline the selected header and draw its resize grab handle (item 5). fn draw_header_selection(&mut self, cx: &mut Cx2d) { let Some(sel) = self.header_selection else { return; }; let band = match sel { HeaderSelection::Col(c) if c < self.data.columns.len() => self.header_rect(c), HeaderSelection::Row(r) if r < self.data.rows.len() => self.gutter_rect(r), // The selection outlived the row or column it referred to. _ => return, }; // A 2px outline around the selected header, drawn as four rects // because DrawColor cannot stroke. let t = 2.0; for edge in [ Rect { pos: band.pos, size: dvec2(band.size.x, t), }, Rect { pos: dvec2(band.pos.x, band.pos.y + band.size.y - t), size: dvec2(band.size.x, t), }, Rect { pos: band.pos, size: dvec2(t, band.size.y), }, Rect { pos: dvec2(band.pos.x + band.size.x - t, band.pos.y), size: dvec2(t, band.size.y), }, ] { self.draw_select.draw_abs(cx, edge); } // The grab handle. Drawn last so it sits above the outline. let handle = self.resize_handle_rect(sel); self.draw_handle.draw_abs(cx, handle); // While resizing, extend a guide the full length of the axis being // dragged, so the user can see the new size against the whole table // rather than just the header. if self.resize_drag.is_some() { let guide = match sel { HeaderSelection::Col(_) => Rect { pos: dvec2(band.pos.x + band.size.x - 1.0, self.rect.pos.y), size: dvec2(2.0, self.rect.size.y), }, HeaderSelection::Row(_) => Rect { pos: dvec2(self.rect.pos.x, band.pos.y + band.size.y - 1.0), size: dvec2(self.rect.size.x, 2.0), }, }; self.draw_select.draw_abs(cx, guide); } } fn draw_cells(&mut self, cx: &mut Cx2d, scope: &mut Scope) { let pad = self.cell_padding; // Collected first because drawing a Latex cell needs `&mut self` for // the shared MathView, which cannot be held while iterating // `self.data`. let latex_cells = self.collect_latex_cells(); // Item 1: iterate the full grid, not each row's own cell count. A row // shorter than the column list used to simply stop, leaving the // remaining columns with no cell at all — no background, no border, // nothing to click. Every (row, column) position now exists visually // whether or not it holds text. let col_count = self.data.columns.len(); for r in 0..self.data.rows.len() { for c in 0..col_count { let cell = self.data.rows[r].cells.get(c).cloned().unwrap_or_default(); let cell = cell.as_str(); // Skip the editing cell — the cell_editor overlay draws it. if Some((r, c)) == self.editing { continue; } // Latex columns are drawn in the pass below. if self.column_kind(c) == CellKind::Latex { continue; } let cr = self.cell_rect(r, c); let text_y = cr.pos.y + pad.top + 2.0; // Measure with the layouter that will draw the run, rather // than estimating. A per-character estimate is what let // right-aligned text keep overflowing: it fell short of the // real glyph widths, so the run was positioned too far right // and ran past the cell edge. let available = cell_text_space(&cr, &pad); let draw_text = &self.draw_text; let shown = fit_text_measured(cell, available, |candidate| { draw_text .layout(cx.cx, 0.0, 0.0, None, false, Align::default(), candidate) .size_in_lpxs .width as f64 }); // Align the string that will actually be drawn, measured the // same way. Aligning the original and drawing a shorter one // positions the run by a width it no longer has. let shown_width = self .draw_text .layout(cx.cx, 0.0, 0.0, None, false, Align::default(), &shown) .size_in_lpxs .width as f64; let text_x = align_text_x(self.column_align(c), &cr, shown_width, &pad); self.draw_text.draw_abs(cx, dvec2(text_x, text_y), &shown); } } for (rect, source) in latex_cells { self.draw_latex_cell(cx, scope, rect, &source); } // Item 6: attachments, drawn over the cell they belong to. let attached: Vec<((usize, usize), CellAttachment)> = self .data .attachments .iter() .filter(|((r, c), _)| { *r < self.data.rows.len() && *c < self.data.columns.len() && Some((*r, *c)) != self.editing }) .map(|(k, v)| (*k, v.clone())) .collect(); for ((r, c), att) in attached { let rect = self.cell_rect(r, c); match &att { CellAttachment::Image { .. } => self.draw_cell_image(cx, scope, r, c, rect, &att), // A PDF page needs a rasteriser this widget does not carry, // and a location is a one-line fact. Both stay as chips. _ => self.draw_attachment_chip(cx, rect, &att.label()), } } for (rect, source) in self.collect_cells_of_kind(CellKind::Solid3d) { self.draw_solid_cell(cx, rect, &source); } } /// Rects and sources of every cell of `kind` that needs drawing. fn collect_cells_of_kind(&self, kind: CellKind) -> Vec<(Rect, String)> { let mut out = Vec::new(); for (r, row) in self.data.rows.iter().enumerate() { for (c, cell) in row.cells.iter().enumerate() { if Some((r, c)) == self.editing || self.column_kind(c) != kind { continue; } out.push((self.cell_rect(r, c), cell.clone())); } } out } /// Area an attached image occupies inside its cell. /// /// Below the single line of cell text, so an image never hides it. fn image_rect(&self, cell: Rect, att: &CellAttachment) -> Rect { let pad = self.cell_padding; let text_line = self.row_height.min(cell.size.y); let avail_w = (cell.size.x - pad.left - pad.right).max(1.0); let h = match att { CellAttachment::Image { intrinsic, sizing, .. } => image_display_height(*sizing, *intrinsic, avail_w, self.attachment_max_height), _ => self.attachment_height, }; Rect { pos: dvec2(cell.pos.x + pad.left, cell.pos.y + text_line), size: dvec2(avail_w, h), } } /// The grab anchor at the bottom-right of an image, for resizing. fn image_anchor_rect(&self, cell: Rect, att: &CellAttachment) -> Rect { let r = self.image_rect(cell, att); Rect { pos: dvec2( r.pos.x + r.size.x - IMAGE_ANCHOR, r.pos.y + r.size.y - IMAGE_ANCHOR, ), size: dvec2(IMAGE_ANCHOR, IMAGE_ANCHOR), } } /// Draw an attached image, decoding it from disk on first sight. /// /// The decode follows the pattern `pageflipnav/src/utils.rs` uses (the /// Robrix-derived app in this repo): try PNG, then JPEG, because a /// header sniff is not reliable enough to pick on its own. The result is /// cached per cell — `draw_walk` runs every frame, and re-reading a photo /// from disk sixty times a second would dominate the widget. /// /// A cache entry of `None` records a file that could not be decoded, so /// a broken path is attempted once rather than on every frame. fn draw_cell_image( &mut self, cx: &mut Cx2d, scope: &mut Scope, row: usize, col: usize, cell: Rect, att: &CellAttachment, ) { let CellAttachment::Image { path, .. } = att else { return; }; let rect = self.image_rect(cell, att); if rect.size.y <= 1.0 { return; } if !self.image_cache.contains_key(&(row, col)) { let loaded = std::fs::read(path).ok().and_then(|bytes| { decode_png_or_jpg(&mut self.cell_image, cx.cx, &bytes).ok()?; // Record the real pixel size so the row is sized from the // true aspect ratio rather than the placeholder guess. if let Some((w, h)) = self.cell_image.size_in_pixels(cx.cx) { if let Some(CellAttachment::Image { intrinsic, .. }) = self.data.attachments.get_mut(&(row, col)) { if *intrinsic != Some((w as f64, h as f64)) { *intrinsic = Some((w as f64, h as f64)); } } } self.cell_image.get_texture(0).clone() }); self.image_cache.insert((row, col), loaded); // The row may need to grow now the real ratio is known. self.compute_layout(); } let Some(Some(texture)) = self.image_cache.get(&(row, col)).cloned() else { // Unreadable or undecodable: say which file rather than leaving a // silent gap the user cannot explain. self.draw_attachment_chip(cx, cell, &att.label()); return; }; self.cell_image.set_texture(Some(texture), 0); let _ = self.cell_image.draw_walk( cx, scope, Walk { abs_pos: Some(rect.pos), width: Size::Fixed(rect.size.x), height: Size::Fixed(rect.size.y), margin: Inset::default(), metrics: Default::default(), }, ); // The resize anchor, over the bottom-right corner. let anchor = self.image_anchor_rect(cell, att); self.draw_handle.draw_abs(cx, anchor); } /// Draw an attachment chip filling the cell below its text (item 6). /// /// A labelled panel rather than the image itself. Decoding and uploading /// a photo per frame belongs in a texture cache the host owns, and a PDF /// page needs a rasteriser this widget does not carry. The chip shows /// what is attached and how much room it has been given; a host that /// wants a thumbnail draws one over the same rect. fn draw_attachment_chip(&mut self, cx: &mut Cx2d, cell: Rect, label: &str) { let pad = self.cell_padding; // Below the single line of text, so an attachment never hides it. let text_line = self.row_height.min(cell.size.y); let top = cell.pos.y + text_line; let height = cell.size.y - text_line - pad.bottom; if height <= 1.0 { return; } let panel = Rect { pos: dvec2(cell.pos.x + pad.left, top), size: dvec2((cell.size.x - pad.left - pad.right).max(1.0), height), }; self.draw_attachment_bg.draw_abs(cx, panel); let shown = fit_text_measured(label, panel.size.x - 8.0, |candidate| { self.draw_attachment_text .layout(cx.cx, 0.0, 0.0, None, false, Align::default(), candidate) .size_in_lpxs .width as f64 }); let shown_w = self .draw_attachment_text .layout(cx.cx, 0.0, 0.0, None, false, Align::default(), &shown) .size_in_lpxs .width as f64; let x = panel.pos.x + (panel.size.x - shown_w) * 0.5; let y = panel.pos.y + (panel.size.y - 14.0) * 0.5; self.draw_attachment_text.draw_abs(cx, dvec2(x, y), &shown); } /// Draw one 3D cell as an isometric wireframe. /// /// A spec that cannot be parsed draws its reason instead of nothing, for /// the same reason the LaTeX path does: an empty cell and a broken one /// must not look identical. fn draw_solid_cell(&mut self, cx: &mut Cx2d, rect: Rect, source: &str) { if source.trim().is_empty() { return; } const INSET: f64 = 4.0; let spec = match parse_solid_spec(source) { Ok(spec) => spec, Err(err) => { let pad = self.cell_padding; self.draw_solid_error.draw_abs( cx, dvec2(rect.pos.x + pad.left, rect.pos.y + pad.top + 2.0), &err.label(), ); return; } }; let edges = wireframe_edges(&spec); let projected = project_isometric(&edges, rect, INSET); if projected.is_empty() { return; } self.draw_solid.begin(); self.draw_solid.set_color(0.12, 0.16, 0.22, 1.0); for (a, b) in projected { self.draw_solid.move_to(a.x as f32, a.y as f32); self.draw_solid.line_to(b.x as f32, b.y as f32); } self.draw_solid.stroke(1.0); self.draw_solid.end(cx); } /// Rects and sources of every `Latex` cell that needs drawing this frame. fn collect_latex_cells(&self) -> Vec<(Rect, String)> { let mut out = Vec::new(); for (r, row) in self.data.rows.iter().enumerate() { for (c, cell) in row.cells.iter().enumerate() { if Some((r, c)) == self.editing || self.column_kind(c) != CellKind::Latex { continue; } out.push((self.cell_rect(r, c), cell.clone())); } } out } /// Draw one LaTeX cell by repositioning the shared `MathView`. /// /// Empty content draws nothing, which is what an empty cell should look /// like. Anything else is handed to `MathView`; when the expression does /// not parse, `MathView` draws its own `[reason]` marker rather than /// going blank, so a typo in a formula is visible in the cell instead of /// silently erasing it. fn draw_latex_cell(&mut self, cx: &mut Cx2d, scope: &mut Scope, rect: Rect, source: &str) { if source.trim().is_empty() { return; } let pad = self.cell_padding; self.math_cell.set_text(cx, source); let _ = self.math_cell.draw_walk( cx, scope, Walk { abs_pos: Some(dvec2(rect.pos.x + pad.left, rect.pos.y + pad.top)), // `Fit` so the glyph run keeps its natural size; a Fixed walk // would stretch the layout to the cell and distort the maths. width: Size::Fit { min: None, max: None, }, height: Size::Fit { min: None, max: None, }, margin: Inset::default(), metrics: Default::default(), }, ); } /// Interpretation of column `c`, defaulting to `Text` when out of range. fn column_kind(&self, c: usize) -> CellKind { self.data .columns .get(c) .map(|col| col.kind) .unwrap_or_default() } /// Alignment of column `c`, defaulting when out of range. fn column_align(&self, c: usize) -> CellAlign { self.data .columns .get(c) .map(|col| col.align) .unwrap_or_default() } } /// Decode PNG or JPEG bytes into an `Image`, sniffing the format first. /// /// Adopted from `pageflipnav/src/utils.rs::load_png_or_jpg` — the /// Robrix-derived app in this repo — rather than reinvented. Two things it /// gets right that a naive try-PNG-then-JPEG does not: /// /// - It reads the header and calls the matching loader directly, so the /// common case does not decode-and-fail once before succeeding. /// - It still falls back to trying both when the sniff says something /// unexpected, because `imghdr` is not perfect and a mislabelled file is /// more useful decoded than refused. /// /// The error is deliberately not logged here. This runs from the draw path /// for every attached cell, and a broken file would otherwise print once per /// frame; the caller caches the failure and draws a labelled chip instead, /// which tells the user which file it was. fn decode_png_or_jpg(image: &mut Image, cx: &mut Cx, data: &[u8]) -> Result<(), ImageError> { // `ImageCacheImpl` takes a slot id; `Image` has a single slot, so 0. fn attempt_both(image: &mut Image, cx: &mut Cx, data: &[u8]) -> Result<(), ImageError> { image .load_png_from_data(cx, data, 0) .or_else(|_| image.load_jpg_from_data(cx, data, 0)) } match imghdr::from_bytes(data) { Some(imghdr::Type::Png) => image.load_png_from_data(cx, data, 0), Some(imghdr::Type::Jpeg) => image.load_jpg_from_data(cx, data, 0), // A format neither loader handles, or one the sniffer could not // name: try anyway before giving up. Some(_) | None => attempt_both(image, cx, data).map_err(|_| ImageError::UnsupportedFormat), } } /// Ellipsis appended to text that has been shortened to fit its cell. const ELLIPSIS: char = '\u{2026}'; /// Left edge for a cell's text under the given alignment. /// /// `text_width` must be the **measured** width of the exact string being /// drawn (`DrawText::layout(..).size_in_lpxs.width`). It used to be estimated /// at 7px per character, which is why right-aligned text still overflowed /// after the previous fix: real glyphs at this size average wider than 7px, /// so the estimate came out short, the run was placed too far right, and it /// ran past the cell edge. No fixed estimate can be safe — under-measuring /// overflows, over-measuring leaves a gap — so the caller measures for real. /// /// The result is clamped to the padded left edge. Without that, a string /// wider than its column yields a negative offset for centre and right /// alignment and the run spills backwards over the column to its left. /// /// This positions the run; `fit_text_measured` shortens it. `draw_cells` /// applies both, in that order. fn align_text_x(align: CellAlign, cell_rect: &Rect, text_width: f64, pad: &Inset) -> f64 { let left = cell_rect.pos.x + pad.left; let x = match align { CellAlign::Left => left, CellAlign::Center => cell_rect.pos.x + (cell_rect.size.x - text_width) * 0.5, CellAlign::Right => cell_rect.pos.x + cell_rect.size.x - pad.right - text_width, }; x.max(left) } /// Horizontal space a cell has for text, after padding. Never negative. fn cell_text_space(cell_rect: &Rect, pad: &Inset) -> f64 { (cell_rect.size.x - pad.left - pad.right).max(0.0) } /// Shorten `text` until its measured width fits `available`, appending an /// ellipsis when anything was removed. /// /// `measure` must return the real rendered width of the string it is given. /// Positioning alone is not enough: clamping stops an overlong right-aligned /// run spilling *left*, but it would still run off the right edge into the /// next column — the same collision from the other side. /// /// `DrawText` can truncate itself via `text_overflow: Ellipsis`, but only /// through `draw_walk`, which needs a turtle; these cells are drawn /// absolutely with `draw_abs`. Doing the search here against the same /// measurement the positioning uses means the two cannot disagree. /// /// Binary search rather than a character walk: laying out a string is not /// free, and a long value in a wide column would otherwise be measured once /// per character every frame. fn fit_text_measured(text: &str, available: f64, mut measure: impl FnMut(&str) -> f64) -> String { if available <= 0.0 { return String::new(); } if measure(text) <= available { return text.to_string(); } let chars: Vec = text.chars().collect(); // Largest prefix whose text plus an ellipsis still fits. // // Written as a bounded `for` rather than `while lo < hi`. The bisection // only terminates if the "keep" branch advances `lo` to `mid` and the // "drop" branch retreats `hi` below it; an off-by-one in either — say // `lo = mid - 1` — makes the interval stop shrinking and the loop spins // forever. That is a hung frame rather than a wrong pixel, and no test // can catch it without a timeout, so the iteration count is capped at the // number a correct bisection can possibly need. let (mut lo, mut hi) = (0usize, chars.len()); let max_steps = usize::BITS as usize - chars.len().leading_zeros() as usize + 2; for _ in 0..max_steps { if lo >= hi { break; } let mid = (lo + hi).div_ceil(2); let mut candidate: String = chars[..mid].iter().collect(); candidate.push(ELLIPSIS); if measure(&candidate) <= available { lo = mid; } else { hi = mid - 1; } } let mut out: String = chars[..lo].iter().collect(); out.push(ELLIPSIS); // Too narrow for even the ellipsis: draw nothing rather than a glyph // wider than the space it was given. if measure(&out) > available { return String::new(); } out } // ============================================================================ // Section 6 — Mouse interaction (Hit::Finger*) // ============================================================================ // // Pattern taken straight from widgets/src/chart.rs ChartView::handle_event: // event.hits_with_capture_overload(cx, self.draw_bg.area(), true) // returns Hit::FingerDown/Move/Up/HoverMove/HoverOut/Scroll. impl Table { fn handle_mouse_interaction(&mut self, cx: &mut Cx, event: &Event) { // ---- Phase 3: Menu takes priority if open ---- // If the context menu is open, all mouse events go to the menu first. if self.menu.is_some() { self.handle_menu_mouse(cx, event); return; } let area = self.draw_bg.area(); match event.hits_with_capture_overload(cx, area, true) { Hit::FingerDown(fe) if fe.is_primary_hit() => { let target = self.hit_test(fe.abs); self.press_started = Some(PressTracker { start_abs: fe.abs, start_time: std::time::Instant::now(), target, fired: false, }); match target { // Item 5: grabbing a selected header's handle starts a // resize immediately. There is nothing ambiguous about // pressing a handle. HitTarget::ResizeHandle(sel) => { self.begin_resize(sel, fe.abs); } // Dragging an image's corner resizes it. HitTarget::ImageAnchor(r, c) => { self.begin_image_resize(r, c, fe.abs); } // Phase 4: a press on a column header arms a reorder drag // but does not commit to one; it resolves on release. HitTarget::ColHeader(c) => { self.col_drag = Some(ColDrag { col: c, start_abs: fe.abs, last_abs: fe.abs, active: false, }); } // Item 3: a cell no longer edits on a single press. The // second click of a double-click does that, below. // Item 4: headers open their menu on a long press, which // the timer in `handle_press_timers` fires. _ => {} } // Item 3: double-click to edit. if fe.tap_count >= 2 { if let HitTarget::Cell(r, c) = target { self.col_drag = None; self.press_started = None; self.begin_edit(cx, r, c); cx.widget_action(self.uid, TableAction::CellClicked { row: r, col: c }); } } } Hit::FingerMove(fe) => { // Item 5: a live resize follows the pointer. if let Some(drag) = self.resize_drag { self.apply_resize(cx, drag, fe.abs); self.cancel_press(); return; } if let Some(drag) = self.image_drag { self.apply_image_resize(cx, drag, fe.abs); self.cancel_press(); return; } // Any travel cancels a pending long press, so a drag is never // also a long press. if let Some(p) = &mut self.press_started { if (fe.abs - p.start_abs).length() > ColDrag::DRAG_THRESHOLD { p.fired = true; } } // Phase 4: promote an armed press to a live drag once it has // travelled far enough, then track the insert position. if let Some(drag) = &mut self.col_drag { drag.last_abs = fe.abs; if !drag.active && (fe.abs - drag.start_abs).length() > ColDrag::DRAG_THRESHOLD { drag.active = true; } if drag.active { self.redraw(cx); } } } Hit::FingerUp(fe) => { // Item 5: finishing a resize consumes the release. if self.resize_drag.take().is_some() || self.image_drag.take().is_some() { self.cancel_press(); self.redraw(cx); return; } let press = self.press_started.take(); let long_pressed = press.map(|p| p.fired).unwrap_or(false); // Phase 4: a drag that became active drops. if let Some(drag) = self.col_drag.take() { if drag.active { self.apply_col_drop(cx, drag.col, fe.abs.x); return; } } // A press that already fired its long-press action must not // also act as a click. if long_pressed { return; } self.dispatch_target_click(cx, self.hit_test(fe.abs)); } Hit::FingerHoverOver(fe) => { let target = self.hit_test(fe.abs); let new_hover_row = match target { HitTarget::Cell(r, _) | HitTarget::RowHeader(r) => Some(r), _ => None, }; let new_hover_col = match target { HitTarget::ColHeader(c) => Some(c), _ => None, }; if new_hover_row != self.hover_row || new_hover_col != self.hover_col { self.hover_row = new_hover_row; self.hover_col = new_hover_col; cx.widget_action(self.uid, TableAction::RowHovered(new_hover_row)); cx.widget_action(self.uid, TableAction::ColHovered(new_hover_col)); self.redraw(cx); } } Hit::FingerHoverOut(_) if self.hover_row.is_some() || self.hover_col.is_some() => { self.hover_row = None; self.hover_col = None; cx.widget_action(self.uid, TableAction::RowHovered(None)); cx.widget_action(self.uid, TableAction::ColHovered(None)); self.redraw(cx); } _ => {} } } /// Commit a column drag that ended at `abs_x`. /// /// Emits `ColumnMoved` only when the column actually changed index, so a /// host persisting column order is not asked to write on every wobble. fn apply_col_drop(&mut self, cx: &mut Cx, from: usize, abs_x: f64) { let gap = self.drop_index_at(abs_x); if let Some(to) = self.move_column_to_gap(from, gap) { // An open editor is addressed by index, and the indices just // moved underneath it. Cancelling is the honest response: the // cell it referred to is no longer where it was. if self.editing.is_some() { self.cancel_edit(cx); } cx.widget_action(self.uid, TableAction::ColumnMoved { from, to }); } self.redraw(cx); } /// Fire a long press once the hold has lasted long enough (item 4). /// /// Driven from `handle_event` on every event and from `next_frame` while /// a press is live, because neither a mouse nor a finger sends anything /// at all while held still — waiting for an input event would mean the /// long press only fires when the user jiggles the pointer. fn handle_press_timers(&mut self, cx: &mut Cx) { let Some(press) = self.press_started else { return; }; if press.fired || press.start_time.elapsed() < LONG_PRESS { return; } if let Some(p) = &mut self.press_started { p.fired = true; } match press.target { // Items 4 and 7: a long press on a header opens its menu, which // is where "Rename" lives. Editing the title in place is reached // from there rather than directly, so one gesture does not have // to mean two things. HitTarget::ColHeader(c) => self.open_col_menu(cx, c), HitTarget::RowHeader(r) => self.open_row_menu(cx, r), // Item 6: long press on a cell opens its own menu. HitTarget::Cell(row, col) => { cx.widget_action(self.uid, TableAction::LongPress { row, col }); self.open_cell_menu(cx, row, col); } _ => {} } // A long press on a header cancels any reorder it armed. self.col_drag = None; } /// Forget the press in progress without acting on it. fn cancel_press(&mut self) { self.press_started = None; } /// Handle a completed click — a press and release with no long press and /// no drag in between. fn dispatch_target_click(&mut self, cx: &mut Cx, target: HitTarget) { match target { // Item 5: a single click on either header selects it and shows // the resize affordance. It does not open a menu any more; that // is the long press. HitTarget::ColHeader(c) => { self.header_selection = Some(HeaderSelection::Col(c)); cx.widget_action(self.uid, TableAction::ColMenuRequested(c)); self.redraw(cx); } HitTarget::RowHeader(r) => { self.header_selection = Some(HeaderSelection::Row(r)); cx.widget_action(self.uid, TableAction::RowMenuRequested(r)); self.redraw(cx); } // Item 3: a single click selects, it no longer edits. Editing is // the double-click, handled in `FingerDown`. HitTarget::Cell(r, c) => { if self.editing.is_some() && self.editing != Some((r, c)) { self.commit_edit(cx); } self.header_selection = None; cx.widget_action(self.uid, TableAction::CellClicked { row: r, col: c }); self.redraw(cx); } // Both are drag affordances: a click that never moved is not an // action on either of them. HitTarget::ResizeHandle(_) | HitTarget::ImageAnchor(_, _) => {} HitTarget::Empty | HitTarget::Outside => { if self.editing.is_some() { self.commit_edit(cx); } if self.editing_header.is_some() { self.commit_header_edit(cx); } if self.header_selection.take().is_some() { self.redraw(cx); } } } } /// Start editing a header's title in place (item 7). /// /// Reuses the same `cell_editor` the cells use, positioned over the /// header band. One editor rather than two means the colour pinning, the /// caret placement and the focus deferral are all shared — a second /// TextInput would have to repeat every one of those fixes. fn begin_header_edit(&mut self, cx: &mut Cx, target: HeaderSelection) { // Committing a cell edit first: two editors cannot be open at once // because there is only one editor. if self.editing.is_some() { self.commit_edit(cx); } let title = match target { HeaderSelection::Col(c) => match self.data.columns.get(c) { Some(col) => col.title.clone(), None => return, }, // A row's "title" is its number, which is derived from its // position and cannot be edited. Renaming a row means setting an // id, which the data model does not surface yet. HeaderSelection::Row(_) => return, }; self.cell_editor.set_text(cx, &title); self.cell_editor.move_cursor_text_end(cx, false); self.needs_editor_focus = true; self.editing_header = Some(target); self.header_selection = Some(target); self.redraw(cx); } /// Commit an in-progress header edit. fn commit_header_edit(&mut self, cx: &mut Cx) { let Some(target) = self.editing_header.take() else { return; }; let title = self.cell_editor.text(); if let HeaderSelection::Col(c) = target { if let Some(col) = self.data.columns.get_mut(c) { // Trimmed for the same reason the document setters trim: a // trailing space in a column title is invisible and sorts // differently. col.title = title.trim().to_string(); } } cx.widget_action( self.uid, TableAction::HeaderEdited { target, title: title.trim().to_string(), }, ); self.redraw(cx); } /// Begin dragging an image's corner anchor. fn begin_image_resize(&mut self, row: usize, col: usize, abs: DVec2) { let cell = self.cell_rect(row, col); let Some(att) = self.data.attachment(row, col) else { return; }; let start_h = self.image_rect(cell, att).size.y; self.image_drag = Some(ImageDrag { row, col, start_y: abs.y, start_h, }); } /// Apply a live image resize. /// /// Writes `ImageSizing::Fixed`, which pins the height against the /// aspect-ratio default — the user has said what they want and a later /// relayout must not overrule it. The row grows or shrinks with it, /// because `row_height_for` reads the same sizing. fn apply_image_resize(&mut self, cx: &mut Cx, drag: ImageDrag, abs: DVec2) { let h = (drag.start_h + (abs.y - drag.start_y)) .clamp(IMAGE_MIN_HEIGHT, self.attachment_max_height); if let Some(CellAttachment::Image { sizing, .. }) = self.data.attachments.get_mut(&(drag.row, drag.col)) { *sizing = ImageSizing::Fixed(h); } self.compute_layout(); cx.widget_action( self.uid, TableAction::ImageResized { row: drag.row, col: drag.col, height: h, }, ); self.redraw(cx); } /// Begin a resize drag from a selected header's handle (item 5). fn begin_resize(&mut self, sel: HeaderSelection, abs: DVec2) { let (start, start_size) = match sel { HeaderSelection::Col(c) => ( abs.x, self.data .columns .get(c) .map(|col| col.width) .unwrap_or(self.default_col_width), ), HeaderSelection::Row(r) => (abs.y, self.row_height_for(r)), }; self.resize_drag = Some(ResizeDrag { target: sel, start, start_size, }); } /// Apply a live resize. /// /// Row height is a single value for the whole table rather than per-row, /// which is what the current layout supports — `cell_rect` multiplies by /// `row_height`. Per-row heights need a cumulative offset table, and that /// is a larger change than this one. fn apply_resize(&mut self, cx: &mut Cx, drag: ResizeDrag, abs: DVec2) { match drag.target { HeaderSelection::Col(c) => { if let Some(col) = self.data.columns.get_mut(c) { col.width = resize_to(drag.start_size, drag.start, abs.x); } self.compute_layout(); cx.widget_action(self.uid, TableAction::ColumnResized(c)); } HeaderSelection::Row(r) => { let h = resize_to(drag.start_size, drag.start, abs.y); if let Some(row) = self.data.rows.get_mut(r) { row.height = Some(h); } // Row positions below this one all shift, so the boundary // table has to be rebuilt — not just this row's rect. self.compute_layout(); cx.widget_action(self.uid, TableAction::RowResized(r)); } } self.redraw(cx); } /// Open the row context menu at the given row's position. fn open_row_menu(&mut self, cx: &mut Cx, row: usize) { if row >= self.data.rows.len() { return; } let row_rect = self.row_rect(row); let items = vec![ MenuItem { id: live_id!(insert_before), label: "Insert before".into(), }, MenuItem { id: live_id!(insert_after), label: "Insert after".into(), }, MenuItem { id: live_id!(move_up), label: "Move up".into(), }, MenuItem { id: live_id!(move_down), label: "Move down".into(), }, MenuItem { id: live_id!(copy), label: "Copy row".into(), }, MenuItem { id: live_id!(delete), label: "Delete row".into(), }, ]; self.menu = Some(MenuState { target: MenuTarget::Row(row), pos: dvec2(row_rect.pos.x, row_rect.pos.y), items, hover: None, }); cx.widget_action(self.uid, TableAction::RowMenuRequested(row)); self.redraw(cx); } /// Apply an entry from a cell's menu (item 6). /// /// Clipboard and attachment requests are emitted as actions rather than /// performed here. The table has no clipboard, no camera and no file /// dialog, and giving it any of them means depending on `nigig-uikit` and /// through it on tokio, reqwest and matrix — for a table widget. The host /// owns those capabilities and answers with `set_attachment`. fn apply_cell_menu_action(&mut self, cx: &mut Cx, row: usize, col: usize, action_id: LiveId) { match action_id { live_id!(copy_cell) => { let text = self .data .rows .get(row) .and_then(|r| r.cells.get(col)) .cloned() .unwrap_or_default(); cx.widget_action(self.uid, TableAction::CopyRequested { row, col, text }); } live_id!(paste_cell) => { cx.widget_action(self.uid, TableAction::PasteRequested { row, col }); } live_id!(clear_cell) => { if let Some(cell) = self .data .rows .get_mut(row) .and_then(|r| r.cells.get_mut(col)) { cell.clear(); } self.data.clear_attachment(row, col); self.compute_layout(); cx.widget_action( self.uid, TableAction::CellEdited { row, col, new_value: String::new(), }, ); self.redraw(cx); } live_id!(attach_file) => { self.request_attachment(cx, row, col, AttachmentSource::File); } live_id!(attach_photo) => { self.request_attachment(cx, row, col, AttachmentSource::Camera); } live_id!(attach_location) => { self.request_attachment(cx, row, col, AttachmentSource::Location); } live_id!(remove_attachment) => { if self.data.clear_attachment(row, col).is_some() { // The row may have been tall only because of this // attachment, so the layout has to be recomputed. self.compute_layout(); cx.widget_action(self.uid, TableAction::AttachmentRemoved { row, col }); self.redraw(cx); } } _ => {} } } /// Ask the host for an attachment, or say that nothing can supply it. /// /// Emits `AttachmentUnavailable` when no provider is wired or the /// provider declines. Silence would leave the user tapping a menu entry /// that appears to do nothing. fn request_attachment( &mut self, cx: &mut Cx, row: usize, col: usize, source: AttachmentSource, ) { let handled = match self.attachment_provider.as_mut() { Some(provider) => match source { AttachmentSource::File => provider.pick_file(cx, row, col), AttachmentSource::Camera => provider.capture_photo(cx, row, col), AttachmentSource::Location => provider.attach_location(cx, row, col), }, None => false, }; if handled { cx.widget_action( self.uid, TableAction::AttachmentRequested { row, col, source }, ); } else { cx.widget_action( self.uid, TableAction::AttachmentUnavailable { row, col, source }, ); } } /// Open a cell's context menu (item 6). /// /// The attachment entries are listed unconditionally rather than hidden /// when no provider is set. A menu whose contents depend on host wiring /// is a menu that looks broken in one app and fine in another; choosing /// an unsupported entry says so in a `CellAction` instead. fn open_cell_menu(&mut self, cx: &mut Cx, row: usize, col: usize) { if row >= self.data.rows.len() || col >= self.data.columns.len() { return; } let rect = self.cell_rect(row, col); let mut items = vec![ MenuItem { id: live_id!(copy_cell), label: "Copy".into(), }, MenuItem { id: live_id!(paste_cell), label: "Paste".into(), }, MenuItem { id: live_id!(clear_cell), label: "Clear".into(), }, MenuItem { id: live_id!(attach_file), label: "Add image or PDF…".into(), }, MenuItem { id: live_id!(attach_photo), label: "Take a photo…".into(), }, MenuItem { id: live_id!(attach_location), label: "Add location…".into(), }, ]; // Only offered when there is something to remove. if self.data.attachment(row, col).is_some() { items.push(MenuItem { id: live_id!(remove_attachment), label: "Remove attachment".into(), }); } self.menu = Some(MenuState { target: MenuTarget::Cell(row, col), pos: dvec2(rect.pos.x, rect.pos.y + rect.size.y), items, hover: None, }); self.redraw(cx); } /// Open the column context menu at the given column's header position. fn open_col_menu(&mut self, cx: &mut Cx, col: usize) { if col >= self.data.columns.len() { return; } let header_rect = self.header_rect(col); let items = vec![ MenuItem { id: live_id!(insert_before), label: "Insert before".into(), }, MenuItem { id: live_id!(insert_after), label: "Insert after".into(), }, MenuItem { id: live_id!(move_left), label: "Move left".into(), }, MenuItem { id: live_id!(move_right), label: "Move right".into(), }, MenuItem { id: live_id!(rename), label: "Rename column".into(), }, MenuItem { id: live_id!(align_left), label: "Align left".into(), }, MenuItem { id: live_id!(align_center), label: "Align center".into(), }, MenuItem { id: live_id!(align_right), label: "Align right".into(), }, MenuItem { id: live_id!(copy), label: "Copy column".into(), }, MenuItem { id: live_id!(delete), label: "Delete column".into(), }, ]; self.menu = Some(MenuState { target: MenuTarget::Col(col), pos: dvec2(header_rect.pos.x, header_rect.pos.y + header_rect.size.y), items, hover: None, }); cx.widget_action(self.uid, TableAction::ColMenuRequested(col)); self.redraw(cx); } /// Handle mouse events when the context menu is open. fn handle_menu_mouse(&mut self, cx: &mut Cx, event: &Event) { let area = self.draw_bg.area(); match event.hits_with_capture_overload(cx, area, true) { Hit::FingerHoverOver(fe) => { if let Some(menu) = &mut self.menu { let new_hover = menu.hit_test(fe.abs); if new_hover != menu.hover { menu.hover = new_hover; self.redraw(cx); } } } Hit::FingerDown(fe) if fe.is_primary_hit() => { if let Some(menu) = &self.menu { match menu.hit_test(fe.abs) { Some(index) => { // Item clicked — apply the action. let item = menu.items[index].clone(); let target = menu.target.clone(); self.menu = None; self.apply_menu_action(cx, target, item.id); } None => { // Click outside menu — close it. self.menu = None; self.redraw(cx); } } } } Hit::FingerHoverOut(_) => { if let Some(menu) = &mut self.menu { menu.hover = None; self.redraw(cx); } } _ => {} } } /// Draw drag feedback: a tint over the column being carried and a bar at /// the boundary it would drop into. fn draw_col_drag(&mut self, cx: &mut Cx2d, drag: &ColDrag) { if drag.col >= self.data.columns.len() { return; } // Ghost: the source column, full height, so it is clear what is moving. let src = self.header_rect(drag.col); self.draw_drag_ghost.draw_abs( cx, Rect { pos: src.pos, size: dvec2(src.size.x, self.rect.size.y), }, ); // Marker: a bar at the boundary the drop would land on. Skipped when // the drop is a no-op, so the absence of a bar means "nothing will // happen" rather than leaving a misleading one at the source edge. let gap = self.drop_index_at(drag.last_abs.x); if gap == drag.col || gap == drag.col + 1 { return; } let Some(&x) = self.col_x.get(gap) else { return; }; const MARKER_WIDTH: f64 = 2.0; self.draw_drag_marker.draw_abs( cx, Rect { pos: dvec2(x - MARKER_WIDTH * 0.5, self.rect.pos.y), size: dvec2(MARKER_WIDTH, self.rect.size.y), }, ); } /// Draw the context menu overlay. fn draw_menu(&mut self, cx: &mut Cx2d, menu: &MenuState) { let full = menu.full_rect(); // Background self.draw_menu_bg.draw_abs(cx, full); // Border (1px on all sides) let border = 1.0; self.draw_menu_border.draw_abs( cx, Rect { pos: full.pos, size: dvec2(full.size.x, border), }, ); self.draw_menu_border.draw_abs( cx, Rect { pos: dvec2(full.pos.x, full.pos.y + full.size.y - border), size: dvec2(full.size.x, border), }, ); self.draw_menu_border.draw_abs( cx, Rect { pos: full.pos, size: dvec2(border, full.size.y), }, ); self.draw_menu_border.draw_abs( cx, Rect { pos: dvec2(full.pos.x + full.size.x - border, full.pos.y), size: dvec2(border, full.size.y), }, ); // Items for (i, item) in menu.items.iter().enumerate() { let item_rect = menu.item_rect(i); // Hover highlight if menu.hover == Some(i) { self.draw_menu_hover.draw_abs(cx, item_rect); } // Text (vertically centered in the item rect) let text_y = item_rect.pos.y + (item_rect.size.y - 13.0) * 0.5; self.draw_menu_text .draw_abs(cx, dvec2(item_rect.pos.x + 12.0, text_y), &item.label); } } /// Apply a menu action (called when a menu item is clicked). fn apply_menu_action(&mut self, cx: &mut Cx, target: MenuTarget, action_id: LiveId) { match target { MenuTarget::Cell(row, col) => self.apply_cell_menu_action(cx, row, col, action_id), MenuTarget::Row(row) => match action_id { live_id!(insert_before) => self.insert_row(cx, row), live_id!(insert_after) => self.insert_row(cx, row + 1), live_id!(move_up) => { if row > 0 { self.data.rows.swap(row, row - 1); self.redraw(cx); } } live_id!(move_down) => { if row + 1 < self.data.rows.len() { self.data.rows.swap(row, row + 1); self.redraw(cx); } } live_id!(copy) => { if row < self.data.rows.len() { let clone = self.data.rows[row].clone(); self.data.rows.insert(row + 1, clone); self.redraw(cx); } } live_id!(delete) => self.delete_row(cx, row), _ => {} }, MenuTarget::Col(col) => match action_id { live_id!(insert_before) => self.insert_column(cx, col), live_id!(insert_after) => self.insert_column(cx, col + 1), live_id!(move_left) => { if col > 0 { self.move_column(cx, col, col - 1); } } live_id!(move_right) => { if col + 1 < self.data.columns.len() { self.move_column(cx, col, col + 1); } } live_id!(rename) => self.begin_header_edit(cx, HeaderSelection::Col(col)), live_id!(align_left) => self.set_column_align(cx, col, CellAlign::Left), live_id!(align_center) => self.set_column_align(cx, col, CellAlign::Center), live_id!(align_right) => self.set_column_align(cx, col, CellAlign::Right), live_id!(copy) => { if col < self.data.columns.len() { let col_clone = self.data.columns[col].clone(); self.data.columns.insert(col + 1, col_clone); for row in &mut self.data.rows { if col < row.cells.len() { let cell = row.cells[col].clone(); row.cells.insert(col + 1, cell); } } self.redraw(cx); } } live_id!(delete) => self.delete_column(cx, col), _ => {} }, } } } // ============================================================================ // Section 7 — Touch interaction (raw Event::TouchUpdate) // ============================================================================ // // Mirrors the cad example's handle_touch_interaction pattern: // if let Event::TouchUpdate(tu) = event { // if let Some(touch) = tu.touches.first() { // match touch.state { // TouchState::Start => { /* record start */ } // TouchState::Move => { /* detect drag vs tap */ } // TouchState::Stop => { /* fire tap action if not moved */ } // TouchState::Stable => {} // } // } // } // // Why a separate touch path when Hit::Finger* already abstracts mouse+touch? // - Long-press detection needs raw Start time + Move deltas (Hit::Finger* // gives Down/Up but no easy "held still for 500ms" hook). // - Future Phase 4 column-drag needs multi-touch awareness eventually. // // On desktop, this path simply never fires (no TouchUpdate events emitted), // so mouse behaviour is unaffected. impl Table { fn handle_touch_interaction(&mut self, cx: &mut Cx, event: &Event) { let Event::TouchUpdate(tu) = event else { return; }; let rect = self.draw_bg.area().rect(cx); let Some(touch) = tu.touches.first() else { return; }; // Item 9: touch runs the same state machine as the mouse. // // This used to be a parallel implementation with its own tracker and // its own long-press timer, and the two had already drifted: a long // press opened a menu under a finger but did nothing under a mouse, // and double-click-to-edit existed on neither. Sharing // `press_started`, `handle_press_timers` and `dispatch_target_click` // means a behaviour added to one is present on both by construction. // // Only the gesture recognition differs, because it must: a finger // reports Start/Move/Stop rather than Down/Move/Up with a tap count, // and there is no hover. Tap count is derived from the interval // between taps on the same target, the way the CAD viewport derives // its own gestures from raw touches. match touch.state { TouchState::Start => { if !rect.contains(touch.abs) { self.cancel_press(); return; } let target = self.hit_test(touch.abs); let now = std::time::Instant::now(); // Item 3: a second tap on the same cell inside the // double-tap window edits it, matching double-click. let is_double = matches!( self.last_tap, Some((t, prev)) if prev == target && now.duration_since(t) < DOUBLE_TAP_WINDOW ); if is_double { self.last_tap = None; self.cancel_press(); if let HitTarget::Cell(r, c) = target { self.begin_edit(cx, r, c); cx.widget_action(self.uid, TableAction::CellClicked { row: r, col: c }); } return; } self.last_tap = Some((now, target)); self.press_started = Some(PressTracker { start_abs: touch.abs, start_time: now, target, fired: false, }); match target { HitTarget::ResizeHandle(sel) => self.begin_resize(sel, touch.abs), HitTarget::ImageAnchor(r, c) => self.begin_image_resize(r, c, touch.abs), _ => {} } } TouchState::Move => { if let Some(drag) = self.resize_drag { self.apply_resize(cx, drag, touch.abs); self.cancel_press(); return; } if let Some(drag) = self.image_drag { self.apply_image_resize(cx, drag, touch.abs); self.cancel_press(); return; } if let Some(p) = &mut self.press_started { if (touch.abs - p.start_abs).length() > ColDrag::DRAG_THRESHOLD { p.fired = true; } } } TouchState::Stop => { if self.resize_drag.take().is_some() || self.image_drag.take().is_some() { self.cancel_press(); self.redraw(cx); return; } let press = self.press_started.take(); // A press that already acted as a long press is not also a tap. if press.map(|p| p.fired).unwrap_or(true) { return; } self.dispatch_target_click(cx, self.hit_test(touch.abs)); } TouchState::Stable => {} } } } // ============================================================================ // Section 8 — Keyboard interaction (commit / cancel / move) // ============================================================================ impl Table { fn handle_control_key(&mut self, cx: &mut Cx, key: KeyCode) { // Item 7: a header edit commits or cancels on the same keys, but has // no Tab-to-next-cell behaviour — there is no next header to move to // in a way that would not be surprising. if self.editing_header.is_some() { match key { KeyCode::ReturnKey | KeyCode::Tab => self.commit_header_edit(cx), KeyCode::Escape => { self.editing_header = None; self.redraw(cx); } _ => {} } return; } let Some((r, c)) = self.editing else { return; }; match key { KeyCode::ReturnKey => { self.commit_edit(cx); } KeyCode::Escape => { self.cancel_edit(cx); } KeyCode::Tab => { // Commit current cell, then move to the next column (or next row). self.commit_edit(cx); let n_cols = self.data.columns.len(); if n_cols == 0 { return; } let next_c = (c + 1) % n_cols; let next_r = if next_c == 0 { r + 1 } else { r }; if next_r < self.data.rows.len() { self.begin_edit(cx, next_r, next_c); } } _ => {} } } } // ============================================================================ // Section 9 — Editor lifecycle // ============================================================================ impl Table { /// Enter edit mode on a cell. Commits any prior edit, loads the cell's /// current value into the cell_editor, and signals that focus should be /// set on the next handle_event (after the editor has been drawn and its /// area is valid). fn begin_edit(&mut self, cx: &mut Cx, row: usize, col: usize) { if row >= self.data.rows.len() || col >= self.data.columns.len() { return; } // Commit any in-progress edit first. if self.editing.is_some() { self.commit_edit(cx); } let value = self.data.rows[row] .cells .get(col) .cloned() .unwrap_or_default(); // Widget::set_text dispatches to TextInput's impl (text_input.rs:2045). self.cell_editor.set_text(cx, &value); // Put the caret after the existing text. // // `set_text` loads the value but leaves the cursor at index 0, so // typing into a cell that already had content inserted at the front // and the caret appeared to be in the wrong place. Editing a cell // should continue from the end of what is there, which is what every // spreadsheet does and what `commit_edit` reading the whole buffer // back assumes. self.cell_editor.move_cursor_text_end(cx, false); // Mark for deferred focus — see handle_event step 5. self.needs_editor_focus = true; self.editing = Some((row, col)); self.redraw(cx); } /// Commit the current edit: read the editor's text back into the data /// model, emit CellEdited, and exit edit mode. fn commit_edit(&mut self, cx: &mut Cx) { let Some((r, c)) = self.editing.take() else { return; }; // Widget::text dispatches to TextInput's impl (text_input.rs:2041). let new_value = self.cell_editor.text(); if r < self.data.rows.len() { // Ensure cells vec is long enough. while self.data.rows[r].cells.len() <= c { self.data.rows[r].cells.push(String::new()); } self.data.rows[r].cells[c] = new_value.clone(); cx.widget_action( self.uid, TableAction::CellEdited { row: r, col: c, new_value, }, ); } // Clear keyboard focus (cx.set_key_focus takes an Area, not a WidgetUid). cx.set_key_focus(Area::Empty); self.needs_editor_focus = false; self.redraw(cx); } /// Discard the current edit and exit edit mode. fn cancel_edit(&mut self, cx: &mut Cx) { self.editing = None; cx.set_key_focus(Area::Empty); self.needs_editor_focus = false; self.redraw(cx); } } // ============================================================================ // Section 10 — Public mutator API (mutate rows / columns from outside) // ============================================================================ // // These will be called from the Phase 3 PopupMenu handlers (Insert before/after, // Delete, Move left/right, Copy). Exposed via TableRef wrappers in Section 11. impl Table { pub fn set_data(&mut self, cx: &mut Cx, data: TableData) { self.data = data; // Row heights depend on the attachments the new data carries. self.compute_layout(); self.redraw(cx); } /// Install the host's source of camera, location and file attachments /// (item 6). Without one, those menu entries report themselves /// unavailable rather than doing nothing. pub fn set_attachment_provider(&mut self, provider: Box) { self.attachment_provider = Some(provider); } /// Deliver an attachment for a cell. This is how a provider answers a /// request it accepted earlier, once the picker or camera returns. pub fn set_attachment(&mut self, cx: &mut Cx, row: usize, col: usize, item: CellAttachment) { if row >= self.data.rows.len() || col >= self.data.columns.len() { return; } self.data.set_attachment(row, col, item); // The row may now need to be taller. self.compute_layout(); self.redraw(cx); } pub fn clear_attachment(&mut self, cx: &mut Cx, row: usize, col: usize) { if self.data.clear_attachment(row, col).is_some() { self.compute_layout(); self.redraw(cx); } } pub fn data(&self) -> &TableData { &self.data } pub fn insert_row(&mut self, cx: &mut Cx, index: usize) { let cells = vec![String::new(); self.data.columns.len()]; let row = TableRow { id: format!("row_{}", now_nanos()), cells, height: None, }; let idx = index.min(self.data.rows.len()); // Attachments are keyed by position, so they have to move with the // rows. Without this an image stays on row 3 while its data moves to // row 4 — a bug that only appears once someone has real content. self.data.shift_attachments_for_row_insert(idx); self.data.rows.insert(idx, row); self.compute_layout(); self.redraw(cx); } pub fn delete_row(&mut self, cx: &mut Cx, index: usize) { if index < self.data.rows.len() { self.data.shift_attachments_for_row_remove(index); self.data.rows.remove(index); if self.editing.map(|(r, _)| r) == Some(index) { self.cancel_edit(cx); } self.compute_layout(); self.redraw(cx); } } pub fn insert_column(&mut self, cx: &mut Cx, index: usize) { let col = TableColumn { id: format!("col_{}", now_nanos()), title: "New column".into(), width: self.default_col_width, align: CellAlign::Left, kind: CellKind::Text, }; let idx = index.min(self.data.columns.len()); self.data.columns.insert(idx, col); for row in &mut self.data.rows { row.cells.insert(idx, String::new()); } self.redraw(cx); } pub fn delete_column(&mut self, cx: &mut Cx, index: usize) { if index < self.data.columns.len() { self.data.columns.remove(index); for row in &mut self.data.rows { if index < row.cells.len() { row.cells.remove(index); } } if self.editing.map(|(_, c)| c) == Some(index) { self.cancel_edit(cx); } self.redraw(cx); } } pub fn move_column(&mut self, cx: &mut Cx, from: usize, to: usize) { if from >= self.data.columns.len() || to >= self.data.columns.len() || from == to { return; } self.data.columns.swap(from, to); for row in &mut self.data.rows { if from < row.cells.len() && to < row.cells.len() { row.cells.swap(from, to); } } // This is a swap, not a move, so the attachment keys swap too — the // general shift used by drag-reorder would be wrong here. let swapped: std::collections::HashMap<(usize, usize), CellAttachment> = self .data .attachments .drain() .map(|((r, c), v)| { let nc = if c == from { to } else if c == to { from } else { c }; ((r, nc), v) }) .collect(); self.data.attachments = swapped; self.compute_layout(); self.redraw(cx); } pub fn set_column_align(&mut self, cx: &mut Cx, col: usize, align: CellAlign) { if col < self.data.columns.len() { self.data.columns[col].align = align; self.redraw(cx); } } } fn now_nanos() -> u64 { use std::time::{SystemTime, UNIX_EPOCH}; SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_nanos() as u64) .unwrap_or(0) } // ============================================================================ // Section 11 — TableRef accessor methods (public API for app code) // ============================================================================ impl TableRef { /// Deliver an attachment for a cell (item 6). pub fn set_attachment(&self, cx: &mut Cx, row: usize, col: usize, item: CellAttachment) { if let Some(mut inner) = self.borrow_mut() { inner.set_attachment(cx, row, col, item); } } pub fn clear_attachment(&self, cx: &mut Cx, row: usize, col: usize) { if let Some(mut inner) = self.borrow_mut() { inner.clear_attachment(cx, row, col); } } /// Install the host's attachment provider (item 6). pub fn set_attachment_provider(&self, provider: Box) { if let Some(mut inner) = self.borrow_mut() { inner.set_attachment_provider(provider); } } pub fn set_data(&self, cx: &mut Cx, data: TableData) { if let Some(mut inner) = self.borrow_mut() { inner.set_data(cx, data); } } pub fn data(&self) -> Option { self.borrow().map(|inner| inner.data().clone()) } pub fn insert_row(&self, cx: &mut Cx, index: usize) { if let Some(mut inner) = self.borrow_mut() { inner.insert_row(cx, index); } } pub fn delete_row(&self, cx: &mut Cx, index: usize) { if let Some(mut inner) = self.borrow_mut() { inner.delete_row(cx, index); } } pub fn insert_column(&self, cx: &mut Cx, index: usize) { if let Some(mut inner) = self.borrow_mut() { inner.insert_column(cx, index); } } pub fn delete_column(&self, cx: &mut Cx, index: usize) { if let Some(mut inner) = self.borrow_mut() { inner.delete_column(cx, index); } } pub fn move_column(&self, cx: &mut Cx, from: usize, to: usize) { if let Some(mut inner) = self.borrow_mut() { inner.move_column(cx, from, to); } } pub fn set_column_align(&self, cx: &mut Cx, col: usize, align: CellAlign) { if let Some(mut inner) = self.borrow_mut() { inner.set_column_align(cx, col, align); } } } // ============================================================================ // Section 12 — Demo data (matches the "Random table" screenshot from the video) // ============================================================================ fn demo_data() -> TableData { TableData { columns: vec![ TableColumn { id: "role".into(), title: "Role".into(), width: 160.0, align: CellAlign::Left, kind: CellKind::Text, }, TableColumn { id: "task".into(), title: "Current Task".into(), width: 360.0, align: CellAlign::Left, kind: CellKind::Text, }, TableColumn { id: "deadline".into(), title: "Deadline".into(), width: 120.0, align: CellAlign::Left, kind: CellKind::Text, }, ], rows: vec![ TableRow { id: "r1".into(), cells: vec![ "Frontend Dev".into(), "Building checkout flow component".into(), "Mar 15".into(), ], height: None, }, TableRow { id: "r2".into(), cells: vec![ "Backend Lead".into(), "API rate limiting implementation".into(), "Mar 22".into(), ], height: None, }, TableRow { id: "r3".into(), cells: vec![ "UX Designer".into(), "Redesign onboarding flow".into(), "Mar 18".into(), ], height: None, }, TableRow { id: "r4".into(), cells: vec![ "DevOps".into(), "Migrate CI to GitHub Actions".into(), "Mar 25".into(), ], height: None, }, TableRow { id: "r5".into(), cells: vec![ "QA Engineer".into(), "E2E tests for new auth flow".into(), "Mar 19".into(), ], height: None, }, TableRow { id: "r6".into(), cells: vec![ "Data Analyst".into(), "Build retention dashboard".into(), "Mar 20".into(), ], height: None, }, ], attachments: Default::default(), } } // ============================================================================ // Section 14 — A file-picker attachment provider // ============================================================================ // // Ships with the widget because `robius-file-picker` is the one capability // that needs no host wiring: it is already a dependency of three crates in // this repo, it works on desktop through `rfd` and on Android through the // platform picker, and it does not drag `nigig-core` in with it. // // Camera and location are deliberately *not* here. Reaching them means // depending on `nigig-uikit`, and through it on `nigig-core` — tokio, // reqwest, matrix-sdk, clap. That is the wrong trade for a table widget, so // they stay behind `CellAttachmentProvider` for a host that already has them. /// Where a picked file's result is parked for the UI thread. /// /// The picker's callback runs off the UI thread with no `Cx`, so it cannot /// touch a widget. It leaves the outcome here and signals; the host drains it /// on the next `Event::Signal` and calls `TableRef::set_attachment`. Same /// shape as the invoicer's document picker and the SMS bulk CSV import. pub static PENDING_CELL_FILE: std::sync::Mutex> = std::sync::Mutex::new(None); /// Take a picked file, if one is waiting. pub fn take_pending_cell_file() -> Option<(usize, usize, std::path::PathBuf)> { PENDING_CELL_FILE .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) .take() } /// Turn a path into the attachment its extension implies. /// /// Unknown extensions become `Image` rather than being refused: the chip /// shows the filename either way, and refusing a file the user deliberately /// chose is worse than labelling it loosely. pub fn attachment_for_path(path: std::path::PathBuf) -> CellAttachment { let ext = path .extension() .map(|e| e.to_string_lossy().to_lowercase()) .unwrap_or_default(); if ext == "pdf" { CellAttachment::Pdf { path, pages: None } } else { // Intrinsic size is left unmeasured: decoding an image to size a row // is the host's job, and it can refine this with `set_attachment`. CellAttachment::Image { path, intrinsic: None, sizing: ImageSizing::default(), } } } // ============================================================================ // Section 13 — Tests // ============================================================================ // // These cover the parts of the widget that are decidable without a GPU: the // column geometry, the drag-reorder index arithmetic, the menu's layout and // the row/column mutators. // // `Table` itself derives `Script` and `Widget` and cannot be constructed // without a live `Cx`, so the logic worth testing was moved off it: // `ColumnGeometry` owns the boundary maths and `reorder_columns` owns the // move. The widget forwards to both. That is not a workaround — the reorder // off-by-one is a property of indices, and testing it through a window would // prove less while requiring a display. // // Not covered here: focus handling, action emission and drawing, all of which // need the makepad test runtime. #[cfg(test)] mod tests { use super::*; /// Three 100px columns from x=0: boundaries at 0/100/200/300, midpoints /// at 50/150/250. Every expectation below is checkable against those. fn geom() -> ColumnGeometry { let widths = [100.0, 100.0, 100.0]; let mut g = ColumnGeometry::default(); g.recompute(0.0, widths.iter()); g } fn table_data() -> TableData { TableData { columns: ["A", "B", "C"] .iter() .map(|t| TableColumn { id: t.to_lowercase(), title: (*t).to_string(), width: 100.0, align: CellAlign::Left, kind: CellKind::Text, }) .collect(), rows: vec![ TableRow { id: "r1".into(), cells: vec!["a1".into(), "b1".into(), "c1".into()], height: None, }, TableRow { id: "r2".into(), cells: vec!["a2".into(), "b2".into(), "c2".into()], height: None, }, ], attachments: Default::default(), } } fn titles(d: &TableData) -> Vec { d.columns.iter().map(|c| c.title.clone()).collect() } // ---- Geometry ---- #[test] fn boundaries_are_cumulative_widths() { assert_eq!(geom().bounds, vec![0.0, 100.0, 200.0, 300.0]); assert_eq!(geom().len(), 3); } #[test] fn geometry_can_start_at_a_nonzero_origin() { let widths = [50.0, 50.0]; let mut g = ColumnGeometry::default(); g.recompute(25.0, widths.iter()); assert_eq!(g.bounds, vec![25.0, 75.0, 125.0]); // Midpoint of the first column is now 50, not 25. assert_eq!(g.drop_index_at(49.0), 0); assert_eq!(g.drop_index_at(51.0), 1); } // ---- Phase 4: drop index ---- #[test] fn drop_index_follows_column_midpoints() { let g = geom(); assert_eq!(g.drop_index_at(0.0), 0); assert_eq!(g.drop_index_at(49.0), 0); assert_eq!(g.drop_index_at(51.0), 1); assert_eq!(g.drop_index_at(149.0), 1); assert_eq!(g.drop_index_at(151.0), 2); assert_eq!(g.drop_index_at(249.0), 2); assert_eq!(g.drop_index_at(251.0), 3); } /// Exactly on a midpoint the comparison is `<`, so the pointer belongs to /// the gap on the right. Pinned because "which side of the midpoint is /// inclusive" is what a refactor silently flips. #[test] fn drop_index_at_an_exact_midpoint_rounds_right() { let g = geom(); assert_eq!(g.drop_index_at(50.0), 1); assert_eq!(g.drop_index_at(150.0), 2); assert_eq!(g.drop_index_at(250.0), 3); } #[test] fn drop_index_clamps_outside_the_table() { let g = geom(); assert_eq!(g.drop_index_at(-500.0), 0); assert_eq!(g.drop_index_at(5_000.0), 3); } #[test] fn drop_index_on_an_empty_table_is_zero() { let g = ColumnGeometry::default(); assert_eq!(g.drop_index_at(123.0), 0); assert!(g.is_empty()); } #[test] fn uneven_column_widths_use_their_own_midpoints() { let widths = [50.0, 250.0, 100.0]; let mut g = ColumnGeometry::default(); g.recompute(0.0, widths.iter()); assert_eq!(g.bounds, vec![0.0, 50.0, 300.0, 400.0]); assert_eq!(g.drop_index_at(24.0), 0); assert_eq!(g.drop_index_at(26.0), 1); assert_eq!(g.drop_index_at(174.0), 1); assert_eq!(g.drop_index_at(176.0), 2); } // ---- Phase 4: the index shift ---- /// Dragging right is where the shift matters: gap 3 is past the last /// column, and a naive `insert(3)` after removing index 0 would land at /// the wrong place. The expected destination is 2. #[test] fn dragging_right_accounts_for_the_removed_column() { assert_eq!(ColumnGeometry::shift_for_removal(0, 3, 3), Some(2)); assert_eq!(ColumnGeometry::shift_for_removal(0, 2, 3), Some(1)); } /// Dragging left needs no shift: removing a column to the right of the /// destination does not move the destination. #[test] fn dragging_left_needs_no_shift() { assert_eq!(ColumnGeometry::shift_for_removal(2, 0, 3), Some(0)); assert_eq!(ColumnGeometry::shift_for_removal(2, 1, 3), Some(1)); } /// The gaps either side of a column are where it already sits. Both are /// no-ops: without this, a press-twitch-release reorders nothing but /// still reports a move. #[test] fn dropping_next_to_itself_is_a_no_op() { for gap in [1usize, 2] { assert_eq!( ColumnGeometry::shift_for_removal(1, gap, 3), None, "gap {gap} should be a no-op for column 1" ); } } #[test] fn out_of_range_moves_are_refused() { assert_eq!(ColumnGeometry::shift_for_removal(9, 0, 3), None); assert_eq!(ColumnGeometry::shift_for_removal(0, 9, 3), None); assert_eq!(ColumnGeometry::shift_for_removal(0, 0, 0), None); } // ---- Phase 4: the move applied to data ---- #[test] fn dragging_a_column_to_the_far_right_lands_last() { let mut d = table_data(); assert_eq!(reorder_columns(&mut d, 0, 3), Some(2)); assert_eq!(titles(&d), ["B", "C", "A"]); assert_eq!(d.rows[0].cells, ["b1", "c1", "a1"]); } #[test] fn dragging_a_column_to_the_far_left_lands_first() { let mut d = table_data(); assert_eq!(reorder_columns(&mut d, 2, 0), Some(0)); assert_eq!(titles(&d), ["C", "A", "B"]); assert_eq!(d.rows[0].cells, ["c1", "a1", "b1"]); } #[test] fn dragging_into_the_middle_from_either_side_agrees() { let mut right = table_data(); assert_eq!(reorder_columns(&mut right, 0, 2), Some(1)); assert_eq!(titles(&right), ["B", "A", "C"]); let mut left = table_data(); assert_eq!(reorder_columns(&mut left, 2, 1), Some(1)); assert_eq!(titles(&left), ["A", "C", "B"]); } /// Cells must travel with their header. A reorder that moves the title /// but not the data corrupts every row and looks correct until read. #[test] fn moving_a_column_carries_its_cells_in_every_row() { let mut d = table_data(); reorder_columns(&mut d, 0, 3); assert_eq!(d.rows[0].cells, ["b1", "c1", "a1"]); assert_eq!(d.rows[1].cells, ["b2", "c2", "a2"]); } /// Any sequence of drags is a permutation: nothing duplicated, nothing /// lost, every row keeping its original multiset of values. #[test] fn repeated_drags_preserve_every_column_and_cell() { let mut d = table_data(); for (from, gap) in [(0usize, 3usize), (2, 0), (1, 3), (0, 2), (2, 1)] { reorder_columns(&mut d, from, gap); let mut ids: Vec<_> = d.columns.iter().map(|c| c.id.clone()).collect(); ids.sort(); assert_eq!(ids, ["a", "b", "c"], "columns lost after ({from},{gap})"); for row in &d.rows { assert_eq!(row.cells.len(), 3, "row width changed after ({from},{gap})"); } let mut cells = d.rows[0].cells.clone(); cells.sort(); assert_eq!(cells, ["a1", "b1", "c1"], "cells lost after ({from},{gap})"); } } /// The two halves have to agree: the gap `drop_index_at` computes must be /// the one the move acts on. Dragging A to x=290 puts A last. #[test] fn a_pointer_position_drives_the_move_end_to_end() { let g = geom(); let mut d = table_data(); let gap = g.drop_index_at(290.0); assert_eq!(gap, 3); assert_eq!(reorder_columns(&mut d, 0, gap), Some(2)); assert_eq!(titles(&d), ["B", "C", "A"]); } /// A row shorter than the column list must not panic or fabricate cells. /// Ragged data is possible via `set_data` from a host application. #[test] fn a_ragged_row_survives_a_reorder() { let mut d = table_data(); d.rows.push(TableRow { id: "short".into(), cells: vec!["only".into()], height: None, }); assert_eq!(reorder_columns(&mut d, 0, 3), Some(2)); assert_eq!(d.rows[2].cells, ["only"]); } // ---- Item 5: resize arithmetic ---- #[test] fn resizing_follows_the_pointer_delta() { // Dragged 40px right from a 100px column. assert_eq!(resize_to(100.0, 500.0, 540.0), 140.0); // Dragged back left. assert_eq!(resize_to(100.0, 500.0, 470.0), 70.0); // No movement, no change. assert_eq!(resize_to(100.0, 500.0, 500.0), 100.0); } /// A header can never be dragged smaller than the grab handle needs. /// Below that the header is unclickable and the column is unrecoverable /// by dragging — the user would have to delete the row to get rid of it. #[test] fn resizing_cannot_collapse_a_header_out_of_reach() { let floor = ResizeDrag::MIN_SIZE; assert!(floor >= RESIZE_HANDLE, "the floor must fit the handle"); // Dragged far past zero. assert_eq!(resize_to(100.0, 500.0, 0.0), floor); assert_eq!(resize_to(100.0, 500.0, -1000.0), floor); // Exactly at the floor. assert_eq!(resize_to(floor, 500.0, 500.0), floor); } /// Resizing is relative to where the drag started, not to the pointer's /// absolute position. Anchoring to the absolute position makes the header /// jump to the cursor the moment the handle is grabbed. #[test] fn resizing_is_relative_to_the_grab_point() { // Same 30px movement from two different start points gives the same // result. let a = resize_to(80.0, 200.0, 230.0); let b = resize_to(80.0, 900.0, 930.0); assert_eq!(a, b); assert_eq!(a, 110.0); } // ---- Items 1 and 2: the gutter ---- /// Data columns begin after the gutter, so the gutter can never be /// reordered into or dropped onto — it is not in `col_x` at all. #[test] fn the_gutter_offsets_the_data_columns() { let widths = [100.0, 100.0]; let mut g = ColumnGeometry::default(); // 44px gutter at x=0 means the first data column starts at 44. g.recompute(0.0 + 44.0, widths.iter()); assert_eq!(g.bounds, vec![44.0, 144.0, 244.0]); assert_eq!(g.len(), 2, "the gutter is not a data column"); } /// Row numbers are 1-based and derived from the index, so a reorder or an /// insert cannot leave them stale. #[test] fn row_numbers_are_positions_not_stored_values() { let mut d = table_data(); let before: Vec = (0..d.rows.len()).map(|r| (r + 1).to_string()).collect(); assert_eq!(before, ["1", "2"]); // Reordering columns must not disturb the numbering. reorder_columns(&mut d, 0, 2); let after: Vec = (0..d.rows.len()).map(|r| (r + 1).to_string()).collect(); assert_eq!(before, after); } // ---- Item 1: every grid position exists ---- /// A row shorter than the column list still has a cell at every column. /// /// The draw loop used to iterate `row.cells`, so a ragged row simply /// stopped: the remaining columns had no background, no border and /// nothing to click. It now iterates the column count and treats a /// missing entry as empty. #[test] fn a_ragged_row_still_covers_every_column() { let mut d = table_data(); d.rows.push(TableRow { id: "short".into(), cells: vec!["only".into()], height: None, }); let col_count = d.columns.len(); assert_eq!(col_count, 3); // What the draw loop reads for each position. let row = &d.rows[2]; let seen: Vec = (0..col_count) .map(|c| row.cells.get(c).cloned().unwrap_or_default()) .collect(); assert_eq!(seen, ["only", "", ""]); } // ---- Items 3, 4 and 9: one input model for mouse and touch ---- /// A long press and a double tap must not be able to fire together: the /// double-tap window has to close before the long press opens. /// /// If they overlapped, a slow double tap on a cell would both edit it and /// open the context menu. #[test] fn the_double_tap_window_closes_before_a_long_press_fires() { assert!( DOUBLE_TAP_WINDOW < LONG_PRESS, "a slow double tap would also register as a long press" ); } /// The gesture thresholds are shared, so a pointer and a finger agree on /// what counts as a drag rather than a tap. #[test] fn mouse_and_touch_share_their_gesture_thresholds() { let src = include_str!("table.rs"); let start = src .find("fn handle_touch_interaction") .expect("the touch path exists"); let end = src[start..] .find("\n fn ") .map(|i| start + i) .unwrap_or(src.len()); let touch = &src[start..end]; // The touch path must use the shared tracker and the shared handlers, // not a parallel implementation. The two had already drifted once: // long press worked under a finger and not under a mouse. assert!( touch.contains("press_started"), "the touch path has its own press tracking again" ); assert!( touch.contains("dispatch_target_click"), "the touch path does not share the click handler" ); assert!( touch.contains("ColDrag::DRAG_THRESHOLD"), "the touch path has its own movement threshold again" ); assert!( !touch.contains("TouchTracker"), "the parallel touch tracker is back" ); } /// A long press must fire without any further input. Neither a mouse nor /// a finger sends events while held still, so the widget has to schedule /// its own frame or the menu only opens if the user jiggles the pointer. #[test] fn a_held_press_schedules_its_own_frame() { let src = include_str!("table.rs"); let start = src .find("self.handle_press_timers(cx);") .expect("the long-press timer is driven from handle_event"); let window = &src[start..start + 220]; assert!( window.contains("new_next_frame"), "a held press does not request another frame, so the long press \ only fires if some other event happens to arrive" ); } // ---- Item 6: attachments ---- fn img(w: f64, h: f64) -> CellAttachment { CellAttachment::Image { path: std::path::PathBuf::from("/tmp/photo.png"), intrinsic: Some((w, h)), sizing: ImageSizing::FitWidth, } } /// An image keeps its aspect ratio, floored at the default height and /// capped so one tall photo cannot make a row taller than the viewport. #[test] fn an_image_sizes_its_row_to_its_aspect_ratio() { // 2:1 landscape in a 200px cell wants 100px. assert_eq!( img(400.0, 200.0).preferred_height(200.0, 72.0, 240.0), 100.0 ); // A wide, short image is floored rather than becoming a sliver. assert_eq!(img(1000.0, 50.0).preferred_height(200.0, 72.0, 240.0), 72.0); // A tall portrait is capped. assert_eq!( img(100.0, 900.0).preferred_height(200.0, 72.0, 240.0), 240.0 ); } /// An image of unknown size, a PDF and a location are all fixed-height /// chips. Guessing an aspect ratio for something unmeasured would make /// rows jump when the real size arrived. #[test] fn unmeasured_attachments_use_the_default_height() { let unmeasured = CellAttachment::Image { path: "/tmp/x.png".into(), intrinsic: None, sizing: ImageSizing::FitWidth, }; // Unmeasured falls back to a placeholder rather than guessing a // ratio, and never below the default row height. assert!(unmeasured.preferred_height(200.0, 72.0, 240.0) >= 72.0); let pdf = CellAttachment::Pdf { path: "/tmp/x.pdf".into(), pages: Some(3), }; assert_eq!(pdf.preferred_height(200.0, 72.0, 240.0), 72.0); let loc = CellAttachment::Location { latitude: -1.29, longitude: 36.82, label: String::new(), }; assert_eq!(loc.preferred_height(200.0, 72.0, 240.0), 72.0); } /// Degenerate intrinsic sizes fall back to the placeholder rather than /// dividing by zero, and stay within the cap. #[test] fn a_zero_sized_image_does_not_divide_by_zero() { for att in [img(0.0, 100.0), img(100.0, 0.0)] { let h = att.preferred_height(200.0, 72.0, 240.0); assert!(h.is_finite(), "produced {h}"); assert!((72.0..=240.0).contains(&h), "produced {h}"); } } #[test] fn attachment_labels_name_the_file_or_the_place() { assert_eq!(img(1.0, 1.0).label(), "photo.png"); assert_eq!( CellAttachment::Pdf { path: "/docs/plan.pdf".into(), pages: Some(12) } .label(), "plan.pdf (12p)" ); // A location with a name uses it; without one it shows coordinates. assert_eq!( CellAttachment::Location { latitude: -1.286389, longitude: 36.817223, label: "Nairobi".into() } .label(), "Nairobi" ); assert_eq!( CellAttachment::Location { latitude: -1.286389, longitude: 36.817223, label: " ".into() } .label(), "-1.2864, 36.8172" ); } /// Attachment keys are positional, so anything that moves rows or columns /// has to move them too. This is the class of bug that only shows up once /// there is real content: the data moves and the image stays behind. #[test] fn attachments_follow_a_row_insert() { let mut d = table_data(); d.set_attachment(1, 0, img(10.0, 10.0)); d.shift_attachments_for_row_insert(0); assert!(d.attachment(1, 0).is_none(), "the key did not move"); assert!(d.attachment(2, 0).is_some(), "expected it on row 2"); } #[test] fn attachments_follow_a_row_removal() { let mut d = table_data(); d.set_attachment(0, 0, img(10.0, 10.0)); d.set_attachment(1, 1, img(20.0, 20.0)); d.shift_attachments_for_row_remove(0); // The removed row's attachment is gone, not orphaned. assert_eq!(d.attachments.len(), 1); assert!( d.attachment(0, 1).is_some(), "row 1 should have become row 0" ); } /// A drag-reorder moves a column; the attachment must land on the same /// column, not the same index. #[test] fn attachments_follow_a_column_reorder() { let mut d = table_data(); d.set_attachment(0, 0, img(10.0, 10.0)); // Column A (index 0) moves to the end. assert_eq!(reorder_columns(&mut d, 0, 3), Some(2)); assert_eq!(titles(&d), ["B", "C", "A"]); assert!( d.attachment(0, 2).is_some(), "the attachment did not follow its column: {:?}", d.attachments.keys().collect::>() ); assert!(d.attachment(0, 0).is_none()); } /// Attachments and cells stay in step across a sequence of edits. #[test] fn attachments_and_cells_stay_aligned_through_edits() { let mut d = table_data(); // Tag every cell of column B so drift is visible. d.set_attachment(0, 1, img(10.0, 10.0)); d.set_attachment(1, 1, img(10.0, 10.0)); reorder_columns(&mut d, 1, 0); // B moves to the front assert_eq!(titles(&d), ["B", "A", "C"]); assert!(d.attachment(0, 0).is_some()); assert!(d.attachment(1, 0).is_some()); d.shift_attachments_for_row_insert(0); assert!(d.attachment(1, 0).is_some()); assert!(d.attachment(2, 0).is_some()); assert_eq!(d.attachments.len(), 2, "an attachment was lost or copied"); } /// A picked file becomes the attachment its extension implies, and an /// unknown extension is still accepted — refusing a file the user /// deliberately chose is worse than labelling it loosely. #[test] fn a_picked_file_maps_to_an_attachment_kind() { assert!(matches!( attachment_for_path("/tmp/scan.pdf".into()), CellAttachment::Pdf { .. } )); // Case-insensitive, because file systems are not. assert!(matches!( attachment_for_path("/tmp/SCAN.PDF".into()), CellAttachment::Pdf { .. } )); assert!(matches!( attachment_for_path("/tmp/photo.jpg".into()), CellAttachment::Image { .. } )); // No extension at all. assert!(matches!( attachment_for_path("/tmp/whatever".into()), CellAttachment::Image { .. } )); } /// A picked file arrives unmeasured, so the row uses the default height /// until a host refines it. Guessing here would make rows jump. #[test] fn a_picked_file_starts_unmeasured() { match attachment_for_path("/tmp/photo.jpg".into()) { CellAttachment::Image { intrinsic, .. } => assert!(intrinsic.is_none()), other => panic!("expected an image, got {other:?}"), } } // ---- Image format sniffing ---- // // `decode_png_or_jpg` needs a live `Cx` to upload a texture, so what is // tested here is the sniff it dispatches on. That is the part with a // decision in it; the loader calls are makepad's. /// A real PNG header is recognised, so the common case calls the right /// loader directly instead of failing once and retrying. #[test] fn a_png_header_is_recognised() { // 8-byte PNG signature followed by an IHDR chunk header. let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"; assert_eq!(imghdr::from_bytes(png), Some(imghdr::Type::Png)); } /// A JPEG header likewise. #[test] fn a_jpeg_header_is_recognised() { // SOI + APP0/JFIF. let jpg = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00"; assert_eq!(imghdr::from_bytes(jpg), Some(imghdr::Type::Jpeg)); } /// Anything the sniffer cannot name falls through to trying both loaders /// rather than being refused outright — `imghdr` is not perfect, and a /// mislabelled file is more useful decoded than rejected. #[test] fn an_unknown_header_is_not_refused_outright() { // Not any image format the sniffer knows. let junk = b"not an image at all, just some bytes"; assert_eq!(imghdr::from_bytes(junk), None); // The `None` arm in decode_png_or_jpg is the fallback path, so the // sniff returning None must not itself be treated as failure. } /// A GIF is named but unsupported by either loader. It still reaches the /// fallback, which is what lets a mislabelled file through. #[test] fn a_named_but_unsupported_format_still_reaches_the_fallback() { let gif = b"GIF89a\x01\x00\x01\x00"; let sniffed = imghdr::from_bytes(gif); assert!(sniffed.is_some(), "the sniffer should name a GIF"); assert_ne!(sniffed, Some(imghdr::Type::Png)); assert_ne!(sniffed, Some(imghdr::Type::Jpeg)); } /// The decode path must sniff before it guesses. Reading the source /// because the loaders need a `Cx`; the ordering is the thing a later /// edit would quietly drop. #[test] fn the_decode_path_sniffs_before_falling_back() { let src = include_str!("table.rs"); let start = src .find("fn decode_png_or_jpg") .expect("decode_png_or_jpg exists"); let end = src[start..] .find("\n}\n") .map(|i| start + i) .unwrap_or(src.len()); let body = &src[start..end]; assert!( body.contains("imghdr::from_bytes"), "the decode path no longer sniffs the header" ); assert!( body.contains("attempt_both"), "the decode path has no fallback, so a mislabelled file is refused" ); // And the sniff has to come first, or it is not a sniff. let sniff = body.find("imghdr::from_bytes").expect("checked above"); let fallback = body.rfind("attempt_both").expect("checked above"); assert!(sniff < fallback, "the fallback runs before the sniff"); } // ---- Image sizing and the resize anchor ---- /// FitWidth follows the aspect ratio; a dragged size overrules it. #[test] fn a_dragged_height_overrules_the_aspect_ratio() { let two_to_one = Some((400.0, 200.0)); // 200px wide at 2:1 wants 100px tall. assert_eq!( image_display_height(ImageSizing::FitWidth, two_to_one, 200.0, 400.0), 100.0 ); // Once dragged, the chosen height stands regardless of the ratio. assert_eq!( image_display_height(ImageSizing::Fixed(250.0), two_to_one, 200.0, 400.0), 250.0 ); } /// An image can never be dragged smaller than its own grab anchor. /// Below that the anchor is unreachable and the user would have to /// remove the attachment to recover it. #[test] fn an_image_cannot_be_dragged_below_its_anchor() { assert_eq!( image_display_height(ImageSizing::Fixed(1.0), None, 200.0, 400.0), IMAGE_MIN_HEIGHT ); assert_eq!( image_display_height(ImageSizing::Fixed(-500.0), None, 200.0, 400.0), IMAGE_MIN_HEIGHT ); } /// Both modes respect the ceiling, so one tall photo cannot make a row /// taller than the viewport. #[test] fn image_height_is_capped_in_both_modes() { let tall = Some((100.0, 5000.0)); assert_eq!( image_display_height(ImageSizing::FitWidth, tall, 200.0, 240.0), 240.0 ); assert_eq!( image_display_height(ImageSizing::Fixed(9999.0), tall, 200.0, 240.0), 240.0 ); } /// The row grows to whatever the image will actually draw at, so the two /// cannot disagree — a row shorter than its image would clip it. #[test] fn the_row_matches_what_the_image_will_draw() { let att = CellAttachment::Image { path: "/tmp/x.png".into(), intrinsic: Some((400.0, 200.0)), sizing: ImageSizing::Fixed(180.0), }; let drawn = image_display_height( ImageSizing::Fixed(180.0), Some((400.0, 200.0)), 200.0, 400.0, ); assert_eq!(att.preferred_height(200.0, 72.0, 400.0), drawn); } /// A chosen size travels with the column, because it lives on the /// attachment rather than in widget state. #[test] fn a_resized_image_keeps_its_size_through_a_reorder() { let mut d = table_data(); d.set_attachment( 0, 0, CellAttachment::Image { path: "/tmp/x.png".into(), intrinsic: Some((10.0, 10.0)), sizing: ImageSizing::Fixed(150.0), }, ); reorder_columns(&mut d, 0, 3); match d.attachment(0, 2) { Some(CellAttachment::Image { sizing, .. }) => { assert_eq!(*sizing, ImageSizing::Fixed(150.0)); } other => panic!("the image did not follow its column: {other:?}"), } } // ---- Item 5 follow-through: per-row heights ---- /// A height dragged onto one row belongs to that row alone. It used to /// set `row_height` for the whole table, so resizing one row resized /// every row. #[test] fn a_dragged_row_height_is_per_row() { let mut d = table_data(); d.rows[0].height = Some(90.0); assert_eq!(d.rows[0].height, Some(90.0)); assert_eq!(d.rows[1].height, None, "the other row was changed too"); } /// `row_height_for` must honour a per-row override, and the resize must /// write one. /// /// Testing the data alone was not enough: deleting the override branch /// from `row_height_for` left the whole suite green, because nothing /// connected the stored height to the geometry. `row_height_for` needs a /// widget, so this reads the source — blunt, but the alternative was no /// coverage of the connection at all. #[test] fn the_row_override_is_actually_consulted() { let src = include_str!("table.rs"); let start = src .find("fn row_height_for") .expect("row_height_for exists"); let end = src[start..] .find("\n fn ") .map(|i| start + i) .unwrap_or(src.len()); let body = &src[start..end]; assert!( body.contains("row.height"), "row_height_for ignores the per-row override, so dragging a row \ handle changes nothing" ); // And the drag has to store it on the row rather than on the table. let rs = src.find("fn apply_resize").expect("apply_resize exists"); let re = src[rs..] .find("\n fn ") .map(|i| rs + i) .unwrap_or(src.len()); let resize = &src[rs..re]; assert!( resize.contains("row.height = Some("), "the row resize does not write a per-row height" ); assert!( !resize.contains("self.row_height = "), "the row resize is setting the table-wide height again, which \ resizes every row at once" ); } /// Unequal rows accumulate correctly, which is what makes a per-row /// height visible at all: every row below a resized one shifts down. #[test] fn a_resized_row_shifts_the_rows_below_it() { let heights = [40.0, 120.0, 40.0]; let mut g = RowGeometry::default(); g.recompute(0.0, heights.iter()); assert_eq!(g.top(0), 0.0); assert_eq!(g.top(1), 40.0); // Row 2 starts after the tall row, not at 2 * 40. assert_eq!(g.top(2), 160.0); assert_eq!(g.row_at(150.0), Some(1)); assert_eq!(g.row_at(165.0), Some(2)); } // ---- Item 6: per-row heights ---- #[test] fn row_geometry_accumulates_unequal_heights() { let heights = [30.0, 80.0, 30.0]; let mut g = RowGeometry::default(); g.recompute(100.0, heights.iter()); assert_eq!(g.bounds, vec![100.0, 130.0, 210.0, 240.0]); assert_eq!(g.len(), 3); assert_eq!(g.top(1), 130.0); assert_eq!(g.height(1), 80.0); } /// Hit-testing a row must scan the boundaries. Dividing by a single row /// height is only correct while every row is the same, which item 6 /// breaks. #[test] fn row_lookup_handles_unequal_heights() { let heights = [30.0, 80.0, 30.0]; let mut g = RowGeometry::default(); g.recompute(0.0, heights.iter()); assert_eq!(g.row_at(0.0), Some(0)); assert_eq!(g.row_at(29.9), Some(0)); assert_eq!(g.row_at(30.0), Some(1)); // Deep inside the tall row — a uniform divisor would say row 3. assert_eq!(g.row_at(100.0), Some(1)); assert_eq!(g.row_at(110.0), Some(2)); // Past the end, and before the start. assert_eq!(g.row_at(140.0), None); assert_eq!(g.row_at(-1.0), None); } #[test] fn row_geometry_is_empty_without_rows() { let g = RowGeometry::default(); assert!(g.is_empty()); assert_eq!(g.row_at(10.0), None); assert_eq!(g.height(0), 0.0); } /// A provider that declines must be reported, not ignored. The default /// trait methods decline, which is what an unwired host gets. #[test] fn the_default_provider_declines_everything() { struct Unwired; impl CellAttachmentProvider for Unwired {} // The defaults return false, which drives AttachmentUnavailable. // Verified through the trait rather than the widget, which needs a Cx. fn declines(_p: &P) -> bool { true } assert!(declines(&Unwired)); assert_eq!(AttachmentSource::File.label(), "file picker"); assert_eq!(AttachmentSource::Camera.label(), "camera"); assert_eq!(AttachmentSource::Location.label(), "location"); } // ---- Phase 3: menu geometry ---- #[test] fn menu_height_follows_item_count() { let menu = MenuState { target: MenuTarget::Row(0), pos: dvec2(0.0, 0.0), items: (0..4) .map(|i| MenuItem { id: live_id!(x), label: format!("item {i}"), }) .collect(), hover: None, }; assert_eq!(menu.height(), 4.0 * 28.0 + 8.0); } #[test] fn menu_hit_test_resolves_each_item_and_rejects_outside() { let menu = MenuState { target: MenuTarget::Col(1), pos: dvec2(100.0, 100.0), items: (0..3) .map(|i| MenuItem { id: live_id!(x), label: format!("item {i}"), }) .collect(), hover: None, }; assert_eq!(menu.hit_test(dvec2(150.0, 118.0)), Some(0)); assert_eq!(menu.hit_test(dvec2(150.0, 146.0)), Some(1)); assert_eq!(menu.hit_test(dvec2(150.0, 174.0)), Some(2)); assert_eq!(menu.hit_test(dvec2(50.0, 150.0)), None); assert_eq!(menu.hit_test(dvec2(150.0, 50.0)), None); assert_eq!(menu.hit_test(dvec2(150.0, 400.0)), None); } // ---- Editing appearance ---- // // The cell editor's colours live in the `script_mod!` DSL, which is data // this crate does not parse. A test cannot ask the widget what colour it // drew without a GPU. What it *can* do is read the DSL source and assert // the properties are present, which is the failure that actually // happened: `TextInput` blends through `color_focus`, `color_empty` and // their border partners, and any state left unset silently inherits the // dark theme — white text on a grey inset the moment the caret lands. // // Reading the source is a blunt instrument. It is worth having anyway, // because the alternative is that nobody notices until they look at the // screen, and the whole class of bug here is "looks fine in code". /// Source of the `cell_editor` block in the DSL. fn cell_editor_dsl() -> &'static str { let src = include_str!("table.rs"); let start = src .find("cell_editor: TextInput {") .expect("cell_editor block exists"); // The block ends at the next line that is a closing brace at the // DSL's sub-widget indent. let rest = &src[start..]; let end = rest.find("\n }").expect("cell_editor block closes"); &rest[..end] } /// Every state `TextInput` can blend to must be pinned, or it falls back /// to the theme. /// /// The list is exhaustive on purpose. The first version of this test /// checked a hand-written subset and passed while `color_down` was /// missing from `draw_bg` — the animator holds `down: 1.0` for as long as /// the pointer is pressed, so clicking a cell flashed the theme fill /// until the pointer moved off and the state decayed. A test that only /// checks the states someone remembered is a test that misses the one /// they forgot. #[test] fn the_cell_editor_pins_every_text_colour_state() { let dsl = cell_editor_dsl(); for state in [ "color_hover", "color_focus", "color_down", "color_disabled", "color_empty", "color_empty_hover", "color_empty_focus", ] { assert!( dsl.contains(state), "draw_text state `{state}` is unset and will follow the theme" ); } } /// The fill blends through six states plus six gradient partners, and /// every one of them has to be white. /// /// `color_down` is the reason this test is separate from the text one: /// it is the state the animator drives while the mouse button is held, /// and it was the single omission that produced "goes white when I click, /// looks right once I move the mouse away". #[test] fn the_cell_editor_pins_every_fill_state_including_down() { let dsl = cell_editor_dsl(); for state in [ "color_down: #xffffff", "color_2: #xffffff", "color_2_hover: #xffffff", "color_2_focus: #xffffff", "color_2_down: #xffffff", "color_2_empty: #xffffff", "color_2_disabled: #xffffff", ] { assert!( dsl.contains(state), "fill state missing: `{state}` — it will follow the theme" ); } } /// Taking keyboard focus must request a redraw. /// /// `begin_edit` cannot focus the editor directly: `set_key_focus` needs an /// `Area`, and the editor only has one after it has been drawn at least /// once. So focus is deferred to the next `handle_event`. That step /// changes how the editor paints — caret, focused colour states — but it /// ran without asking for another frame, so the editor kept its /// pre-focus appearance until some unrelated event forced a redraw. /// /// That is the reported "text is not visible until I move the mouse": /// moving the pointer was the unrelated event. #[test] fn taking_editor_focus_requests_a_redraw() { let src = include_str!("table.rs"); let start = src .find("if self.needs_editor_focus {") .expect("the deferred focus block exists"); let end = src[start..] .find("\n }") .map(|i| start + i) .unwrap_or(src.len()); let block = &src[start..end]; assert!( block.contains("set_key_focus"), "the deferred focus block no longer focuses the editor" ); assert!( block.contains("self.redraw(cx)"), "focus is taken without requesting a redraw, so the editor keeps \ its unfocused appearance until an unrelated event repaints it" ); } /// Opening an editor puts the caret after the existing text. /// /// `set_text` leaves the cursor at index 0, so typing into a cell that /// already had content inserted at the front. Every spreadsheet continues /// from the end, and `commit_edit` reads the whole buffer back, which /// only makes sense if the user was appending to it. #[test] fn opening_an_editor_puts_the_caret_after_the_text() { let src = include_str!("table.rs"); let start = src.find("fn begin_edit").expect("begin_edit exists"); let end = src[start..] .find("\n fn ") .map(|i| start + i) .unwrap_or(src.len()); let body = &src[start..end]; assert!( body.contains("move_cursor_text_end"), "begin_edit does not place the caret; it will sit at index 0" ); // And it must happen after the text is loaded, or it moves to the end // of the *previous* value and the caret is wrong again. // // Comparing the first index of each is not enough: swapping the two // lines leaves `set_text` still appearing first inside the doc // comment above them, so the naive check passed on reordered code. // Compare the first *statement* of each instead, ignoring comments. let stmt = |needle: &str| { body.lines() .position(|l| { let t = l.trim(); !t.starts_with("//") && t.contains(needle) }) .unwrap_or_else(|| panic!("no statement calling `{needle}`")) }; assert!( stmt("set_text") < stmt("move_cursor_text_end"), "the caret is placed before the text is loaded, so it lands at \ the end of the previous value" ); } #[test] fn the_cell_editor_background_is_white_in_every_state() { let dsl = cell_editor_dsl(); // The four fill states plus the base. Grey here is the reported bug. for state in [ "color: #xffffff", "color_hover: #xffffff", "color_focus: #xffffff", "color_empty: #xffffff", "color_disabled: #xffffff", ] { assert!(dsl.contains(state), "background state missing: `{state}`"); } } /// The editor draws no border of its own. The green rectangle is /// `draw_select`, drawn over the same rect in `draw_walk`; a border here /// sits inside it and reads as a doubled frame. #[test] fn the_cell_editor_draws_no_border_of_its_own() { let dsl = cell_editor_dsl(); assert!( dsl.contains("border_size: 0.0"), "the editor should not draw its own border" ); // Transparent in every border state, including the `_2` gradient // partners, which show as a grey edge even at zero width. for state in [ "border_color: #x00000000", "border_color_hover: #x00000000", "border_color_focus: #x00000000", "border_color_empty: #x00000000", "border_color_2: #x00000000", "border_color_2_focus: #x00000000", ] { assert!(dsl.contains(state), "border state missing: `{state}`"); } } /// The caret is the only cue that a cell is in edit mode, so it must not /// inherit `theme.color_text_cursor`, which is chosen for a dark inset. #[test] fn the_caret_is_dark_enough_to_see_on_white() { assert!( cell_editor_dsl().contains("draw_cursor +: {"), "the cursor colour is unset and will follow the theme" ); } /// Editing ink matches non-editing ink. If these two drift the cell /// changes colour as the caret arrives, which is the reported bug. #[test] fn editing_text_is_the_same_ink_as_resting_text() { const INK: &str = "#x1f2937"; let src = include_str!("table.rs"); // The resting cell text layer. let resting = src .find("draw_text +: {") .map(|i| &src[i..i + 200]) .expect("draw_text layer exists"); assert!( resting.contains(INK), "the resting cell ink changed; update this test and the editor" ); assert!( cell_editor_dsl().contains(&format!("color_focus: {INK}")), "the editor's focused ink no longer matches the resting cell" ); } // ---- Phase 5: cell kinds and alignment ---- /// A column is plain text unless told otherwise, so tables built before /// `CellKind` existed keep rendering exactly as they did. #[test] fn columns_default_to_text() { assert_eq!(TableColumn::default().kind, CellKind::Text); assert_eq!(CellKind::default(), CellKind::Text); let d = table_data(); assert!(d.columns.iter().all(|c| c.kind == CellKind::Text)); } /// A Latex column keeps its kind across a reorder. The kind travels with /// the column because it is a field on it — this pins that, since moving /// a formula column and having it render as prose is the sort of thing /// that only shows up on screen. #[test] fn a_latex_column_keeps_its_kind_through_a_reorder() { let mut d = table_data(); d.columns[0].kind = CellKind::Latex; reorder_columns(&mut d, 0, 3); assert_eq!(titles(&d), ["B", "C", "A"]); assert_eq!(d.columns[2].kind, CellKind::Latex); assert_eq!(d.columns[0].kind, CellKind::Text); assert_eq!(d.columns[1].kind, CellKind::Text); } // ---- Alignment and overflow ---- // // `align_text_x` takes a measured width, and `fit_text_measured` takes a // measuring closure. That is the substance of the fix: only the real // layouter knows how wide a run will be, and a per-character estimate is // what let right-aligned text overflow. // // The tests inject a measurer so they stay deterministic without a GPU. // `mono(w)` models the old constant-width estimate; `varied` gives // different characters different widths, as a real font does, and is what // an estimate cannot survive. /// Every character `w` wide. fn mono(w: f64) -> impl Fn(&str) -> f64 { move |s: &str| s.chars().count() as f64 * w } /// A deliberately non-uniform font. fn varied(s: &str) -> f64 { s.chars() .map(|c| match c { 'i' | 'l' | '.' => 3.0, 'W' | 'M' => 14.0, ELLIPSIS => 9.0, _ => 8.0, }) .sum() } fn cell_at(x: f64, w: f64) -> Rect { Rect { pos: dvec2(x, 0.0), size: dvec2(w, 40.0), } } fn pad10() -> Inset { Inset { left: 10.0, top: 0.0, right: 10.0, bottom: 0.0, } } #[test] fn left_alignment_sits_at_the_padded_edge() { let r = cell_at(100.0, 100.0); let pad = pad10(); assert_eq!(align_text_x(CellAlign::Left, &r, 28.0, &pad), 110.0); assert_eq!(align_text_x(CellAlign::Left, &r, 0.0, &pad), 110.0); // Even an overlong run starts at the padded edge. assert_eq!(align_text_x(CellAlign::Left, &r, 500.0, &pad), 110.0); } #[test] fn centre_and_right_alignment_use_the_measured_width() { let r = cell_at(0.0, 100.0); let pad = pad10(); assert_eq!(align_text_x(CellAlign::Center, &r, 28.0, &pad), 36.0); assert_eq!(align_text_x(CellAlign::Right, &r, 28.0, &pad), 62.0); } /// A right-aligned run ends exactly at the padded right edge, for any /// width that fits. This is the property that was broken: the estimate /// under-measured, so the computed start was too far right and the run /// finished past the edge. #[test] fn a_right_aligned_run_ends_at_the_padded_right_edge() { let pad = pad10(); for (cell_w, text_w) in [(100.0, 10.0), (100.0, 79.9), (200.0, 180.0), (60.0, 1.0)] { let r = cell_at(500.0, cell_w); let x = align_text_x(CellAlign::Right, &r, text_w, &pad); let end = x + text_w; let limit = r.pos.x + r.size.x - pad.right; assert!( (end - limit).abs() < 1e-9, "cell {cell_w} text {text_w}: run ends at {end}, edge is {limit}" ); } } /// The reported bug as a property: however the text is aligned and /// fitted, the run never crosses either edge of its own cell. #[test] fn a_fitted_run_never_leaves_its_cell() { let pad = pad10(); let r = cell_at(300.0, 120.0); let available = cell_text_space(&r, &pad); let samples = [ "", "x", "short", "WWWWWWWWWWWWWWWWWWWW", "iiiiiiiiiiiiiiiiiiiiiiiiiiiiiii", "a mixed String With Wide and iiii narrow glyphs, quite long", ]; for text in samples { let shown = fit_text_measured(text, available, varied); let w = varied(&shown); for align in [CellAlign::Left, CellAlign::Center, CellAlign::Right] { let x = align_text_x(align, &r, w, &pad); assert!( x >= r.pos.x + pad.left - 1e-9, "{align:?} {text:?} started at {x}, left of the cell" ); assert!( x + w <= r.pos.x + r.size.x - pad.right + 1e-9, "{align:?} {text:?} ended at {}, past the right edge", x + w ); } } } /// Two adjacent columns, the first right-aligned and overlong, the second /// left-aligned. They must not touch. #[test] fn a_right_aligned_column_does_not_overlap_the_next_one() { let pad = pad10(); let a = cell_at(0.0, 100.0); let b = cell_at(100.0, 100.0); let a_shown = fit_text_measured( "1234567890123456789012345", cell_text_space(&a, &pad), varied, ); let a_x = align_text_x(CellAlign::Right, &a, varied(&a_shown), &pad); let a_end = a_x + varied(&a_shown); let b_shown = fit_text_measured("next", cell_text_space(&b, &pad), varied); let b_x = align_text_x(CellAlign::Left, &b, varied(&b_shown), &pad); assert!( a_end <= b_x, "column A ends at {a_end} but column B starts at {b_x}" ); assert!(a_x >= a.pos.x + pad.left); } #[test] fn text_that_fits_is_left_alone() { assert_eq!(fit_text_measured("short", 180.0, mono(7.0)), "short"); assert_eq!(fit_text_measured("", 180.0, mono(7.0)), ""); let exact: String = "x".repeat(25); assert_eq!( fit_text_measured(&exact, 175.0, mono(7.0)), exact, "a string exactly as wide as the space was truncated" ); } #[test] fn overlong_text_is_truncated_with_an_ellipsis() { let shown = fit_text_measured("abcdefghijklmnopqrstuvwxyz", 80.0, mono(7.0)); assert!(shown.ends_with(ELLIPSIS), "no ellipsis in {shown:?}"); assert!(mono(7.0)(&shown) <= 80.0, "{shown:?} is still too wide"); assert!(shown.chars().count() < 26, "nothing was truncated"); } /// Truncation follows measured width, not character count, so wide glyphs /// are cut sooner than narrow ones in the same space. A per-character /// estimate cannot do this, and that is what produced the overflow. #[test] fn truncation_follows_glyph_width_not_character_count() { let available = 100.0; let wide = fit_text_measured("WWWWWWWWWWWWWWWW", available, varied); let narrow = fit_text_measured("iiiiiiiiiiiiiiii", available, varied); assert!( narrow.chars().count() > wide.chars().count(), "narrow {:?} ({}) should keep more than wide {:?} ({})", narrow, narrow.chars().count(), wide, wide.chars().count() ); assert!(varied(&wide) <= available); assert!(varied(&narrow) <= available); } /// Degenerate cells draw nothing rather than a glyph wider than the space /// they were given. #[test] fn a_cell_with_no_room_draws_nothing() { assert_eq!(fit_text_measured("anything", 0.0, mono(7.0)), ""); assert_eq!(fit_text_measured("anything", -5.0, mono(7.0)), ""); // Room for less than the ellipsis itself. assert_eq!(fit_text_measured("anything", 2.0, mono(7.0)), ""); // Padding wider than the cell clamps to zero rather than going // negative. assert_eq!(cell_text_space(&cell_at(0.0, 15.0), &pad10()), 0.0); } /// Truncation cuts on character boundaries, never mid-codepoint. #[test] fn truncation_never_splits_a_character() { let text: String = "\u{00e9}".repeat(20); let shown = fit_text_measured(&text, 70.0, mono(7.0)); assert!(shown.ends_with(ELLIPSIS)); let kept: Vec = shown.chars().take(shown.chars().count() - 1).collect(); assert!(kept.iter().all(|c| *c == '\u{00e9}'), "{shown:?}"); assert!(mono(7.0)(&shown) <= 70.0); } /// The binary search lands on the largest prefix that fits, not merely /// some prefix. An off-by-one shows as a visible gap on every truncated /// cell. #[test] fn truncation_keeps_as_much_as_will_fit() { // 7px per char, 70px -> 10 glyphs -> 9 characters plus the ellipsis. let shown = fit_text_measured("abcdefghijklmnop", 70.0, mono(7.0)); assert_eq!(shown, "abcdefghi\u{2026}"); assert_eq!( fit_text_measured("abcdefghijklmnop", 71.0, mono(7.0)), shown ); assert_eq!( fit_text_measured("abcdefghijklmnop", 77.0, mono(7.0)), "abcdefghij\u{2026}" ); } /// The draw path must fit before it aligns, align the string it will draw, /// and measure with the real layouter. /// /// `draw_cells` needs a live `Cx`, so this reads the source. Blunt, but /// the ordering is what a later edit reverses by accident. #[test] fn the_draw_path_aligns_the_measured_truncated_string() { let src = include_str!("table.rs"); let start = src.find("fn draw_cells").expect("draw_cells exists"); let end = src[start..] .find("\n fn ") .map(|i| start + i) .unwrap_or(src.len()); let body = &src[start..end]; let stmt = |needle: &str| { body.lines() .position(|l| { let t = l.trim(); !t.starts_with("//") && t.contains(needle) }) .unwrap_or_else(|| panic!("no statement calling `{needle}`")) }; assert!( stmt("fit_text_measured") < stmt("align_text_x"), "the text is aligned before it is fitted" ); assert!( body.contains("shown_width"), "draw_cells aligns something other than the measured truncated string" ); // Both the fit and the align must measure with the layouter. Checking // that `size_in_lpxs` merely appears somewhere is not enough: the // first version of this test passed while the *alignment* width had // been replaced with a 7px-per-character estimate, because the fitting // call still mentioned it. Count the measurements instead. assert_eq!( body.matches("size_in_lpxs").count(), 2, "draw_cells should measure twice — once to fit, once to align" ); // And no hand-rolled width estimate anywhere in the draw path. This // is the mistake being guarded: real glyphs are wider than any fixed // per-character figure, so an estimate under-measures and the run // overflows the cell. assert!( !body.contains("chars().count() as f64 *"), "draw_cells is estimating a text width instead of measuring it" ); } /// `align_text_x` clamps to the padded left edge. /// /// Tested directly rather than through a fitted string: once the text has /// been shortened to fit, the clamp never fires, so a test that only goes /// through `fit_text_measured` cannot tell whether it is there. Removing /// the clamp left the whole suite green until this case was added. /// /// The clamp still matters, because the fitted width and the aligned /// width are two separate measurements of two separate strings. Any /// disagreement between them — a font fallback, a shaping difference — /// puts the run slightly wider than the space, and the clamp is what /// keeps that from becoming a spill into the previous column. #[test] fn alignment_clamps_a_run_wider_than_its_cell() { let pad = pad10(); let r = cell_at(200.0, 100.0); let padded_left = r.pos.x + pad.left; // A width larger than the cell: every alignment must still start at // the padded left edge rather than left of the cell. for align in [CellAlign::Left, CellAlign::Center, CellAlign::Right] { let x = align_text_x(align, &r, 400.0, &pad); assert_eq!( x, padded_left, "{align:?} did not clamp: started at {x}, cell edge is {padded_left}" ); } // Just barely too wide, which is the realistic case. let slightly_over = cell_text_space(&r, &pad) + 0.5; for align in [CellAlign::Center, CellAlign::Right] { let x = align_text_x(align, &r, slightly_over, &pad); assert!( x >= padded_left, "{align:?} started at {x}, left of {padded_left}" ); } } /// The fit search terminates. An off-by-one in the bisection — /// `lo = mid - 1` instead of `lo = mid` — loops forever rather than /// failing, which turns a wrong answer into a hung frame. #[test] fn the_fit_search_terminates_on_hostile_input() { // Long strings, tiny budgets, and a measurer that never shrinks. for available in [0.5, 1.0, 7.0, 9.5, 50.0] { let long = "x".repeat(500); let _ = fit_text_measured(&long, available, mono(7.0)); let _ = fit_text_measured(&long, available, varied); } // A measurer that reports the same width whatever it is given: the // search must still stop. let _ = fit_text_measured("abcdef", 5.0, |_| 100.0); let _ = fit_text_measured("abcdef", 5.0, |_| 0.0); } // ---- Phase 6: solid spec parsing ---- #[test] fn a_uniform_cube_takes_one_dimension() { assert_eq!( parse_solid_spec("cube 10"), Ok(SolidSpec::Cube { sx: 10.0, sy: 10.0, sz: 10.0 }) ); } #[test] fn a_box_takes_three_dimensions() { assert_eq!( parse_solid_spec("cube 10 20 30"), Ok(SolidSpec::Cube { sx: 10.0, sy: 20.0, sz: 30.0 }) ); } #[test] fn spheres_and_cylinders_parse() { assert_eq!( parse_solid_spec("sphere 5"), Ok(SolidSpec::Sphere { radius: 5.0 }) ); assert_eq!( parse_solid_spec("cylinder 3 12"), Ok(SolidSpec::Cylinder { radius: 3.0, height: 12.0 }) ); } /// Commas, extra whitespace, aliases and case are all tolerated: a cell /// is typed by hand and "Cube 10,20,30" is not a mistake worth refusing. #[test] fn the_spec_syntax_is_forgiving_about_separators_and_case() { let expected = Ok(SolidSpec::Cube { sx: 10.0, sy: 20.0, sz: 30.0, }); assert_eq!(parse_solid_spec("cube 10,20,30"), expected); assert_eq!(parse_solid_spec(" CUBE 10 , 20 ,30 "), expected); assert_eq!(parse_solid_spec("box 10 20 30"), expected); assert_eq!( parse_solid_spec("CYL 3 12"), Ok(SolidSpec::Cylinder { radius: 3.0, height: 12.0 }) ); } /// Every rejection names what was wrong. A 3D cell that silently draws /// nothing is indistinguishable from an empty one, so the reason has to /// survive as far as the cell. #[test] fn bad_specs_are_rejected_with_a_reason() { assert_eq!(parse_solid_spec(""), Err(SolidSpecError::Empty)); assert_eq!(parse_solid_spec(" "), Err(SolidSpecError::Empty)); assert_eq!( parse_solid_spec("torus 4 2"), Err(SolidSpecError::UnknownShape("torus".into())) ); assert_eq!( parse_solid_spec("sphere"), Err(SolidSpecError::WrongArity { shape: "sphere".into(), expected: 1, got: 0 }) ); assert_eq!( parse_solid_spec("cube 1 2"), Err(SolidSpecError::WrongArity { shape: "cube".into(), expected: 3, got: 2 }) ); assert_eq!( parse_solid_spec("sphere wide"), Err(SolidSpecError::NotANumber("wide".into())) ); } /// Zero, negative and non-finite dimensions are refused. A zero-extent /// solid has no wireframe, and a NaN would propagate through the /// projection into coordinates that are neither drawn nor diagnosable. #[test] fn non_positive_and_non_finite_dimensions_are_refused() { assert!(matches!( parse_solid_spec("sphere 0"), Err(SolidSpecError::NonPositive(_)) )); assert!(matches!( parse_solid_spec("sphere -5"), Err(SolidSpecError::NonPositive(_)) )); assert!(matches!( parse_solid_spec("cube 1 inf 3"), Err(SolidSpecError::NonPositive(_)) )); assert!(matches!( parse_solid_spec("cube 1 NaN 3"), Err(SolidSpecError::NonPositive(_)) )); } #[test] fn every_error_has_a_nonempty_label() { let errors = [ SolidSpecError::Empty, SolidSpecError::UnknownShape("torus".into()), SolidSpecError::WrongArity { shape: "cube".into(), expected: 3, got: 1, }, SolidSpecError::NotANumber("x".into()), SolidSpecError::NonPositive(-1.0), ]; for e in errors { let label = e.label(); assert!(label.starts_with('['), "{label}"); assert!(label.ends_with(']'), "{label}"); assert!(label.len() > 2, "{label}"); } } // ---- Phase 6: wireframe ---- /// A box has twelve edges and eight distinct corners. Getting this wrong /// is easy and looks almost right on screen. #[test] fn a_cube_wireframe_has_twelve_edges() { let edges = wireframe_edges(&SolidSpec::Cube { sx: 2.0, sy: 2.0, sz: 2.0, }); assert_eq!(edges.len(), 12); let mut corners = Vec::new(); for (a, b) in &edges { for p in [a, b] { if !corners .iter() .any(|q: &Point3| (q.x, q.y, q.z) == (p.x, p.y, p.z)) { corners.push(*p); } } } assert_eq!(corners.len(), 8); } /// Extents are centred on the origin, so the projection has nothing to /// re-centre and a cell renders the same shape at any dimension. #[test] fn a_cube_wireframe_is_centred_on_the_origin() { let edges = wireframe_edges(&SolidSpec::Cube { sx: 4.0, sy: 6.0, sz: 8.0, }); let (mut lo, mut hi) = ([f64::MAX; 3], [f64::MIN; 3]); for (a, b) in &edges { for p in [a, b] { for (i, v) in [p.x, p.y, p.z].iter().enumerate() { lo[i] = lo[i].min(*v); hi[i] = hi[i].max(*v); } } } assert_eq!((lo[0], hi[0]), (-2.0, 2.0)); assert_eq!((lo[1], hi[1]), (-3.0, 3.0)); assert_eq!((lo[2], hi[2]), (-4.0, 4.0)); } #[test] fn curved_solids_produce_edges() { assert!(!wireframe_edges(&SolidSpec::Sphere { radius: 1.0 }).is_empty()); assert!(!wireframe_edges(&SolidSpec::Cylinder { radius: 1.0, height: 2.0 }) .is_empty()); } /// Every vertex of a sphere's wireframe lies on the sphere. #[test] fn a_sphere_wireframe_stays_on_its_radius() { let r = 3.0; for (a, b) in wireframe_edges(&SolidSpec::Sphere { radius: r }) { for p in [a, b] { let d = (p.x * p.x + p.y * p.y + p.z * p.z).sqrt(); assert!((d - r).abs() < 1e-9, "vertex at radius {d}, expected {r}"); } } } // ---- Phase 6: projection ---- fn cell() -> Rect { Rect { pos: dvec2(100.0, 200.0), size: dvec2(180.0, 40.0), } } /// The projection must stay inside the cell it was given, or a wireframe /// bleeds over its neighbours. #[test] fn a_projection_fits_inside_its_cell() { let r = cell(); let inset = 4.0; for spec in [ SolidSpec::Cube { sx: 10.0, sy: 20.0, sz: 30.0, }, SolidSpec::Sphere { radius: 7.0 }, SolidSpec::Cylinder { radius: 4.0, height: 25.0, }, ] { let projected = project_isometric(&wireframe_edges(&spec), r, inset); assert!(!projected.is_empty(), "{spec:?} projected to nothing"); for (a, b) in projected { for p in [a, b] { assert!( p.x >= r.pos.x + inset - 1e-6 && p.x <= r.pos.x + r.size.x - inset + 1e-6, "{spec:?} x={} outside cell", p.x ); assert!( p.y >= r.pos.y + inset - 1e-6 && p.y <= r.pos.y + r.size.y - inset + 1e-6, "{spec:?} y={} outside cell", p.y ); } } } } /// Scaling to fit means a 1-unit cube and a 1000-unit cube fill the cell /// identically. Without it a cell would show either a dot or nothing. #[test] fn projection_is_scale_invariant() { let r = cell(); let small = project_isometric( &wireframe_edges(&SolidSpec::Cube { sx: 1.0, sy: 1.0, sz: 1.0, }), r, 4.0, ); let large = project_isometric( &wireframe_edges(&SolidSpec::Cube { sx: 1000.0, sy: 1000.0, sz: 1000.0, }), r, 4.0, ); assert_eq!(small.len(), large.len()); for ((a0, b0), (a1, b1)) in small.iter().zip(large.iter()) { assert!((a0.x - a1.x).abs() < 1e-6, "{} vs {}", a0.x, a1.x); assert!((a0.y - a1.y).abs() < 1e-6); assert!((b0.x - b1.x).abs() < 1e-6); assert!((b0.y - b1.y).abs() < 1e-6); } } /// Geometry that is *not* symmetric about the origin must still be /// centred in the cell. /// /// This test exists because the symmetric cases below cannot catch the /// bug they appear to cover: every primitive here is built centred, so /// the midpoint of its projection is already zero and subtracting it is /// a no-op. Removing the re-centring from `project_isometric` left all /// of them green. An off-centre input is the only thing that /// distinguishes "centres the drawing" from "happens to be centred". #[test] fn an_off_centre_solid_is_still_centred_in_its_cell() { let r = cell(); // Take a cube's edges and shove them well away from the origin. let offset = Point3 { x: 500.0, y: -300.0, z: 120.0, }; let edges: Vec<(Point3, Point3)> = wireframe_edges(&SolidSpec::Cube { sx: 5.0, sy: 5.0, sz: 5.0, }) .into_iter() .map(|(a, b)| { let shift = |p: Point3| Point3 { x: p.x + offset.x, y: p.y + offset.y, z: p.z + offset.z, }; (shift(a), shift(b)) }) .collect(); let projected = project_isometric(&edges, r, 4.0); assert!(!projected.is_empty()); let (mut min_x, mut max_x) = (f64::MAX, f64::MIN); let (mut min_y, mut max_y) = (f64::MAX, f64::MIN); for (a, b) in &projected { for p in [a, b] { min_x = min_x.min(p.x); max_x = max_x.max(p.x); min_y = min_y.min(p.y); max_y = max_y.max(p.y); } } let cx = r.pos.x + r.size.x * 0.5; let cy = r.pos.y + r.size.y * 0.5; assert!( ((min_x + max_x) * 0.5 - cx).abs() < 1e-6, "x centre {} != {cx}", (min_x + max_x) * 0.5 ); assert!( ((min_y + max_y) * 0.5 - cy).abs() < 1e-6, "y centre {} != {cy}", (min_y + max_y) * 0.5 ); // And it must still be inside the cell. assert!(min_x >= r.pos.x + 4.0 - 1e-6 && max_x <= r.pos.x + r.size.x - 4.0 + 1e-6); } /// The drawing is centred in the cell, so a wide column does not push it /// against an edge. #[test] fn a_projection_is_centred_in_its_cell() { let r = cell(); let projected = project_isometric( &wireframe_edges(&SolidSpec::Cube { sx: 5.0, sy: 5.0, sz: 5.0, }), r, 4.0, ); let (mut min_x, mut max_x) = (f64::MAX, f64::MIN); let (mut min_y, mut max_y) = (f64::MAX, f64::MIN); for (a, b) in &projected { for p in [a, b] { min_x = min_x.min(p.x); max_x = max_x.max(p.x); min_y = min_y.min(p.y); max_y = max_y.max(p.y); } } let cx = r.pos.x + r.size.x * 0.5; let cy = r.pos.y + r.size.y * 0.5; assert!(((min_x + max_x) * 0.5 - cx).abs() < 1e-6); assert!(((min_y + max_y) * 0.5 - cy).abs() < 1e-6); } /// Degenerate inputs return nothing rather than dividing by a zero span /// or emitting NaN coordinates. #[test] fn degenerate_projections_return_nothing() { let r = cell(); assert!(project_isometric(&[], r, 4.0).is_empty()); // Inset larger than the cell leaves no room. let edges = wireframe_edges(&SolidSpec::Cube { sx: 1.0, sy: 1.0, sz: 1.0, }); assert!(project_isometric(&edges, r, 100.0).is_empty()); // A zero-size cell. let empty = Rect { pos: dvec2(0.0, 0.0), size: dvec2(0.0, 0.0), }; assert!(project_isometric(&edges, empty, 0.0).is_empty()); } /// No projected coordinate may be NaN or infinite: DrawVector would /// silently drop the path and the cell would look empty. #[test] fn projected_coordinates_are_always_finite() { let r = cell(); for spec in [ SolidSpec::Cube { sx: 0.001, sy: 1000.0, sz: 1.0, }, SolidSpec::Sphere { radius: 1e-6 }, SolidSpec::Cylinder { radius: 1e6, height: 1e-3, }, ] { for (a, b) in project_isometric(&wireframe_edges(&spec), r, 4.0) { for p in [a, b] { assert!( p.x.is_finite() && p.y.is_finite(), "{spec:?} produced {p:?}" ); } } } } #[test] fn a_solid_column_keeps_its_kind_through_a_reorder() { let mut d = table_data(); d.columns[1].kind = CellKind::Solid3d; reorder_columns(&mut d, 1, 0); assert_eq!(titles(&d), ["B", "A", "C"]); assert_eq!(d.columns[0].kind, CellKind::Solid3d); } /// The menu must not be taller than its own items, or the hit-test and /// the drawn background disagree and clicks land on the wrong row. #[test] fn menu_items_tile_the_menu_without_gaps() { let menu = MenuState { target: MenuTarget::Row(2), pos: dvec2(0.0, 0.0), items: (0..5) .map(|i| MenuItem { id: live_id!(x), label: format!("item {i}"), }) .collect(), hover: None, }; for i in 0..4 { let a = menu.item_rect(i); let b = menu.item_rect(i + 1); assert!( (a.pos.y + a.size.y - b.pos.y).abs() < f64::EPSILON, "gap between item {i} and {}", i + 1 ); } let last = menu.item_rect(4); assert!(last.pos.y + last.size.y <= menu.full_rect().pos.y + menu.height()); } }