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.
12 KiB
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:
- Data tables. Output of
show data <T>,insert/update/deleteauto-show, and the future query DSL. - Table structure. Output of
create table,add column,add 1:n relationship, andshow 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 (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/falseare 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
0x20except 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 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,DEFAULTfrom 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. TheReferences:/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:
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.rscovering: each alignment rule, NULL rendering, control-character substitution, the structure layout for a representative schema, and width computation under multibyte input. - Snapshot tests via
instafor 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 reusesrender_data_table(with eventual type-aware alignment); V4's session log adds chrome around whatever this module produces. DataResultgains acolumn_typesfield; the four call sites indb.rs(query_data,insert,update,delete) populate it from the same metadata tabledescribe_tablealready 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.