# 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 (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<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 `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.

### 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<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.
