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>
This commit is contained in:
minjaesong
2026-05-24 01:49:42 +09:00
parent 4e7fe82690
commit dfcc0c7729

View File

@@ -0,0 +1,329 @@
# 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.