Files
rdbms-playground/docs/adr/0016-pretty-table-rendering.md
T
claude@clouddev1 8ac3537df0 feat(render): incidental-DDL confirmations show structure only, no relationships (#28)
Per ADR-0050 (closing issue #28): the confirmation echo after an
incidental structural edit — create table, add/drop/rename/change
column, add/drop index — now renders the structure only (header +
column box + indexes + constraints) and no longer appends the
References:/Referenced by: relationship block.

Rationale: a confirmation reports the change just made, not the
table's relationships, which the user didn't touch. Relationship info
is still one `show table <T>` away, and the relationship-subject
surfaces (show table, add/drop relationship) keep their ADR-0044
diagrams unchanged.

Scope is all incidental DDL (user-confirmed). Mechanism: drop the
relationship-block call from render_structure (all its callers are
incidental DDL); the handle_dsl_success diagram-vs-structure routing
is unchanged. The orphaned relationship_prose_lines + cols_disp
helpers are deleted (the prose format survives in ADR-0016 §5 + git
history for a future OOS-7 always-prose setting).

ADR-0050 supersedes ADR-0044 §1's incidental-DDL prose clause and the
relationship-block half of ADR-0016 §5 (both annotated). Tests: prose-
presence unit test + snapshot removed; new unit test locks structure-
only with inbound+outbound relationships present; the misnamed add-
column integration test inverted + renamed. 2458 pass / 0 fail / 0
skip, clippy clean.
2026-06-12 22:45:18 +00:00

12 KiB
Raw Blame History

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 <T>, 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 <T>.

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 (550). 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:

pub struct DataResult {
    pub table_name: String,
    pub columns: Vec<String>,
    /// 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<Option<Type>>,
    pub rows: Vec<Vec<Option<String>>>,
}

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/<T>.csv is untouched; the round-trip from insertshow 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.
  • TypeType::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 ~70150 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:

pub fn render_data_table(data: &DataResult) -> Vec<String>;
pub fn render_structure(desc: &TableDescription) -> Vec<String>;

Each returns a Vec<String>, 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.