# ADR-0016: Pretty table rendering for data and structure views ## Status Accepted ## Context The output panel today renders tabular content with hand-rolled pipe-and-dash separators (`render_data_view` in `app.rs`) and plain indented lines for table-structure summaries. Both work, both are clearly placeholders. The result is functional but unmistakably "first pass": a learner pulling up the app for the first time sees a default-looking TUI rather than a polished teaching tool. The walking skeleton's NFR-4 commits to "distinctive design" — a reviewer should be able to identify the app from a screenshot of any view — and NFR-5 requires colour to convey information. Neither bar is met by the current rendering. This ADR commits to a focused upgrade of two specific output surfaces: 1. **Data tables.** Output of `show data `, `insert` / `update` / `delete` auto-show, and the future query DSL. 2. **Table structure.** Output of `create table`, `add column`, `add 1:n relationship`, and `show table `. Relationship visualization (two structures side-by-side with an arrow indicating the relationship) is **deliberately out of scope**; the structure renderer here is sized so the future relationship view can compose two of them. The bigger UX project — V4's scrollable session log, smart structure rendering tied to the items panel selection, Markdown export — also remains out of scope. This ADR is a narrow visual-quality pass on the existing output panel. ## Decision ### 1. Border style **Header-only Unicode borders.** Outer frame plus a header underline; data rows are separated only by the column dividers, no per-row horizontal rule. ``` ┌────┬───────┬──────────────┐ │ id │ Name │ Email │ ├────┼───────┼──────────────┤ │ 1 │ Alice │ a@example.io │ │ 2 │ Bob │ b@example.io │ └────┴───────┴──────────────┘ ``` Box-drawing characters: `┌─┐│└─┘├┤┬┴┼`. UTF-8 only; we already require UTF-8 throughout the app and crossterm handles the encoding. No ASCII fallback in v1: if a terminal can't render box-drawing, the user has bigger problems than table aesthetics. Rejected: - **Full grid (per-row separators).** Visually noisy for typical row counts (5–50). The header underline alone is enough to anchor the eye. - **Minimal (no outer frame).** Cleaner, but the lack of containment makes the table blend into surrounding output in a cluttered session. ### 2. Column alignment - **Numeric** (`int`, `real`, `decimal`, `serial`): right-aligned. - **Boolean**: left-aligned (`true` / `false` are roughly equal width and read better left-aligned next to text). - **Everything else** (`text`, `date`, `datetime`, `blob`, `shortid`): left-aligned. Type is the source of truth: `DataResult` carries the user-facing `Type` per column, looked up from the column-metadata table (the same lookup `describe_table` already does for `ColumnDescription.user_type`). The existing `query_data` / `insert` / `update` / `delete` paths in `db.rs` populate this alongside the column names and rows so the renderer doesn't have to guess and the display is deterministic regardless of cell content. `DataResult` extends to: ```rust pub struct DataResult { pub table_name: String, pub columns: Vec, /// User-facing type per column. Same semantics as /// `ColumnDescription.user_type`: always populated for /// tables created through the DSL; `None` only for the /// edge case of a foreign-attached database we did not /// create. The renderer falls back to left-alignment /// when `None`. pub column_types: Vec>, pub rows: Vec>>, } ``` Structure rendering uses the same `Type` value directly from `ColumnDescription.user_type`. ### 3. NULL and special values - **NULL** renders as the literal text `(null)`. The parentheses distinguish it from a string `"null"` in a text column. - **Newlines** in cell text are replaced by `↵` for display. Preserves the visual single-row-per-record property; the underlying CSV still round-trips faithfully. - **Tab** characters in cell text are replaced by `→` for the same reason. - **Other control characters** (anything below `0x20` except those handled above) replaced by `·` (middle dot). These substitutions are display-only. The underlying data in `playground.db` and `data/.csv` is untouched; the round-trip from `insert` → `show data` → CSV → `rebuild` preserves the original characters. ### 4. Width and truncation The renderer computes intrinsic column widths as `max(header_width, max(cell_width))` per column, in **Unicode codepoint count** (`chars().count()`). East Asian wide characters and grapheme clusters are counted as one codepoint each, which under-counts visual width slightly; acceptable for v1 and consistent with how the rest of the codebase counts widths today. If the rendered table is wider than the output panel, ratatui's existing line-truncation handles the right edge. **No proactive cell truncation in this iteration.** Adding a per-cell ellipsis pass (`…` when a cell exceeds a budget) is a clean follow-up once we have a working renderer to measure against; doing it up front commits to a width budget we can't yet justify. ### 5. Structure view Each structure rendering emits one pretty table: ``` ┌──────┬────────┬─────────────┐ │ Name │ Type │ Constraints │ ├──────┼────────┼─────────────┤ │ id │ serial │ PK │ │ Name │ text │ NOT NULL │ └──────┴────────┴─────────────┘ ``` Columns: - **Name** — the column name, verbatim. - **Type** — `Type::keyword()` (e.g., `serial`, `text`, `shortid`). Falls back to the SQLite-reported type if metadata is missing — only happens for foreign-attached databases we didn't create, not in normal use. - **Constraints** — comma-separated list of declared constraints in this priority order: `PK`, `NOT NULL`. Future constraints (`UNIQUE`, `CHECK`, `DEFAULT` from C3) append to the list as they land. Empty when no constraints apply (rendered as a single space, which the border still frames). Foreign-key references are **not** rendered inside the constraint column. They live in a separate `References:` / `Referenced by:` block below the structure table, formatted as today's plain-text indented list. The rationale is that relationships are a multi-table concept and deserve their own visualization (the future "two structures + arrow" layout); flattening them into a single column would foreclose that design. The block-level layout under a single command: ``` [ok] create table Customers ┌──────┬────────┬─────────────┐ │ Name │ Type │ Constraints │ ├──────┼────────┼─────────────┤ │ id │ serial │ PK │ │ Name │ text │ │ └──────┴────────┴─────────────┘ References: cust_id → Orders.cust_id (cust_orders, on delete cascade, on update no_action) Referenced by: Orders.cust_id → id (cust_orders, on delete cascade, on update no_action) ``` The relationship sections retain today's plain-text format to leave room for the future relationship-rendering ADR. > **Superseded.** ADR-0044 replaced this prose block with compact > diagrams on relationship-subject surfaces (`show table`, > `add`/`drop relationship`). **ADR-0050 (2026-06-12, issue #28)** then > removed the relationship block entirely from incidental-DDL structure > echoes (`create table`, `add`/`drop`/`rename`/`change column`, > `add`/`drop index`) — those render structure only — and **deleted the > prose renderer**. The `References:` / `Referenced by:` format above is > retained here as documentation/provenance should the OOS-7 > always-prose display setting ever be built. ### 6. Theme integration Theme colors apply to the box-drawing characters via the existing output-panel rendering path (the lines go through `OutputLine` with `OutputKind::System` styling). No per-cell theming in this iteration — that's V4 territory (NFR-5 mentions "query result types" colouring, which implies per-cell awareness; we set that up but don't ship it yet). The chosen border characters render legibly on both light and dark backgrounds in standard terminals. NFR-7 is inherited rather than newly designed for. ### 7. Implementation **Hand-rolled.** Consistent with the project's pattern of narrow hand-rolled writers (CSV serializer, YAML writer, history.log appender). A crate (`tabled`, `comfy-table`) would save ~150 lines but cost ~70–150 KB of binary, remove direct control over the substitutions in §3, and add a dependency that's worth carrying only if our needs later outgrow what we hand-roll. The renderer lives in a new module `src/output_render.rs` (distinct from `src/ui.rs`, which is the ratatui Frame renderer for the whole TUI layout). Public surface: ```rust pub fn render_data_table(data: &DataResult) -> Vec; pub fn render_structure(desc: &TableDescription) -> Vec; ``` Each returns a `Vec`, one display row per element — matches the existing `OutputLine`-per-line discipline so scroll math remains accurate. Internal helpers (border characters, padding, alignment detection, control-character substitution) are private to the module. ### 8. Testing - **Unit tests** in `output_render.rs` covering: each alignment rule, NULL rendering, control-character substitution, the structure layout for a representative schema, and width computation under multibyte input. - **Snapshot tests** via `insta` for the canonical examples (a 2-column data table, a 3-column structure with PK + NOT NULL, a structure with relationships). The snapshots pin the exact rendered output and catch unintended drift. - **Existing integration tests** (Tier-3) continue to exercise the call sites; their assertions on output text may need updates where they currently match pipe-separator format. ### 9. Out of scope - **OOS-1.** Relationship visualization (two structures + arrow). Its own ADR; will compose `render_structure`. - **OOS-2.** V4's scrollable session log + Markdown export. - **OOS-3.** Per-cell theming for query-result type highlighting (NFR-5 partial — sets up the architecture, defers the colours until the query DSL ships). - **OOS-4.** Cell-level truncation with ellipsis. - **OOS-5.** ASCII fallback for terminals that can't render box-drawing characters. ## Consequences - The two most-frequented output surfaces (data results, structure listings) gain a polished, distinctive look matching NFR-4. - The renderer module is a stable dependency for future work: relationship visualization composes `render_structure`; query-result rendering reuses `render_data_table` (with eventual type-aware alignment); V4's session log adds chrome around whatever this module produces. - `DataResult` gains a `column_types` field; the four call sites in `db.rs` (`query_data`, `insert`, `update`, `delete`) populate it from the same metadata table `describe_table` already consults. No new query paths. - Light/dark theme legibility is inherited from the current output-panel styling. Per-cell theming is deliberately deferred. - Shipping this *before* the query-DSL ADR means the new command's results land already looking polished, which is the right order: pedagogy is worse when the visualisation lags the feature.