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.
6195 lines
229 KiB
Rust
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());
|
|
}
|
|
}
|