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>
12 KiB
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
- Click "Click to add" → modal popup that adds a new entry.
- Click an existing todo → toggle done. Click an existing QA entry → launch
its program via
execApp, then return to fsh. - Right-click any existing entry → modal popup for edit / delete.
- Hover (or keyboard focus) highlights the row under the pointer.
- Keyboard navigation: arrows move focus; Enter = left-click; Shift+Enter = right-click; Esc exits fsh.
- 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
mouseDownbyte 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
- 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. - Focus state — single
_fsh.focus = {widgetId, index}driven by whichever input device moved last. Cleared when neither mouse nor keyboard selects anything actionable. - Widget hit-test + draw — each interactive widget gains a
hitTest(charX, charY)returning{kind: "add"|"item", index}ornull, and itsdraw()accepts the focus state to invert the highlighted row. - Modal dialog primitive —
_fsh.showDialog(opts)blocks input until the user submits or cancels. Returns a tagged result. - Config I/O —
_fsh.loadConfig()runs once at startup;_fsh.saveConfig()runs after every mutation. - Dispatcher — translates click / keyboard events into widget mutations,
execAppinvocations, 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.
- If mouse moved since last frame: focus = hit-test under cursor (or
- Drawing always honours
_fsh.focus; widgets that don't matchwidgetIddraw normally.
Keyboard nav rules:
- Indexing convention (matches the existing draw): for a list of length
N, indices0..N-1are existing entries and indexNis the+ Click to addrow. Indices pastNare not focusable. ↑/↓: moveindex± 1, clamped to[0, min(N, maxRows-1)]for the current widget. No wrap.←/→: switchwidgetIdbetweencom.fsh.todo_listandcom.fsh.quick_access, withindexclamped to the target widget's range.- If focus is
nullon 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}):
- Read the file at
cmd(using the existing path-resolution pattern fromcommand.js). execApp(programCode, [cmd]).- On return, redraw wallpaper + titlebar + all widgets.
- Errors from
execAppare caught and logged viaserial.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.scrheightgrid. 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, matchingcommand.jsline 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):
// ~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 viaserial.printerr, continue. In-memory state is still correct for the session.execAppthrows → caught, logged viaserial.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):
- First run: delete
fshrc, launch fsh — expect default QA entries and empty todo list with a single+ Click to addrow. - Add todo: left-click
+ Click to addon todo widget → dialog appears → type "Buy groceries" → Enter. Row added. Restart fsh — row persists. - Toggle done: left-click an existing todo → checkbox flips. Restart → state preserved.
- Edit todo: right-click an existing todo → dialog opens pre-filled. OK saves edit; Delete removes; Cancel discards.
- Add QA: left-click
+ Click to addon QA widget → dialog with two fields (Label, Command). Submit. - Launch QA: left-click
Editor→edit.jsruns. Quit edit → fsh redraws. - Edit/Delete QA: right-click an entry → edit dialog (with Delete button) appears.
- Keyboard nav: cursor not over any item — press ↓ — first todo highlights. Use arrows to traverse, ← / → to switch widgets, Enter to activate.
- Hover highlight: move mouse over items — row inverts under cursor.
- Esc: exits fsh cleanly.
- 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.