From 26303c63af2373c7328ea4dd755bf28026c98ca2 Mon Sep 17 00:00:00 2001 From: minjaesong Date: Sun, 24 May 2026 09:50:21 +0900 Subject: [PATCH] more fshell --- assets/disk0/home/config/zfmrc | 5 - assets/disk0/home/fsh.js | 876 +++++++++-- .../2026-05-24-fsh-interactive-widgets.md | 1386 ----------------- ...26-05-24-fsh-interactive-widgets-design.md | 329 ---- terranmon.txt | 2 +- .../net/torvald/tsvm/peripheral/IOSpace.kt | 9 +- 6 files changed, 793 insertions(+), 1814 deletions(-) delete mode 100644 assets/disk0/home/config/zfmrc delete mode 100644 docs/superpowers/plans/2026-05-24-fsh-interactive-widgets.md delete mode 100644 docs/superpowers/specs/2026-05-24-fsh-interactive-widgets-design.md diff --git a/assets/disk0/home/config/zfmrc b/assets/disk0/home/config/zfmrc deleted file mode 100644 index bdd8ab8..0000000 --- a/assets/disk0/home/config/zfmrc +++ /dev/null @@ -1,5 +0,0 @@ -[EXEC_FUNS] -nes,A:/home/tvnes/tvnes.js {0} - -[COL_HL_EXT] -nes,156 \ No newline at end of file diff --git a/assets/disk0/home/fsh.js b/assets/disk0/home/fsh.js index 46a167b..0b69b70 100644 --- a/assets/disk0/home/fsh.js +++ b/assets/disk0/home/fsh.js @@ -1,24 +1,414 @@ -graphics.setBackground(2,1,3); -graphics.resetPalette(); +graphics.setBackground(2,1,3) +graphics.resetPalette() +const GL = require("gl") +const win = require("wintex") +const keysym = require("keysym") function captureUserInput() { - sys.poke(-40, 1); + sys.poke(-40, 1) } function getKeyPushed(keyOrder) { - return sys.peek(-41 - keyOrder); + return sys.peek(-41 - keyOrder) } -let _fsh = {}; -_fsh.titlebarTex = new GL.Texture(2, 14, base64.atob("/u/+/v3+/f39/f39/f39/f39/P39/Pz8/Pv7+w==")); -_fsh.scrdim = con.getmaxyx(); -_fsh.scrwidth = _fsh.scrdim[1]; -_fsh.scrheight = _fsh.scrdim[0]; -_fsh.brandName = "f\xb3Sh"; +function readMousePos() { + let lx = sys.peek(-33) & 0xFF + let hx = sys.peek(-34) & 0xFF + let ly = sys.peek(-35) & 0xFF + let hy = sys.peek(-36) & 0xFF + return [(hx << 8) | lx, (hy << 8) | ly] +} + +function readMouseButtons() { + return sys.peek(-37) & 0xFF +} + +// Returns true if any of the eight key event buffer slots holds keycode `kc`. +function isKeyDown(kc) { + for (let i = 0; i < 8; i++) { + if ((sys.peek(-41 - i) & 0xFF) === kc) return true + } + return false +} + +let _fsh = {} + +// Config file path +_fsh.CONFIG_PATH = "A:/home/config/fshrc" + +// Widget row caps (must match the loop bounds in draw()) +_fsh.TODO_MAX_ROWS = 13 // todoWidget draws i = 0..12 +_fsh.QA_MAX_ROWS = 22 // quickAccessWidget draws i = 0..21 +_fsh.TODO_TEXT_WIDTH = 24 // visible characters per todo row +_fsh.QA_LABEL_WIDTH = 24 // visible characters per QA label +_fsh.QA_CMD_WIDTH = 60 // command path field width in dialog + +// Highlight foreground for keyboard focus on widget lists. The background +// stays transparent (255) so the wallpaper continues to show through. +_fsh.HL_FG = 230 +_fsh.HL_BG = 255 + +// Dialog colour pair. Background MUST be opaque (bg 255 is transparent +// in TSVM and lets the pixel-layer wallpaper bleed through dialog cells). +_fsh.DIALOG_FG = 254 +_fsh.DIALOG_BG = 242 +_fsh.FIELD_BG = 240 +_fsh.DIALOG_DIM_FG = 249 + +// Default Quick Access entries when fshrc is missing or empty +_fsh.DEFAULT_QA = [ + ["Files", "/tvdos/bin/zsh.js"], + ["Editor", "/tvdos/bin/edit.js"], + ["BASIC", "/tbas/basic.js"], + ["DOS Shell", "/tvdos/bin/command.js /fancy"] +] + +// Mouse button bits (MMIO[36] layout per IOSpace.kt) +_fsh.MB_LEFT = 1 +_fsh.MB_RIGHT = 2 + +// Current focus: null or {widgetId: string, index: number}. +// Index uses the same convention as hitTest: 0..length-1 are entries, +// `length` is the "+ Click to add" row. +_fsh.focus = null + +// Parse fshrc text into {todos: [[text, done], ...], qa: [[label, cmd], ...]}. +// Returns null for both arrays when input is empty/whitespace. +_fsh.parseConfig = function(text) { + let todos = [] + let qa = [] + let section = null + if (!text) return {todos: todos, qa: qa} + let lines = text.split("\n") + for (let i = 0; i < lines.length; i++) { + let line = lines[i] + // strip trailing \r if any + if (line.length && line.charCodeAt(line.length - 1) === 13) { + line = line.substring(0, line.length - 1) + } + if (line.length === 0) continue + if (line.charAt(0) === "[") { + let close = line.indexOf("]") + if (close > 0) { + let name = line.substring(1, close).trim().toUpperCase() + if (name === "TODO" || name === "QUICK_ACCESS") section = name + else section = null // unknown section: ignore until next header + } + continue + } + if (section === "TODO") { + if (line.length < 2) continue + let marker = line.charAt(0) + if ((marker === "+" || marker === "-") && line.charAt(1) === " ") { + todos.push([line.substring(2), marker === "+"]) + } + } else if (section === "QUICK_ACCESS") { + let comma = line.indexOf(",") + if (comma <= 0) continue // need a non-empty label + let label = line.substring(0, comma) + let cmd = line.substring(comma + 1) + qa.push([label, cmd]) + } + } + return {todos: todos, qa: qa} +} + +// Build fshrc text from in-memory model. Inverse of parseConfig. +_fsh.serializeConfig = function(todos, qa) { + let out = "[TODO]\n" + for (let i = 0; i < todos.length; i++) { + let t = todos[i] + out += (t[1] ? "+ " : "- ") + t[0] + "\n" + } + out += "\n[QUICK_ACCESS]\n" + for (let i = 0; i < qa.length; i++) { + out += qa[i][0] + "," + qa[i][1] + "\n" + } + return out +} + +// Read fshrc; populate todoWidget.todoList and quickAccessWidget.entries. +// Falls back to defaults on missing/empty/malformed file. +_fsh.loadConfig = function() { + let f = files.open(_fsh.CONFIG_PATH) + let parsed = {todos: [], qa: []} + if (f.exists) { + try { + parsed = _fsh.parseConfig(f.sread()) + } catch (e) { + serial.printerr("fsh.loadConfig: parse failed: " + e) + parsed = {todos: [], qa: []} + } + } + todoWidget.todoList = parsed.todos + quickAccessWidget.entries = (parsed.qa.length > 0) + ? parsed.qa + : _fsh.DEFAULT_QA.slice() // copy so saves don't mutate the constant +} + +// Persist the current in-memory todos + QA entries to fshrc. +_fsh.saveConfig = function() { + try { + let f = files.open(_fsh.CONFIG_PATH) + if (!f.exists) f.mkFile() + f.swrite(_fsh.serializeConfig(todoWidget.todoList, quickAccessWidget.entries)) + } catch (e) { + serial.printerr("fsh.saveConfig: write failed: " + e) + } +} + +// Draw the bordered popup background. (row, col) is the top-left, (h, w) +// the size. Paints an opaque interior first (otherwise the wallpaper bleeds +// through cells with bg 255), then delegates frame drawing to wintex so the +// corner/edge glyphs always connect correctly. +_fsh.drawDialogFrame = function(row, col, h, w, title) { + con.color_pair(_fsh.DIALOG_FG, _fsh.DIALOG_BG) + for (let y = 0; y < h; y++) { + con.move(row + y, col) + print(' '.repeat(w)) + } + let wo = new win.WindowObject(col, row, w, h, function(){}, function(){}, title) + wo.isHighlighted = true + wo.titleBack = _fsh.DIALOG_BG + wo.drawFrame() + con.color_pair(_fsh.DIALOG_FG, _fsh.DIALOG_BG) +} + +// Slide the visible window so the caret stays inside (cursor at the +// rightmost column once it passes the field width). +_fsh.fieldScroll = function(cursor, width) { + return cursor < width ? 0 : cursor - width + 1 +} + +// Draw a single-line bordered input field at (row, col) with given width. +// content is the current text; cursor is the caret offset within content +// focused brightens the border colour. +_fsh.drawDialogField = function(row, col, width, content, cursor, focused) { + let frameFg = focused ? _fsh.DIALOG_FG : _fsh.DIALOG_DIM_FG + // Clear the field area (3 rows × width+2 cols) with FIELD_BG first so any + // stale chars from a previous render are wiped before we draw on top. + con.color_pair(_fsh.DIALOG_FG, _fsh.FIELD_BG) + con.move(row + 1, col + 1) + print(' '.repeat(width)) + // Top border + con.color_pair(frameFg, _fsh.DIALOG_BG) + con.move(row, col) + print('\u00DA') // ┌ + print('\u00C4'.repeat(width)) // ─ + print('\u00BF') // ┐ + // Vertical borders + content + con.move(row + 1, col) + print('\u00B3') // │ + con.color_pair(_fsh.DIALOG_FG, _fsh.DIALOG_BG) + let scroll = _fsh.fieldScroll(cursor, width) + let visible = content.substring(scroll, scroll + width) + print(visible) + con.color_pair(frameFg, _fsh.DIALOG_BG) + con.move(row + 1, col + width + 1) + print('\u00B3') // │ + // Bottom border + con.move(row + 2, col) + print('\u00C0') // └ + print('\u00C4'.repeat(width)) // ─ + print('\u00D9') // ┘ + con.color_pair(_fsh.DIALOG_FG, _fsh.DIALOG_BG) +} + +// Draw a button as "[ Label ]" at the given position; highlights when focused. +_fsh.drawDialogButton = function(row, col, label, focused) { + if (focused) con.color_pair(_fsh.HL_FG, _fsh.DIALOG_BG) + else con.color_pair(_fsh.DIALOG_FG, _fsh.DIALOG_BG) + con.move(row, col) + print("[ " + label + " ]") + con.color_pair(_fsh.DIALOG_FG, _fsh.DIALOG_BG) +} + +// Modal dialog. opts = { +// title: string, +// fields: [{label, initial, width}, ...], +// allowDelete: bool, +// } +// Returns {action: "ok"|"cancel"|"delete", values: [string, ...]}. +_fsh.showDialog = function(opts) { + let fields = opts.fields + let values = fields.map(function(f) { return f.initial || "" }) + // Caret position per field. Start at end of any pre-filled initial text. + let cursors = values.map(function(v) { return v.length }) + + // Layout + let maxFieldW = fields.reduce(function(m, f) { return Math.max(m, f.width) }, 16) + let titleW = (opts.title ? opts.title.length : 0) + 4 + let w = Math.max(maxFieldW + 6, titleW + 4, 24) + let buttonsRow = 2 + fields.length * 4 + 1 // 1 label + 3 field rows per field + let h = buttonsRow + 2 + let screen = con.getmaxyx() + let row = Math.max(2, Math.floor((screen[0] - h) / 2)) + let col = Math.max(2, Math.floor((screen[1] - w) / 2)) + + // Buttons list: indices follow Tab order after the last field + let buttons = [{label: "OK", action: "ok"}, {label: "Cancel", action: "cancel"}] + if (opts.allowDelete) buttons.splice(1, 0, {label: "Delete", action: "delete"}) + + let focusIdx = 0 // 0..fields.length-1 = field; then buttons + let totalFocus = fields.length + buttons.length + let done = null // {action, values} when set + + function render() { + _fsh.drawDialogFrame(row, col, h, w, opts.title) + // Fields + for (let i = 0; i < fields.length; i++) { + let labelRow = row + 1 + i * 4 + let fieldRow = labelRow + 1 + con.color_pair(_fsh.DIALOG_FG, _fsh.DIALOG_BG) + con.move(labelRow, col + 2) + print(fields[i].label + ":") + _fsh.drawDialogField(fieldRow, col + 2, fields[i].width, + values[i], cursors[i], i === focusIdx) + } + // Buttons centred on buttonsRow + let totalBtnW = buttons.reduce(function(s, b) { return s + b.label.length + 5 }, 0) - 1 + let bx = col + Math.floor((w - totalBtnW) / 2) + for (let i = 0; i < buttons.length; i++) { + let bIdx = fields.length + i + _fsh.drawDialogButton(row + buttonsRow, bx, buttons[i].label, bIdx === focusIdx) + bx += buttons[i].label.length + 5 + } + // Position the visible caret. Inside a field: place it on the content + // row at the cursor offset (corrected for horizontal scroll). On a + // button: hide the caret entirely. + if (focusIdx < fields.length) { + let fldWidth = fields[focusIdx].width + let scroll = _fsh.fieldScroll(cursors[focusIdx], fldWidth) + let contentRow = row + 1 + focusIdx * 4 + 2 + let contentCol = col + 2 + 1 + (cursors[focusIdx] - scroll) + con.move(contentRow, contentCol) + con.curs_set(1) + } else { + con.curs_set(0) + } + } + + render() + + // Note: con.getch() returns TSVM scancodes (defined in JS_INIT.js as + // con.KEY_UP=200, KEY_DOWN=208, KEY_LEFT=203, KEY_RIGHT=205, + // con.KEY_BACKSPACE=8, KEY_TAB=9, KEY_RETURN=10). Esc isn't in JS_INIT's + // map — it arrives as ASCII 27 via keyTyped(). + while (done === null) { + let k = con.getch() + + if (k === 27) { // Esc + done = {action: "cancel", values: values} + break + } + if (k === con.KEY_TAB) { + focusIdx = (focusIdx + 1) % totalFocus + render() + continue + } + // Up/Down always cycles focus across fields/buttons. + if (k === con.KEY_UP) { + focusIdx = (focusIdx - 1 + totalFocus) % totalFocus + render() + continue + } + if (k === con.KEY_DOWN) { + focusIdx = (focusIdx + 1) % totalFocus + render() + continue + } + // Left/Right moves the caret inside a field; on a button it cycles. + if (k === con.KEY_LEFT) { + if (focusIdx < fields.length) { + if (cursors[focusIdx] > 0) { + cursors[focusIdx] -= 1 + render() + } + } else { + focusIdx = (focusIdx - 1 + totalFocus) % totalFocus + render() + } + continue + } + if (k === con.KEY_RIGHT) { + if (focusIdx < fields.length) { + if (cursors[focusIdx] < values[focusIdx].length) { + cursors[focusIdx] += 1 + render() + } + } else { + focusIdx = (focusIdx + 1) % totalFocus + render() + } + continue + } + // On a field + if (focusIdx < fields.length) { + if (k === con.KEY_RETURN) { + if (focusIdx < fields.length - 1) { + focusIdx += 1 + } else { + focusIdx = fields.length // move to OK button + } + render() + continue + } + if (k === con.KEY_BACKSPACE) { + let c = cursors[focusIdx] + if (c > 0) { + let v = values[focusIdx] + values[focusIdx] = v.substring(0, c - 1) + v.substring(c) + cursors[focusIdx] = c - 1 + render() + } + continue + } + // Printable: insert at the caret. + if (k >= 32 && k < 256 && values[focusIdx].length < fields[focusIdx].width * 4) { + let v = values[focusIdx] + let c = cursors[focusIdx] + values[focusIdx] = v.substring(0, c) + String.fromCharCode(k) + v.substring(c) + cursors[focusIdx] = c + 1 + render() + } + continue + } + // On a button + if (k === con.KEY_RETURN || k === 32) { + done = {action: buttons[focusIdx - fields.length].action, values: values} + break + } + } + + con.curs_set(0) + return done +} + +// Map (mouse char x, mouse char y) to a row index for a widget drawn at +// (xoff, yoff) with `length` existing entries and `maxRows` total rows. +// Returns null / {kind:"add"} / {kind:"item", index: i}. +_fsh.hitTestList = function(charX, charY, xoff, yoff, textWidth, length, maxRows) { + // Each row sits at (yoff + i + 2, xoff..xoff + textWidth + 1). + // Column range: icon at xoff, text at xoff+2 .. xoff+1+textWidth. + // Allow clicks anywhere on the row's char cells (icon + text region). + let relY = charY - yoff - 2 + if (relY < 0 || relY >= maxRows) return null + if (charX < xoff || charX > xoff + 1 + textWidth) return null + if (relY < length) return {kind: "item", index: relY} + if (relY === length) return {kind: "add"} + return null +} + +_fsh.titlebarTex = new GL.Texture(2, 14, base64.atob("/u/+/v3+/f39/f39/f39/f39/P39/Pz8/Pv7+w==")) +_fsh.scrdim = con.getmaxyx() +_fsh.scrwidth = _fsh.scrdim[1] +_fsh.scrheight = _fsh.scrdim[0] +_fsh.brandName = "f\xb3Sh" _fsh.brandLogoTexSmall = new GL.Texture(24, 14, gzip.decomp(base64.atob( "H4sIAAAAAAAAAPv/Hy/4Qbz458+fIeILQQBIwoSh6qECuMVBukCmIJkDVQ+RQNgLE0MX/w+1lyhxqIUwTLJ/sQMAcIXsbVABAAA=" -))); -_fsh.scrlayout = ["com.fsh.clock","com.fsh.calendar","com.fsh.todo_list", "com.fsh.quick_access"]; +))) +_fsh.scrlayout = ["com.fsh.clock","com.fsh.calendar","com.fsh.todo_list", "com.fsh.quick_access"] _fsh.drawWallpaper = function() { let wp = files.open("A:/home/wall.bytes") @@ -28,85 +418,85 @@ _fsh.drawWallpaper = function() { wp.pread(b, 250880, 0) dma.ramToFrame(b, 0, 250880) sys.free(b) -}; +} _fsh.drawTitlebar = function(titletext) { - GL.drawTexPattern(_fsh.titlebarTex, 0, 0, 560, 14); + GL.drawTexPattern(_fsh.titlebarTex, 0, 0, 560, 14) if (titletext === undefined || titletext.length == 0) { - con.move(1,1); - print(" ".repeat(_fsh.scrwidth)); - GL.drawTexImageOver(_fsh.brandLogoTexSmall, 268, 0); + con.move(1,1) + print(" ".repeat(_fsh.scrwidth)) + GL.drawTexImageOver(_fsh.brandLogoTexSmall, 268, 0) } else { - con.color_pair(240, 255); - GL.drawTexPattern(_fsh.titlebarTex, 268, 0, 24, 14); - con.move(1, 1 + (_fsh.scrwidth - titletext.length) / 2); - print(titletext); + con.color_pair(240, 255) + GL.drawTexPattern(_fsh.titlebarTex, 268, 0, 24, 14) + con.move(1, 1 + (_fsh.scrwidth - titletext.length) / 2) + print(titletext) } - con.color_pair(254, 255); -}; + con.color_pair(254, 255) +} _fsh.Widget = function(id, w, h) { - this.identifier = id; - this.width = w; - this.height = h; + this.identifier = id + this.width = w + this.height = h if (!this.identifier) { - this.identifier = ""; + this.identifier = "" } - //this.update = function() {}; + //this.update = function() {} /** * Params charXoff and charYoff are ZERO-BASED! */ - this.draw = function(charXoff, charYoff) {}; + this.draw = function(charXoff, charYoff) {} } _fsh.widgets = {} _fsh.registerNewWidget = function(widget) { - _fsh.widgets[widget.identifier] = widget; + _fsh.widgets[widget.identifier] = widget } -let clockWidget = new _fsh.Widget("com.fsh.clock", _fsh.scrwidth - 8, 7*2); +let clockWidget = new _fsh.Widget("com.fsh.clock", _fsh.scrwidth - 8, 7*2) clockWidget.numberSheet = new GL.SpriteSheet(19, 22, new GL.Texture(190, 22, gzip.decomp(base64.atob( "H4sIAAAAAAAAAMWVW3LEMAgE739aHcFJJV5ZMD2I9ToVfcl4GBr80HF8r/FaR1ozMuIyoUu87lEXI0al5qVR5AebSwchSaNE6Nyo1Nw5HXF3SfPT4Bshl"+ "EycA8RD96mLlHbuhTgOrfLnUDZspafbSQWk56WEGvQEtWaWwgb8iz7a8AOXhsraO/q9Qw2/GnXovfVN+q2wM/p/oddn2cjF239GX3y11+SWCtc6FTHC1v"+ "TVPkDPWWn0w+DDz93UX9v9mF5KIsQ6OdN2KJoB4ui1bXXr0AMp0YfiQo//4XhpK8555dsNehAqVS5uhb5iHn3Kko769J59KmLBe/TSR7hcsd+hr+HnrwR"+ "9uvRF9+D3MP14gN7lqx+8OuNT+uqt3NFX3SN9fTbeeHNq+C29pRWzX5+Rcm7SZyjOKJ/2hkSPqul4xN279DrSYvCrNu2NI7ZMp1ouBxK3KBVVnEeAUWbK"+ "MUDn5DPsPxmUqHZQjGpy2hergM3EVBAAAA==" -)))); +)))) -clockWidget.clockColon = new GL.Texture(4, 3, base64.atob("7+/v7+/v7+/v7+/v")); -clockWidget.monthNames = ["Spring", "Summer", "Autumn", "Winter"]; -clockWidget.dayNames = ["Mondag ", "Tysdag ", "Midtveke", "Torsdag ", "Fredag ", "Laurdag ", "Sundag ", "Verddag "]; +clockWidget.clockColon = new GL.Texture(4, 3, base64.atob("7+/v7+/v7+/v7+/v")) +clockWidget.monthNames = ["Spring", "Summer", "Autumn", "Winter"] +clockWidget.dayNames = ["Mondag ", "Tysdag ", "Midtveke", "Torsdag ", "Fredag ", "Laurdag ", "Sundag ", "Verddag "] clockWidget.draw = function(charXoff, charYoff) { - con.color_pair(254, 255); - let xoff = charXoff * 7; - let yoff = charYoff * 14 + 3; - let timeInMinutes = ((sys.currentTimeInMills() / 60000)|0); - let mins = timeInMinutes % 60; - let hours = ((timeInMinutes / 60)|0) % 24; - let ordinalDay = ((timeInMinutes / (60*24))|0) % 120; - let visualDay = (ordinalDay % 30) + 1; - let months = ((timeInMinutes / (60*24*30))|0) % 4; - let dayName = ordinalDay % 7; // 0 for Mondag - if (ordinalDay == 119) dayName = 7; // Verddag - let years = ((timeInMinutes / (60*24*30*120))|0) + 125; + con.color_pair(254, 255) + let xoff = charXoff * 7 + let yoff = charYoff * 14 + 3 + let timeInMinutes = ((sys.currentTimeInMills() / 60000)|0) + let mins = timeInMinutes % 60 + let hours = ((timeInMinutes / 60)|0) % 24 + let ordinalDay = ((timeInMinutes / (60*24))|0) % 120 + let visualDay = (ordinalDay % 30) + 1 + let months = ((timeInMinutes / (60*24*30))|0) % 4 + let dayName = ordinalDay % 7 // 0 for Mondag + if (ordinalDay == 119) dayName = 7 // Verddag + let years = ((timeInMinutes / (60*24*30*120))|0) + 125 // draw timepiece - GL.drawSprite(clockWidget.numberSheet, (hours / 10)|0, 0, xoff, yoff, 1); - GL.drawSprite(clockWidget.numberSheet, hours % 10, 0, xoff + 24, yoff, 1); - GL.drawTexImage(clockWidget.clockColon, xoff + 48, yoff + 5, 1); - GL.drawTexImage(clockWidget.clockColon, xoff + 48, yoff + 14, 1); - GL.drawSprite(clockWidget.numberSheet, (mins / 10)|0, 0, xoff + 57, yoff, 1); - GL.drawSprite(clockWidget.numberSheet, mins % 10, 0, xoff + 81, yoff, 1); + GL.drawSprite(clockWidget.numberSheet, (hours / 10)|0, 0, xoff, yoff, 1) + GL.drawSprite(clockWidget.numberSheet, hours % 10, 0, xoff + 24, yoff, 1) + GL.drawTexImage(clockWidget.clockColon, xoff + 48, yoff + 5, 1) + GL.drawTexImage(clockWidget.clockColon, xoff + 48, yoff + 14, 1) + GL.drawSprite(clockWidget.numberSheet, (mins / 10)|0, 0, xoff + 57, yoff, 1) + GL.drawSprite(clockWidget.numberSheet, mins % 10, 0, xoff + 81, yoff, 1) // print month and date - con.move(1 + charYoff, 17 + charXoff); - print(clockWidget.monthNames[months]+" "+visualDay); + con.move(1 + charYoff, 17 + charXoff) + print(clockWidget.monthNames[months]+" "+visualDay) // print year and dayname - con.move(2 + charYoff, 17 + charXoff); - print("\xE7"+years+" "+clockWidget.dayNames[dayName]); -}; + con.move(2 + charYoff, 17 + charXoff) + print("\xE7"+years+" "+clockWidget.dayNames[dayName]) +} let calendarWidget = new _fsh.Widget("com.fsh.calendar", (_fsh.scrwidth - 8) / 2, 7*6) @@ -171,70 +561,281 @@ calendarWidget.draw = function(charXoff, charYoff) { let todoWidget = new _fsh.Widget("com.fsh.todo_list", (_fsh.scrwidth - 8) / 2, 7*10) todoWidget.todoList = [["Hello, world!", true]] todoWidget.draw = function(charXoff, charYoff) { + let focusIndex = (_fsh.focus && _fsh.focus.widgetId === todoWidget.identifier) + ? _fsh.focus.index : -1 + con.color_pair(254, 255) let xoff = charXoff * 7 let yoff = charYoff * 14 + 3 con.move(charYoff, charXoff) - print("========== TODO ==========") + print('\u00CD'.repeat(10)+" TODO "+'\u00CD'.repeat(10)) for (let i = 0; i <= 12; i++) { - let list = todoWidget.todoList[i] || ["Click to add", null] + let list = todoWidget.todoList[i] || ["Click to add"+" ".repeat(_fsh.TODO_TEXT_WIDTH - 12), null] + let isFocused = (i === focusIndex) - if (list[1] === null) con.color_pair(249, 255) + if (isFocused) con.color_pair(_fsh.HL_FG, _fsh.HL_BG) + else if (list[1] === null) con.color_pair(249, 255) else con.color_pair(254, 255) con.move(charYoff + i + 2, charXoff) con.addch((list[1] === null) ? 43 : (list[1]) ? 0x9F : 0x9E) if (i > todoWidget.todoList.length) { + // Filler row \u2014 keep underscores but don't highlight (can't focus here) + con.color_pair(254, 255) for (let k = 0; k < 24; k++) { con.mvaddch(charYoff + i + 2, charXoff + 2 + k, 95) } } else { con.move(charYoff + i + 2, charXoff + 2) - print(`${list[0]}`) + // Pad text to TODO_TEXT_WIDTH so the highlight bar covers full row + let text = `${list[0]}` + if (text.length > _fsh.TODO_TEXT_WIDTH) text = text.substring(0, _fsh.TODO_TEXT_WIDTH) + if (isFocused) text = text + " ".repeat(_fsh.TODO_TEXT_WIDTH - text.length) + print(text) } } } let quickAccessWidget = new _fsh.Widget("com.fsh.quick_access", (_fsh.scrwidth - 8) / 2, 7*20) -quickAccessWidget.entries = [ - ["Files", "/tvdos/bin/explorer.js"], +quickAccessWidget.entries = [ // TODO read from /home/config/fshrc + ["Files", "/tvdos/bin/zfm.js"], ["Editor", "/tvdos/bin/edit.js"], ["BASIC", "/tbas/basic.js"], - ["DOS Shell", "/tvdos/bin/command.js /fancy"] + ["DOS Shell", "/tvdos/bin/command.js -fancy"] ] quickAccessWidget.draw = function(charXoff, charYoff) { + let focusIndex = (_fsh.focus && _fsh.focus.widgetId === quickAccessWidget.identifier) + ? _fsh.focus.index : -1 + con.color_pair(254, 255) let xoff = charXoff * 7 let yoff = charYoff * 14 + 3 con.move(charYoff, charXoff) - print("====== QUICK ACCESS ======") + print('\u00CD'.repeat(6)+" QUICK ACCESS "+'\u00CD'.repeat(6)) for (let i = 0; i <= 21; i++) { - let list = quickAccessWidget.entries[i] || ["Click to add", null] + let list = quickAccessWidget.entries[i] || ["Click to add"+" ".repeat(_fsh.QA_LABEL_WIDTH - 12), null] + let isFocused = (i === focusIndex) - if (list[1] === null) con.color_pair(249, 255) + if (isFocused) con.color_pair(_fsh.HL_FG, _fsh.HL_BG) + else if (list[1] === null) con.color_pair(249, 255) else con.color_pair(254, 255) con.move(charYoff + i + 2, charXoff) con.addch((list[1] === null) ? 0xF9 : (list[1]) ? 7 : 0x7F) if (i > quickAccessWidget.entries.length) { + con.color_pair(254, 255) for (let k = 0; k < 24; k++) { con.mvaddch(charYoff + i + 2, charXoff + 2 + k, 95) } } else { con.move(charYoff + i + 2, charXoff + 2) - print(`${list[0]}`) + let text = `${list[0]}` + if (text.length > _fsh.QA_LABEL_WIDTH) text = text.substring(0, _fsh.QA_LABEL_WIDTH) + if (isFocused) text = text + " ".repeat(_fsh.QA_LABEL_WIDTH - text.length) + print(text) } } } +todoWidget.hitTest = function(charX, charY, xoff, yoff) { + return _fsh.hitTestList(charX, charY, xoff, yoff, + _fsh.TODO_TEXT_WIDTH, todoWidget.todoList.length, _fsh.TODO_MAX_ROWS) +} + +quickAccessWidget.hitTest = function(charX, charY, xoff, yoff) { + return _fsh.hitTestList(charX, charY, xoff, yoff, + _fsh.QA_LABEL_WIDTH, quickAccessWidget.entries.length, _fsh.QA_MAX_ROWS) +} + + +// Re-render the whole shell. Use after a dialog closes (which clobbered +// the underlying char cells) or after execApp returns. +_fsh.redrawAll = function() { + con.color_pair(254, 255) + con.clear() + graphics.clearPixels(255) + graphics.clearPixels2(255) + graphics.setFramebufferScroll(0, 0) + _fsh.drawWallpaper() + _fsh.drawTitlebar() + _fsh.widgets["com.fsh.clock"].draw(25, 3) + _fsh.widgets["com.fsh.calendar"].draw(12, 8) + _fsh.widgets["com.fsh.todo_list"].draw(10, 17) + _fsh.widgets["com.fsh.quick_access"].draw(47, 8) +} + +_fsh.openAddTodoDialog = function() { + let res = _fsh.showDialog({ + title: "New Todo", + fields: [{label: "Text", initial: "", width: _fsh.TODO_TEXT_WIDTH}], + allowDelete: false + }) + _fsh.redrawAll() + if (res.action !== "ok") return + let text = res.values[0].trim() + if (text.length === 0) return + if (todoWidget.todoList.length >= _fsh.TODO_MAX_ROWS) return + todoWidget.todoList.push([text, false]) + _fsh.saveConfig() +} + +_fsh.openEditTodoDialog = function(index) { + let entry = todoWidget.todoList[index] + if (!entry) return + let res = _fsh.showDialog({ + title: "Edit Todo", + fields: [{label: "Text", initial: entry[0], width: _fsh.TODO_TEXT_WIDTH}], + allowDelete: true + }) + _fsh.redrawAll() + if (res.action === "cancel") return + if (res.action === "delete") { + todoWidget.todoList.splice(index, 1) + _fsh.saveConfig() + return + } + let text = res.values[0].trim() + if (text.length === 0) return + todoWidget.todoList[index] = [text, entry[1]] + _fsh.saveConfig() +} + +_fsh.openAddQaDialog = function() { + let res = _fsh.showDialog({ + title: "New Quick Access", + fields: [ + {label: "Label", initial: "", width: _fsh.QA_LABEL_WIDTH}, + {label: "Command", initial: "", width: _fsh.QA_CMD_WIDTH} + ], + allowDelete: false + }) + _fsh.redrawAll() + if (res.action !== "ok") return + let label = res.values[0].trim() + let cmd = res.values[1].trim() + if (label.length === 0 || cmd.length === 0) return + if (quickAccessWidget.entries.length >= _fsh.QA_MAX_ROWS) return + quickAccessWidget.entries.push([label, cmd]) + _fsh.saveConfig() +} + +_fsh.openEditQaDialog = function(index) { + let entry = quickAccessWidget.entries[index] + if (!entry) return + let res = _fsh.showDialog({ + title: "Edit Quick Access", + fields: [ + {label: "Label", initial: entry[0], width: _fsh.QA_LABEL_WIDTH}, + {label: "Command", initial: entry[1], width: _fsh.QA_CMD_WIDTH} + ], + allowDelete: true + }) + _fsh.redrawAll() + if (res.action === "cancel") return + if (res.action === "delete") { + quickAccessWidget.entries.splice(index, 1) + _fsh.saveConfig() + return + } + let label = res.values[0].trim() + let cmd = res.values[1].trim() + if (label.length === 0 || cmd.length === 0) return + quickAccessWidget.entries[index] = [label, cmd] + _fsh.saveConfig() +} + +_fsh.toggleTodoDone = function(index) { + let entry = todoWidget.todoList[index] + if (!entry) return + entry[1] = !entry[1] + _fsh.saveConfig() +} + +// Launch a Quick Access entry. cmd is the verbatim string the user typed. +// We split on first space to derive a program path + args; if the path +// has no leading "/", we treat it as relative to the current drive. +_fsh.launchEntry = function(label, cmd) { + let firstSpace = cmd.indexOf(" ") + let progPath = (firstSpace >= 0) ? cmd.substring(0, firstSpace) : cmd + let argTail = (firstSpace >= 0) ? cmd.substring(firstSpace + 1) : "" + let fullPath = progPath.startsWith("/") ? ("A:" + progPath) : progPath + + try { + let f = files.open(fullPath) + if (!f.exists) { + serial.printerr("fsh.launchEntry: not found: " + fullPath) + return + } + let code = f.sread() + let tokens = [progPath].concat(argTail.length ? argTail.split(" ") : []) + + // erase all pixels and draw wallpaper + con.reset_graphics() + con.clear() + graphics.clearPixels(255) + graphics.clearPixels2(255) + _fsh.drawWallpaper() + con.curs_set(1) + + execApp(code, tokens) + } catch (e) { + serial.printerr("fsh.launchEntry: " + label + " failed: " + e) + } + con.curs_set(0) + graphics.setBackground(2,1,3) + graphics.resetPalette() + _fsh.redrawAll() +} + +// Layout map: widget positions hard-coded to match the draw calls below. +_fsh.layouts = { + "com.fsh.todo_list": {xoff: 10, yoff: 17, widget: null}, + "com.fsh.quick_access": {xoff: 47, yoff: 8, widget: null} +} + +// Find which widget (if any) was hit by (charX, charY). Returns +// {widgetId, hit} or null. +_fsh.findHit = function(charX, charY) { + let ids = ["com.fsh.todo_list", "com.fsh.quick_access"] + for (let i = 0; i < ids.length; i++) { + let id = ids[i] + let layout = _fsh.layouts[id] + let widget = _fsh.widgets[id] + let hit = widget.hitTest(charX, charY, layout.xoff, layout.yoff) + if (hit) return {widgetId: id, hit: hit} + } + return null +} + +_fsh.dispatchLeft = function(widgetId, hit) { + if (hit.kind === "add") { + if (widgetId === "com.fsh.todo_list") _fsh.openAddTodoDialog() + else _fsh.openAddQaDialog() + return + } + // hit.kind === "item" + if (widgetId === "com.fsh.todo_list") { + _fsh.toggleTodoDone(hit.index) + } else { + let entry = quickAccessWidget.entries[hit.index] + if (entry) _fsh.launchEntry(entry[0], entry[1]) + } +} + +_fsh.dispatchRight = function(widgetId, hit) { + if (hit.kind !== "item") return + if (widgetId === "com.fsh.todo_list") _fsh.openEditTodoDialog(hit.index) + else _fsh.openEditQaDialog(hit.index) +} + // change graphics mode and check if it's supported graphics.setGraphicsMode(3) @@ -260,29 +861,126 @@ _fsh.drawWallpaper() _fsh.drawTitlebar() -// TEST -con.move(2,1); -print("fSh is very much in-dev! Hit backspace to exit") +// Load persisted state before the first draw +_fsh.loadConfig(); + +// keyEventBuffers (read via sys.peek(-41-i)) holds *raw libGDX keycodes*, +// not the cooked TSVM scancodes that con.getch() returns. Existing fsh.js +// already uses 67 for Backspace (libGDX DEL); follow the same scheme here. +const KEY_ESC = keysym.ESCAPE +const KEY_ENTER = keysym.ENTER +const KEY_UP = keysym.UP +const KEY_DOWN = keysym.DOWN +const KEY_LEFT = keysym.LEFT +const KEY_RIGHT = keysym.RIGHT +const KEY_LSHIFT = keysym.SHIFT_LEFT +const KEY_RSHIFT = keysym.SHIFT_RIGHT + +let prevButtons = 0 +let prevMouseCharX = -1 +let prevMouseCharY = -1 +let keyLatch = {} // {keycode: true} while the key is held — debounces "just pressed" -// TODO update for events: key down (updates some widgets), timer (updates clock and calendar widgets) while (true) { - captureUserInput(); - if (getKeyPushed(0) == 67) break; + captureUserInput() - _fsh.widgets["com.fsh.clock"].draw(25, 3); - _fsh.widgets["com.fsh.calendar"].draw(12, 8); - _fsh.widgets["com.fsh.todo_list"].draw(10, 17); - _fsh.widgets["com.fsh.quick_access"].draw(47, 8); + // -- keyboard -- + if (isKeyDown(KEY_ESC)) break; - sys.spin();sys.spin() + let shiftDown = isKeyDown(KEY_LSHIFT) || isKeyDown(KEY_RSHIFT) + let enterPressed = false + + // Edge-detect each navigation key + function edge(kc) { + let down = isKeyDown(kc) + let was = !!keyLatch[kc] + keyLatch[kc] = down + return down && !was + } + + if (edge(KEY_ENTER)) enterPressed = true; + let navUp = edge(KEY_UP) + let navDown = edge(KEY_DOWN) + let navLeft = edge(KEY_LEFT) + let navRight = edge(KEY_RIGHT) + + // -- mouse -- + let pos = readMousePos() + let charX = (pos[0] / 7) | 0 + let charY = (pos[1] / 14) | 0 + let mouseMoved = (charX !== prevMouseCharX || charY !== prevMouseCharY) + prevMouseCharX = charX + prevMouseCharY = charY + + let buttons = readMouseButtons() + let leftEdge = ((buttons & _fsh.MB_LEFT) !== 0) && ((prevButtons & _fsh.MB_LEFT) === 0) + let rightEdge = ((buttons & _fsh.MB_RIGHT) !== 0) && ((prevButtons & _fsh.MB_RIGHT) === 0) + prevButtons = buttons + + // -- focus update -- + if (navUp || navDown || navLeft || navRight) { + if (!_fsh.focus) _fsh.focus = {widgetId: "com.fsh.todo_list", index: 0} + if (navUp || navDown) { + let layout = _fsh.layouts[_fsh.focus.widgetId] + let maxRows = (_fsh.focus.widgetId === "com.fsh.todo_list") + ? _fsh.TODO_MAX_ROWS : _fsh.QA_MAX_ROWS + let length = (_fsh.focus.widgetId === "com.fsh.todo_list") + ? todoWidget.todoList.length : quickAccessWidget.entries.length + let maxIdx = Math.min(length, maxRows - 1) + let next = _fsh.focus.index + (navDown ? 1 : -1) + if (next < 0) next = 0 + if (next > maxIdx) next = maxIdx + _fsh.focus.index = next + } else { + // Left/right switches widget + let other = (_fsh.focus.widgetId === "com.fsh.todo_list") + ? "com.fsh.quick_access" : "com.fsh.todo_list" + let otherLength = (other === "com.fsh.todo_list") + ? todoWidget.todoList.length : quickAccessWidget.entries.length + let otherMaxRows = (other === "com.fsh.todo_list") + ? _fsh.TODO_MAX_ROWS : _fsh.QA_MAX_ROWS + let otherMaxIdx = Math.min(otherLength, otherMaxRows - 1) + _fsh.focus = {widgetId: other, index: Math.min(_fsh.focus.index, otherMaxIdx)} + } + } else if (mouseMoved) { + let h = _fsh.findHit(charX, charY) + _fsh.focus = h ? {widgetId: h.widgetId, index: h.hit.kind === "add" + ? ((h.widgetId === "com.fsh.todo_list") + ? todoWidget.todoList.length + : quickAccessWidget.entries.length) + : h.hit.index} : null + } + + // -- mouse click dispatch -- + if (leftEdge) { + let h = _fsh.findHit(charX, charY) + if (h) _fsh.dispatchLeft(h.widgetId, h.hit) + } else if (rightEdge) { + let h = _fsh.findHit(charX, charY) + if (h) _fsh.dispatchRight(h.widgetId, h.hit) + } + + // -- keyboard dispatch (synthesise click at focus) -- + if (enterPressed && _fsh.focus) { + let length = (_fsh.focus.widgetId === "com.fsh.todo_list") + ? todoWidget.todoList.length : quickAccessWidget.entries.length + let hit = (_fsh.focus.index < length) + ? {kind: "item", index: _fsh.focus.index} + : (_fsh.focus.index === length ? {kind: "add"} : null) + if (hit) { + if (shiftDown) _fsh.dispatchRight(_fsh.focus.widgetId, hit) + else _fsh.dispatchLeft(_fsh.focus.widgetId, hit) + } + } + + // -- redraw -- + _fsh.widgets["com.fsh.clock"].draw(25, 3) + _fsh.widgets["com.fsh.calendar"].draw(12, 8) + _fsh.widgets["com.fsh.todo_list"].draw(10, 17) + _fsh.widgets["com.fsh.quick_access"].draw(47, 8) + + sys.spin(); sys.spin() } -con.move(3,1); -con.color_pair(201,255); -print("cya!"); - -let konsht = 3412341241; -println(konsht); - -let pppp = graphics.getCursorYX(); -println(pppp.toString()); \ No newline at end of file +con.reset_graphics() +con.clear() \ No newline at end of file diff --git a/docs/superpowers/plans/2026-05-24-fsh-interactive-widgets.md b/docs/superpowers/plans/2026-05-24-fsh-interactive-widgets.md deleted file mode 100644 index cf4a6d3..0000000 --- a/docs/superpowers/plans/2026-05-24-fsh-interactive-widgets.md +++ /dev/null @@ -1,1386 +0,0 @@ -# fSh Interactive Widgets Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make the `com.fsh.todo_list` and `com.fsh.quick_access` widgets in `assets/disk0/home/fsh.js` fully interactive — mouse + keyboard navigation, a modal add/edit/delete popup, item launching for Quick Access, and state persistence to `assets/disk0/home/config/fshrc`. - -**Architecture:** All UI work lives in `assets/disk0/home/fsh.js`. One small engine change in `tsvm_core/src/net/torvald/tsvm/peripheral/IOSpace.kt` widens MMIO[36] from a single boolean to a two-bit field so JS can distinguish left and right clicks. See `docs/superpowers/specs/2026-05-24-fsh-interactive-widgets-design.md` for the full design. - -**Tech Stack:** JavaScript (GraalVM, ES5-ish dialect used by TSVM), Kotlin (libGDX). The TSVM cannot be machine-invoked, so verification is `node --check` for JS syntax and manual review for runtime behaviour. The spec explicitly waived automated tests for this iteration — final verification is a manual smoke test in the running emulator. - ---- - -## File Structure - -| File | Action | Responsibility | -|-----------------------------------------------------------------|---------|-------------------------------------------------------------------------------| -| `tsvm_core/src/net/torvald/tsvm/peripheral/IOSpace.kt` | Modify | Replace `mouseDown: Boolean` with `mouseButtons: Int` (bit 0 = L, bit 1 = R) | -| `assets/disk0/home/fsh.js` | Modify | All widget interaction, focus, dialog, dispatcher, config I/O | -| `assets/disk0/home/config/fshrc` | (lazy) | Persistent state — created by fsh on first save; do **not** commit it | - -The whole feature stays in one JS file because the existing `fsh.js` is organised around widget object literals inside one script. Splitting now would force a new module-loading pattern that doesn't exist in this codebase. - ---- - -## Verification approach - -After each task that edits JS, run: - -```bash -node --check assets/disk0/home/fsh.js -``` - -Expected: no output, exit code 0. Any output means a syntax error — fix and re-check before committing. - -The Kotlin change cannot be checked from the CLI (no Gradle wrapper). The diff is small enough to verify by inspection; the user will rebuild via IntelliJ when they smoke-test. - ---- - -## Task 1: Engine change — expose right-click bit in MMIO - -**Files:** -- Modify: `tsvm_core/src/net/torvald/tsvm/peripheral/IOSpace.kt` - -- [ ] **Step 1: Read the current state of the three touch points** - -Run: -```bash -sed -n '99,105p;281,285p;298,318p' tsvm_core/src/net/torvald/tsvm/peripheral/IOSpace.kt -``` - -Expected lines (line numbers may shift slightly): the read at `36L`, the field declaration `private var mouseDown = false`, the assignment `mouseDown = Gdx.input.isTouched`, and the clear `mouseDown = false`. - -- [ ] **Step 2: Replace the field declaration** - -Locate (around line 283): - -```kotlin - private var mouseDown = false -``` - -Replace with: - -```kotlin - private var mouseButtons: Int = 0 // bit 0 = LEFT, bit 1 = RIGHT -``` - -- [ ] **Step 3: Update the MMIO read** - -Locate (around line 101): - -```kotlin - 36L -> mouseDown.toInt().toByte() -``` - -Replace with: - -```kotlin - 36L -> mouseButtons.toByte() -``` - -- [ ] **Step 4: Update the assignment inside the `isFocused` branch** - -Locate (around line 302): - -```kotlin - mouseDown = Gdx.input.isTouched -``` - -Replace with: - -```kotlin - mouseButtons = (if (Gdx.input.isButtonPressed(Input.Buttons.LEFT)) 1 else 0) or - (if (Gdx.input.isButtonPressed(Input.Buttons.RIGHT)) 2 else 0) -``` - -(The file already imports `com.badlogic.gdx.Input`, so `Input.Buttons.LEFT/RIGHT` resolve.) - -- [ ] **Step 5: Update the clear in the `else` branch** - -Locate (around line 316): - -```kotlin - mouseDown = false -``` - -Replace with: - -```kotlin - mouseButtons = 0 -``` - -- [ ] **Step 6: Verify no other references remain** - -Run: -```bash -grep -n "mouseDown" tsvm_core/src/net/torvald/tsvm/peripheral/IOSpace.kt -``` - -Expected: no output. If anything remains, it's a missed reference — update it to `mouseButtons` with the appropriate bit test. - -- [ ] **Step 7: Commit** - -```bash -git add tsvm_core/src/net/torvald/tsvm/peripheral/IOSpace.kt -git commit -m "$(cat <<'EOF' -IOSpace: expose right-click as MMIO[36] bit 1 - -MMIO[36] becomes a two-bit field (bit 0 = left, bit 1 = right) so JS -programs can distinguish mouse buttons. Existing callers that read this -byte as a truthy/falsy "is pressed" still work because left-click sets -bit 0. - -Co-Authored-By: Claude Opus 4.7 -EOF -)" -``` - ---- - -## Task 2: Constants block at top of fsh.js - -**Files:** -- Modify: `assets/disk0/home/fsh.js` - -- [ ] **Step 1: Add constants after the existing `_fsh` initialisation** - -Locate the line `let _fsh = {};` (around line 13). Immediately after it, before `_fsh.titlebarTex = ...`, insert: - -```javascript -// Config file path -_fsh.CONFIG_PATH = "A:/home/config/fshrc"; - -// Widget row caps (must match the loop bounds in draw()) -_fsh.TODO_MAX_ROWS = 13; // todoWidget draws i = 0..12 -_fsh.QA_MAX_ROWS = 22; // quickAccessWidget draws i = 0..21 -_fsh.TODO_TEXT_WIDTH = 24; // visible characters per todo row -_fsh.QA_LABEL_WIDTH = 24; // visible characters per QA label -_fsh.QA_CMD_WIDTH = 60; // command path field width in dialog - -// Highlight colour pair (used for hover / keyboard focus) -_fsh.HL_FG = 255; -_fsh.HL_BG = 17; - -// Default Quick Access entries when fshrc is missing or empty -_fsh.DEFAULT_QA = [ - ["Files", "/tvdos/bin/zsh.js"], - ["Editor", "/tvdos/bin/edit.js"], - ["BASIC", "/tbas/basic.js"], - ["DOS Shell", "/tvdos/bin/command.js /fancy"] -]; - -// Mouse button bits (MMIO[36] layout per IOSpace.kt) -_fsh.MB_LEFT = 1; -_fsh.MB_RIGHT = 2; -``` - -- [ ] **Step 2: Syntax check** - -Run: -```bash -node --check assets/disk0/home/fsh.js -``` - -Expected: exit code 0, no output. - -- [ ] **Step 3: Commit** - -```bash -git add assets/disk0/home/fsh.js -git commit -m "$(cat <<'EOF' -fsh: introduce constants for widget bounds, colours, defaults - -Co-Authored-By: Claude Opus 4.7 -EOF -)" -``` - ---- - -## Task 3: Config parser and serializer - -**Files:** -- Modify: `assets/disk0/home/fsh.js` - -This task adds two pure functions that operate only on strings. They can be reviewed by reading the code; no live VM needed. - -- [ ] **Step 1: Add the parser** - -After the constants block from Task 2 and before `_fsh.titlebarTex`, insert: - -```javascript -// Parse fshrc text into {todos: [[text, done], ...], qa: [[label, cmd], ...]}. -// Returns null for both arrays when input is empty/whitespace. -_fsh.parseConfig = function(text) { - let todos = []; - let qa = []; - let section = null; - if (!text) return {todos: todos, qa: qa}; - let lines = text.split("\n"); - for (let i = 0; i < lines.length; i++) { - let line = lines[i]; - // strip trailing \r if any - if (line.length && line.charCodeAt(line.length - 1) === 13) { - line = line.substring(0, line.length - 1); - } - if (line.length === 0) continue; - if (line.charAt(0) === "[") { - let close = line.indexOf("]"); - if (close > 0) { - let name = line.substring(1, close).trim().toUpperCase(); - if (name === "TODO" || name === "QUICK_ACCESS") section = name; - else section = null; // unknown section: ignore until next header - } - continue; - } - if (section === "TODO") { - if (line.length < 2) continue; - let marker = line.charAt(0); - if ((marker === "+" || marker === "-") && line.charAt(1) === " ") { - todos.push([line.substring(2), marker === "+"]); - } - } else if (section === "QUICK_ACCESS") { - let comma = line.indexOf(","); - if (comma <= 0) continue; // need a non-empty label - let label = line.substring(0, comma); - let cmd = line.substring(comma + 1); - qa.push([label, cmd]); - } - } - return {todos: todos, qa: qa}; -}; -``` - -- [ ] **Step 2: Add the serializer** - -Immediately after `_fsh.parseConfig`: - -```javascript -// Build fshrc text from in-memory model. Inverse of parseConfig. -_fsh.serializeConfig = function(todos, qa) { - let out = "[TODO]\n"; - for (let i = 0; i < todos.length; i++) { - let t = todos[i]; - out += (t[1] ? "+ " : "- ") + t[0] + "\n"; - } - out += "\n[QUICK_ACCESS]\n"; - for (let i = 0; i < qa.length; i++) { - out += qa[i][0] + "," + qa[i][1] + "\n"; - } - return out; -}; -``` - -- [ ] **Step 3: Add the load function** - -Immediately after `_fsh.serializeConfig`: - -```javascript -// Read fshrc; populate todoWidget.todoList and quickAccessWidget.entries. -// Falls back to defaults on missing/empty/malformed file. -_fsh.loadConfig = function() { - let f = files.open(_fsh.CONFIG_PATH); - let parsed = {todos: [], qa: []}; - if (f.exists) { - try { - parsed = _fsh.parseConfig(f.sread()); - } catch (e) { - serial.printerr("fsh.loadConfig: parse failed: " + e); - parsed = {todos: [], qa: []}; - } - } - todoWidget.todoList = parsed.todos; - quickAccessWidget.entries = (parsed.qa.length > 0) - ? parsed.qa - : _fsh.DEFAULT_QA.slice(); // copy so saves don't mutate the constant -}; -``` - -- [ ] **Step 4: Add the save function** - -Immediately after `_fsh.loadConfig`: - -```javascript -// Persist the current in-memory todos + QA entries to fshrc. -_fsh.saveConfig = function() { - try { - let f = files.open(_fsh.CONFIG_PATH); - if (!f.exists) f.mkFile(); - f.swrite(_fsh.serializeConfig(todoWidget.todoList, quickAccessWidget.entries)); - } catch (e) { - serial.printerr("fsh.saveConfig: write failed: " + e); - } -}; -``` - -- [ ] **Step 5: Syntax check** - -Run: -```bash -node --check assets/disk0/home/fsh.js -``` - -Expected: exit code 0. - -- [ ] **Step 6: Sanity test the parser/serializer round-trip with Node** - -This catches logic mistakes without needing TSVM. Run: - -```bash -node -e ' -const fs = require("fs"); -const src = fs.readFileSync("assets/disk0/home/fsh.js", "utf8"); -// Extract just the parseConfig + serializeConfig bodies by eval-ing the whole file -// is not feasible because of TSVM-specific globals. So copy the two functions inline: -function parseConfig(text) { - let todos = []; let qa = []; let section = null; - if (!text) return {todos, qa}; - let lines = text.split("\n"); - for (let i = 0; i < lines.length; i++) { - let line = lines[i]; - if (line.length && line.charCodeAt(line.length - 1) === 13) - line = line.substring(0, line.length - 1); - if (line.length === 0) continue; - if (line.charAt(0) === "[") { - let close = line.indexOf("]"); - if (close > 0) { - let name = line.substring(1, close).trim().toUpperCase(); - if (name === "TODO" || name === "QUICK_ACCESS") section = name; - else section = null; - } - continue; - } - if (section === "TODO") { - if (line.length < 2) continue; - let m = line.charAt(0); - if ((m === "+" || m === "-") && line.charAt(1) === " ") - todos.push([line.substring(2), m === "+"]); - } else if (section === "QUICK_ACCESS") { - let c = line.indexOf(","); - if (c <= 0) continue; - qa.push([line.substring(0, c), line.substring(c + 1)]); - } - } - return {todos, qa}; -} -function serializeConfig(todos, qa) { - let out = "[TODO]\n"; - for (let i = 0; i < todos.length; i++) - out += (todos[i][1] ? "+ " : "- ") + todos[i][0] + "\n"; - out += "\n[QUICK_ACCESS]\n"; - for (let i = 0; i < qa.length; i++) out += qa[i][0] + "," + qa[i][1] + "\n"; - return out; -} -const sample = "[TODO]\n+ Buy groceries\n- Read CLAUDE.md\n\n[QUICK_ACCESS]\nFiles,/tvdos/bin/zsh.js\nEditor,/tvdos/bin/edit.js\n"; -const parsed = parseConfig(sample); -console.log("parsed:", JSON.stringify(parsed)); -const re = serializeConfig(parsed.todos, parsed.qa); -console.log("re-serialized:", JSON.stringify(re)); -const reparsed = parseConfig(re); -console.log("round-trip equal:", JSON.stringify(parsed) === JSON.stringify(reparsed)); -// commas-in-cmd test -const cmdWithComma = parseConfig("[QUICK_ACCESS]\nThing,/bin/x,--flag\n"); -console.log("cmd-with-comma:", JSON.stringify(cmdWithComma.qa)); -// malformed test -const malformed = parseConfig("garbage\n[UNKNOWN]\nfoo\n[TODO]\n+ ok\n"); -console.log("malformed-ok:", JSON.stringify(malformed.todos)); -' -``` - -Expected output: -``` -parsed: {"todos":[["Buy groceries",true],["Read CLAUDE.md",false]],"qa":[["Files","/tvdos/bin/zsh.js"],["Editor","/tvdos/bin/edit.js"]]} -re-serialized: "[TODO]\n+ Buy groceries\n- Read CLAUDE.md\n\n[QUICK_ACCESS]\nFiles,/tvdos/bin/zsh.js\nEditor,/tvdos/bin/edit.js\n" -round-trip equal: true -cmd-with-comma: [["Thing","/bin/x,--flag"]] -malformed-ok: [["ok",true]] -``` - -If `round-trip equal` is not `true`, or `cmd-with-comma` doesn't preserve the trailing flag, the parser logic is wrong — fix and re-run. - -- [ ] **Step 7: Commit** - -```bash -git add assets/disk0/home/fsh.js -git commit -m "$(cat <<'EOF' -fsh: add fshrc parser, serializer, load, and save - -Pure-data round-trip verified via Node. - -Co-Authored-By: Claude Opus 4.7 -EOF -)" -``` - ---- - -## Task 4: Hit-test helpers on widgets - -**Files:** -- Modify: `assets/disk0/home/fsh.js` - -The current `todoWidget.draw` and `quickAccessWidget.draw` use `charXoff` / `charYoff` passed by the main loop. The known positions from the existing loop are: - -```javascript -_fsh.widgets["com.fsh.todo_list"].draw(10, 17); -_fsh.widgets["com.fsh.quick_access"].draw(47, 8); -``` - -We need hit-test functions that take the **mouse char coords** and the widget's draw offsets, and return `null`, `{kind: "add"}`, or `{kind: "item", index}`. - -Looking at the draw loops (already in the file): each row `i` in `0..max-1` is rendered at `con.move(charYoff + i + 2, charXoff)` (icon col) and `con.move(charYoff + i + 2, charXoff + 2)` (text col). Rows with `i < list.length` show an entry; row `i === list.length` shows "Click to add"; rows `i > list.length` show underscores. Text spans 24 chars (`charXoff + 2 .. charXoff + 25`). - -- [ ] **Step 1: Add a generic hit-test helper** - -After `_fsh.saveConfig`, before `_fsh.Widget`: - -```javascript -// Map (mouse char x, mouse char y) to a row index for a widget drawn at -// (xoff, yoff) with `length` existing entries and `maxRows` total rows. -// Returns null / {kind:"add"} / {kind:"item", index: i}. -_fsh.hitTestList = function(charX, charY, xoff, yoff, textWidth, length, maxRows) { - // Each row sits at (yoff + i + 2, xoff..xoff + textWidth + 1). - // Column range: icon at xoff, text at xoff+2 .. xoff+1+textWidth. - // Allow clicks anywhere on the row's char cells (icon + text region). - let relY = charY - yoff - 2; - if (relY < 0 || relY >= maxRows) return null; - if (charX < xoff || charX > xoff + 1 + textWidth) return null; - if (relY < length) return {kind: "item", index: relY}; - if (relY === length) return {kind: "add"}; - return null; -}; -``` - -- [ ] **Step 2: Attach widget-specific hit-test** - -Right after the `quickAccessWidget.draw = function(...) { ... }` block (last line is `}` near the bottom of the file before `// change graphics mode`), add: - -```javascript -todoWidget.hitTest = function(charX, charY, xoff, yoff) { - return _fsh.hitTestList(charX, charY, xoff, yoff, - _fsh.TODO_TEXT_WIDTH, todoWidget.todoList.length, _fsh.TODO_MAX_ROWS); -}; - -quickAccessWidget.hitTest = function(charX, charY, xoff, yoff) { - return _fsh.hitTestList(charX, charY, xoff, yoff, - _fsh.QA_LABEL_WIDTH, quickAccessWidget.entries.length, _fsh.QA_MAX_ROWS); -}; -``` - -- [ ] **Step 3: Syntax check** - -```bash -node --check assets/disk0/home/fsh.js -``` - -Expected: exit code 0. - -- [ ] **Step 4: Sanity-test the hit-test math in Node** - -```bash -node -e ' -function hitTestList(charX, charY, xoff, yoff, textWidth, length, maxRows) { - let relY = charY - yoff - 2; - if (relY < 0 || relY >= maxRows) return null; - if (charX < xoff || charX > xoff + 1 + textWidth) return null; - if (relY < length) return {kind: "item", index: relY}; - if (relY === length) return {kind: "add"}; - return null; -} -// Todo widget at xoff=10, yoff=17, 3 entries -const T = (x,y) => hitTestList(x,y,10,17,24,3,13); -console.log("above:", T(15,18)); // null (relY=-1) -console.log("first item:", T(15,19)); // {kind:"item",index:0} -console.log("third item:", T(15,21)); // {kind:"item",index:2} -console.log("add row:", T(15,22)); // {kind:"add"} -console.log("filler row:", T(15,23)); // null -console.log("right of text:",T(40,19)); // null (xoff+1+textWidth = 35) -console.log("on icon col:", T(10,19)); // {kind:"item",index:0} -' -``` - -Expected output: -``` -above: null -first item: { kind: 'item', index: 0 } -third item: { kind: 'item', index: 2 } -add row: { kind: 'add' } -filler row: null -right of text: null -on icon col: { kind: 'item', index: 0 } -``` - -- [ ] **Step 5: Commit** - -```bash -git add assets/disk0/home/fsh.js -git commit -m "$(cat <<'EOF' -fsh: add hit-test helpers for todo and quick-access widgets - -Co-Authored-By: Claude Opus 4.7 -EOF -)" -``` - ---- - -## Task 5: Focus state model and highlight in draw() - -**Files:** -- Modify: `assets/disk0/home/fsh.js` - -- [ ] **Step 1: Initialise focus state** - -After the constants block in Task 2, append: - -```javascript -// Current focus: null or {widgetId: string, index: number}. -// Index uses the same convention as hitTest: 0..length-1 are entries, -// `length` is the "+ Click to add" row. -_fsh.focus = null; -``` - -- [ ] **Step 2: Update `todoWidget.draw` to accept a focus argument** - -Replace the entire `todoWidget.draw = function(charXoff, charYoff) { ... }` body with: - -```javascript -todoWidget.draw = function(charXoff, charYoff) { - let focusIndex = (_fsh.focus && _fsh.focus.widgetId === todoWidget.identifier) - ? _fsh.focus.index : -1; - - con.color_pair(254, 255) - let xoff = charXoff * 7 - let yoff = charYoff * 14 + 3 - - con.move(charYoff, charXoff) - print('Í'.repeat(10)+" TODO "+'Í'.repeat(10)) - - for (let i = 0; i <= 12; i++) { - let list = todoWidget.todoList[i] || ["Click to add", null] - let isFocused = (i === focusIndex); - - if (isFocused) con.color_pair(_fsh.HL_FG, _fsh.HL_BG) - else if (list[1] === null) con.color_pair(249, 255) - else con.color_pair(254, 255) - - con.move(charYoff + i + 2, charXoff) - con.addch((list[1] === null) ? 43 : (list[1]) ? 0x9F : 0x9E) - - if (i > todoWidget.todoList.length) { - // Filler row — keep underscores but don't highlight (can't focus here) - con.color_pair(254, 255) - for (let k = 0; k < 24; k++) { - con.mvaddch(charYoff + i + 2, charXoff + 2 + k, 95) - } - } - else { - con.move(charYoff + i + 2, charXoff + 2) - // Pad text to TODO_TEXT_WIDTH so the highlight bar covers full row - let text = `${list[0]}`; - if (text.length > _fsh.TODO_TEXT_WIDTH) text = text.substring(0, _fsh.TODO_TEXT_WIDTH); - if (isFocused) text = text + " ".repeat(_fsh.TODO_TEXT_WIDTH - text.length); - print(text) - } - } -} -``` - -- [ ] **Step 3: Update `quickAccessWidget.draw` the same way** - -Replace its body with: - -```javascript -quickAccessWidget.draw = function(charXoff, charYoff) { - let focusIndex = (_fsh.focus && _fsh.focus.widgetId === quickAccessWidget.identifier) - ? _fsh.focus.index : -1; - - con.color_pair(254, 255) - let xoff = charXoff * 7 - let yoff = charYoff * 14 + 3 - - con.move(charYoff, charXoff) - print('Í'.repeat(6)+" QUICK ACCESS "+'Í'.repeat(6)) - - for (let i = 0; i <= 21; i++) { - let list = quickAccessWidget.entries[i] || ["Click to add", null] - let isFocused = (i === focusIndex); - - if (isFocused) con.color_pair(_fsh.HL_FG, _fsh.HL_BG) - else if (list[1] === null) con.color_pair(249, 255) - else con.color_pair(254, 255) - - con.move(charYoff + i + 2, charXoff) - con.addch((list[1] === null) ? 0xF9 : (list[1]) ? 7 : 0x7F) - - if (i > quickAccessWidget.entries.length) { - con.color_pair(254, 255) - for (let k = 0; k < 24; k++) { - con.mvaddch(charYoff + i + 2, charXoff + 2 + k, 95) - } - } - else { - con.move(charYoff + i + 2, charXoff + 2) - let text = `${list[0]}`; - if (text.length > _fsh.QA_LABEL_WIDTH) text = text.substring(0, _fsh.QA_LABEL_WIDTH); - if (isFocused) text = text + " ".repeat(_fsh.QA_LABEL_WIDTH - text.length); - print(text) - } - } -} -``` - -- [ ] **Step 4: Syntax check** - -```bash -node --check assets/disk0/home/fsh.js -``` - -Expected: exit code 0. - -- [ ] **Step 5: Commit** - -```bash -git add assets/disk0/home/fsh.js -git commit -m "$(cat <<'EOF' -fsh: render row highlight when focused - -Each interactive widget now consults _fsh.focus and inverts the matching -row's colour pair so hover and keyboard navigation share one visual. - -Co-Authored-By: Claude Opus 4.7 -EOF -)" -``` - ---- - -## Task 6: Modal dialog primitive - -**Files:** -- Modify: `assets/disk0/home/fsh.js` - -The dialog is the biggest single piece. It draws a centred box, edits one or more text fields, and returns a tagged result. It blocks the main loop while open by running its own `con.getch()` loop (matching the pattern in `command.js`). - -- [ ] **Step 1: Add dialog drawing helpers** - -After `_fsh.saveConfig`, insert: - -```javascript -// Draw a double-line bordered box. (row, col) is the top-left, (h, w) the size. -_fsh.drawDialogFrame = function(row, col, h, w, title) { - con.color_pair(254, 255); - // Top - con.move(row, col); - con.addch(0xC9); // ╔ - for (let i = 0; i < w - 2; i++) con.addch(0xCD); // ═ - con.addch(0xBB); // ╗ - // Sides + interior fill - for (let y = 1; y < h - 1; y++) { - con.move(row + y, col); - con.addch(0xBA); // ║ - for (let i = 0; i < w - 2; i++) con.addch(32); - con.addch(0xBA); // ║ - } - // Bottom - con.move(row + h - 1, col); - con.addch(0xC8); // ╚ - for (let i = 0; i < w - 2; i++) con.addch(0xCD); // ═ - con.addch(0xBC); // ╝ - // Title centred on top border - if (title) { - let t = " " + title + " "; - let tcol = col + Math.floor((w - t.length) / 2); - con.move(row, tcol); - print(t); - } -}; - -// Draw a single-line bordered input field at (row, col) with given width. -// content is the current text; cursorPos the caret position; focused styles -// the frame with a brighter colour. -_fsh.drawDialogField = function(row, col, width, content, focused) { - con.color_pair(focused ? 254 : 249, 255); - con.move(row, col); - con.addch(0xDA); // ┌ - for (let i = 0; i < width; i++) con.addch(0xC4); // ─ - con.addch(0xBF); // ┐ - con.move(row + 1, col); - con.addch(0xB3); // │ - con.color_pair(254, 255); - let visible = content.length > width ? content.substring(content.length - width) : content; - print(visible + " ".repeat(width - visible.length)); - con.color_pair(focused ? 254 : 249, 255); - con.addch(0xB3); - con.move(row + 2, col); - con.addch(0xC0); // └ - for (let i = 0; i < width; i++) con.addch(0xC4); - con.addch(0xD9); // ┘ - con.color_pair(254, 255); -}; - -// Draw a button as "[ Label ]" at the given position; highlights when focused. -_fsh.drawDialogButton = function(row, col, label, focused) { - if (focused) con.color_pair(_fsh.HL_FG, _fsh.HL_BG); - else con.color_pair(254, 255); - con.move(row, col); - print("[ " + label + " ]"); - con.color_pair(254, 255); -}; -``` - -- [ ] **Step 2: Add the dialog driver** - -Immediately after the helpers: - -```javascript -// Modal dialog. opts = { -// title: string, -// fields: [{label, initial, width}, ...], -// allowDelete: bool, -// } -// Returns {action: "ok"|"cancel"|"delete", values: [string, ...]}. -_fsh.showDialog = function(opts) { - let fields = opts.fields; - let values = fields.map(function(f) { return f.initial || ""; }); - - // Layout - let maxFieldW = fields.reduce(function(m, f) { return Math.max(m, f.width); }, 16); - let titleW = (opts.title ? opts.title.length : 0) + 4; - let w = Math.max(maxFieldW + 6, titleW + 4, 24); - let buttonsRow = 2 + fields.length * 4 + 1; // 1 label + 3 field rows per field - let h = buttonsRow + 2; - let screen = con.getmaxyx(); - let row = Math.max(2, Math.floor((screen[0] - h) / 2)); - let col = Math.max(2, Math.floor((screen[1] - w) / 2)); - - // Buttons list: indices follow Tab order after the last field - let buttons = [{label: "OK", action: "ok"}, {label: "Cancel", action: "cancel"}]; - if (opts.allowDelete) buttons.splice(1, 0, {label: "Delete", action: "delete"}); - - let focusIdx = 0; // 0..fields.length-1 = field; then buttons - let totalFocus = fields.length + buttons.length; - let done = null; // {action, values} when set - - // Hide the main wallpaper region we cover; we'll redraw fully after close. - - function render() { - _fsh.drawDialogFrame(row, col, h, w, opts.title); - // Fields - for (let i = 0; i < fields.length; i++) { - let labelRow = row + 1 + i * 4; - let fieldRow = labelRow + 1; - con.color_pair(254, 255); - con.move(labelRow, col + 2); - print(fields[i].label + ":"); - _fsh.drawDialogField(fieldRow, col + 2, fields[i].width, values[i], i === focusIdx); - } - // Buttons centred on buttonsRow - let totalBtnW = buttons.reduce(function(s, b) { return s + b.label.length + 5; }, 0) - 1; - let bx = col + Math.floor((w - totalBtnW) / 2); - for (let i = 0; i < buttons.length; i++) { - let bIdx = fields.length + i; - _fsh.drawDialogButton(row + buttonsRow, bx, buttons[i].label, bIdx === focusIdx); - bx += buttons[i].label.length + 5; - } - } - - render(); - - // Note: con.getch() returns TSVM scancodes (defined in JS_INIT.js as - // con.KEY_UP=200, KEY_DOWN=208, KEY_LEFT=203, KEY_RIGHT=205, - // KEY_BACKSPACE=8, KEY_TAB=9, KEY_RETURN=10). Esc isn't in JS_INIT's - // map — it arrives as ASCII 27 via keyTyped(). - while (done === null) { - let k = con.getch(); - - if (k === 27) { // Esc - done = {action: "cancel", values: values}; - break; - } - if (k === con.KEY_TAB) { - focusIdx = (focusIdx + 1) % totalFocus; - render(); - continue; - } - // On a field - if (focusIdx < fields.length) { - if (k === con.KEY_RETURN) { - if (focusIdx < fields.length - 1) { - focusIdx += 1; - } else { - focusIdx = fields.length; // move to OK button - } - render(); - continue; - } - if (k === con.KEY_BACKSPACE) { - if (values[focusIdx].length > 0) - values[focusIdx] = values[focusIdx].substring(0, values[focusIdx].length - 1); - render(); - continue; - } - // Printable - if (k >= 32 && k < 256 && values[focusIdx].length < fields[focusIdx].width * 4) { - values[focusIdx] += String.fromCharCode(k); - render(); - } - continue; - } - // On a button - if (k === con.KEY_RETURN || k === 32) { - done = {action: buttons[focusIdx - fields.length].action, values: values}; - break; - } - // Arrow keys cycle buttons too - if (k === con.KEY_LEFT) { - focusIdx = (focusIdx - 1 + totalFocus) % totalFocus; - render(); - } else if (k === con.KEY_RIGHT) { - focusIdx = (focusIdx + 1) % totalFocus; - render(); - } - } - - return done; -}; -``` - -- [ ] **Step 3: Syntax check** - -```bash -node --check assets/disk0/home/fsh.js -``` - -Expected: exit code 0. - -- [ ] **Step 4: Logic walkthrough — verify by reading** - -Read your inserted `_fsh.showDialog` carefully and confirm: - -1. `totalFocus = fields.length + buttons.length` matches the focus index range. -2. The buttons array order is `[OK, (Delete?), Cancel]`. -3. Pressing Enter on the last field jumps to OK (`focusIdx = fields.length`). -4. Esc returns `{action: "cancel"}` without saving. -5. Backspace truncates the current field; no underflow when empty. -6. Printable check `k >= 32 && k < 256` admits TSVM extended chars. - -If any of these fails to hold by inspection, fix the code before committing. - -- [ ] **Step 5: Commit** - -```bash -git add assets/disk0/home/fsh.js -git commit -m "$(cat <<'EOF' -fsh: add modal dialog primitive for add/edit/delete popups - -Centred bordered dialog with one or more text fields plus OK/Cancel -(and optional Delete) buttons. Driven by con.getch() so it blocks the -main loop cleanly while open. Returns {action, values}. - -Co-Authored-By: Claude Opus 4.7 -EOF -)" -``` - ---- - -## Task 7: Dispatcher — add/edit/delete handlers - -**Files:** -- Modify: `assets/disk0/home/fsh.js` - -These functions translate hits into mutations on `todoWidget.todoList` and `quickAccessWidget.entries`, save the config, and force a redraw of the whole screen (wallpaper + titlebar + widgets) when a dialog has been on screen. - -- [ ] **Step 1: Add a redraw-all helper** - -After `quickAccessWidget.hitTest` (added in Task 4), append: - -```javascript -// Re-render the whole shell. Use after a dialog closes (which clobbered -// the underlying char cells) or after execApp returns. -_fsh.redrawAll = function() { - con.color_pair(254, 255); - con.clear(); - graphics.clearPixels(255); - graphics.clearPixels2(255); - graphics.setFramebufferScroll(0, 0); - _fsh.drawWallpaper(); - _fsh.drawTitlebar(); - _fsh.widgets["com.fsh.clock"].draw(25, 3); - _fsh.widgets["com.fsh.calendar"].draw(12, 8); - _fsh.widgets["com.fsh.todo_list"].draw(10, 17); - _fsh.widgets["com.fsh.quick_access"].draw(47, 8); -}; -``` - -- [ ] **Step 2: Add the dispatcher functions** - -Immediately after `_fsh.redrawAll`: - -```javascript -_fsh.openAddTodoDialog = function() { - let res = _fsh.showDialog({ - title: "New Todo", - fields: [{label: "Text", initial: "", width: _fsh.TODO_TEXT_WIDTH}], - allowDelete: false - }); - _fsh.redrawAll(); - if (res.action !== "ok") return; - let text = res.values[0].trim(); - if (text.length === 0) return; - if (todoWidget.todoList.length >= _fsh.TODO_MAX_ROWS) return; - todoWidget.todoList.push([text, false]); - _fsh.saveConfig(); -}; - -_fsh.openEditTodoDialog = function(index) { - let entry = todoWidget.todoList[index]; - if (!entry) return; - let res = _fsh.showDialog({ - title: "Edit Todo", - fields: [{label: "Text", initial: entry[0], width: _fsh.TODO_TEXT_WIDTH}], - allowDelete: true - }); - _fsh.redrawAll(); - if (res.action === "cancel") return; - if (res.action === "delete") { - todoWidget.todoList.splice(index, 1); - _fsh.saveConfig(); - return; - } - let text = res.values[0].trim(); - if (text.length === 0) return; - todoWidget.todoList[index] = [text, entry[1]]; - _fsh.saveConfig(); -}; - -_fsh.openAddQaDialog = function() { - let res = _fsh.showDialog({ - title: "New Quick Access", - fields: [ - {label: "Label", initial: "", width: _fsh.QA_LABEL_WIDTH}, - {label: "Command", initial: "", width: _fsh.QA_CMD_WIDTH} - ], - allowDelete: false - }); - _fsh.redrawAll(); - if (res.action !== "ok") return; - let label = res.values[0].trim(); - let cmd = res.values[1].trim(); - if (label.length === 0 || cmd.length === 0) return; - if (quickAccessWidget.entries.length >= _fsh.QA_MAX_ROWS) return; - quickAccessWidget.entries.push([label, cmd]); - _fsh.saveConfig(); -}; - -_fsh.openEditQaDialog = function(index) { - let entry = quickAccessWidget.entries[index]; - if (!entry) return; - let res = _fsh.showDialog({ - title: "Edit Quick Access", - fields: [ - {label: "Label", initial: entry[0], width: _fsh.QA_LABEL_WIDTH}, - {label: "Command", initial: entry[1], width: _fsh.QA_CMD_WIDTH} - ], - allowDelete: true - }); - _fsh.redrawAll(); - if (res.action === "cancel") return; - if (res.action === "delete") { - quickAccessWidget.entries.splice(index, 1); - _fsh.saveConfig(); - return; - } - let label = res.values[0].trim(); - let cmd = res.values[1].trim(); - if (label.length === 0 || cmd.length === 0) return; - quickAccessWidget.entries[index] = [label, cmd]; - _fsh.saveConfig(); -}; - -_fsh.toggleTodoDone = function(index) { - let entry = todoWidget.todoList[index]; - if (!entry) return; - entry[1] = !entry[1]; - _fsh.saveConfig(); -}; -``` - -- [ ] **Step 3: Add the launcher** - -Immediately after `_fsh.toggleTodoDone`: - -```javascript -// Launch a Quick Access entry. cmd is the verbatim string the user typed. -// We split on first space to derive a program path + args; if the path -// has no leading "/", we treat it as relative to the current drive. -_fsh.launchEntry = function(label, cmd) { - let firstSpace = cmd.indexOf(" "); - let progPath = (firstSpace >= 0) ? cmd.substring(0, firstSpace) : cmd; - let argTail = (firstSpace >= 0) ? cmd.substring(firstSpace + 1) : ""; - let fullPath = progPath.startsWith("/") ? ("A:" + progPath) : progPath; - - try { - let f = files.open(fullPath); - if (!f.exists) { - serial.printerr("fsh.launchEntry: not found: " + fullPath); - return; - } - let code = f.sread(); - let tokens = [progPath].concat(argTail.length ? argTail.split(" ") : []); - execApp(code, tokens); - } catch (e) { - serial.printerr("fsh.launchEntry: " + label + " failed: " + e); - } - _fsh.redrawAll(); -}; -``` - -- [ ] **Step 4: Syntax check** - -```bash -node --check assets/disk0/home/fsh.js -``` - -Expected: exit code 0. - -- [ ] **Step 5: Commit** - -```bash -git add assets/disk0/home/fsh.js -git commit -m "$(cat <<'EOF' -fsh: add dispatcher handlers for add/edit/delete + QA launch - -Each handler opens a modal, forces a full screen redraw on close, and -saves the mutated config. launchEntry resolves QA commands against the -A: drive and execApps them, redrawing on return. - -Co-Authored-By: Claude Opus 4.7 -EOF -)" -``` - ---- - -## Task 8: Main loop — input polling, dispatch, keyboard nav - -**Files:** -- Modify: `assets/disk0/home/fsh.js` - -The existing main loop is small: - -```javascript -while (true) { - captureUserInput(); - if (getKeyPushed(0) == 67) break; - - _fsh.widgets["com.fsh.clock"].draw(25, 3); - _fsh.widgets["com.fsh.calendar"].draw(12, 8); - _fsh.widgets["com.fsh.todo_list"].draw(10, 17); - _fsh.widgets["com.fsh.quick_access"].draw(47, 8); - - sys.spin();sys.spin() -} -``` - -We replace it with one that polls mouse + buttons + keys, edge-detects clicks, manages focus, dispatches actions, and uses Esc to exit. - -- [ ] **Step 1: Add a click-dispatch helper** - -After `_fsh.launchEntry`, insert: - -```javascript -// Layout map: widget positions hard-coded to match the draw calls below. -_fsh.layouts = { - "com.fsh.todo_list": {xoff: 10, yoff: 17, widget: null}, - "com.fsh.quick_access": {xoff: 47, yoff: 8, widget: null} -}; - -// Find which widget (if any) was hit by (charX, charY). Returns -// {widgetId, hit} or null. -_fsh.findHit = function(charX, charY) { - let ids = ["com.fsh.todo_list", "com.fsh.quick_access"]; - for (let i = 0; i < ids.length; i++) { - let id = ids[i]; - let layout = _fsh.layouts[id]; - let widget = _fsh.widgets[id]; - let hit = widget.hitTest(charX, charY, layout.xoff, layout.yoff); - if (hit) return {widgetId: id, hit: hit}; - } - return null; -}; - -_fsh.dispatchLeft = function(widgetId, hit) { - if (hit.kind === "add") { - if (widgetId === "com.fsh.todo_list") _fsh.openAddTodoDialog(); - else _fsh.openAddQaDialog(); - return; - } - // hit.kind === "item" - if (widgetId === "com.fsh.todo_list") { - _fsh.toggleTodoDone(hit.index); - } else { - let entry = quickAccessWidget.entries[hit.index]; - if (entry) _fsh.launchEntry(entry[0], entry[1]); - } -}; - -_fsh.dispatchRight = function(widgetId, hit) { - if (hit.kind !== "item") return; - if (widgetId === "com.fsh.todo_list") _fsh.openEditTodoDialog(hit.index); - else _fsh.openEditQaDialog(hit.index); -}; -``` - -- [ ] **Step 2: Add mouse + key helpers near the top of the file** - -After `getKeyPushed` (around line 9-11), insert: - -```javascript -function readMousePos() { - let lx = sys.peek(-33) & 0xFF; - let hx = sys.peek(-34) & 0xFF; - let ly = sys.peek(-35) & 0xFF; - let hy = sys.peek(-36) & 0xFF; - return [(hx << 8) | lx, (hy << 8) | ly]; -} - -function readMouseButtons() { - return sys.peek(-37) & 0xFF; -} - -// Returns true if any of the eight key event buffer slots holds keycode `kc`. -function isKeyDown(kc) { - for (let i = 0; i < 8; i++) { - if ((sys.peek(-41 - i) & 0xFF) === kc) return true; - } - return false; -} -``` - -- [ ] **Step 3: Replace the main loop** - -Locate the existing block: - -```javascript -// TODO update for events: key down (updates some widgets), timer (updates clock and calendar widgets) -while (true) { - captureUserInput(); - if (getKeyPushed(0) == 67) break; - - _fsh.widgets["com.fsh.clock"].draw(25, 3); - _fsh.widgets["com.fsh.calendar"].draw(12, 8); - _fsh.widgets["com.fsh.todo_list"].draw(10, 17); - _fsh.widgets["com.fsh.quick_access"].draw(47, 8); - - sys.spin();sys.spin() -} -``` - -Replace with: - -```javascript -// Load persisted state before the first draw -_fsh.loadConfig(); - -// keyEventBuffers (read via sys.peek(-41-i)) holds *raw libGDX keycodes*, -// not the cooked TSVM scancodes that con.getch() returns. Existing fsh.js -// already uses 67 for Backspace (libGDX DEL); follow the same scheme here. -const KEY_ESC = 131; // Input.Keys.ESCAPE -const KEY_ENTER = 66; // Input.Keys.ENTER -const KEY_UP = 19; // Input.Keys.UP -const KEY_DOWN = 20; // Input.Keys.DOWN -const KEY_LEFT = 21; // Input.Keys.LEFT -const KEY_RIGHT = 22; // Input.Keys.RIGHT -const KEY_LSHIFT = 59; // Input.Keys.SHIFT_LEFT -const KEY_RSHIFT = 60; // Input.Keys.SHIFT_RIGHT - -let prevButtons = 0; -let prevMouseCharX = -1; -let prevMouseCharY = -1; -let keyLatch = {}; // {keycode: true} while the key is held — debounces "just pressed" - -while (true) { - captureUserInput(); - - // -- keyboard -- - if (isKeyDown(KEY_ESC)) break; - - let shiftDown = isKeyDown(KEY_LSHIFT) || isKeyDown(KEY_RSHIFT); - let enterPressed = false; - - // Edge-detect each navigation key - function edge(kc) { - let down = isKeyDown(kc); - let was = !!keyLatch[kc]; - keyLatch[kc] = down; - return down && !was; - } - - if (edge(KEY_ENTER)) enterPressed = true; - let navUp = edge(KEY_UP); - let navDown = edge(KEY_DOWN); - let navLeft = edge(KEY_LEFT); - let navRight = edge(KEY_RIGHT); - - // -- mouse -- - let pos = readMousePos(); - let charX = (pos[0] / 7) | 0; - let charY = (pos[1] / 14) | 0; - let mouseMoved = (charX !== prevMouseCharX || charY !== prevMouseCharY); - prevMouseCharX = charX; - prevMouseCharY = charY; - - let buttons = readMouseButtons(); - let leftEdge = ((buttons & _fsh.MB_LEFT) !== 0) && ((prevButtons & _fsh.MB_LEFT) === 0); - let rightEdge = ((buttons & _fsh.MB_RIGHT) !== 0) && ((prevButtons & _fsh.MB_RIGHT) === 0); - prevButtons = buttons; - - // -- focus update -- - if (navUp || navDown || navLeft || navRight) { - if (!_fsh.focus) _fsh.focus = {widgetId: "com.fsh.todo_list", index: 0}; - if (navUp || navDown) { - let layout = _fsh.layouts[_fsh.focus.widgetId]; - let maxRows = (_fsh.focus.widgetId === "com.fsh.todo_list") - ? _fsh.TODO_MAX_ROWS : _fsh.QA_MAX_ROWS; - let length = (_fsh.focus.widgetId === "com.fsh.todo_list") - ? todoWidget.todoList.length : quickAccessWidget.entries.length; - let maxIdx = Math.min(length, maxRows - 1); - let next = _fsh.focus.index + (navDown ? 1 : -1); - if (next < 0) next = 0; - if (next > maxIdx) next = maxIdx; - _fsh.focus.index = next; - } else { - // Left/right switches widget - let other = (_fsh.focus.widgetId === "com.fsh.todo_list") - ? "com.fsh.quick_access" : "com.fsh.todo_list"; - let otherLength = (other === "com.fsh.todo_list") - ? todoWidget.todoList.length : quickAccessWidget.entries.length; - let otherMaxRows = (other === "com.fsh.todo_list") - ? _fsh.TODO_MAX_ROWS : _fsh.QA_MAX_ROWS; - let otherMaxIdx = Math.min(otherLength, otherMaxRows - 1); - _fsh.focus = {widgetId: other, index: Math.min(_fsh.focus.index, otherMaxIdx)}; - } - } else if (mouseMoved) { - let h = _fsh.findHit(charX, charY); - _fsh.focus = h ? {widgetId: h.widgetId, index: h.hit.kind === "add" - ? ((h.widgetId === "com.fsh.todo_list") - ? todoWidget.todoList.length - : quickAccessWidget.entries.length) - : h.hit.index} : null; - } - - // -- mouse click dispatch -- - if (leftEdge) { - let h = _fsh.findHit(charX, charY); - if (h) _fsh.dispatchLeft(h.widgetId, h.hit); - } else if (rightEdge) { - let h = _fsh.findHit(charX, charY); - if (h) _fsh.dispatchRight(h.widgetId, h.hit); - } - - // -- keyboard dispatch (synthesise click at focus) -- - if (enterPressed && _fsh.focus) { - let layout = _fsh.layouts[_fsh.focus.widgetId]; - let widget = _fsh.widgets[_fsh.focus.widgetId]; - let length = (_fsh.focus.widgetId === "com.fsh.todo_list") - ? todoWidget.todoList.length : quickAccessWidget.entries.length; - let hit = (_fsh.focus.index < length) - ? {kind: "item", index: _fsh.focus.index} - : (_fsh.focus.index === length ? {kind: "add"} : null); - if (hit) { - if (shiftDown) _fsh.dispatchRight(_fsh.focus.widgetId, hit); - else _fsh.dispatchLeft(_fsh.focus.widgetId, hit); - } - } - - // -- redraw -- - _fsh.widgets["com.fsh.clock"].draw(25, 3); - _fsh.widgets["com.fsh.calendar"].draw(12, 8); - _fsh.widgets["com.fsh.todo_list"].draw(10, 17); - _fsh.widgets["com.fsh.quick_access"].draw(47, 8); - - sys.spin(); sys.spin(); -} -``` - -- [ ] **Step 4: Syntax check** - -```bash -node --check assets/disk0/home/fsh.js -``` - -Expected: exit code 0. - -- [ ] **Step 5: Logic walkthrough — verify by reading** - -Read the new main loop and confirm: - -1. `edge(kc)` returns true exactly once per key press, then false until release. -2. Keyboard nav (arrow press) sets focus, mouse motion sets focus — last-write-wins because both branches are mutually exclusive per frame. -3. The "add" row index is `length` for both widgets, matching `hitTestList`. -4. Enter dispatch correctly skips frames where focus is `null` or out of range. -5. Esc exits without saving (config saves happen synchronously inside each dispatcher anyway). - -- [ ] **Step 6: Commit** - -```bash -git add assets/disk0/home/fsh.js -git commit -m "$(cat <<'EOF' -fsh: drive interaction from polled mouse + keyboard in the main loop - -Edge-detects left/right click and Enter, tracks focus from whichever -input device moved most recently, dispatches into the add/edit/launch -handlers, and exits on Esc instead of Backspace. - -Co-Authored-By: Claude Opus 4.7 -EOF -)" -``` - ---- - -## Task 9: Manual smoke test - -**Files:** -- (no edits — user-driven verification) - -The TSVM is not machine-interactable, so this is a checklist the user runs in the running emulator after rebuilding from IntelliJ. - -- [ ] **Step 1: Ask the user to rebuild and launch** - -Tell the user: - -> "Please rebuild the project in IntelliJ (the `IOSpace.kt` change needs the Kotlin module recompiled) and launch the emulator. Then run `fsh` from the TVDOS prompt." - -- [ ] **Step 2: Walk through the spec's testing scenarios** - -The user verifies each item from the spec (or you do, if you can see the screen): - -1. **First run** — delete `assets/disk0/home/config/fshrc` (if it exists). Launch fsh. Expect: default QA entries (Files / Editor / BASIC / DOS Shell), empty todo list with one `+ Click to add` row. -2. **Add todo** — left-click `+ Click to add` on todo widget. Dialog appears. Type text → Enter → entry added. Quit (Esc) and relaunch fsh. Entry persists. -3. **Toggle done** — left-click an existing todo. Checkbox flips. Relaunch — state persisted. -4. **Edit todo** — right-click an existing todo. Edit dialog opens pre-filled. Test OK / Cancel / Delete paths. -5. **Add QA** — left-click `+ Click to add` on QA widget. Two-field dialog. Submit. Verify file content of `assets/disk0/home/config/fshrc`. -6. **Launch QA** — left-click `Editor`. Verify `edit.js` runs and fsh redraws on return. -7. **Edit/Delete QA** — right-click an entry. Edit dialog with Delete button. Test all three buttons. -8. **Keyboard nav** — no mouse — press ↓ → first todo highlights. Use arrows to traverse, ← / → to switch widgets, Enter to activate, Shift+Enter to edit. -9. **Hover highlight** — move mouse over items — row inverts under cursor. -10. **Esc** — exits fsh cleanly back to TVDOS prompt. -11. **Malformed fshrc** — hand-edit the file to contain garbage. fsh should start with defaults and not crash. - -- [ ] **Step 3: If any scenario fails, file a follow-up task with the specific failure** - -Don't try to fix-in-place during the smoke test — note the failure, finish the rest of the checklist, then return to writing-plans / inline-execution for the fixes. - ---- - -## Self-review checklist - -This was checked before handing the plan off: - -- **Spec coverage**: every goal in the spec (popups, click-to-add, right-click edit/delete, persistence, hover, keyboard nav, QA launch, IOSpace right-click bit) has a corresponding task. -- **Placeholders**: no TODOs, no "appropriate error handling," every step has concrete code. -- **Type consistency**: `_fsh.focus.widgetId` / `_fsh.focus.index` is the single shape across all consumers; `{kind, index?}` is the hit-test shape across hit-test and dispatchers; `{action, values}` is the dialog return shape across all dispatch paths. -- **Indexing convention** (the one fix the spec self-review caught): `0..length-1` = items, `length` = add row, `> length` = filler. Used consistently in Task 4 (hit-test), Task 5 (draw), Task 7 (dispatchers), and Task 8 (keyboard nav). diff --git a/docs/superpowers/specs/2026-05-24-fsh-interactive-widgets-design.md b/docs/superpowers/specs/2026-05-24-fsh-interactive-widgets-design.md deleted file mode 100644 index 5817aee..0000000 --- a/docs/superpowers/specs/2026-05-24-fsh-interactive-widgets-design.md +++ /dev/null @@ -1,329 +0,0 @@ -# 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. diff --git a/terranmon.txt b/terranmon.txt index c02b5f9..d7d022c 100644 --- a/terranmon.txt +++ b/terranmon.txt @@ -49,7 +49,7 @@ MMIO 0..31 RO: Raw Keyboard Buffer read. Won't shift the key buffer 32..33 RO: Mouse X pos 34..35 RO: Mouse Y pos -36 RO: Mouse down? (1 for TRUE, 0 for FALSE) +36 RO: Mouse down? (1 for LEFT, 2 for RIGHT, 3 for BOTH) 37 RW: Read/Write single key input. Key buffer will be shifted. Manual writing is usually unnecessary as such action must be automatically managed via LibGDX input processing. diff --git a/tsvm_core/src/net/torvald/tsvm/peripheral/IOSpace.kt b/tsvm_core/src/net/torvald/tsvm/peripheral/IOSpace.kt index 139c845..6cae0a8 100644 --- a/tsvm_core/src/net/torvald/tsvm/peripheral/IOSpace.kt +++ b/tsvm_core/src/net/torvald/tsvm/peripheral/IOSpace.kt @@ -98,7 +98,7 @@ class IOSpace(val vm: VM) : PeriBase("io"), InputProcessor { in 0..31 -> keyboardBuffer[(addr.toInt())] ?: -1 in 32..33 -> (mouseX.toInt() shr (adi - 32).times(8)).toByte() in 34..35 -> (mouseY.toInt() shr (adi - 34).times(8)).toByte() - 36L -> mouseDown.toInt().toByte() + 36L -> mouseButtons.toByte() // only bits 0..1 used; higher bits intentionally truncated 37L -> { val key = keyboardBuffer.removeTail() ?: -1 keyPushed = !keyboardBuffer.isEmpty // Clear flag when buffer becomes empty @@ -280,7 +280,7 @@ class IOSpace(val vm: VM) : PeriBase("io"), InputProcessor { private var mouseX: Short = 0 private var mouseY: Short = 0 - private var mouseDown = false + private var mouseButtons: Int = 0 // bit 0 = LEFT, bit 1 = RIGHT private var systemUptime = 0L private var rtc = 0L @@ -299,7 +299,8 @@ class IOSpace(val vm: VM) : PeriBase("io"), InputProcessor { // store mouse info mouseX = (Gdx.input.x + guiPosX).toShort() mouseY = (Gdx.input.y + guiPosY).toShort() - mouseDown = Gdx.input.isTouched + mouseButtons = (if (Gdx.input.isButtonPressed(Input.Buttons.LEFT)) 1 else 0) or + (if (Gdx.input.isButtonPressed(Input.Buttons.RIGHT)) 2 else 0) // strobe keys to fill the key read buffer var keysPushed = 0 @@ -313,7 +314,7 @@ class IOSpace(val vm: VM) : PeriBase("io"), InputProcessor { } } else { - mouseDown = false + mouseButtons = 0 } }