Merge branch 'main' into website (m:n, logging, UI nav, demo overlays, vi-nav)
Brings a large batch of app work onto the website branch so the docs (and casts) can reflect it: - #24 vi-style j/k/g/G navigation in the load picker (ADR-0047 era) — unblocks a scriptable projects cast (autocast can send j/k; not arrows) - #22 demonstration overlay layer (ADR-0047): `--demo` mode, keystroke badges, and step-caption info banners — usable from casts to highlight key moments - C4 m:n convenience command (ADR-0045): `add m:n relationship … via <junction>` - ADR-0046 UI: width-derived schema sidebar + Ctrl-O nav mode, responsive two-row input + horizontal scroll, geometry-fixed hint panel - X1 comprehensive logging sweep across worker/parser/app/persistence/runtime - FK fixes: compound-FK violation message names every column pair; inline FK referencing a compound PK points at the table-level form Merged clean — no conflicts (the docs/website/ ADR namespace split kept the new main ADRs 0045–0047 from colliding). Tests on the merged tree: 2290 passed, 0 failed (1 ignored doctest, inherited from main).
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
# ADR-0045: `create m:n relationship` convenience command (C4)
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (2026-06-10); **implemented 2026-06-10**. Closes
|
||||
`requirements.md` **C4**.
|
||||
|
||||
**Implementation note — a corrected ADR premise.** The plan claimed
|
||||
"the walker already dispatches multiple nodes per entry word" and used
|
||||
that to justify a *separate* `CREATE_M2N` node. That is true only in
|
||||
**advanced** mode. The build hit **two** places hard-coded to assume
|
||||
**≤1 DSL form per entry word in simple mode**: (1) the dispatcher
|
||||
(`decide`) committed `simple.first()` unconditionally, so `create
|
||||
table` shadowed `create m:n`; (2) the completion continuation-merge was
|
||||
gated `if mode == Advanced`, so simple mode never surfaced `m:n` as a
|
||||
candidate. Both were generalized to support multiple DSL forms per
|
||||
entry word — **behaviour-preserving for every existing single-form
|
||||
command** (the dispatch reduces to the old single-candidate commit; the
|
||||
completion merge is gated on `simple_count > 1`). Verified: zero ripple
|
||||
beyond the new command's own surfaces. The teaching echo (advanced-mode
|
||||
DSL→SQL, ADR-0038) was also wired: `render_create_m2n` emits the
|
||||
generated `CREATE TABLE … FOREIGN KEY …` from the post-exec junction
|
||||
description (round-trips as valid SQL).
|
||||
|
||||
A second `/runda` DA pass (pre-commit) closed five coverage gaps
|
||||
(highlighting, persistence round-trip, junction rename, name-collision,
|
||||
missing-parent — the first two had been wrongly claimed verified) and
|
||||
found two more issues: (a) `create m:n … as __rdbms_*` was accepted —
|
||||
a hidden-orphan hole the new `as` slot **exposed**, but rooted in the
|
||||
simple-mode `TABLE_NAME_NEW` slot having no internal-name guard (so
|
||||
plain `create table __rdbms_*` had it too). Fixed at the **root** —
|
||||
a `reject_internal_table_name` guard in the shared `do_create_table`,
|
||||
closing every path (the advanced-SQL path already rejected at parse).
|
||||
(b) the usage disambiguator (`usage_key_for_input`) handled the `1:n`
|
||||
opener but not `m:n`, so `create m:n …` resolved to no usage form —
|
||||
fixed with an explicit `m:n` branch. All four
|
||||
design forks were escalated and user-confirmed at the recommended
|
||||
option (compound-over-FKs junction PK; `CASCADE` actions; auto-name +
|
||||
optional `as`; both modes). Two follow-up points were also confirmed
|
||||
in a `/runda` DA pass: **self-referential m:n is refused outright**
|
||||
(user: "refuse — full stop"; it is a beginner-facing convenience, not
|
||||
the place for directional-naming complexity), and the FK column naming
|
||||
is **`{parent_table}_{pk_column}`**. The DA pass additionally
|
||||
established — against the user's initial assumption — that **PK-less
|
||||
tables *are* reachable** (advanced-mode SQL `create table t (a int)`
|
||||
declares no PK; `sql_create_table.rs` asserts `pk.is_empty()`), so the
|
||||
parent-PK guard (D7) is retained as a correctness check.
|
||||
|
||||
Builds on ADR-0013 (named 1:n relationships, the relationship
|
||||
metadata table, the rebuild-table primitive), ADR-0043 (compound,
|
||||
list-based FK references — the junction may reference compound parent
|
||||
PKs), ADR-0011 (`Type::fk_target_type()` for FK column typing), and
|
||||
the existing `do_create_table` executor (which already accepts
|
||||
`foreign_keys: Vec<SqlForeignKey>` and writes relationship metadata
|
||||
per FK). Honours ADR-0003 (mode model), ADR-0009 (DSL conventions),
|
||||
ADR-0002 (no engine name in user-facing strings), and ADR-0024
|
||||
(unified grammar / `CommandNode` registration, completion, hints,
|
||||
help-id, usage-id wiring).
|
||||
|
||||
## Context
|
||||
|
||||
A many-to-many relationship is modelled in a relational database by a
|
||||
**junction table** (a.k.a. associative / bridge table) that holds one
|
||||
foreign key to each of the two parents. Today a learner can build this
|
||||
by hand: `create table` the junction, then `add 1:n relationship`
|
||||
twice. That is three commands and requires the learner to already know
|
||||
the junction-table pattern — exactly the concept C4 is meant to
|
||||
*teach*.
|
||||
|
||||
C4 (`requirements.md`): *"`create m:n relationship from <T1> to <T2>`
|
||||
produces an auto-named junction table the user can rename; pulls
|
||||
primary keys and FK definitions automatically."*
|
||||
|
||||
The relationship machinery this builds on is freshly solid: ADR-0043
|
||||
made the relationship model list-based (compound-aware) across six
|
||||
layers, and ADR-0044 gave relationships a visual representation. C4 is
|
||||
the natural convenience layer on top.
|
||||
|
||||
## Decision
|
||||
|
||||
Add a DSL command:
|
||||
|
||||
```
|
||||
create m:n relationship from <T1> to <T2> [as <name>]
|
||||
```
|
||||
|
||||
It generates a **junction table** with one FK column per primary-key
|
||||
column of each parent, a **compound primary key** over all of those FK
|
||||
columns, and **two 1:n relationships** (junction → T1, junction → T2),
|
||||
all in a single transaction (= one undo step). The junction is a
|
||||
normal table: `rename table`, `drop table`, `show table`, `insert`,
|
||||
etc. all work on it afterward.
|
||||
|
||||
### D1 — Junction primary key: compound over the FK columns (fork, user-chosen)
|
||||
|
||||
The junction's PK is the **combination of all its FK columns**. For
|
||||
`create m:n relationship from Students to Courses` (both PK `id`):
|
||||
|
||||
```
|
||||
Students_Courses
|
||||
Students_id int ┐ PRIMARY KEY (Students_id, Courses_id)
|
||||
Courses_id int ┘
|
||||
FOREIGN KEY (Students_id) REFERENCES Students(id)
|
||||
FOREIGN KEY (Courses_id) REFERENCES Courses(id)
|
||||
```
|
||||
|
||||
This is the textbook junction: the `(Students_id, Courses_id)` pair is
|
||||
unique, so a student cannot be linked to the same course twice. It is
|
||||
the most pedagogically correct model and needs no surrogate key.
|
||||
|
||||
*Rejected:* a surrogate `serial` PK + `UNIQUE` over the FK pair (adds a
|
||||
key the learner did not ask for); two FK columns with no PK (allows
|
||||
duplicate links — wrong lesson).
|
||||
|
||||
### D2 — Referential actions: `CASCADE` on delete and update (fork, user-chosen)
|
||||
|
||||
Both generated FKs default to `ON DELETE CASCADE ON UPDATE CASCADE`. A
|
||||
junction row is meaningless without both ends, so deleting a parent
|
||||
(a Student or a Course) removes its link rows automatically — the
|
||||
natural junction semantics, and a clean teaching demonstration of
|
||||
cascade. There is no syntax in this command to override the actions;
|
||||
the learner who wants different actions builds the junction by hand
|
||||
(or drops + re-adds a relationship). This keeps the convenience
|
||||
command convenient.
|
||||
|
||||
### D3 — Naming: auto-name `<T1>_<T2>`, optional `as <name>` (fork, user-chosen)
|
||||
|
||||
The junction table is auto-named `{T1}_{T2}` (e.g. `Students_Courses`).
|
||||
An optional `as <name>` clause overrides it — consistent with
|
||||
`add 1:n relationship [as <name>]` and saving a follow-up
|
||||
`rename table`. The two generated relationships are auto-named by the
|
||||
existing relationship-name resolver (e.g.
|
||||
`Students_id_to_Students_Courses_Students_id`), exactly as `create
|
||||
table` with inline FKs already names them.
|
||||
|
||||
### D4 — FK column naming: `{parent_table}_{pk_column}`
|
||||
|
||||
Each FK column is named `{parent_table}_{pk_column}` — one per PK
|
||||
column of each parent. This disambiguates the common case where both
|
||||
parents share a PK column name (both `id` → `Students_id`,
|
||||
`Courses_id`), and generalises to compound parent PKs: a parent
|
||||
`Sections(course_id, term)` contributes `Sections_course_id` and
|
||||
`Sections_term`. Column types come from each parent PK column's
|
||||
`Type::fk_target_type()` (ADR-0011): `serial → int`, `shortid → text`,
|
||||
others identity — so the junction columns are plain storable types,
|
||||
never auto-generating.
|
||||
|
||||
### D5 — Mode availability: both simple and advanced
|
||||
|
||||
`create m:n relationship` is a `CommandCategory::Simple` DSL command,
|
||||
reachable in **both** input modes — the same posture as the sibling
|
||||
relationship commands (`drop relationship … Mode::Advanced` is a tested
|
||||
path today). There is no SQL spelling for it; an advanced-mode user who
|
||||
prefers raw SQL still has `CREATE TABLE` + `FOREIGN KEY`. The command
|
||||
is purely additive teaching sugar, so making it available everywhere is
|
||||
harmless and consistent.
|
||||
|
||||
### D6 — Implementation: one `do_create_table` call, not a batch
|
||||
|
||||
The junction table and both relationships are created by **building a
|
||||
single `Command`/worker request that `do_create_table` already
|
||||
handles**: `do_create_table` accepts `columns`, `primary_key`, and
|
||||
`foreign_keys: Vec<SqlForeignKey>`, and already inserts relationship
|
||||
metadata for each FK. So the executor:
|
||||
|
||||
1. Canonicalises `T1`, `T2` (`require_canonical_table`) and reads each
|
||||
parent's PK (`read_schema().primary_key`). **D7 — parent-PK guard:**
|
||||
if either parent's `primary_key` is empty, error with a friendly
|
||||
"`<table>` has no primary key, so it cannot anchor an m:n
|
||||
relationship" *before* building any FK. This is a real case — a
|
||||
table created via advanced-mode SQL `create table t (a int)` has no
|
||||
PK (`sql_create_table.rs` asserts `pk.is_empty()` for that form) —
|
||||
not a theoretical one, so the guard is a correctness requirement,
|
||||
not defensive padding.
|
||||
2. Builds the junction `ColumnSpec`s (one per parent PK column, typed
|
||||
via `fk_target_type`), the compound `primary_key` list, and two
|
||||
`SqlForeignKey` values (`on_delete = on_update = Cascade`).
|
||||
3. Calls the create-table path, which creates the table + both FKs +
|
||||
all metadata in **one transaction** — naturally one undo step, no
|
||||
`BeginBatch`/`EndBatch` bracketing needed.
|
||||
|
||||
This reuses the most-tested machinery and inherits its persistence,
|
||||
metadata, and FK-validation behaviour for free.
|
||||
|
||||
A new typed command variant `Command::CreateM2nRelationship { t1, t2,
|
||||
name }` carries the parsed form; the runtime/executor expands it to the
|
||||
junction definition. (We do **not** lower it to `Command::CreateTable`
|
||||
at parse time — keeping a distinct command preserves command identity
|
||||
per the X5 "unique commands for every unique case" principle, and lets
|
||||
the teaching echo speak in m:n terms.)
|
||||
|
||||
## Grammar, AST, and cross-cutting wiring
|
||||
|
||||
A new command is not done when it parses — it must light up every
|
||||
surface a learner touches. Enumerated here so none is missed
|
||||
(verification in **Testing**).
|
||||
|
||||
- **Grammar node** `CREATE_M2N` (`ddl.rs`): a separate `CommandNode`
|
||||
with `entry: create`, shape `m:n relationship [as <name>] from <T1>
|
||||
to <T2>`, registered in `REGISTRY` as `CommandCategory::Simple`. A
|
||||
separate node (not a branch inside `CREATE`) keeps the tested
|
||||
create-table builder untouched; the walker already dispatches
|
||||
multiple nodes per entry word. The `m:n` opener mirrors `1:n`
|
||||
(`Word("m")`, `Punct(':')`, `Word("n")`).
|
||||
- **AST builder** `build_create_m2n` → `Command::CreateM2nRelationship`.
|
||||
- **Command + worker plumbing:** `Command::CreateM2nRelationship`
|
||||
variant; `Request::CreateM2nRelationship`; runtime
|
||||
`execute_command_typed` arm; `Database::create_m2n_relationship`
|
||||
public API; executor `do_create_m2n_relationship` (per D6).
|
||||
- **Completion:** add `("m", "m:n")` to `COMPOSITE_CANDIDATES`
|
||||
(`completion.rs`) so Tab on `create m` offers `m:n` as one fluent
|
||||
piece (exactly as `("1", "1:n")` does for `add`). Identifier slots
|
||||
for `<T1>`/`<T2>` inherit table-name completion from the walker's
|
||||
`IdentSource::Tables` automatically.
|
||||
- **Hints:** set `HintMode`s on the `CREATE_M2N` nodes so the ambient
|
||||
hint panel guides `from` / table / `to` / table / optional `as`,
|
||||
matching the `add 1:n relationship` hinting.
|
||||
- **Highlighting:** automatic — `walker/highlight.rs` is grammar-driven
|
||||
with no per-command special-casing; verify the line highlights.
|
||||
- **Help:** `help_id: Some("ddl.create_m2n")` → the command appears in
|
||||
`help` automatically (REGISTRY iteration) and under `help create`;
|
||||
add the `help.ddl.create_m2n` catalog string.
|
||||
- **Usage / parse errors:** `usage_ids: &["parse.usage.create_m2n"]`
|
||||
→ a malformed `create m:n …` shows the form in the usage block; add
|
||||
the `parse.usage.create_m2n` catalog string. Add the near-miss cases
|
||||
to the `parse_error_pedagogy` matrix (ADR-0042) for the `create`
|
||||
entry word.
|
||||
- **Teaching echo** (`echo.rs` + `build_schema_echo`): a
|
||||
`CreateM2nRelationship` arm that echoes the generated junction —
|
||||
the `create table` it built plus the two `FOREIGN KEY` lines — so the
|
||||
learner sees the pattern the convenience expanded to.
|
||||
- **Structure render:** the executor returns the junction's
|
||||
`TableDescription`; the ADR-0044 render path already draws its
|
||||
relationships as diagrams on the create echo? No — incidental
|
||||
`create table` echoes keep prose (ADR-0044 reach); the m:n echo shows
|
||||
the junction structure with its outbound FKs in the standard prose
|
||||
form. (A future enhancement could draw both relationships as
|
||||
diagrams; out of scope here.)
|
||||
|
||||
## Genuine forks (escalated, all resolved 2026-06-10)
|
||||
|
||||
1. **Junction PK** — compound-over-FKs (chosen) vs surrogate serial +
|
||||
UNIQUE vs no PK. → D1.
|
||||
2. **Referential actions** — `CASCADE` (chosen) vs `NO ACTION` vs
|
||||
`RESTRICT`. → D2.
|
||||
3. **Naming** — auto-name + optional `as` (chosen) vs auto-name only.
|
||||
→ D3.
|
||||
4. **Mode** — both (chosen by default, unobjected) vs simple-only. → D5.
|
||||
|
||||
## Testing
|
||||
|
||||
Integration (`tests/it/`), test-first:
|
||||
|
||||
- **Functional:** `create m:n relationship from A to B` creates table
|
||||
`A_B` with the two FK columns, compound PK over them, and two
|
||||
enforced FKs; inserting a junction row with a non-existent parent is
|
||||
refused; the `(fk1, fk2)` pair is unique (duplicate link refused).
|
||||
- **`as <name>`** overrides the junction name.
|
||||
- **Compound parent PK:** a parent with a 2-column PK contributes two
|
||||
FK columns; the junction PK spans all of them; FKs enforce per pair.
|
||||
- **Cascade:** deleting a parent row removes its junction rows.
|
||||
- **Undo:** one `create m:n` is exactly one undo step (table + both
|
||||
relationships gone after `undo`).
|
||||
- **Persistence round-trip:** the junction + both relationships survive
|
||||
a save → rebuild-from-text.
|
||||
- **Errors:** missing parent table; parent without a PK; junction-name
|
||||
collision with an existing table; (self-m:n → OOS error, below).
|
||||
|
||||
Cross-cutting (the surfaces a new command must light up):
|
||||
|
||||
- **Completion:** Tab after `create ` surfaces `m:n relationship`;
|
||||
table-name completion fires at the `<T1>`/`<T2>` slots.
|
||||
- **Help:** `help` lists the command; `help create` includes the m:n
|
||||
form; the catalog string renders.
|
||||
- **Usage / parse pedagogy:** a bare/half `create m:n` shows the usage
|
||||
block; near-miss matrix entries added (`parse_error_pedagogy`).
|
||||
- **Hints + highlighting:** ambient hint progression through the form;
|
||||
the line highlights (snapshot or assertion as the sibling commands
|
||||
use).
|
||||
|
||||
All tiers green, zero skips; clippy clean (nursery).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Self-referential m:n** (`from T to T`) — **refused outright**
|
||||
(user-confirmed, "full stop"): the two FK column sets would collide
|
||||
on `{T}_{pkcol}`, and directional disambiguation (`from_*`/`to_*`)
|
||||
is more complexity than this beginner-facing convenience warrants.
|
||||
The executor detects `t1 == t2` (on the canonical names) and errors
|
||||
with a friendly pointer ("an m:n relationship needs two different
|
||||
tables — to link a table to itself, add the junction by hand").
|
||||
Not a deferred follow-up; a deliberate non-goal.
|
||||
- **Per-relationship action overrides** in the command syntax (D2 fixes
|
||||
`CASCADE`); use a hand-built junction for other actions.
|
||||
- **Extra junction columns** (payload attributes on the link, e.g. an
|
||||
enrolment date) — add them afterward with `add column`.
|
||||
- **m:n visualization as diagrams** on the create echo (ADR-0044 reach
|
||||
keeps incidental create echoes in prose).
|
||||
- **Renaming the auto-generated *relationships*** (only the table is
|
||||
`as`-nameable); drop + re-add covers it.
|
||||
@@ -0,0 +1,556 @@
|
||||
# ADR-0046: Schema sidebar focus/navigation mode and responsive input & hint layout (UI #20 / #21 / #23)
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (2026-06-10); **implemented 2026-06-10**, phased **A → B → C**
|
||||
(see *Decision — phasing*) across commits `9f5f76b` (DA1/DA2) · `e0b9470`
|
||||
(DA3) · `41bae99` (DA4) · `386627a` (DB1) · `94825d0` (DB2/DB4) ·
|
||||
`c9da6ff` (DC1/DC2/DC4) · `22bec61` (DC3 + DC2 refinement). Closes Gitea
|
||||
issues **#20** (hint-panel height jumpiness), **#21** (database-structure
|
||||
/ left-column improvements), and **#23** (long command input). Issue
|
||||
#23's own note ("handle after #21 is decided") is honoured: the input
|
||||
work is split so the part that depends on the sidebar's width budget
|
||||
lands with it.
|
||||
|
||||
Two decisions landed differently from the original draft and are
|
||||
recorded inline: the relationship data lives on **`App`, not
|
||||
`SchemaCache`** (DB2), and the navigation overlay clears **only the
|
||||
sidebar strip + a one-column gutter** (panels stay visible behind),
|
||||
not the whole area (DC2).
|
||||
|
||||
Builds on and honours: **ADR-0003** (the persistent Simple/Advanced
|
||||
mode model — navigation mode is *not* a third input mode, see DC1),
|
||||
**ADR-0027** (the input validity indicator's reserved 6 right columns —
|
||||
horizontal scroll and 2-line display preserve that reserve),
|
||||
**ADR-0044** (relationship visualization — the relationships panel
|
||||
renders the same `RelationshipSchema` data the `show relationship`
|
||||
diagram already consumes), **ADR-0013 / ADR-0043** (the
|
||||
`RelationshipSchema` model: name, parent/child tables, list-based
|
||||
compound columns, referential actions), **ADR-0015** (project file
|
||||
format — sidebar visibility is **session-only**, so the format is
|
||||
untouched), and **ADR-0002** (no engine name in user-facing strings).
|
||||
Preserves the **pure-render-from-`App`-state** invariant (CLAUDE.md):
|
||||
visual changes here are driven either by new `App` *state* fields
|
||||
(mutated in `update()`) or by pure *render-time* functions of the frame
|
||||
geometry (see State section); `update()` stays pure-sync.
|
||||
|
||||
**Requirements & issues touched (verified against `requirements.md`).**
|
||||
Evolves **S1** (the always-present three-region layout — the left items
|
||||
region becomes width-optional, DB1). **Overrides S2**, which planned
|
||||
additional element kinds as *nested* items in the tables list;
|
||||
relationships get their own panel instead (DB2/DB4 — see Genuine forks
|
||||
§11). **Corrects S4**: its "keyboard-toggleable hint area" was never
|
||||
implemented (no toggle keybinding exists in the code) and is not wanted
|
||||
— the hint panel became indispensable once completion moved into it
|
||||
(ADR-0022) — so the toggle phrase is struck from `requirements.md` and
|
||||
no toggle is added here. Extends **I1a** (single-line cursor editing →
|
||||
horizontal scroll, DA3) and honours **S6 / ADR-0027** (the 6-column
|
||||
validity-indicator reserve, DA3/DA4). The PageUp/PageDown context-rebind
|
||||
(DC3) does **not** regress **V4**'s output scroll, which stays live in
|
||||
input mode. Adjacent but separate: Gitea **#22** (an in-app
|
||||
overlay/annotation layer for casts and guided lessons — its own ADR)
|
||||
shares the overlay-render and screencast context with DC2's `Clear`
|
||||
overlay; the two are meant to coexist, not merge.
|
||||
|
||||
## Context
|
||||
|
||||
Three UI issues were raised together because they are coupled through
|
||||
the terminal's width and height budget; treating them as one decision
|
||||
avoids three conflicting partial fixes.
|
||||
|
||||
**Current layout (verified in `src/ui.rs`).** `render()` splits
|
||||
vertically into `Min(8)` main / `Length(1)` project label / `Length(1)`
|
||||
status. The main area splits horizontally into a **fixed
|
||||
`Length(28)`** left column (`render_items_panel`, a "Tables" list with
|
||||
indented index names) and a `Min(20)` right column. The left column's
|
||||
block has `Borders::ALL`, so its **usable inner width is 26 columns**
|
||||
(28 − 2 borders). The right column splits vertically into output
|
||||
(`Min(5)`), input (`Length(3)`), and hint (`Length(hint_content)`).
|
||||
|
||||
**#20 — hint jumpiness.** `hint_content` is recomputed **every frame**
|
||||
as `clamp(wrapped_lines, 1, MAX_HINT_ROWS=3) + 2`, i.e. 3–5 rows. As the
|
||||
user types and hint strings appear, grow, and vanish, the hint panel
|
||||
resizes and **shoves the input and output panels**, producing the
|
||||
flicker visible in screencasts. The root cause is that height tracks
|
||||
*content* rather than terminal *geometry*.
|
||||
|
||||
The hint catalog (`src/friendly/strings/en-US.yaml`) was measured: the
|
||||
two longest strings are `value_literal_slot` (106 chars) and
|
||||
`create_table_element` (102); four more are 50–57; the rest ≤ 50. The
|
||||
wrapping consequence is sharp: at a right-column inner width ≥ ~54
|
||||
columns the worst string needs **at most 2 lines**; a **3rd line is
|
||||
only ever required when the right column is narrower than ~54** (a
|
||||
sub-~83-column terminal *with the sidebar shown*, or a sub-~55-column
|
||||
terminal). On the project's screencasts (90 columns wide, sidebar
|
||||
hidden — see DB1) two lines are provably sufficient.
|
||||
|
||||
**#21 — the left column.** A persistent 26-column Tables list is rarely
|
||||
filled even half-way by a teaching database, yet it permanently costs
|
||||
horizontal space the output and input panels want — acutely so on the
|
||||
90-column screencasts. The pedagogical value of an *always-visible*
|
||||
schema overview is real (CLAUDE.md "pedagogy wins ties"), so the column
|
||||
is **kept but made optional and more useful**, not deleted.
|
||||
|
||||
**#23 — long input.** The command input is a **single logical `String`**
|
||||
rendered by a `Paragraph` with no wrap and no horizontal scroll
|
||||
(`render_input_panel`); text past the panel width **clips silently**.
|
||||
The cursor is a byte offset on a char boundary; Up/Down drive history.
|
||||
The fix needs the width the sidebar's removal frees, hence the coupling.
|
||||
|
||||
**Keybinding space (verified).** Taken: Tab/Shift-Tab (completion),
|
||||
Enter (submit), Up/Down (history), Left/Right/Home/End (cursor),
|
||||
PageUp/PageDown (output scroll), Backspace/Delete, Esc
|
||||
(completion-undo / modal cancel), Ctrl-C (quit). Reserved-but-deferred
|
||||
(I1b readline): Ctrl-A/E/W/K/U. Printable keys all route to the input.
|
||||
Terminal-hijacked and therefore unusable: Ctrl-S/Q (flow control),
|
||||
Ctrl-Z (suspend), Ctrl-H (backspace), Ctrl-I/M (Tab/Enter), Ctrl-G
|
||||
(BEL). This leaves a narrow band of safe combinations for new controls.
|
||||
|
||||
## Decision — phasing
|
||||
|
||||
The work ships in three phases so the screencasts benefit from the
|
||||
least-controversial part first and the riskiest part (the focus/scroll
|
||||
model) is isolated:
|
||||
|
||||
- **Phase A — input & hint (DA1–DA4):** self-contained, no sidebar
|
||||
dependency; fixes #20 and the baseline of #23.
|
||||
- **Phase B — optional, richer sidebar (DB1–DB3):** visibility model +
|
||||
relationships panel + schema-cache enrichment.
|
||||
- **Phase C — navigation mode (DC1–DC4):** the Ctrl-O focus/scroll/
|
||||
expand model that makes the sidebar browsable.
|
||||
|
||||
Each phase is independently shippable and independently green.
|
||||
|
||||
## Decision — Phase A: responsive input & hint heights
|
||||
|
||||
### DA1 — Hint height is a function of terminal geometry, fixed between resizes
|
||||
|
||||
The hint panel's height is **decoupled from hint content**. It is
|
||||
computed from the terminal's width and height **once per resize** and
|
||||
held constant as the user types. Because the panel no longer resizes on
|
||||
every keystroke, it never shoves the input/output panels — the #20 jump
|
||||
is eliminated at the source, not damped. Content that exceeds the fixed
|
||||
height is ellipsized (the existing `clamp_wrapped` truncation), which is
|
||||
now a rare, width-driven event rather than a per-keystroke one.
|
||||
|
||||
### DA2 — Responsive height buckets
|
||||
|
||||
Heights are chosen by terminal **height** (rows), with the hint's
|
||||
optional 3rd line gated on right-column **width** (per the Context
|
||||
measurement):
|
||||
|
||||
| Terminal height | Input content rows | Hint content rows |
|
||||
| --- | --- | --- |
|
||||
| **Compact** (`H < 40` — covers the 25-row screencasts) | 1 (+ horizontal scroll, DA3) | 2 |
|
||||
| **Comfortable** (`H ≥ 40` — fullscreen terminals) | 2 (soft-wrap, DA4) | 2 (→ 3 only if right-column inner < ~54) |
|
||||
|
||||
A safety degradation protects tiny terminals: the output panel's
|
||||
`Min(5)` is honoured first; if rows are insufficient, the hint shrinks
|
||||
to 1, then the input to 1. The `40`-row threshold is a tunable constant.
|
||||
|
||||
### DA3 — Input horizontal scroll (single logical line)
|
||||
|
||||
The input keeps its **single-`String`** model (no embedded newlines —
|
||||
this is explicitly *not* multi-line input, see Out of scope). A new
|
||||
`App` field `input_scroll_offset: usize` tracks the first visible
|
||||
column; the renderer shows a window of the line and keeps the cursor in
|
||||
view, mirroring the candidate-line horizontal-scroll markers already in
|
||||
`render_candidate_line`. The ADR-0027 6-column indicator reserve is
|
||||
preserved (the scroll window is the text area = `inner.width − 6`, not
|
||||
the full inner width). Because `update()` does not know the panel width,
|
||||
the renderer feeds it back via a `note_input_viewport(text_width)` call
|
||||
(the analogue of the existing `note_output_viewport`), against which the
|
||||
offset is clamped to keep the cursor visible. `input_scroll_offset`
|
||||
**resets to 0** whenever the buffer is replaced wholesale — on `submit`,
|
||||
on history navigation (Up/Down), and on any clear. This is the baseline
|
||||
#23 fix and is sufficient on its own for the compact (1-row) layout.
|
||||
|
||||
### DA4 — Two-line input display when tall (`H ≥ 40`)
|
||||
|
||||
On comfortable terminals the input renders across **2 visual rows** by
|
||||
soft-wrapping the single logical line, with the cursor mapped to a
|
||||
(row, col) within the two rows. Content longer than two rows scrolls
|
||||
the two-row window horizontally (DA3) so the cursor stays visible. The
|
||||
**ADR-0027 `[ERR]`/`[WRN]` indicator stays anchored to the right edge
|
||||
of the *first* row** (its 6-column reserve applies to row 1; the soft-
|
||||
wrap on row 1 stops 6 columns short, row 2 uses the full text width) —
|
||||
S6 is preserved.
|
||||
|
||||
This is display-only over the same single-`String` model — distinct
|
||||
from the deferred true multi-line-input feature (I1, which adds
|
||||
*multiple logical lines* with Enter-inserts-newline). **Forward-compat
|
||||
note:** I1, when built, should reuse DA4's row-rendering and cursor
|
||||
(row, col) mapping rather than introduce a parallel one — DA4 is the
|
||||
substrate, not a competitor.
|
||||
|
||||
## Decision — Phase B: optional, richer sidebar
|
||||
|
||||
### DB1 — Width-derived visibility plus transient peek (session-only)
|
||||
|
||||
Sidebar visibility is **derived, not stored**: the sidebar is visible
|
||||
iff the terminal **width > 90** *or* navigation mode is currently
|
||||
focused on a sidebar panel (the Ctrl-O peek, DC1). It is recomputed
|
||||
every frame from terminal width and `NavFocus`; nothing persists to
|
||||
`project.yaml` (ADR-0015 untouched), so it is session-only by
|
||||
construction — and there is no stored visibility field to keep in sync.
|
||||
|
||||
At ≤ 90 columns the sidebar is hidden by default — so the 90-column
|
||||
screencasts never show it and the output panel gets the full width it
|
||||
needs there — but `Ctrl-O` temporarily reveals it for the duration of a
|
||||
browse and re-hides it on exit (DC1).
|
||||
|
||||
**No persistent show/hide toggle (resolved 2026-06-10, user).** Issue
|
||||
#21's original wording asked for "a keystroke to show and hide it"; the
|
||||
Ctrl-O peek covers that need, so no separate toggle and no
|
||||
force-shown/force-hidden override is added. Visibility stays a pure
|
||||
function of `(terminal width, NavFocus)` — the simplest model that
|
||||
satisfies the requirement. Should pinning ever prove necessary, a
|
||||
persistent override is an additive follow-up (see Out of scope).
|
||||
|
||||
### DB2 — Add a relationships panel; enrich the schema cache
|
||||
|
||||
The left column gains a **second panel** below Tables: a list of the
|
||||
project's relationships. This is a deliberate **override of S2**, whose
|
||||
note proposed additional element kinds (relations, views) as *nested*
|
||||
items inside the existing tables list. Relationships are *cross-table*,
|
||||
not per-table, so nesting them under a single table reads wrong; a
|
||||
sibling panel is the honest shape (user-confirmed 2026-06-10). S2's
|
||||
"without restructuring" intent is still met — the items column simply
|
||||
holds two stacked panels (DB4) instead of one.
|
||||
|
||||
The panel needs the full `RelationshipSchema` (name, parent/child
|
||||
tables, list-based columns, on-delete/on-update actions) that the `show
|
||||
relationship` path already fetches.
|
||||
|
||||
**Data home — `App`, not `SchemaCache` (revised at implementation,
|
||||
2026-06-10).** The design first proposed an additive
|
||||
`SchemaCache.relationship_details: Vec<RelationshipSchema>` field.
|
||||
Implementation revised this to a **parallel `App.relationships:
|
||||
Vec<RelationshipSchema>`** field for two reasons: (1) `SchemaCache` is
|
||||
*walker/completion-facing* — it needs only relationship **names**
|
||||
(unchanged in `SchemaCache.relationships`, still borrowed as
|
||||
`&Vec<String>` by `IdentSource::Relationships`); the full records are
|
||||
**UI-only**, so `App` is the architecturally correct home, mirroring
|
||||
`app.tables` (which the items panel already reads alongside the cache).
|
||||
(2) Adding a field to `SchemaCache` would force edits to ~23 full
|
||||
struct literals across the test suite, whereas `App` gains one field.
|
||||
The /runda guard it answered — *don't break completion by retyping
|
||||
`relationships`* — is fully honoured either way. Delivery: a worker
|
||||
`Request::ReadAllRelationships` (→ `Database::read_all_relationships`,
|
||||
returning `Vec<RelationshipSchema>` via the existing
|
||||
`read_all_relationships(conn)`); the runtime's `refresh_schema_cache`
|
||||
posts a new `AppEvent::RelationshipsRefreshed` alongside
|
||||
`SchemaCacheRefreshed`, and the `App` stores it. No behavioural
|
||||
difference from the original design.
|
||||
|
||||
The panel has **two display states** keyed off focus (DC2):
|
||||
|
||||
- **Unfocused (26-col)** — an ambient glance. Per relationship: the
|
||||
name (ellipsized past the inner width), and the endpoints broken at
|
||||
the arrow to fit a narrow column:
|
||||
|
||||
```
|
||||
Customers_Orders
|
||||
Customers.id ->
|
||||
Orders.customer_id
|
||||
```
|
||||
|
||||
- **Focused + expanded (40–50 col, DC2)** — a browse view. At the wider
|
||||
width the endpoints fit on one line
|
||||
(`Customers.id -> Orders.customer_id`); the arrow-break is used only
|
||||
when even the expanded width cannot hold a (possibly compound)
|
||||
endpoint pair. The wider width minimises horizontal truncation so the
|
||||
panel needs **mainly vertical scrolling** (DC3).
|
||||
|
||||
### DB3 — Sidebar width unchanged when unfocused
|
||||
|
||||
The unfocused sidebar keeps `Length(28)` / 26 inner columns. Widening
|
||||
happens only on focus (DC2), as an overlay, so the unfocused layout and
|
||||
the right-column reflow are unchanged from today.
|
||||
|
||||
### DB4 — Vertical split of the two left-column panels
|
||||
|
||||
The items column stacks **Tables (top)** and **Relationships (bottom)**.
|
||||
The Relationships panel's height is content-driven within bounds, so it
|
||||
stays small when there is little to show and never dominates the column
|
||||
(user-chosen 2026-06-10):
|
||||
|
||||
- **No relationships:** fixed at **5 rows** (3 content + 2 border),
|
||||
rendering a single `None` line. This is the floor.
|
||||
- **With relationships:** grows with content (`content_rows + 2`, where
|
||||
the unfocused format is ~3 rows per relationship) up to a **cap of
|
||||
50 % of the column height**; beyond the cap the panel **scrolls**
|
||||
(DC3). Formally `rel_h = clamp(content_rows + 2, 5, ⌊col_h / 2⌋)`.
|
||||
- **Tables** takes the remainder (`col_h − rel_h`) and scrolls if it
|
||||
overflows (it, too, is a focusable, scrollable panel — DC3).
|
||||
- **Degradation:** on a column too short to honour the 5-row floor plus
|
||||
a usable Tables panel (`col_h < ~10`), the floor yields first so
|
||||
Tables keeps at least its border + one row; both panels stay
|
||||
renderable. The `50 %` cap and `5`-row floor are tunable constants.
|
||||
|
||||
Heights are a pure render-time function of the column height and the
|
||||
cached relationship count, so they are unit-testable without a terminal
|
||||
(see Testing).
|
||||
|
||||
## Decision — Phase C: navigation mode
|
||||
|
||||
### DC1 — `Ctrl-O` navigation mode: a focus cycle, not an input mode
|
||||
|
||||
`Ctrl-O` enters a **navigation mode** that is orthogonal to the
|
||||
Simple/Advanced input mode (ADR-0003) — it changes *where keystrokes
|
||||
go*, not *how commands parse*. It drives a focus cycle:
|
||||
|
||||
1. **Press 1 →** focus the **Tables** panel (revealing the sidebar if
|
||||
it is currently hidden — a temporary peek).
|
||||
2. **Press 2 →** focus the **Relationships** panel.
|
||||
3. **Press 3 →** leave navigation mode: restore the sidebar width,
|
||||
re-hide it if the peek revealed it, and return focus to the command
|
||||
input.
|
||||
|
||||
`Esc` exits navigation mode directly from any focused panel (a
|
||||
short-cut for step 3); `Esc` is otherwise only completion-undo, which
|
||||
does not apply while browsing.
|
||||
|
||||
**Why `Ctrl-O` and not `Ctrl-B`.** `Ctrl-B` is the *default tmux prefix*
|
||||
and `Ctrl-A` is *screen's* — a multiplexer eats them before the app
|
||||
sees them, so either would make navigation mode unreachable for the many
|
||||
students who run inside tmux/screen. `Ctrl-O` is not a multiplexer
|
||||
prefix; in the raw mode the TUI sets, its legacy line-discipline meaning
|
||||
(discard-output) is disabled, so it reaches the app. It is free in the
|
||||
app today (the main key handler's catch-all, `app.rs:1001`). The
|
||||
mnemonic is weak ("**O**utline"); reachability won over mnemonic.
|
||||
|
||||
**Routing.** Navigation mode is handled inside the **main** key handler,
|
||||
which runs only when no modal is open (`app.rs:919` gates on
|
||||
`self.modal.is_some()`). So `Ctrl-O` and the nav keys are **inert while
|
||||
a modal dialog is active** — modals keep full keyboard ownership. Within
|
||||
the main handler, a `NavFocus != Input` branch precedes the normal
|
||||
input-editing arms and routes keys per DC3/DC4.
|
||||
|
||||
### DC2 — Expand-on-focus as an overlay
|
||||
|
||||
A focused sidebar panel widens to a **45-column** overlay
|
||||
(`NAV_EXPANDED_WIDTH`): the renderer `Clear`s the strip the expanded
|
||||
panel occupies **plus a one-column gutter** (`NAV_OVERLAY_GUTTER`) and
|
||||
paints the wide panel on top. The output/input/hint panels underneath
|
||||
keep their exact layout — **unused and unchanging** while browsing,
|
||||
**still visible to the right** of the overlay (just partially occluded
|
||||
on the left) — and are restored fully by the next frame on exit. The
|
||||
gutter keeps them from butting against the expanded panel's border so
|
||||
the overlay edge reads cleanly. This is cheap because the renderer is a
|
||||
pure function of `App` state: focus state selects the width and the
|
||||
overlay path. (The input underneath is inactive in navigation mode.)
|
||||
|
||||
*Implementation note (2026-06-10):* a full-area clear (hiding the base
|
||||
panels entirely during browse) was tried first and rejected — leaving
|
||||
the base visible is truer to "underneath keep their layout," and the
|
||||
one-column gutter resolves the only wrinkle (the panels' left edges
|
||||
being cut by the overlay reading harshly without separation).
|
||||
|
||||
### DC3 — Scroll the focused panel; focus highlight
|
||||
|
||||
While a sidebar panel is focused it scrolls, reusing the output panel's
|
||||
proven mechanism (a `usize` offset clamped against a renderer-reported
|
||||
viewport via a `note_*_viewport` call):
|
||||
|
||||
- **Up / Down — line-by-line** scroll (the lazygit `j`/`k` feel;
|
||||
user-chosen 2026-06-10).
|
||||
- **PageUp / PageDown — page** scroll.
|
||||
|
||||
This is a context-sensitive rebind: Up/Down drive *history* and
|
||||
PageUp/PageDown scroll the *output* in input mode, whereas in navigation
|
||||
mode they scroll the *focused sidebar panel*. The two contexts never
|
||||
apply simultaneously (`NavFocus` selects which). The focused panel shows
|
||||
an **accent border** so it is obvious where keys are going (lazygit
|
||||
convention).
|
||||
|
||||
### DC4 — Other keys are inert in navigation mode
|
||||
|
||||
The command input is visibly occluded by the overlay while browsing, so
|
||||
keys that have no navigation meaning are **inert** rather than acting on
|
||||
the hidden input. Specifically, **only** `Ctrl-O` (advance focus),
|
||||
Up/Down + PageUp/PageDown (scroll, DC3), and `Esc` (exit) are live;
|
||||
printable characters, Enter, Tab, Backspace/Delete, Left/Right, and
|
||||
Home/End all do nothing until navigation mode is exited. The occlusion
|
||||
signals "not typing," so swallowing these is clearer than letting them
|
||||
silently edit an invisible buffer.
|
||||
|
||||
## State, keybindings, and cross-cutting wiring
|
||||
|
||||
**Stored `App` state** (mutated in `update()`, read by the renderer):
|
||||
|
||||
- `input_scroll_offset: usize` (DA3) — reset on submit / history-nav /
|
||||
clear.
|
||||
- `NavFocus { Input, SidebarTables, SidebarRelationships }` (DC1) — the
|
||||
navigation-mode focus cursor; `Input` ≙ not in navigation mode.
|
||||
- Per-panel scroll offsets for the Tables and Relationships panels, each
|
||||
clamped against a renderer-reported viewport (DC3), mirroring
|
||||
`output_scroll` / `note_output_viewport`.
|
||||
- **`App.relationships: Vec<RelationshipSchema>`** (DB2) — the full
|
||||
relationship records for the sidebar panel, delivered by
|
||||
`AppEvent::RelationshipsRefreshed` from the runtime's schema refresh.
|
||||
`SchemaCache.relationships: Vec<String>` (names, for completion) is
|
||||
unchanged. (See DB2 for why this lives on `App`, not `SchemaCache`.)
|
||||
|
||||
**Render-time derived** (pure functions of `frame.area()` + cached
|
||||
counts — *not* stored fields; this keeps the pure-render invariant and
|
||||
makes the geometry logic unit-testable without a terminal):
|
||||
|
||||
- Sidebar visibility — `(width > 90) || NavFocus is a sidebar panel`
|
||||
(DB1).
|
||||
- Input/hint row counts — a pure helper `panel_heights(area) ->
|
||||
(input_rows, hint_rows)` (DA1/DA2), the same helper the renderer and
|
||||
the Tier-1 tests call.
|
||||
- Left-column split `rel_h = clamp(content_rows + 2, 5, ⌊col_h/2⌋)`
|
||||
(DB4).
|
||||
- Input width fed back to `update()` via `note_input_viewport`
|
||||
(DA3), since `update()` cannot read `frame.area()`.
|
||||
|
||||
Keybindings introduced/affected:
|
||||
|
||||
| Key | Input mode | Navigation mode |
|
||||
| --- | --- | --- |
|
||||
| `Ctrl-O` | enter nav mode, focus Tables (peek-reveal) | advance focus (Tables → Relationships → exit) |
|
||||
| `Up` / `Down` | history (unchanged) | line-scroll focused panel |
|
||||
| `PageUp` / `PageDown` | scroll output (unchanged) | page-scroll focused panel |
|
||||
| `Esc` | completion-undo (unchanged) | exit nav mode directly |
|
||||
| printable / Enter / Tab / Backspace / Left / Right / Home / End | edit/submit input (unchanged) | inert |
|
||||
|
||||
All nav keys are inert while a modal is open (the main handler is gated
|
||||
on `!modal.is_some()`, `app.rs:919`).
|
||||
|
||||
Renderer changes (`src/ui.rs`): geometry-driven hint/input height
|
||||
(DA1/DA2), input window + cursor windowing (DA3) and 2-row soft-wrap
|
||||
with row-1 indicator (DA4), the relationships panel + two-panel split
|
||||
(DB2/DB4), the focus accent border and expand-on-focus `Clear` overlay
|
||||
(DC2/DC3); `note_input_viewport` feedback added alongside the existing
|
||||
`note_output_viewport`.
|
||||
|
||||
## Genuine forks (escalated, resolved 2026-06-10)
|
||||
|
||||
1. **Left column fate** — remove entirely vs narrow vs **keep + make
|
||||
optional and richer** (chosen, user). → DB1/DB2.
|
||||
2. **Focus/scroll model** — a navigation mode (chosen, user) vs
|
||||
modeless modifier-key scroll vs deferring scroll. → DC1.
|
||||
3. **Navigation shortcut** — **`Ctrl-O`** (chosen, user); `Ctrl-B`
|
||||
*rejected on review* (it is the default tmux prefix → unreachable
|
||||
inside tmux); Ctrl-T also viable; terminal-hijacked combos excluded.
|
||||
→ DC1.
|
||||
4. **Expand-on-focus rendering** — **overlay with `Clear`** (chosen,
|
||||
keeps the right panels unchanging) vs re-splitting the layout (would
|
||||
reflow output). → DC2.
|
||||
5. **Navigation-mode printables** — **ignore** (chosen, user) vs
|
||||
drop-to-input-and-type. → DC4.
|
||||
6. **Hint anti-jump** — **fix height to terminal geometry** (chosen)
|
||||
vs damping/hysteresis vs always-reserve-max. → DA1.
|
||||
7. **Height thresholds** — `H < 40` compact / `H ≥ 40` comfortable, with
|
||||
1/2 and 2/2 splits (chosen, user). → DA2.
|
||||
8. **Visibility persistence** — **session-only** (chosen, user) vs
|
||||
per-project in `project.yaml`. → DB1.
|
||||
9. **Persistent show/hide toggle** — **deferred** (chosen, user): the
|
||||
Ctrl-O peek covers #21's "keystroke to show and hide", so visibility
|
||||
stays width-derived with no override. → DB1.
|
||||
10. **Nav-mode Up/Down** — **line-scroll the focused panel** (chosen,
|
||||
user) vs leaving scroll to PageUp/PageDown only. → DC3.
|
||||
11. **Relationships placement** — **a separate sibling panel** (chosen,
|
||||
user — *overrides S2*) vs nesting relations inside the tables list
|
||||
per S2's documented extension model. → DB2/DB4.
|
||||
12. **Hint-area toggle (S4)** — **no toggle** (chosen, user): the hint
|
||||
panel is indispensable since completion moved into it; S4's stale
|
||||
"keyboard-toggleable" claim (never implemented) is struck from
|
||||
`requirements.md`. → Status (Requirements & issues touched).
|
||||
|
||||
## Testing
|
||||
|
||||
Tier-1 (`app.rs` pure `update()` unit tests), **Tier-2 (`insta`
|
||||
snapshots, `src/snapshots/`) for the visual surfaces** — this change is
|
||||
heavily render-side, so the geometry/format/overlay assertions belong in
|
||||
snapshots, not only behavioural tests — and Tier-3 integration.
|
||||
Test-first per CLAUDE.md. The geometry helpers (`panel_heights`, the
|
||||
DB4 split, visibility) are **pure functions** exercised directly in
|
||||
Tier-1 without a terminal.
|
||||
|
||||
Phase A:
|
||||
- **Hint anti-jump:** `panel_heights(area)` is invariant under changing
|
||||
hint content at a fixed terminal size (assert it does not change as
|
||||
`app.hint` varies); it *does* change across the `H < 40` / `H ≥ 40`
|
||||
boundary and the width-< 54 boundary.
|
||||
- **Height buckets:** compact → input 1 row / hint 2; comfortable →
|
||||
input 2 / hint 2 (3 only when right-column inner < ~54); tiny-terminal
|
||||
degradation honours output `Min(5)`.
|
||||
- **Input horizontal scroll:** a line longer than the panel keeps the
|
||||
cursor visible while moving Left/Right/Home/End; ADR-0027's 6-column
|
||||
reserve is intact; no characters are lost (buffer = full string);
|
||||
`input_scroll_offset` resets on submit / history-nav / clear.
|
||||
- **Two-line input:** at `H ≥ 40` a line wrapping to two rows renders
|
||||
both rows with correct cursor (row, col), the `[ERR]`/`[WRN]`
|
||||
indicator on row 1's right edge (Tier-2 snapshot); a longer line
|
||||
scrolls.
|
||||
|
||||
Phase B:
|
||||
- **Relationship data path:** `Database::read_all_relationships`
|
||||
returns full records through the worker thread (integration test, real
|
||||
DB via an m:n junction); `AppEvent::RelationshipsRefreshed` populates
|
||||
`App.relationships`; `SchemaCache.relationships` names are undisturbed
|
||||
(completion still resolves them).
|
||||
- **Relationships panel render (Tier-2):** empty → a single `None` line
|
||||
at the 5-row floor; the unfocused narrow format (name + arrow-break,
|
||||
ellipsis past inner width); a compound endpoint pair arrow-breaks
|
||||
correctly.
|
||||
- **Two-panel split (DB4):** `rel_h = clamp(content_rows + 2, 5,
|
||||
⌊col_h/2⌋)` — 5 when empty; grows with content; capped at 50 %;
|
||||
Tables takes the remainder; degrades sanely at `col_h < 10`.
|
||||
- **Width-derived visibility:** width ≤ 90 hides, > 90 shows, recomputed
|
||||
on resize (the peek interaction is covered under Phase C).
|
||||
|
||||
Phase C:
|
||||
- **Focus cycle:** `Ctrl-O` cycles Input → Tables → Relationships →
|
||||
Input; `Esc` exits directly; a peek-revealed sidebar re-hides on exit;
|
||||
a width-shown (> 90) sidebar stays shown on exit; `Ctrl-O` is inert
|
||||
while a modal is open.
|
||||
- **Expand overlay (Tier-2):** focusing widens to the expanded width;
|
||||
the underlying output/input/hint state is unchanged across enter/exit
|
||||
(no reflow); the focus accent border marks the focused panel.
|
||||
- **Scroll rebind:** in nav mode Up/Down line-scroll and PageUp/PageDown
|
||||
page-scroll the focused panel (clamped to its viewport); in input mode
|
||||
Up/Down still drive history and PageUp/PageDown still scroll output
|
||||
(no V4 regression); inert keys (printable/Enter/Tab/Backspace) do
|
||||
nothing in nav mode.
|
||||
|
||||
All tiers green, zero skips; clippy clean (nursery).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **True multi-line input (I1)** — Enter-inserts-newline / Ctrl-Enter-
|
||||
submits over a multi-logical-line buffer. DA3/DA4 keep a single
|
||||
logical line; this remains a separate, deferred feature.
|
||||
- **Readline shortcuts (I1b)** — Ctrl-A/E/W/K/U stay reserved-deferred;
|
||||
not touched here.
|
||||
- **Cross-session sidebar persistence** — visibility is session-only
|
||||
(DB1); persisting it would amend ADR-0015.
|
||||
- **The output panel as a third navigation focus target** — navigation
|
||||
mode cycles the two sidebar panels only; output keeps its input-mode
|
||||
PageUp/PageDown scroll.
|
||||
- **Relationship search / filtering within the panel** — the panel is a
|
||||
scrollable list; no query box.
|
||||
- **Relationship rename / edit from the panel** — it is read-only;
|
||||
mutation stays with the DSL/SQL commands.
|
||||
- **A persistent show/hide toggle / force-shown override** (DB1,
|
||||
resolved deferred) — visibility is width-derived + Ctrl-O peek; a
|
||||
pin/force override is an additive follow-up if ever needed.
|
||||
- **A hint-area toggle (old S4 wording)** — not implemented today and
|
||||
not wanted (the hint panel is indispensable since completion moved in;
|
||||
fork §12). The stale "keyboard-toggleable" phrase is removed from S4.
|
||||
- **In-app overlay / keystroke-annotation layer (Gitea #22)** — a
|
||||
separate feature with its own ADR; DC2's `Clear` overlay is built to
|
||||
coexist with it, not to provide it.
|
||||
|
||||
## Accepted consequences
|
||||
|
||||
- **Width-threshold discontinuity.** Because `Auto` visibility flips at
|
||||
width 90 and the sidebar costs 28 columns, widening a terminal across
|
||||
the boundary (89 → 91) makes the *output* narrower (≈ 89 → 63 inner)
|
||||
as the sidebar appears. This is inherent to any width-gated auto-hide
|
||||
and is accepted: 90 is the screencast width, real terminals sit well
|
||||
to one side of it, and `Ctrl-O` peek covers the in-between case. The
|
||||
`90` threshold is a tunable constant.
|
||||
@@ -0,0 +1,418 @@
|
||||
# ADR-0047: Demonstration overlay layer — keystroke badges and step captions
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (2026-06-10); **implemented 2026-06-11**, phased A→B→C (closes
|
||||
Gitea **#22**). Addresses Gitea **#22**. Builds the in-app
|
||||
overlay/annotation primitive that screencast recording (ADR-website-001
|
||||
§2, the `autocast` pipeline) and a future guided-lesson system both
|
||||
need. Adjacent to ADR-0046 (the nav-mode sidebar overlay it must
|
||||
coexist with) and unblocks the polished version of the assistive-editor
|
||||
and projects (`#24`) casts.
|
||||
|
||||
**Implementation (commits `f879d54` → `2d0f4b2`).** Phase A
|
||||
(`f879d54`): `--demo` flag + `RDBMS_PLAYGROUND_DEMO` env →
|
||||
`App.demo_mode`, mirroring the `--no-undo` plumbing; help text mentions
|
||||
only the visible badges (the `Ctrl+]` caption trigger stays
|
||||
low-profile, D6). Phase B (`2584e76`): automatic keystroke badges — pure
|
||||
`demo_badge_label`, set in `App::update` before the modal gate, expired
|
||||
by a ~1.5 s runtime timer via the new `nearest_deadline` helper that
|
||||
extends the time-boxed-`recv` arm condition **without** regressing the
|
||||
ADR-0027 indicator debounce (the rewrite tracks `Instant` deadlines;
|
||||
verified equivalent). Phase C (`241f60c`): the stealth `Ctrl+]`
|
||||
caption buffer in `App::update`, intercepted before the modal gate so
|
||||
captions work over the load picker. Post-build (`2d0f4b2`, user
|
||||
decision): the overlays render as **flat filled yellow rectangles** (no
|
||||
border glyphs, one-cell text margin) to read as a distinct callout. A
|
||||
whole-implementation `/runda` pass returned **PASS** with no blockers;
|
||||
the only untested wiring is the `run_loop` badge timer (not unit-testable
|
||||
in isolation — same posture as the existing `IndicatorDebounce`; the
|
||||
pure pieces are all tested). One intentional, user-acknowledged
|
||||
behaviour: `Ctrl-C` is inert while capturing (every non-`Ctrl+]` key is,
|
||||
by spec; exit capture with `Ctrl+]`). Tests: 2290 passing / 0 failing /
|
||||
0 skipped (Tier-1 label fn + caption FSM + `nearest_deadline`, Tier-2
|
||||
dark/light/stacked/wrapped/clamp snapshots + black-on-yellow style,
|
||||
CLI parse/env); clippy clean.
|
||||
|
||||
All primary forks and the visual placement were **user-confirmed** —
|
||||
including the two follow-ups settled after the first draft: the trigger
|
||||
key (**`Ctrl+]`**, the maximally-obscure valid single-byte code, over
|
||||
`Ctrl+!` which autocast cannot send) and caption sizing (**wrap to 3
|
||||
lines**). A `/runda` pass over this ADR ran before implementation and
|
||||
tightened it — its findings are folded in below (caption/badge
|
||||
interception placement, in-capture key disposition, badge suppression
|
||||
during capture, the timer arm-condition, box clamping, the new
|
||||
output-rect field, and the control-code decode note).
|
||||
|
||||
**Requirements traceability.** There is **no `requirements.md` item**
|
||||
for this work — verified by sweep. It is tracked as Gitea issue **#22**
|
||||
plus this ADR, consistent with the project's convention ("issues are
|
||||
the lightweight tracker; ADRs are the decisions"). The website-side cast
|
||||
scope lives in **ADR-website-001** (website branch), not main's
|
||||
`requirements.md`.
|
||||
|
||||
## Context
|
||||
|
||||
The website records its demos as asciinema `.cast` files driven by
|
||||
**`autocast`** (ADR-website-001 §2; STYLE.md): source step-lists in
|
||||
`casts-src/casts.mjs` (`type` / `wait` / `key`) expand to **one key per
|
||||
character, Enter = `^M`**, recorded against the real `target/debug`
|
||||
binary. The hard constraint — the same one that drove `#24` — is that
|
||||
autocast can only emit **typeable characters, ASCII control codes
|
||||
(`^X`), and waits**. It cannot send arrow keys, function keys, or any
|
||||
multi-byte escape sequence.
|
||||
|
||||
Two classes of on-screen event are therefore invisible or
|
||||
unexplained in a cast:
|
||||
|
||||
1. **Keystrokes that cause a visible change but render no glyph of
|
||||
their own** — most acutely **Tab** completion: the command line
|
||||
jumps from `show data bo` to `show data books` with no sign a key
|
||||
was pressed. Enter, the arrows, Ctrl-O, Esc are the same.
|
||||
2. **Step structure / "what just happened" narration** — a cast is a
|
||||
silent moving picture; there is no channel to separate or explain
|
||||
steps for a visual learner.
|
||||
|
||||
asciinema-player has no inline keystroke overlay, and a website-side
|
||||
HTML overlay layered on the player would be fragile (its timings would
|
||||
have to track every recording and break on each re-record). The robust
|
||||
place to solve this is **in the app**: if the app renders the overlay,
|
||||
the cast captures it natively and it re-records for free. The same
|
||||
primitive is exactly what a future **guided-lesson** system needs to
|
||||
point at things and narrate steps — so it is built as a general
|
||||
capability, not a cast-only hack (the issue's "pays off twice"). It is
|
||||
also directly useful for a **teacher demonstrating the playground
|
||||
live** — pressing Tab in front of a class has the same
|
||||
invisible-keystroke problem as a cast.
|
||||
|
||||
The app's renderer is a pure function of `App` state and already draws
|
||||
two kinds of last-pass overlay over the base render with **no layout
|
||||
reflow**: modals and the ADR-0046 nav-mode sidebar overlay. The event
|
||||
loop already **time-boxes `event_rx.recv()`** with a `tokio` timeout
|
||||
(the ADR-0027 `IndicatorDebounce`) and redraws when the timer elapses —
|
||||
the exact mechanism a self-expiring badge needs. These two existing
|
||||
seams make the feature cheap.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1 — Activation: a `--demo` flag (+ env var), off by default
|
||||
|
||||
Demonstration mode is entered with a **`--demo`** CLI flag, or
|
||||
equivalently the **`RDBMS_PLAYGROUND_DEMO`** environment variable (set
|
||||
truthy) — mirroring the existing `--log-file` / `RDBMS_PLAYGROUND_LOG_FILE`
|
||||
pair. It combines freely with every other flag (`--resume`, `--mode`, a
|
||||
positional path); there are no exclusions.
|
||||
|
||||
When the flag is **off** (the default), none of the key handling or
|
||||
rendering below is active and the app behaves exactly as today — **zero
|
||||
footprint for real users** (R8). `autocast` sets the flag when it
|
||||
launches the binary; a teacher sets it on their own command line.
|
||||
|
||||
It is framed as a general **demonstration mode**, not "cast mode" — the
|
||||
honest name for what it does, and it reads sensibly in `--help`. The
|
||||
flag is documented in the CLI banner (one line); obscurity is not a
|
||||
security property here and a harmless opt-in flag is better surfaced
|
||||
than hidden. What stays "low-profile" (per #22) is that there is **no
|
||||
normal in-app command** for it and **no persistent on-screen indicator**
|
||||
(see D7) — so a cast frame is never polluted by a `[DEMO]` marker.
|
||||
|
||||
### D2 — Keystroke badges: automatic, app-detected
|
||||
|
||||
In demo mode the app shows a transient badge **automatically** whenever
|
||||
it handles one of a curated set of *otherwise-invisible* keys. The cast
|
||||
does nothing special — it presses the key it was going to press anyway,
|
||||
and the badge re-records for free. The set:
|
||||
|
||||
| Key | Badge | | Key | Badge |
|
||||
|-----|-------|-|-----|-------|
|
||||
| Tab | `[TAB]` | | Home | `[HOME]` |
|
||||
| Shift-Tab | `[SHIFT-TAB]` | | End | `[END]` |
|
||||
| Enter | `[ENTER]` | | PageUp | `[PGUP]` |
|
||||
| Esc | `[ESC]` | | PageDown | `[PGDN]` |
|
||||
| ↑ | `[UP]` | | Backspace | `[BKSP]` |
|
||||
| ↓ | `[DOWN]` | | Delete | `[DEL]` |
|
||||
| ← | `[LEFT]` | | Ctrl-O | `[CTRL-O]` |
|
||||
| → | `[RIGHT]` | | | |
|
||||
|
||||
Plain character keys render a glyph on the input line already, so they
|
||||
produce **no** badge (that is the definition of the set — "invisible"
|
||||
keys). The badge fires on **key press**, regardless of whether the key
|
||||
had an effect in the current state (e.g. `↑` with no history still shows
|
||||
`[UP]`): simpler, and the demo author controls the script. Badge text is
|
||||
bracketed ASCII (`[TAB]`) per the user's preference — renders on every
|
||||
terminal and is cast-safe, unlike the `⇥` glyph mocked earlier.
|
||||
|
||||
The label mapping is a **pure function** `demo_badge_label(&KeyEvent) ->
|
||||
Option<&'static str>` (Tier-1 testable). The badge **auto-expires on a
|
||||
timer** (D5).
|
||||
|
||||
### D3 — Step captions: a stealth, control-code-delimited input buffer
|
||||
|
||||
Caption text must arrive through typeable input only (R4). A **single
|
||||
toggle control code — `Ctrl+]`** (byte `0x1D`) drives a **stealth
|
||||
capture buffer**. `Ctrl+]` was chosen (over the bound `Ctrl-O`/`Ctrl-C`,
|
||||
the readline-reserve letters Ctrl-A/E/W/K/U, the tmux-prefix Ctrl-B, the
|
||||
signal/flow-control codes Ctrl-\\=SIGQUIT and Ctrl-S/Q=XON/XOFF, and a
|
||||
plain letter chord like Ctrl-G) because it is **maximally non-obvious**
|
||||
— the classic telnet escape, almost never pressed by accident — while
|
||||
still being a single ASCII control byte autocast can emit. It has **no
|
||||
signal or flow-control baggage** and is **multiplexer-safe**. Note
|
||||
collision risk is already near-zero in casts (a fresh `--demo` binary
|
||||
sees only scripted keys); the obscurity mainly protects a live teacher
|
||||
from a stray trigger.
|
||||
|
||||
- First `Ctrl+]` **opens** capture. The command input line and the
|
||||
output are untouched. If a caption is already visible, opening clears
|
||||
it (you are starting a new annotation).
|
||||
- Subsequent typed characters **accumulate into the caption buffer
|
||||
invisibly** — they do **not** appear on the prompt, do not execute,
|
||||
and do not enter history. **`Backspace`** deletes the last buffered
|
||||
character. **Every other key while capturing — Enter, the arrows,
|
||||
Tab, … — is inert** (swallowed, no effect): only typing and `Ctrl+]`
|
||||
do anything.
|
||||
- A second `Ctrl+]` **commits** the buffer to the caption box (D4).
|
||||
An **empty** commit (toggle-toggle with nothing typed) clears any
|
||||
visible caption — the author's explicit dismiss.
|
||||
|
||||
Because nothing about the capture shows on the prompt, the caption
|
||||
"pops" into its box with no ugly typing artifact, while the caption text
|
||||
still lives **inline in `casts.mjs`** at the right spot (one source of
|
||||
truth, no separate notes file to keep ordered).
|
||||
|
||||
This is all keyboard-stream interpretation, so it lives in the
|
||||
pure-sync `App::update()` (Tier-1 testable) and is **only active in demo
|
||||
mode** — when off, `Ctrl+]` is inert and characters reach the input
|
||||
line normally.
|
||||
|
||||
**Placement in `handle_key` — before the modal gate (runda finding).**
|
||||
The capture interception (`Ctrl+]` and the accumulating characters)
|
||||
**and** the "clear a visible caption on the next keystroke" check sit at
|
||||
the **very top of `handle_key`, before the `self.modal.is_some()`
|
||||
gate** — *not* alongside the `Ctrl-O` handler, which is gated behind it.
|
||||
This is required so captions can be authored **while a modal is open** —
|
||||
specifically the load-picker, which is exactly the **projects / `#24`
|
||||
cast** (annotating "press j/k to move", with an `[ENTER]` badge as the
|
||||
selection is made). While capturing, the modal is frozen (capture
|
||||
swallows keys), which is the intended behaviour. `App` exposes
|
||||
`demo_capturing` so the runtime can read it (see D5).
|
||||
|
||||
The control-code path is sound end to end, verified against our
|
||||
crossterm (0.29, `event/sys/unix/parse.rs:110-113`): `autocast` emits
|
||||
`^]` = byte `0x1D`; crossterm decodes `0x1C..=0x1F` →
|
||||
`KeyCode::Char('4'..='7') + CONTROL`, so **`Ctrl+]` (0x1D) arrives in
|
||||
the app as `KeyCode::Char('5') + KeyModifiers::CONTROL`** — that is the
|
||||
pattern `handle_key` matches. (The same routine decodes `0x09`/`0x0D`/
|
||||
`0x1B`/`0x7F` to the named `Tab`/`Enter`/`Esc`/`Backspace` keys and
|
||||
`0x01..=0x1A` to `Ctrl+a..z`, so `0x1D` is unambiguously distinct.) The
|
||||
canonical way to produce it is **Ctrl+]**; on some layouts `Ctrl+5`
|
||||
yields the same byte. *(This is the Unix/Linux decode path — the
|
||||
cast-recording platform; crossterm's separate Windows backend would be
|
||||
confirmed by test if live `--demo` on Windows is exercised.)*
|
||||
|
||||
### D4 — Both overlays are floating boxes at the output panel's inner bottom-right
|
||||
|
||||
The badge and the caption both render as **floating, flat filled
|
||||
rectangles anchored to the inside of the output panel's bottom-right
|
||||
corner** (inset one cell from the panel's inner edge), drawn **last over
|
||||
the base render** — after modals, so they remain visible while the
|
||||
load-picker (the `#24` cast) or any modal is up, and with **no layout
|
||||
reflow** (consistent with the modal / nav-overlay precedent; honours
|
||||
R8).
|
||||
|
||||
**Flat rectangle, not a bordered box (user decision, post-build).** The
|
||||
overlays draw as a **solid yellow rectangle with no border glyphs** and
|
||||
a one-cell margin around the text — deliberately *unlike* the app's
|
||||
rounded-border panels, so they read as a distinct callout that "stands
|
||||
out nicely" rather than as another panel. Implemented with a borderless
|
||||
`Block` fill (the `paint_background` mechanism) plus a `Paragraph` inset
|
||||
into a one-cell `Margin`.
|
||||
|
||||
The top-level `render()` does not currently know the output-panel rect
|
||||
(it is computed inside `render_right_column`), so a **new field
|
||||
`App.last_output_area: Rect`** is set in `render_output_panel` and read
|
||||
at the top-level draw pass to anchor the overlay — the established
|
||||
"renderer reports metrics back to `App`" pattern (sibling to
|
||||
`note_output_viewport`, which stores row counts, not a rect).
|
||||
|
||||
When **both** are present, the **keystroke badge stacks directly above
|
||||
the caption box** (both right-aligned in the corner) so they never
|
||||
overlap.
|
||||
|
||||
**Styling — deliberately high-contrast:** **bold black text on a yellow
|
||||
fill** — hard to overlook, identical in light and dark themes (a fixed
|
||||
high-contrast pair centralised in `theme.rs`, not theme-derived).
|
||||
|
||||
**Caption sizing (user-confirmed).** The caption is **word-wrapped to at
|
||||
most 3 lines** within a content width of `min(40, output_inner_width −
|
||||
4)` columns, ellipsised beyond the third line. So the caption rectangle
|
||||
is **3–5 rows** tall (1–3 text rows + a one-cell margin top and bottom),
|
||||
its height varying with the text — a full sentence fits without forcing
|
||||
the author to split it, while the 3-line cap keeps it corner-sized. The
|
||||
**badge** rectangle is always a single short token (`[TAB]` …
|
||||
`[SHIFT-TAB]`), so it is a fixed **3 rows** (1 text row + the margin),
|
||||
narrow.
|
||||
|
||||
**Clamping (runda finding).** Stacked, the two boxes are up to 8 rows
|
||||
(5 caption + 3 badge); the output panel's inner height is only `Min(5)`,
|
||||
so on a short terminal they could exceed it. Both boxes are **clamped to
|
||||
the output inner area**: width to `output_inner_width`, the caption's
|
||||
wrap-line count reduced so the stack fits the available height (badge
|
||||
first — it is the time-critical one), and if a box cannot fit at all
|
||||
(pathologically small terminal) it is **not drawn** rather than
|
||||
overflowing. Cast geometry (90×26) leaves ~18 output rows — ample; the
|
||||
guard only protects a real user who runs `--demo` in a tiny window.
|
||||
|
||||
### D5 — Timing: badges expire on a ~1.5 s timer; captions persist until the next keystroke
|
||||
|
||||
- **Keystroke badge:** auto-expires on a **time-based TTL**, default
|
||||
**1.5 s** (a single tunable constant; the user asked for 1–2 s). This
|
||||
matters for both media: in a cast the badge fades on its own so a
|
||||
trailing `wait` ends on a clean frame, and in live teaching the badge
|
||||
clears without the presenter needing another key. A new badge replaces
|
||||
the current one and resets the timer.
|
||||
- **Caption:** persists **until the next keystroke**, which clears it
|
||||
and is then processed normally (or until an explicit empty-`Ctrl+]`
|
||||
dismiss, or replacement by a new caption).
|
||||
|
||||
The timer reuses the runtime's existing time-boxed-`recv` pattern: the
|
||||
loop already arms a `tokio::time::timeout` for the indicator debounce.
|
||||
|
||||
**Arm-condition extension (runda finding).** Today the loop time-boxes
|
||||
`recv` **only while `debounce.is_armed()`** — and the debounce settles
|
||||
at `INDICATOR_DEBOUNCE` (1000 ms), shorter than the 1500 ms badge TTL.
|
||||
So the arm condition becomes **`debounce.is_armed() || badge_pending`**,
|
||||
and the loop waits on the **nearest deadline** of the two. On a wake it
|
||||
checks each independently: at the 1000 ms debounce deadline it settles
|
||||
the indicator **without clearing the badge**; at the 1500 ms badge
|
||||
deadline it clears the badge; then redraws. The pure "nearest deadline"
|
||||
computation is unit-testable on its own.
|
||||
|
||||
The badge's expiry `Instant` lives in the **runtime** (so `App` stays
|
||||
clock-free and Tier-1-pure, exactly as `IndicatorDebounce` keeps timing
|
||||
out of `App`); `App.demo_badge: Option<&'static str>` is the render
|
||||
mirror, **set by the runtime** on a significant key and cleared on timer
|
||||
elapse.
|
||||
|
||||
**Badge suppression during capture (runda finding).** Because the
|
||||
runtime sets badges from the raw key independently of `App` state, it
|
||||
must **not** badge a key that capture swallowed (e.g. an inert `Tab`
|
||||
while a caption is being typed would otherwise flash `[TAB]` for a
|
||||
no-op). The runtime sets a badge only when **`!app.demo_capturing`**.
|
||||
|
||||
**Ownership note.** `demo_caption` is mutated inside `update()`
|
||||
(input-driven) while `demo_badge` is mutated by the runtime
|
||||
(timing-driven). This split is deliberate and mirrors the existing
|
||||
`input` (set in `update()`) vs `input_indicator` (set by the runtime
|
||||
from `IndicatorDebounce`) pair — not an inconsistency.
|
||||
|
||||
### D6 — Help text and strings
|
||||
|
||||
The CLI banner (`help.cli_banner` in `en-US.yaml`) gains a `--demo`
|
||||
line. User-facing wording obeys the house rules (no engine name, no
|
||||
"DSL"): *"Demonstration mode — show on-screen badges for otherwise-
|
||||
invisible keys (Tab, Enter, …), for screencasts and live teaching."*
|
||||
|
||||
The help text **deliberately mentions only the visible badges, not the
|
||||
`Ctrl+]` step-caption mechanism** (user decision): the caption trigger
|
||||
stays low-profile, true to #22's "secret trigger" framing — a cast
|
||||
author or lesson script knows it; a casual `--help` reader is not
|
||||
pointed at it. Badge labels and the `[…]` chrome are fixed ASCII, not
|
||||
localised; caption content is author-supplied free text and likewise
|
||||
not a catalog string.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Scripted badges** (cast pushes each badge explicitly) — rejected:
|
||||
the app already sees every key, so automatic detection (D2) is more
|
||||
robust and re-records for free. *(User-confirmed.)*
|
||||
- **Typed hidden command for captions** (a secret-prefixed line) —
|
||||
rejected: the command is briefly visible being typed on the prompt.
|
||||
**Preloaded notes file + advance key** — rejected: a separate file
|
||||
that must stay ordered/in-sync with the cast. The **stealth buffer**
|
||||
(D3) is self-contained in the cast script *and* leaves the prompt
|
||||
clean. *(User-confirmed.)*
|
||||
- **Fixed-corner HUD badge / badge by the input line** — rejected in
|
||||
favour of a floating box at the output panel's bottom-right; **top
|
||||
banner / subtitle band** for captions — rejected in favour of the
|
||||
matching floating box. *(User-confirmed via mockups.)*
|
||||
- **A persistent `[DEMO]` status-bar marker** — rejected: it would show
|
||||
in every cast frame. Demo mode is silent except for the transient
|
||||
overlays (D7).
|
||||
- **Caption persists for a fixed time** (instead of until next
|
||||
keystroke) — noted as a one-constant change if the next-keystroke rule
|
||||
proves too eager in practice; the user chose next-keystroke.
|
||||
- **Trigger via `Ctrl+!` / a Kitty-protocol chord** — rejected: not
|
||||
representable as a single ASCII control byte, so autocast cannot send
|
||||
it (fails R4, the same wall as arrow keys). **`Ctrl+G` / a letter
|
||||
chord** — workable but less non-obvious; the user chose the
|
||||
maximally-obscure `Ctrl+]` from the valid single-byte set.
|
||||
- **Single-line ellipsised caption** — rejected in favour of wrap-to-3-
|
||||
lines so a full sentence fits. *(User-confirmed via mockups.)*
|
||||
|
||||
## Consequences
|
||||
|
||||
- A general overlay primitive exists that the cast pipeline uses now and
|
||||
the guided-lesson system can reuse later (`App.demo_caption` and the
|
||||
badge channel are the seam).
|
||||
- `autocast` casts gain a real Tab-completion moment, key indicators for
|
||||
the projects/`#24` round-trip, and step captions — all by adding
|
||||
`key: ^G` / `type:` / `key: ^G` and ordinary keys to `casts.mjs`, then
|
||||
re-running `pnpm casts`. No website-side overlay machinery.
|
||||
- Teachers get the same affordance live via `--demo`.
|
||||
- One new control-code binding (`Ctrl+]`) is consumed, but only inside
|
||||
demo mode — normal sessions are unaffected, so it does not encroach on
|
||||
the reserved readline chords (I1b).
|
||||
- The renderer must expose the output-panel rect to `App`; a small,
|
||||
pattern-consistent addition.
|
||||
|
||||
## Scope / non-goals (OOS)
|
||||
|
||||
- **Manual/scripted badge push** and **badges for plain character
|
||||
keys** — out; badges are automatic over the fixed invisible-key set.
|
||||
- **Configurable overlay styling or placement** — out; fixed
|
||||
black-on-yellow boxes at the output panel's bottom-right.
|
||||
- **The guided-lesson / tutorial system itself** — out (its own ADR);
|
||||
this ADR only builds the primitive it will reuse.
|
||||
- **Persisting demo mode across project switches / sessions** — out;
|
||||
it is a per-run flag.
|
||||
- **Localising caption content** — out; captions are author-supplied
|
||||
free text.
|
||||
- **Output-pane scroll-in-casts** and other arrow-only interactions —
|
||||
out (separate enhancement; same autocast limitation as noted in #24).
|
||||
|
||||
## Testing
|
||||
|
||||
Per ADR-0008 and the project's test discipline (test-first; green, no
|
||||
skips):
|
||||
|
||||
- **Tier 1 (`app.rs` units):** `demo_badge_label` mapping over the full
|
||||
key set **and** the no-badge cases (plain chars, `Ctrl+]`, `Ctrl-C`);
|
||||
the stealth-caption state machine — open on `Ctrl+]`; characters
|
||||
accumulate with the **input line unchanged**; `Backspace` edits the
|
||||
buffer; **non-typing keys inert while capturing**; commit sets the
|
||||
caption; empty commit clears; opening over a visible caption clears
|
||||
it; next keystroke clears a visible caption **then processes
|
||||
normally**; capture works **with a modal open** (caption set while the
|
||||
load-picker modal is up, picker state untouched); the **demo-off
|
||||
gate** (`Ctrl+]` inert, characters reach the input, no caption/badge
|
||||
state ever set); the pure "nearest deadline" helper.
|
||||
- **Tier 2 (insta snapshots, `ui.rs`):** badge box, caption box, both
|
||||
stacked, at 90×26 in light and dark — verifying the bottom-right
|
||||
anchor, the stack order, and the black-on-yellow styling; plus a
|
||||
short-terminal case exercising the clamp/skip guard.
|
||||
- **Tier 3 (integration):** `--demo` plumbs `app.demo_mode`; a
|
||||
significant-key event sets `app.demo_badge` and a swallowed key during
|
||||
capture does **not**; a `Ctrl+]` / type / `Ctrl+]` sequence sets
|
||||
`app.demo_caption` without touching `app.input`.
|
||||
- **CLI (`cli.rs` units):** `--demo` parses (mirrors `--no-undo`); the
|
||||
`RDBMS_PLAYGROUND_DEMO` env fallback; default-off.
|
||||
|
||||
**Honest coverage limit.** The badge **timer-expiry wiring** runs inside
|
||||
`run_loop` (terminal + db worker), which is not unit-testable in
|
||||
isolation; it is a thin reuse of the already-proven `IndicatorDebounce`
|
||||
time-boxed-`recv` path. We therefore test the **pure pieces**
|
||||
exhaustively (label fn, capture state machine, nearest-deadline helper)
|
||||
and assert plumbing via Tier-3, rather than over-claiming an integration
|
||||
test of the `tokio` timeout itself.
|
||||
</content>
|
||||
</invoke>
|
||||
@@ -57,3 +57,6 @@ This directory contains the project's ADRs, recorded per
|
||||
- [ADR-0042 — H1a parse-error pedagogy in the grammar-tree era](0042-h1a-parse-error-pedagogy-grammar-tree.md) — **Accepted 2026-06-03.** Continues **H1a** from ADR-0021 against the ADR-0024 grammar tree (ADR-0021's chumsky mechanism is dead). Records the **baseline already shipped** — per-command `usage:` block (38 `parse.usage.*` templates), available-commands fallback, structural "after `…`, expected …" wording, source-derived ident slot labels ("table name"/"column name"), curated `parse.custom.*` near-miss messages, and the ADR-0027/0033/0036 schema-aware `[ERR]` diagnostics — so H1a is *substantially* delivered at the intent level. Defines the remaining work as **(1)** a verified per-command **near-miss matrix** (`tests/typing_surface/` + `tests/it/parse_error_pedagogy.rs`) as the definition of done, test-first; **(2)** **friendlier literal expectation labels** — optional prose glosses on `Word`/`Punct`/`Flag` positions that *add* role context while always keeping the exact literal visible (e.g. "a filter clause: `where …` or `--all-rows`"); **(3)** **advanced-mode SQL** near-miss parity (RETURNING scope, CTE-arity positioning, `CROSS JOIN … ON`, INSERT…SELECT count) — **in scope**, kept distinct from ADR-0019 §OOS-2 which covers advanced-SQL *engine*-error sanitisation, a different layer. Catalog/anchor-phrase discipline (ADR-0019) preserved; no public API change. OOS: I3/I4, spell-correction, multi-error reporting, verbosity-gating the usage block
|
||||
- [ADR-0043 — Compound-primary-key foreign-key references (T3)](0043-compound-pk-foreign-key-references.md) — **Accepted + implemented 2026-06-09** (all four forks confirmed at the recommended option: full-PK matching, house-style uniform lists, parenthesized DSL syntax, bare-SQL-FK auto-expansion). Closes `requirements.md` **T3** `[x]` — the relationship model went list-based across six layers (single-column preserved, no migration), DSL `from P.(a,b) to C.(x,y)` + SQL `FOREIGN KEY (a,b) REFERENCES P(x,y)` parse/execute/enforce, 12 tests in `tests/it/compound_fk.rs`. Closes the open leg of `requirements.md` **T3**: a foreign key that *references* a parent's compound primary key. A 2026-06-09 audit found single-column FK woven through ~15–20 sites (metadata table, `RelationshipSchema`, `project.yaml` `RawEndpoint`, both grammar surfaces, executor FK-DDL emission, per-column type-compat, display) — earns an ADR, not an inline build. **Decision:** reference the parent's **full** compound PK, matched **positionally** to an equal-length child column list, per-pair `fk_target_type` compat (ADR-0011, element-wise); DSL `from <P>.(a, b) to <C>.(x, y)` (single form unchanged), SQL `FOREIGN KEY (x, y) REFERENCES P(a, b)` (extend the existing one-cap lists; bare table-level FK auto-expands to the parent PK when arities match). **Storage — no migration (back-compat not required, user-confirmed 2026-06-09; no installed base):** the relationship endpoint joins the list convention `project.yaml` *already* uses — `columns: [a, b]` like `primary_key: [id]` and index `columns: [...]` (the endpoint was the lone scalar `column:` holdout); the metadata `TEXT` columns are unchanged and store the list **comma-joined** (`a,b`; the bare name for single — safe because identifiers are `[A-Za-z0-9_]+`). No F3 migrator, no version bump; accepted trade-off is that a pre-change `project.yaml` with relationships won't load (clean cutover). In-memory model goes list-based (`Vec<String>`) through all six layers; the enforced FK is the rebuilt child-table DDL (`FOREIGN KEY (a,b) REFERENCES P(x,y)`), one relationship = one undo step (ADR-0013). Genuine forks escalated: matching policy (full-PK vs subset), storage (house-style uniform lists vs normalized table), DSL syntax (parenthesized vs repeated-dotted), bare-SQL-FK auto-expansion. OOS: subset/non-PK (UNIQUE-targeted) FK references; any single-column behaviour change
|
||||
- [ADR-0044 — Relationship visualization (two-table connector diagrams)](0044-relationship-visualization.md) — **Accepted 2026-06-09; implemented 2026-06-10** (closes `requirements.md` V1; second `/runda` pass over the implementation; §3 last-resort helper line considered and rejected). Resolves **ADR-0016 OOS-1** and closes the open half of `requirements.md` **V1** ("a selected relationship as two tables joined by a line"). Renders a relationship as **Style A** (two structure boxes + connector). **Reach = "relationship-relevant"** (user-chosen over global / show-only): diagrams on the surfaces where the relationship is the *subject* — `show relationship <name>` (one full diagram), `show table <T>` (T's structure box then a **Relationships** section of **stacked compact** per-relationship diagrams — chosen over a focal-centred subgraph: no crossing lines, scales via scroll, two-boxes-wide fits any terminal), and relationship DDL echoes (`add`/`drop`/`modify relationship`); incidental DDL echoes (`add column`, `drop index`, `change column`, plain `create table`) keep the terse prose, via a `Diagram`|`Prose` render mode on `render_structure`. Reading convention **child(FK)-left / parent-right, arrow →, `n`…`1` cardinality**, applied uniformly; every box gets a **bold title row + rule** so the name can't read as a column. **Compound FKs** (ADR-0043) route one connector per positional pair + an explicit pairing line. **Width-aware** (first in the codebase) but **App-side**: `render_structure`/diagram rendering runs in `app.rs` (the worker only returns `TableDescription`s), a new `App::last_output_width` (set from `ui.rs`) drives side-by-side vs a **vertical-stack** fallback + last-resort "run `show relationship`" pointer; rendered once at command time, **no live reflow** (V4). `show relationship`'s worker path (`do_show_one`, prose-only) is restructured to return both endpoint `TableDescription`s. Styling reuses **ADR-0028** App-side styled runs (new classes: table-name/key/connector/cardinality/action) — no worker→UI contract change. **Partially supersedes ADR-0016 §5** (prose block replaced on relationship-subject surfaces, retained on incidental ones); extends §4 (layout width-awareness, still no cell truncation) and §6 (per-span theming). Tests: insta snapshots (single, compound, vertical fallback, helper line, self-referential, multi-rel `show table`) + width-threshold/routing unit tests + Tier-3 wiring; enumerated prose-fallout updates (`output_render.rs:121/135/793`, the relationships snapshot, `walking_skeleton.rs:477/530`). A `/runda` DA pass corrected three inverted-architecture claims (App-side rendering, untracked width, prose-in-worker show-relationship) before acceptance. OOS: user-configurable display setting (OOS-7), live reflow (V4), whole-DB ER export (V3), m:n (C4), ASCII fallback (ADR-0016 OOS-5)
|
||||
- [ADR-0045 — `create m:n relationship` convenience command (C4)](0045-mn-convenience.md) — **Accepted + implemented 2026-06-10** (closes `requirements.md` **C4**; all forks user-confirmed + a `/runda` DA pass that verified the `do_create_table` reuse against code and corrected the "no PK-less tables" assumption — advanced SQL `create table t (a int)` has none, so a parent-PK guard is retained). Implementation corrected a second ADR premise: "the walker already dispatches multiple nodes per entry word" held only in *advanced* mode — two simple-mode spots (dispatcher `decide`, completion continuation-merge) assumed ≤1 DSL form per entry word and were generalized **behaviour-preservingly** (dispatch reduces to the old single-candidate commit; completion merge gated on `simple_count > 1`). Junction echo wired (`render_create_m2n`, round-trips as SQL). `create m:n relationship from <T1> to <T2> [as <name>]` generates a junction table with one FK column per parent PK column, a **compound PK over all the FK columns** (the textbook junction — the pair is unique, no duplicate links), and **two 1:n relationships**, all in **one transaction = one undo step** (built by reusing `do_create_table`, which already takes `foreign_keys` + writes relationship metadata — no batch bracketing). Forks all user-chosen: junction PK = compound-over-FKs (vs surrogate serial / no PK); referential actions = **`CASCADE`** on delete+update (vs NO ACTION / RESTRICT); naming = auto `{T1}_{T2}` + optional `as` (vs auto-only); available in **both modes** (Simple-category DSL, like the sibling relationship commands). FK columns named `{parent_table}_{pk_column}` (disambiguates shared `id`; generalises to compound parents via ADR-0043), typed via `fk_target_type` (ADR-0011). A distinct `Command::CreateM2nRelationship` (not lowered to `CreateTable`) preserves command identity (X5) and lets the teaching echo speak in m:n terms. Cross-cutting wiring enumerated: separate `CREATE_M2N` `CommandNode` (own `help_id`/`usage_ids`), `("m","m:n")` completion composite, `HintMode`s, grammar-driven highlighting, `help`/`help create`, `parse_error_pedagogy` near-miss matrix, teaching echo. OOS: **self-referential m:n** (`from T to T`) refused outright (user-confirmed "full stop" — directional column-naming is more than this beginner convenience warrants); per-relationship action overrides; extra junction payload columns; m:n diagram echo; renaming the auto-generated relationships
|
||||
- [ADR-0046 — Schema sidebar focus/navigation mode and responsive input & hint layout (UI #20/#21/#23)](0046-sidebar-navigation-and-responsive-input-hint.md) — **Accepted + implemented 2026-06-10, phased A→B→C** (8 commits `9f5f76b`…`22bec61`; closes Gitea **#20** hint jumpiness, **#21** left-column improvements, **#23** long input — all forks user-confirmed, including the persistent show/hide toggle which is **deferred**: the Ctrl-O peek covers #21's "keystroke to show and hide"). Two decisions landed differently from the draft (recorded inline): relationship data on **`App`** not `SchemaCache` (DB2); the nav overlay clears **only the sidebar strip + a one-column gutter**, panels staying visible behind (DC2). Treats the three UI issues as one coupled decision because they share the terminal's width/height budget. **Phase A (input & hint):** the hint panel's height becomes a function of **terminal geometry, fixed between resizes** (not of hint content), eliminating the #20 jump at its source — measured catalog shows ≥ ~54-col right-column width never needs > 2 hint lines, so 3 lines is a rare narrow-terminal-only case; height buckets `H<40` compact (input 1 row + horizontal scroll / hint 2) vs `H≥40` comfortable (input 2 rows soft-wrap / hint 2), output `Min(5)` honoured first under degradation; input gains horizontal scroll (`input_scroll_offset`, single logical `String` — **not** I1 multi-line) and 2-row soft-wrap display when tall, preserving ADR-0027's 6-col indicator reserve. **Phase B (sidebar):** the 26-col Tables column is **kept but made optional and richer** (not deleted — pedagogy wins ties) — **width-derived session-only** visibility (visible iff width > 90 or a Ctrl-O peek is active — no stored field; hides at width ≤ 90 so the 90-col screencasts drop it; ADR-0015 format untouched), plus a **relationships panel** rendered narrow with endpoints broken at the arrow, ellipsized — a **separate sibling panel** that **overrides S2**'s nested-list extension model (relationships are cross-table). the full records live on a new **`App.relationships`** field (revised from the ADR's original `SchemaCache.relationship_details` at implementation — `SchemaCache` is walker-facing and needs only the names, kept in `relationships: Vec<String>`; details are UI-only, so `App` mirrors `app.tables` and avoids ~23 fixture edits), delivered by `Database::read_all_relationships` + an `AppEvent::RelationshipsRefreshed`; the two left panels split vertically with the relationships panel floored at 5 rows ("(none)" when empty) and capped at 50 % of the column (DB4). **Phase C (navigation mode):** **`Ctrl-O`** enters a focus cycle (Input → Tables → Relationships → Input; `Esc` exits) orthogonal to the ADR-0003 input mode — **`Ctrl-B` was rejected on review as the default tmux prefix** (unreachable inside tmux); the focused panel **expands to ~40–50 cols as a `Clear` overlay** (right panels stay unchanging underneath) and scrolls via **Up/Down (line) + PageUp/PageDown (page)** (context-rebind, reusing the output-scroll viewport mechanism), with an accent focus border; all non-nav keys inert in nav mode (and nav keys inert while a modal is open). Forks all user-chosen: keep-optional-richer (vs remove/narrow); navigation-mode (vs modeless modifier scroll); `Ctrl-O` (Ctrl-B rejected = tmux prefix); overlay (vs layout re-split); inert-non-nav-keys; geometry-fixed hint height; `H<40/≥40` thresholds; session-only persistence; Up/Down line-scroll; **separate relationships panel overriding S2**; **no hint-area toggle** (S4's stale "keyboard-toggleable" claim struck — never implemented, unwanted). A pre-build `/runda` DA pass drove these corrections: caught the `Ctrl-B`/tmux collision, the `SchemaCache` retype that would have broken completion, the 2-row-input/indicator placement, the missing nav-mode key disposition + modal gate, and three unreferenced requirements (S1 evolved, S2 overridden, S4 corrected); also cross-checked open issue **#22** (overlay/annotation layer — separate ADR, adjacent). OOS: true multi-line input (I1); readline shortcuts (I1b); cross-session sidebar persistence; output as a third nav focus; relationship search/edit from the panel; hint-area toggle; #22's annotation layer. Accepted consequence: the 90-col visibility threshold makes a terminal's output *narrower* when widened across the boundary (sidebar appears)
|
||||
- [ADR-0047 — Demonstration overlay layer (keystroke badges + step captions)](0047-demonstration-overlay-layer.md) — **Accepted 2026-06-10; implemented 2026-06-11, phased A→B→C (closes Gitea #22)** (commits `f879d54`→`2d0f4b2`; no `requirements.md` item — tracked by issue + ADR per convention; all forks user-confirmed + a pre-build `/runda` pass that produced 10 tightening findings and a whole-implementation `/runda` pass that returned PASS, no blockers). An in-app **demonstration mode** (`--demo` flag / `RDBMS_PLAYGROUND_DEMO` env, **off by default, zero footprint when off**) that renders two transient overlays so `autocast` screencasts — and live teaching, and a future guided-lesson system — can show otherwise-invisible interactions. **Keystroke badges** (`[TAB]`, `[ENTER]`, `[UP]`, …): **automatic, app-detected** over a fixed set of glyph-less keys (the app already sees every key, so it re-records for free), label via a pure `demo_badge_label(&KeyEvent)`; the badge **auto-expires on a ~1.5 s timer** that extends the runtime's existing time-boxed-`recv` arm condition (`debounce.is_armed() || badge_pending`; expiry `Instant` in the runtime, `App.demo_badge` the render mirror — mirroring the `input` vs `input_indicator` split). **Step captions**: a **stealth, control-code-delimited input buffer** toggled by **`Ctrl+]`** (byte `0x1D` → arrives as `Char('5')+CONTROL`, verified against crossterm 0.29 `parse.rs:110-113`; chosen over `Ctrl+!`, which is **not a single ASCII byte so autocast cannot send it** — the same wall as arrow keys, R4) — typed characters accumulate **invisibly** (prompt untouched, no echo/history), `Backspace` edits, other keys inert, a second `Ctrl+]` **commits** to the caption box (empty commit dismisses); lives in pure-sync `App::update()`, **intercepted before the modal gate** so captions/badges work **over the load picker** (the `#24` projects cast). Both render as **floating flat black-on-yellow rectangles** (solid fill, **no border glyphs** — a one-cell text margin, deliberately unlike the app's bordered panels; user decision post-build, `2d0f4b2`) **at the output panel's inner bottom-right**, drawn **last over modals**, badge **stacked above** the caption, **no layout reflow**; caption **word-wraps to ≤ 3 lines** (3–5 rows), badge fixed 3 rows; clamp/skip guard for tiny terminals; a new **`App.last_output_area: Rect`** (set in `render_output_panel`) gives the top-level draw the anchor. Caption persists **until the next keystroke**; badge suppressed while capturing. Forks all user-chosen: `--demo` activation (vs hidden command / chord); automatic badges (vs scripted); stealth buffer (vs typed-command / preloaded-file); floating bottom-right boxes (vs HUD / banner / subtitle); `Ctrl+]` trigger; wrap-to-3-line captions; ~1.5 s badge / next-keystroke caption timing. Tested test-first across Tier 1 (label fn, capture state machine incl. over-modal + demo-off gate, nearest-deadline helper), Tier 2 (insta snapshots: badge/caption/both-stacked at 90×26 light+dark, short-terminal clamp), Tier 3 (`--demo` plumbing, badge set/suppressed, caption-without-input wiring), CLI (`--demo` parse + env fallback) — with an **honest limit** noted: the `tokio` timer wiring inside `run_loop` is exercised via the pure pieces + Tier-3 plumbing, not a standalone integration test of the timeout (same posture as the existing `IndicatorDebounce`). One intentional, user-acknowledged behaviour: `Ctrl-C` is inert while capturing (every non-`Ctrl+]` key is, by spec). Final tally **2290 passing / 0 failing / 0 skipped** (1 long-standing ignored doctest), clippy clean. OOS: scripted/manual badge push; badges for glyph keys; configurable styling/placement; the guided-lesson system itself (own ADR); cross-session/-switch persistence; localised caption content; arrow-only cast interactions (output-pane scroll); wiring the overlays into the website `casts.mjs` scripts (website-branch follow-up). Implementation phased **A** (`--demo` plumbing) → **B** (badges) → **C** (captions) + a flat-rectangle restyle
|
||||
|
||||
Reference in New Issue
Block a user