nigig-org/crates/apps/makepad_table/src/table.rs
andodeki 7737096858
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
makepad-table / model (push) Has been cancelled
makepad-table / widget (push) Has been cancelled
makepad-table / hygiene (push) Has been cancelled
refactor(makepad-table): adopt Robrix's image decode path
Replaces the hand-rolled try-PNG-then-JPEG with
`pageflipnav/src/utils.rs::load_png_or_jpg`, the pattern the Robrix-derived
app in this repo already uses. Two things it does better:

- It sniffs the header with `imghdr` and calls the matching loader directly,
  so a JPEG does not decode-and-fail as a PNG first on every cold cache.
- It still falls back to trying both when the sniff names something
  unexpected or nothing at all. `imghdr` is not perfect, and a mislabelled
  file is more useful decoded than refused.

`imghdr` has no transitive dependencies — it reads a header and names a
format. It is already a dependency of `pageflipnav` at the same version.

The upstream version logs the failure and dumps the bad bytes to disk. That
is right for a chat client receiving untrusted media and wrong here: this
runs from the draw path for every attached cell, so a broken file would log
once per frame. The caller already caches the failure and draws a labelled
chip naming the file, which tells the user more than a log line would.

Tests 94 -> 99. Verified by removing the sniff and by removing the fallback;
each fails the ordering test.

`TextOrImage`, the other candidate for reuse, is referenced in
`room_screen.rs` but not defined anywhere in this checkout — it is upstream
Robrix only, so there was no baseline here to adopt.
2026-08-18 16:51:47 +00:00

6195 lines
229 KiB
Rust

// ============================================================================
// 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<String>,
/// 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<f64>,
}
#[derive(Clone, Debug, Default)]
pub struct TableData {
pub columns: Vec<TableColumn>,
pub rows: Vec<TableRow>,
/// Attachments keyed by `(row, column)` (item 6).
///
/// A side-table rather than a field on the cell, so `cells` stays
/// `Vec<String>` 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<CellAttachment> {
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::<TableAction>() {
/// 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<usize>),
/// Pointer is hovering over a column header.
ColHovered(Option<usize>),
/// 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<MenuItem>,
/// Index of the hovered item (if any).
hover: Option<usize>,
}
// ---- 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<SolidSpec, SolidSpecError> {
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<usize>,
},
/// 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<f64>,
}
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<Item = &'a f64>) {
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<usize> {
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<usize> {
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<f64>,
}
impl ColumnGeometry {
/// Recompute boundaries from a starting x and the column widths.
pub fn recompute<'a>(&mut self, start_x: f64, widths: impl Iterator<Item = &'a f64>) {
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<usize> {
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<usize> {
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<usize>,
/// Mouse hover target (column index) — drives column header hover.
#[rust]
hover_col: Option<usize>,
/// 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<MenuState>,
// ---- Phase 4: column drag-reorder state ----
/// Column currently being dragged by its header (None = not dragging).
#[rust]
col_drag: Option<ColDrag>,
// ---- Item 5: header selection and resizing ----
/// Header selected by a single click. Drives the resize affordance.
#[rust]
header_selection: Option<HeaderSelection>,
/// Live resize drag started from a selected header's handle.
#[rust]
resize_drag: Option<ResizeDrag>,
// ---- 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<HeaderSelection>,
/// The press currently being tracked, for long-press and click/drag
/// discrimination. Shared by the mouse and touch paths.
#[rust]
press_started: Option<PressTracker>,
/// 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<Texture>>,
/// Live drag of an image's corner anchor.
#[rust]
image_drag: Option<ImageDrag>,
/// 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<Box<dyn CellAttachmentProvider>>,
// ---- Cached layout (recomputed each draw_walk) ----
#[rust]
rect: Rect,
/// x-position of each column boundary. Length = columns.len() + 1.
#[rust]
col_x: Vec<f64>,
/// 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<f64>,
/// 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<f64> = 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<f64> = (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<usize> {
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<char> = 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<dyn CellAttachmentProvider>) {
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<dyn CellAttachmentProvider>) {
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<TableData> {
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<Option<(usize, usize, std::path::PathBuf)>> =
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<String> {
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<String> = (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<String> = (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<String> = (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::<Vec<_>>()
);
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: CellAttachmentProvider>(_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<char> = 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());
}
}