20 KiB
Wide Markdown Table Scrolling — Tech Spec
Product spec: specs/zachlloyd/wide-markdown-table-scrolling/PRODUCT.md
Context
The branch implements the behavior described in the sibling PRODUCT.md: wide Markdown tables scroll horizontally inside their own viewport, and selection / caret / hit-testing / copy stay attached to content while the table or any clipped surface is scrolled.
Investigation against crates/markdown_parser/examples/table-demo/all_test_cases.md surfaced two classes of bugs that both needed to be addressed:
- Editor-backed tables did not yet own a horizontal viewport, so wide tables simply overflowed the surrounding editor.
- Once a cell contained inline Markdown (
**Bold**,*Italic*, links), source offsets and rendered offsets were mixed in selection and copy paths, producing drift even before the wide-table work.
Separately, the shared clipped-scrollable path could re-use stale screen-space coordinates after a scroll, which showed up as wrong word selection and wrong copied text.
Relevant code
crates/editor/src/render/model/mod.rs—LaidOutTable(horizontal scroll state, reveal logic, character bounds) andRenderState::autoscroll(table-aware reveal)crates/editor/src/render/element/table.rs—RenderableTablepainting, clipping, and event dispatch for scroll/drag/hovercrates/editor/src/render/model/location.rs— hit-testing that accounts for local table scrollcrates/editor/src/render/model/table_offset_map.rs—TableOffsetMap(table-wide) andTableCellOffsetMap(per-cell source↔rendered)crates/editor/src/render/layout.rs—TextLayout::with_container_scrolls_horizontallycarries the carve-out flagcrates/editor/src/content/text.rs—BufferBlockStyle::Tablevariant,TableCache/TableBlockCache, andtable_cell_offset_mapscrates/editor/src/content/edit.rs—layout_table_block,measure_table_cells, per-cell size clampcrates/editor/src/content/buffer.rs— table-aware clipboard text extraction and partial-table HTML filteringcrates/warpui_core/src/elements/shared_scrollbar.rs— sharedScrollbarAppearance/ScrollbarGeometryand scrollbar mathcrates/warpui_core/src/elements/clipped_scrollable.rs— selection anchor (anchor_and_adjust_selection_for_scroll)crates/warpui_core/src/elements/new_scrollable/mod.rs— dispatches selection APIs through the anchor helpercrates/warpui_core/src/elements/formatted_text_element.rs— horizontal bounds check for smart selectionapp/src/notebooks/editor/mod.rs— notebook table appearance (scrollbar colors, etc.)
Proposed changes
Shared scrollbar primitives in warpui_core
crates/warpui_core/src/elements/shared_scrollbar.rs is the single source of truth for:
ScrollbarAppearance/ScrollbarGeometry(overlay scrollbar geometry, thumb bounds, track bounds)- Minimum thumb sizing (
MIN_SCROLLBAR_THUMB_LENGTH) compute_scrollbar_geometry(...)project_scroll_delta_by_sensitivity(...)for axis projection of mixed-axis gesturesscroll_delta_for_pointer_movement(...)for pointer-drag-to-scroll translation
Both the editor table renderer and the existing new_scrollable utilities call through these helpers. The goal is not to nest a NewScrollable inside tables but to share scrollbar math and mixed-axis resolution.
Editor tables own a local horizontal viewport
LaidOutTable in crates/editor/src/render/model/mod.rs gains local horizontal scroll state:
scroll_left: Cell<Pixels>TableScrollbarInteractionState(drag state, hovered)viewport_width(),max_scroll_left(),set_scroll_left(),scroll_horizontally()reveal_offset()for keyboard/caret autoscrollhorizontal_scroll_allowedflag (see carve-out below)
RenderableTable::paint in crates/editor/src/render/element/table.rs:
- Computes a clipped viewport rectangle for the visible region.
- Translates painted content by
scroll_left. - Paints inside a clipped layer.
- Derives the thumb via
compute_scrollbar_geometry()and paints it using notebook theme colors fromMarkdownTableAppearance/TableStyle.
RenderableTable::dispatch_event handles LeftMouseDown (thumb + gutter), LeftMouseDragged, LeftMouseUp, MouseMoved, and ScrollWheel. Two event-propagation rules worth calling out:
- The
ScrollWheelhandler returns the boolean result ofscroll_horizontally()rather than unconditionally consuming the event. When the table is pinned at an edge, the event falls through to the surrounding vertical scroller (PRODUCT invariant 11). - The
MouseMovedhandler returnsfalseregardless of whether the pointer is over the thumb; scrollbar hover state is still updated viaset_scrollbar_hovered()andctx.notify(), but the event continues to propagate to downstream handlers (PRODUCT invariant 12).
Horizontally scrollable container carve-out
Table-local horizontal scrolling only applies when the surrounding surface doesn't own horizontal scroll. In code editors using WidthSetting::InfiniteWidth, wide tables render at full intrinsic width:
TextLayoutcarries acontainer_scrolls_horizontallyflag, set viaTextLayout::with_container_scrolls_horizontally(...).RenderState::container_scrolls_horizontally()returnstruewhenwidth_setting == WidthSetting::InfiniteWidth;RenderState::layout_context()andTextLayout::from_layout_context(...)propagate the flag.layout_table_block()reads the flag and setshorizontal_scroll_allowedon the resultingLaidOutTableto its inverse.- When
horizontal_scroll_allowed == false:viewport_width()returns the full table width,max_scroll_left()returns zero, andscroll_left()/scroll_horizontally()/reveal_offset()/set_scroll_left()become no-ops. Paint widens the clip layer to the full table,table_scrollbar()returnsNone, and scrollbar drag/hover handling short-circuits. Wheel events fall through to the surrounding editor.
AI block list contexts keep the default false, preserving existing behavior.
Per-cell source↔rendered offset mapping
TableCellOffsetMap in crates/editor/src/render/model/table_offset_map.rs tracks source↔rendered ranges per inline fragment. For each fragment it records rendered start/end, visible source start/end, and source end including Markdown markers. It supports rendered_length(), source_length(), rendered_to_source(), and source_to_rendered().
TableCellOffsetMap::from_inline_and_source(source, inline) derives spans by walking the raw cell source character-by-character alongside each fragment's rendered text, rather than reconstructing marker lengths from style flags. For each fragment:
- Advance through source until the fragment's first rendered character is found. Intervening source characters are attributed to this fragment as markers.
- Consume the remaining rendered characters from source, treating
\<punct>as a single-character escape (two source chars → one rendered char).
This replaces an earlier hardcoded fragment_source_marker_lengths helper and therefore:
- Handles backslash-escaped punctuation correctly (previously silently drifted).
- Stays correct if the Markdown parser ever changes marker syntax, because we walk actual source rather than regenerating it.
- Handles nested styles where adjacent fragments share outer markers (e.g.
**a *b* c**), attributing each marker once.
content::text::table_cell_offset_maps(table, source) receives the parsed FormattedTable and the raw tab/newline-separated source, splits the source into rows/cells, and passes each cell's source through the builder. Synthetic cells added by normalize_shape get an empty source string and produce empty maps.
An in-code TODO above impl TableCellOffsetMap notes that moving cell/row boundaries into the SumTree with new BufferText marker types would let editable tables derive per-cell offsets by seeking to boundaries rather than reparsing. That refactor is deferred to the editable-tables workstream.
Table layout is source-based, rendering stays rendered-text-based
layout_table_block() in crates/editor/src/content/edit.rs:
- Parses the table into formatted inline fragments (via the cache below when available).
- Builds
TableCellOffsetMaps for every cell. - Builds the table-wide
TableOffsetMapfrom cell source lengths. - Preserves
content_lengthfrom the original text block source.
LaidOutTable stores the per-cell maps and uses them whenever a source offset touches rendered geometry (coordinate→offset conversion, link lookup, relative character bounds, selection highlight ranges, cursor placement).
The rule: editor-facing APIs stay source-based; text layout, frame widths, and painting stay rendered-text-based; conversions happen explicitly at the cell boundary.
Hit-testing, selection, and caret geometry
crates/editor/src/render/model/location.rsaddsscroll_leftbefore callingcoordinate_to_offset().LaidOutTable::character_bounds()subtractsscroll_leftwhen returning screen-space bounds.RenderState::autoscroll()callsreveal_autoscroll_offsets_in_tables()so keyboard movement horizontally reveals the active caret/selection inside the table.softwrap_point_to_offset()for tables resolves to the first visible character in the row instead of using raw row starts.crates/editor/src/render/element/mod.rshit-testing uses the editor element's own layer bounds rather than the clipped child layer bounds, so clicks inside a wide table are not rejected before block-level hit-testing runs.
Per-cell maximum content width
crates/editor/src/content/edit.rs introduces:
MAX_TABLE_CELL_CONTENT_WIDTH_PX= 500.0 — maximum content width (exclusive of cell padding).maximum_table_cell_width(table_style)helper, mirroringminimum_table_cell_width(..).
In measure_table_cells, per-cell measured widths are clamped with both .max(minimum_table_cell_width(..)) and .min(maximum_table_cell_width(..)) before they fold into the shared column width. The second layout pass in layout_table_block lays each cell at cell_content_width = column_width - cell_padding * 2.0, and the existing text layout soft-wraps the clamped cells without further changes.
The cap is applied unconditionally — including when horizontal_scroll_allowed == false — so infinite-width containers still get readability benefits. Single very long unbreakable tokens (e.g. long URLs) may still render wider than the cap because soft-wrap can't break them; the column width stays clamped and the token visually overflows inside the clipped table region.
Clipboard behavior is table-aware
crates/editor/src/content/buffer.rs:
- Plain-text copy walks selected block segments and routes table segments through
clipboard_table_text_in_range(). - Table copy rebuilds the formatted table (from cache when available), computes source-based cell ranges, converts the selected source span in each cell to rendered offsets, and slices visible cell text accordingly.
- Partial table selections return rendered plain text with tabs/newlines preserved.
- HTML export in
selected_text_as_htmlfilters only the ranges that contain a partial table selection, serializing the remaining clean ranges to HTML normally. Only when every range is a partial-table range does it returnNone.
Clipped scrollables keep selections anchored to content
crates/warpui_core/src/elements/clipped_scrollable.rs:
ClippedScrollStateHandlestores aselection_scroll_anchor(original selection + scroll position at the time it was observed).anchor_and_adjust_selection_for_scroll(selection, axis)either records the anchor (first time) or shifts the selection by the delta between current scroll and the anchored scroll. Doc comment spells out the three branches (None clears anchor; unmatched Selection installs a new anchor; matched Selection returns a scroll-compensated copy).clear_selection_scroll_anchor()resets that state when a fresh mouse-down starts a new interaction.
NewScrollable routes selection APIs through the anchor helper during paint, in get_selection(), and in calculate_clickable_bounds(). New mouse-down clears the anchor.
Smart selection respects visible horizontal bounds
FormattedTextElement::smart_select() returns None when the click point is outside the visible horizontal bounds of the text frame (not just outside vertical bounds). This prevents double-click word selection from targeting text that is off-screen after horizontal scrolling.
Lazy cache on the BufferBlockStyle::Table marker
Both the clipboard copy path and the layout path previously parsed each table block on every invocation. That parse is deterministic from the block's plain text plus alignments, so it is lifted onto the marker:
crates/editor/src/content/text.rsintroducesTableBlockCache(owns parsedFormattedTable,Vec<Vec<TableCellOffsetMap>>, andTableOffsetMap) and aTableCachenewtype wrappingArc<OnceLock<TableBlockCache>>.BufferBlockStyle::Tablegains acache: TableCachefield. ABufferBlockStyle::table(alignments)helper constructs the variant with an empty cache.TableCacheimplementsPartialEq/Eq/Hashas no-ops soBufferBlockStyleequality and hashing remain a function ofalignments.Clonestays cheap because the field is a sharedArc.TableCache::get_or_populate(text, alignments)runs the parse once and returns&TableBlockCacheon subsequent calls. Marker clones share the sameOnceLock.clipboard_table_text_in_rangeandlayout_table_blockboth route through the cache. A defensive fallback inlayout_table_blockbuilds an owned cache on the stack when the style isn'tTable.
The staleness trade-off is the same one that already applies to alignments: if a cell were edited in place without replacing the block marker, the cache could go stale. This matters only when editable tables land; for read-only tables, marker replacement on edit is sufficient.
Testing and validation
Each numbered invariant in PRODUCT.md maps to at least one test or verification step below.
Automated tests
- PRODUCT invariants 2–3 (overflow rule), 9 (scrollbar interaction):
render::element::table::tests::table_scrollbar_uses_shared_overlay_geometry,table_scrollbar_drag_state_survives_renderable_recreation,table_scrollbar_pointer_movement_matches_drag_and_gutter_behavior. - PRODUCT invariant 4 (container carve-out): exercised via
horizontal_scroll_allowed == falsecode paths in model tests; manual pass in a code editor surface configured withWidthSetting::InfiniteWidth. - PRODUCT invariants 7–8 (per-cell max width):
content::edit::tests::test_layout_table_block_clamps_cell_width_to_max. - PRODUCT invariant 11 (wheel edge propagation): covered by the
ScrollWheelhandler returningscroll_horizontally()'s boolean; manual trackpad pass confirms fall-through. - PRODUCT invariant 12 (
MouseMovednot consumed): covered by the handler returningfalse; manual hover-link check confirms downstream handlers still fire. - PRODUCT invariants 13–15 (selection and copy while scrolled):
content::buffer::tests::test_selected_table_copy_uses_visible_plain_text,test_partial_table_selection_does_not_export_html,test_partial_table_selection_still_exports_html_for_non_table_ranges,test_clipboard_table_copy_uses_source_offsets_for_later_formatted_cells. - PRODUCT invariants 13–14 (selection anchor stability):
warpui_core::elements::new_scrollable::scrollable_testregressions for viewport-coordinate selection APIs, re-anchoring existing selections across horizontal scroll, and clearing the anchor on new mouse-down interactions. - PRODUCT invariant 13 (double-click word selection across columns):
smart_select_returns_none_when_point_is_outside_horizontal_bounds(formatted_text_element_tests). - PRODUCT invariant 18 (caret reveal during keyboard movement):
render::model::location_tests(hit-testing after horizontal scroll) andrender::model::mod_tests(reveal/autoscroll for offsets inside tables). - PRODUCT invariants 6 and 13–15 as applied to formatted cells (inline Markdown correctness):
render::model::table_offset_map::tests::test_table_cell_offset_map_handles_bold_and_links,test_table_cell_offset_map_handles_backslash_escaped_punctuation,test_table_cell_offset_map_handles_nested_styles.
Manual validation
crates/markdown_parser/examples/table-demo/all_test_cases.mdopened in a notebook for a visual pass covering PRODUCT invariants 2–3, 6, 7–8, 9–15, 19–21.- AI block list responses containing a wide Markdown table — trackpad scroll, direct scrollbar interaction, double-click word selection across columns after scroll, partial-range text copy, and an existing selection that remains visually anchored across further horizontal scroll.
- Regression pass confirming vertical scrolling stays owned by the surrounding notebook or block list (PRODUCT invariant 5).
- Regression pass confirming narrow tables and non-table Markdown remain unchanged (PRODUCT invariant 22).
Pre-merge gates
cargo fmtcargo clippy --workspace --all-targets --all-features --tests -- -D warningscargo check -p warp_editorandcargo check -p warpui_core- Targeted
cargo nextest run --no-fail-fast --workspace ...for the regressions listed above.
Risks and mitigations
Two offset spaces drift again
If any table path assumes source and rendered offsets are interchangeable, formatted cells will reintroduce selection/copy drift. Mitigation: keep TableCellOffsetMap as the single translation layer; build the table-wide TableOffsetMap from source lengths only; route hit-testing, cursor, selection, and copy through explicit source↔rendered conversions.
Selection pinned to viewport coordinates
If clipped scrollables reuse raw selection rectangles after scroll changes, highlights and copied text target the wrong content. Mitigation: anchor selections via ClippedScrollStateHandle, clear the anchor on fresh mouse interactions, and route selection APIs through anchor_and_adjust_selection_for_scroll().
Scrollbar behavior diverges between surfaces
If the editor table path reintroduces bespoke thumb sizing or drag math, it will drift from the rest of WarpUI. Mitigation: keep geometry and pointer/scroll conversion in shared_scrollbar.rs and call the shared helpers from both the editor table renderer and the new_scrollable utilities.
Clipped child layers interfere with editor hit-testing
If editor bounds checks use the clipped child layer instead of the editor element bounds, clicks can be rejected before block-level hit-testing runs. Mitigation: use the editor element's own layer bounds in crates/editor/src/render/element/mod.rs.
Stale cache on in-place cell edits
TableCache is keyed by marker instance. If a future change edits a cell in place without replacing the block marker, the cache will be stale. Mitigation (current): marker replacement on edit is sufficient for read-only tables. Mitigation (longer-term): the SumTree follow-up below invalidates per-cell state naturally.
Follow-ups
- If additional inline Markdown styles are supported in table cells, add a focused
TableCellOffsetMap::from_inline_and_sourceregression alongside the parser change — the source-walking algorithm should handle them automatically, but a test pins the behavior. - Other clipped horizontal surfaces adopting the same selection APIs inherit the
ClippedScrollStateHandlebehavior automatically, but they should get a manual regression pass when adopting new selection UX. - When editable tables or other complex table operations land, evaluate moving cell/row boundaries into the
SumTreewith newBufferTextmarker types so per-cell offsets can be derived by seeking instead of reparsing. See the in-codeTODOaboveimpl TableCellOffsetMap.