Files
tsvm/docs/superpowers/specs/2026-05-24-fsh-interactive-widgets-design.md
minjaesong dfcc0c7729 docs: design spec for interactive fSh widgets
Spec for making com.fsh.todo_list and com.fsh.quick_access functional,
with state persisted to assets/disk0/home/config/fshrc. Includes an
IOSpace.kt change to expose right-click as MMIO[36] bit 1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 01:49:42 +09:00

330 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# fSh Interactive Widgets — Design
**Date**: 2026-05-24
**Scope**: Make `com.fsh.todo_list` and `com.fsh.quick_access` widgets in
`assets/disk0/home/fsh.js` functional. Persist state to
`assets/disk0/home/config/fshrc`. Add a single `IOSpace.kt` change to expose
the right mouse button.
## Goals
1. Click "Click to add" → modal popup that adds a new entry.
2. Click an existing todo → toggle done. Click an existing QA entry → launch
its program via `execApp`, then return to fsh.
3. Right-click any existing entry → modal popup for edit / delete.
4. Hover (or keyboard focus) highlights the row under the pointer.
5. Keyboard navigation: arrows move focus; Enter = left-click; Shift+Enter =
right-click; Esc exits fsh.
6. State persists across runs via `A:/home/config/fshrc`.
## Non-goals
- Drag-and-drop reordering of items.
- Multi-line todos.
- Validation or autocomplete on the QA "Command" field — whatever the user
types is stored verbatim and passed to `execApp`.
- Any UI for resolving errors in a malformed `fshrc`: invalid lines are
silently dropped on load. fsh is the only writer.
- Right-click support exposed via any new dedicated MMIO range; we just
promote the existing single-bit `mouseDown` byte to a two-bit field.
## Architecture
### Source files touched
| File | Change |
|-----------------------------------------------------------------|------------------------------------------------------------------------|
| `assets/disk0/home/fsh.js` | Widget interaction, dialog primitive, config I/O, new main loop. |
| `tsvm_core/src/net/torvald/tsvm/peripheral/IOSpace.kt` | MMIO[36] becomes a button bitfield (bit 0 = left, bit 1 = right). |
No new files. `assets/disk0/home/config/fshrc` is created lazily on first
save.
### High-level units in fsh.js
1. **Input polling layer** — reads mouse position (MMIO 3235), mouse buttons
(MMIO 36), and keyboard events (existing `captureUserInput()` /
`getKeyPushed()`). Provides edge-detected click events and a per-frame
"cursor moved?" signal.
2. **Focus state** — single `_fsh.focus = {widgetId, index}` driven by
whichever input device moved last. Cleared when neither mouse nor keyboard
selects anything actionable.
3. **Widget hit-test + draw** — each interactive widget gains a
`hitTest(charX, charY)` returning `{kind: "add"|"item", index}` or `null`,
and its `draw()` accepts the focus state to invert the highlighted row.
4. **Modal dialog primitive**`_fsh.showDialog(opts)` blocks input until
the user submits or cancels. Returns a tagged result.
5. **Config I/O**`_fsh.loadConfig()` runs once at startup;
`_fsh.saveConfig()` runs after every mutation.
6. **Dispatcher** — translates click / keyboard events into widget mutations,
`execApp` invocations, or dialog opens.
## Detailed behaviour
### Input polling
```
Mouse X = (sys.peek(-33) & 0xFF) | ((sys.peek(-34) & 0xFF) << 8)
Mouse Y = (sys.peek(-35) & 0xFF) | ((sys.peek(-36) & 0xFF) << 8)
Buttons = sys.peek(-37) & 0xFF // bit 0 = left, bit 1 = right
```
Mouse pixel → char-grid conversion: `charX = mouseX / 7`, `charY = mouseY / 14`
(matching the existing widget coordinate system).
Each frame the loop computes `(prevButtons, currButtons)` and emits at most
one event:
- left-pressed edge (`!(prev & 1) && (curr & 1)`) → `leftClick(charX, charY)`
- right-pressed edge (`!(prev & 2) && (curr & 2)`) → `rightClick(charX, charY)`
Keyboard events use the existing `captureUserInput() / getKeyPushed(k)` mechanism
the file already uses. We don't need `con.getch()` in the main loop because the
dialog handles its own text input.
### Focus state
- `_fsh.focus = null | {widgetId: string, index: number}`.
- After each frame's input poll, focus is reassigned by the most recent input:
- If mouse moved since last frame: focus = hit-test under cursor (or `null`).
- If a nav key was pressed: focus = computed from previous focus + key.
- Drawing always honours `_fsh.focus`; widgets that don't match `widgetId` draw
normally.
Keyboard nav rules:
- Indexing convention (matches the existing draw): for a list of length `N`,
indices `0..N-1` are existing entries and index `N` is the `+ Click to
add` row. Indices past `N` are not focusable.
- `↑` / `↓`: move `index` ± 1, clamped to `[0, min(N, maxRows-1)]` for the
current widget. No wrap.
- `←` / `→`: switch `widgetId` between `com.fsh.todo_list` and
`com.fsh.quick_access`, with `index` clamped to the target widget's range.
- If focus is `null` on key press, default to `{widgetId: "com.fsh.todo_list",
index: 0}`.
### Hit-testing
Each interactive widget exports:
```
widget.hitTest(charX, charY) → null
| {kind: "add"}
| {kind: "item", index: i} // i is the 0-based model index
```
The hit region is the widget's rendered row range. For the Todo widget that's
charY in `[charYoff + 2, charYoff + 14]` for rows 0..12; charX in
`[charXoff, charXoff + 26)`. Same shape for QA, with its own `charYoff`,
`charXoff`, and 22 rows. The widget owns these magic numbers because they
already live in its `draw()`.
The clicked row index maps to:
- `0..N-1` (existing entries) → `{kind: "item", index}`.
- `N` (the row that draws "Click to add") → `{kind: "add"}`.
- `> N` (the underscore filler rows) → `null`.
### Dispatcher
```
on leftClick(cx, cy):
hit = widget.hitTest(cx, cy)
if hit is null: return
if hit.kind == "add":
openAddDialog(widget)
elif widget == todo:
toggleDone(hit.index); saveConfig()
elif widget == qa:
launchEntry(qa.entries[hit.index])
on rightClick(cx, cy):
hit = widget.hitTest(cx, cy)
if hit is null or hit.kind == "add": return
openEditDialog(widget, hit.index)
on Enter: leftClick at focus
on Shift+Enter: rightClick at focus
```
`launchEntry({label, cmd})`:
1. Read the file at `cmd` (using the existing path-resolution pattern from
`command.js`).
2. `execApp(programCode, [cmd])`.
3. On return, redraw wallpaper + titlebar + all widgets.
4. Errors from `execApp` are caught and logged via `serial.printerr`; fsh
continues running. No bulletin shown (out of scope).
### Modal dialog
```
_fsh.showDialog({
title: "New Todo",
fields: [{label: "Text", initial: "", width: 24}],
allowDelete: false, // adds [Delete] button when true
}) → {action: "ok"|"delete"|"cancel", values: [string, ...]}
```
Render:
- Centred on a `_fsh.scrwidth × _fsh.scrheight` grid. Width = max(title length
+ 4, longest field width + 6, 16). Height = 4 + 3 × fields.length + 1.
- Frame: `╔═╗ ║ ╚═╝` (double-line). Inner field box: `┌─┐ │ └─┘`.
- Saves a snapshot of the underlying char cells via `con.peekch`-style reads
(if available) or simply redraws wallpaper + widgets after close. The
simpler "redraw everything" approach is acceptable given the small screen
budget.
Input loop inside the dialog (separate from the main loop):
- Uses `con.getch()` for character entry, matching `command.js` line 505.
- Printable ASCII (32..126) and the TSVM extended chars append to the active
field.
- Backspace deletes one char.
- Tab cycles fields (forward).
- Enter: if active field is not last, advance to next field; if last, submit.
- Esc: cancel.
- Mouse: re-uses the main-loop hit-tester logic to detect clicks on `[OK]`,
`[Cancel]`, or `[Delete]` buttons, and to focus a field when clicked.
The dialog drives its own input loop. The main loop is **not** running while a
dialog is open. This avoids race conditions on shared input state.
### Config (fshrc)
Path: `A:/home/config/fshrc`.
Format (re-stated for the spec):
```
[TODO]
+ Buy groceries
- Read CLAUDE.md
+ Take out trash
[QUICK_ACCESS]
Files,/tvdos/bin/zsh.js
Editor,/tvdos/bin/edit.js
BASIC,/tbas/basic.js
```
Parse rules:
- Lines starting with `[` open a new section. Recognised names: `TODO`,
`QUICK_ACCESS`. Unknown sections cause subsequent lines to be ignored until
the next header.
- Inside `[TODO]`: line must match `^[+-] (.*)$`. `+` → done; `-` → not done.
Whitespace-only lines skipped.
- Inside `[QUICK_ACCESS]`: split on the **first** comma. Label = left side
(trimmed); cmd = right side (verbatim, no trim — leading space may be
intentional). Lines without a comma are skipped.
- Blank lines anywhere are ignored.
- Trailing newline tolerated.
Load behaviour:
- If file does not exist: todoList stays `[]`, QA falls back to the hardcoded
default entries (`Files / Editor / BASIC / DOS Shell`). On first save, the
file is created and defaults are written out.
- If file exists but is empty or only contains unknown sections: same as
above (defaults for QA, empty todo).
Save behaviour:
- Whole file rewrite via `file.swrite(serialized)` on every mutation.
- Order in file matches in-memory order; in-memory order matches click order
(newest at the bottom for new adds).
### Engine change (`IOSpace.kt`)
Convert MMIO[36] from a single boolean to a two-bit field. Touch points
(all within `IOSpace.kt`):
```kotlin
// ~line 283: rename and retype
private var mouseButtons: Int = 0 // bit 0 = LEFT, bit 1 = RIGHT
// ~line 101: change the read
36L -> mouseButtons.toByte()
// ~line 302: set both bits in the touched branch
mouseButtons = (if (Gdx.input.isButtonPressed(Buttons.LEFT)) 1 else 0) or
(if (Gdx.input.isButtonPressed(Buttons.RIGHT)) 2 else 0)
// ~line 316: clear when no touch
mouseButtons = 0
```
Backwards compatibility: existing JS does `sys.peek(-37)` and treats non-zero
as "pressed." Since LEFT (the only previously available button) is bit 0,
non-zero is preserved for left-click. No JS callers currently inspect the
high bits, so no callers break.
## Data flow
```
startup
└─ loadConfig() → populates todoWidget.todoList and quickAccessWidget.entries
└─ registerNewWidget(...)
└─ enter main loop
main loop, per frame
├─ poll mouse pos + buttons + keyboard
├─ update _fsh.focus
├─ if leftClick edge: dispatchLeftClick()
├─ if rightClick edge: dispatchRightClick()
├─ if Enter / Shift+Enter: synthesize click at focus
├─ if Esc: break
└─ redraw widgets (each receives _fsh.focus)
dispatch
├─ openAddDialog → showDialog → mutate model → saveConfig() → redraw all
├─ openEditDialog → showDialog → mutate model → saveConfig() → redraw all
├─ toggleDone → mutate model → saveConfig() → no full redraw needed
└─ launchEntry → execApp → redraw all on return
shutdown (Esc)
└─ con.reset_graphics(); con.clear()
```
## Error handling
- `loadConfig`: any parse failure on a single line → drop the line, keep
parsing. No user-visible error.
- `saveConfig`: file open failure → log via `serial.printerr`, continue.
In-memory state is still correct for the session.
- `execApp` throws → caught, logged via `serial.printerr`, fsh continues.
- Dialog cancel → model untouched, no save, redraw.
## Testing
Manual verification path (the project doesn't have a JS test harness for
fsh):
1. **First run**: delete `fshrc`, launch fsh — expect default QA entries and
empty todo list with a single `+ Click to add` row.
2. **Add todo**: left-click `+ Click to add` on todo widget → dialog appears →
type "Buy groceries" → Enter. Row added. Restart fsh — row persists.
3. **Toggle done**: left-click an existing todo → checkbox flips. Restart →
state preserved.
4. **Edit todo**: right-click an existing todo → dialog opens pre-filled. OK
saves edit; Delete removes; Cancel discards.
5. **Add QA**: left-click `+ Click to add` on QA widget → dialog with two
fields (Label, Command). Submit.
6. **Launch QA**: left-click `Editor` → `edit.js` runs. Quit edit → fsh
redraws.
7. **Edit/Delete QA**: right-click an entry → edit dialog (with Delete button)
appears.
8. **Keyboard nav**: cursor not over any item — press ↓ — first todo
highlights. Use arrows to traverse, ← / → to switch widgets, Enter to
activate.
9. **Hover highlight**: move mouse over items — row inverts under cursor.
10. **Esc**: exits fsh cleanly.
11. **Malformed fshrc**: hand-edit the file to contain garbage — fsh should
start with defaults and not crash.
## Open questions
None — all design decisions are settled. Implementation can begin.