Replaces the placeholder pipe-and-dash output with Unicode box-drawing tables for both data results and table-structure listings, per ADR-0016. * New `src/output_render.rs` module with `render_data_table` and `render_structure`. Hand-rolled to match the project's existing CSV/YAML pattern; ~300 lines. * Header-only outer-frame border style: outer ┌─┐│└─┘ box + ├─┤ header underline, no per-row separators. NULL renders as `(null)`; cell newlines/tabs/control chars become `↵`/`→`/`·` as display-only substitutions. * Type-aware column alignment: numeric types right-aligned, everything else left. `DataResult` gains a `column_types: Vec<Option<Type>>` field, populated from the existing metadata lookup at the two query sites in db.rs (no new query paths). * Structure view shows Name | Type | Constraints columns; References / Referenced-by sections retain plain-text format, leaving room for the future relationship-rendering ADR. * 18 new unit tests in output_render.rs (plus 4 insta snapshots for the canonical layouts). Existing assertions in app.rs and walking_skeleton.rs updated to match the new format. Total: 426 passing, 0 failing, 0 skipped (up from 408). Clippy clean.
11 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.
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.