mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-06 05:28:31 +09:00
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:
@@ -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 32–35), 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.
|
||||||
Reference in New Issue
Block a user