From 0d564d5f82426c7cf6fe4f26b326562aba224d9a Mon Sep 17 00:00:00 2001 From: minjaesong Date: Mon, 25 May 2026 00:14:38 +0900 Subject: [PATCH] tsvm: more mouse operated stuffs --- assets/disk0/home/fsh.js | 241 +---------- assets/disk0/tvdos/bin/zfm.js | 548 ++++++++++++++++++++++---- assets/disk0/tvdos/include/wintex.mjs | 343 +++++++++++++++- 3 files changed, 811 insertions(+), 321 deletions(-) diff --git a/assets/disk0/home/fsh.js b/assets/disk0/home/fsh.js index 1ddaa70..9d5da53 100644 --- a/assets/disk0/home/fsh.js +++ b/assets/disk0/home/fsh.js @@ -49,13 +49,6 @@ _fsh.QA_CMD_WIDTH = 60 // command path field width in dialog _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"], @@ -158,232 +151,6 @@ _fsh.saveConfig = function() { } } -// 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. @@ -673,7 +440,7 @@ _fsh.redrawAll = function() { } _fsh.openAddTodoDialog = function() { - let res = _fsh.showDialog({ + let res = win.showDialog({ title: "New Todo", fields: [{label: "Text", initial: "", width: _fsh.TODO_TEXT_WIDTH}], allowDelete: false @@ -690,7 +457,7 @@ _fsh.openAddTodoDialog = function() { _fsh.openEditTodoDialog = function(index) { let entry = todoWidget.todoList[index] if (!entry) return - let res = _fsh.showDialog({ + let res = win.showDialog({ title: "Edit Todo", fields: [{label: "Text", initial: entry[0], width: _fsh.TODO_TEXT_WIDTH}], allowDelete: true @@ -709,7 +476,7 @@ _fsh.openEditTodoDialog = function(index) { } _fsh.openAddQaDialog = function() { - let res = _fsh.showDialog({ + let res = win.showDialog({ title: "New Quick Access", fields: [ {label: "Label", initial: "", width: _fsh.QA_LABEL_WIDTH}, @@ -730,7 +497,7 @@ _fsh.openAddQaDialog = function() { _fsh.openEditQaDialog = function(index) { let entry = quickAccessWidget.entries[index] if (!entry) return - let res = _fsh.showDialog({ + let res = win.showDialog({ title: "Edit Quick Access", fields: [ {label: "Label", initial: entry[0], width: _fsh.QA_LABEL_WIDTH}, diff --git a/assets/disk0/tvdos/bin/zfm.js b/assets/disk0/tvdos/bin/zfm.js index 9ebccd0..eb07b61 100644 --- a/assets/disk0/tvdos/bin/zfm.js +++ b/assets/disk0/tvdos/bin/zfm.js @@ -21,6 +21,10 @@ const FILELIST_WIDTH = WIDTH - SIDEBAR_WIDTH - 3 - FILESIZE_WIDTH const POPUP_WIDTH = 52 // always even number const POPUP_HEIGHT = 16 +const [SCRPW, SCRPH] = graphics.getPixelDimension() +const CELL_PW = (SCRPW / WIDTH) | 0 +const CELL_PH = (SCRPH / WHEIGHT) | 0 + const COL_HL_EXT = { "js": 215, "bas": 215, @@ -69,6 +73,13 @@ const EXEC_FUNS = { "taud": (f) => _G.shell.execute(`playtaud "${f}"`), } +const EDIT_FUNS = { + "bas": (f) => _G.shell.execute(`edit "${f}"`), + "txt": (f) => _G.shell.execute(`edit "${f}"`), + "md": (f) => _G.shell.execute(`edit "${f}"`), + "taud": (f) => _G.shell.execute(`microtone "${f}"`), +} + function makeExecFun(template) { return (f) => _G.shell.execute(template.replaceAll("{0}", `"${f}"`)) } @@ -118,6 +129,57 @@ function loadZfmrc() { loadZfmrc() +/////////////////////////////////////////////////////////////////////////////////////////////////// +// Mouse region registry +/////////////////////////////////////////////////////////////////////////////////////////////////// + +const MOUSE_PANEL = [] +const MOUSE_POPUP_STACK = [] +let lastHoveredRegion = null + +function pixelToCell(px, py) { + return [(py / CELL_PH | 0) + 1, (px / CELL_PW | 0) + 1] +} +function regionHits(r, cy, cx) { + return cy >= r.y && cy < r.y + r.h && cx >= r.x && cx < r.x + r.w +} +function clearPanelMouseRegions() { MOUSE_PANEL.length = 0; lastHoveredRegion = null } +function addPanelMouseRegion(x, y, w, h, handlers) { MOUSE_PANEL.push(Object.assign({x, y, w, h}, handlers)) } +function pushMousePopup(regions) { MOUSE_POPUP_STACK.push(regions); lastHoveredRegion = null } +function popMousePopup() { MOUSE_POPUP_STACK.pop(); lastHoveredRegion = null } + +function dispatchMouseEvent(event) { + const t = event[0] + if (t !== 'mouse_down' && t !== 'mouse_wheel' && t !== 'mouse_up' && t !== 'mouse_move') return false + const [cy, cx] = pixelToCell(event[1], event[2]) + const pool = (MOUSE_POPUP_STACK.length > 0) + ? MOUSE_POPUP_STACK[MOUSE_POPUP_STACK.length - 1] + : MOUSE_PANEL + + if (t === 'mouse_move') { + let hit = null + for (let i = pool.length - 1; i >= 0; i--) { + const r = pool[i] + if (regionHits(r, cy, cx) && (r.onHover || r.onHoverLeave)) { hit = r; break } + } + if (hit !== lastHoveredRegion) { + if (lastHoveredRegion && lastHoveredRegion.onHoverLeave) lastHoveredRegion.onHoverLeave() + lastHoveredRegion = hit + } + if (hit && hit.onHover) { hit.onHover(cy, cx, event); return true } + return false + } + + for (let i = pool.length - 1; i >= 0; i--) { + const r = pool[i] + if (!regionHits(r, cy, cx)) continue + if (t === 'mouse_down' && r.onClick) { r.onClick(cy, cx, event[3], event); return true } + if (t === 'mouse_wheel' && r.onWheel) { r.onWheel(cy, cx, event[3], event); return true } + if (t === 'mouse_up' && r.onRelease) { r.onRelease(cy, cx, event[3], event); return true } + } + return false +} + let windowMode = 0 // 0 == left, 1 == right let windowFocus = [0] // is a stack; 0: files window, 1: palette window, 2: popup window @@ -347,11 +409,30 @@ let filesPanelDraw = (wo) => { con.color_pair(COL_TEXT, COL_BACK) } +// Op panel buttons. yOff is the row offset (icon) inside the op panel frame; +// label sits at yOff+1. Hit regions span both rows. +// hitH is the row count for the mouse hit-box. The switch button gets a taller +// hit-box than the others because the icon glyph above its label leaves extra +// whitespace inside the cell above the first horizontal rule. +const OP_BUTTONS = [ + { id: 'switch', yOff: 0, hitH: 5, key: 'z' }, + { id: 'up', yOff: 6, hitH: 2, key: 'u' }, + { id: 'copy', yOff: 9, hitH: 2, key: 'c' }, + { id: 'move', yOff: 12, hitH: 2, key: 'v' }, + { id: 'delete', yOff: 15, hitH: 2, key: 'd' }, + { id: 'mkdir', yOff: 18, hitH: 2, key: 'k' }, + { id: 'rename', yOff: 21, hitH: 2, key: 'r' }, + { id: 'more', yOff: 24, hitH: 2, key: 'm' }, + { id: 'quit', yOff: 27, hitH: 2, key: 'q' }, +] +let opHover = -1 + let opPanelDraw = (wo) => { function hr(y) { con.move(y, xp) print(`\x84196u`.repeat(SIDEBAR_WIDTH - 2)) } + function labCol(i) { return (opHover === i) ? COL_HLTEXT : COL_TEXT } con.color_pair(COL_TEXT, COL_BACK) @@ -362,14 +443,14 @@ let opPanelDraw = (wo) => { con.move(yp + 2, xp + 3) con.prnch((windowMode) ? 0x11 : 0x10) con.move(yp + 3, xp) - print(` \x1B[38;5;${COL_TEXT}m[\x1B[38;5;${COL_HLACTION}mZ\x1B[38;5;${COL_TEXT}m]`) + print(` \x1B[38;5;${labCol(0)}m[\x1B[38;5;${COL_HLACTION}mZ\x1B[38;5;${labCol(0)}m]`) hr(yp+5) // go up con.mvaddch(yp + 6, xp + 3, 0x18) con.move(yp + 7, xp) - print(` \x1B[38;5;${COL_TEXT}mGo \x1B[38;5;${COL_HLACTION}mU\x1B[38;5;${COL_TEXT}mp`) + print(` \x1B[38;5;${labCol(1)}mGo \x1B[38;5;${COL_HLACTION}mU\x1B[38;5;${labCol(1)}mp`) hr(yp+8) @@ -377,7 +458,7 @@ let opPanelDraw = (wo) => { con.move(yp + 9, xp + 2) con.prnch(0xDB);con.prnch((windowMode) ? 0x1B : 0x1A);con.prnch(0xDB) con.move(yp + 10, xp) - print(` \x1B[38;5;${COL_HLACTION}mC\x1B[38;5;${COL_TEXT}mopy`) + print(` \x1B[38;5;${COL_HLACTION}mC\x1B[38;5;${labCol(2)}mopy`) hr(yp+11) @@ -385,7 +466,7 @@ let opPanelDraw = (wo) => { con.move(yp + 12, xp + 2) if (windowMode) con.prnch([0xDB, 0x1B, 0xB0]); else con.prnch([0xB0, 0x1A, 0xDB]) con.move(yp + 13, xp) - print(` \x1B[38;5;${COL_TEXT}mMo\x1B[38;5;${COL_HLACTION}mv\x1B[38;5;${COL_TEXT}me`) + print(` \x1B[38;5;${labCol(3)}mMo\x1B[38;5;${COL_HLACTION}mv\x1B[38;5;${labCol(3)}me`) hr(yp+14) @@ -393,7 +474,7 @@ let opPanelDraw = (wo) => { con.move(yp + 15, xp + 2) if (windowMode) con.prnch([0xDB, 0x1A, 0xF9]); else con.prnch([0xF9, 0x1B, 0xDB]) con.move(yp + 16, xp) - print(` \x1B[38;5;${COL_HLACTION}mD\x1B[38;5;${COL_TEXT}melete`) + print(` \x1B[38;5;${COL_HLACTION}mD\x1B[38;5;${labCol(4)}melete`) hr(yp+17) @@ -403,7 +484,7 @@ let opPanelDraw = (wo) => { con.video_reverse();con.prnch(0x2B);con.video_reverse() con.prnch(0xDF) con.move(yp + 19, xp) - print(` \x1B[38;5;${COL_TEXT}mM\x1B[38;5;${COL_HLACTION}mk\x1B[38;5;${COL_TEXT}mDir`) + print(` \x1B[38;5;${labCol(5)}mM\x1B[38;5;${COL_HLACTION}mk\x1B[38;5;${labCol(5)}mDir`) hr(yp+20) @@ -411,7 +492,7 @@ let opPanelDraw = (wo) => { con.move(yp + 21, xp + 2) con.prnch(0x4E);con.prnch(0x1A);con.prnch(0x52) con.move(yp + 22, xp) - print(` \x1B[38;5;${COL_HLACTION}mR\x1B[38;5;${COL_TEXT}mename`) + print(` \x1B[38;5;${COL_HLACTION}mR\x1B[38;5;${labCol(6)}mename`) hr(yp+23) @@ -419,7 +500,7 @@ let opPanelDraw = (wo) => { con.move(yp + 24, xp + 3) con.prnch(0xf0) con.move(yp + 25, xp) - print(` \x1B[38;5;${COL_HLACTION}mM\x1B[38;5;${COL_TEXT}more`) + print(` \x1B[38;5;${COL_HLACTION}mM\x1B[38;5;${labCol(7)}more`) hr(yp+26) @@ -427,7 +508,7 @@ let opPanelDraw = (wo) => { con.move(yp + 27, xp + 3) con.prnch(0x58) con.move(yp + 28, xp) - print(` \x1B[38;5;${COL_HLACTION}mQ\x1B[38;5;${COL_TEXT}muit`) + print(` \x1B[38;5;${COL_HLACTION}mQ\x1B[38;5;${labCol(8)}muit`) } @@ -463,9 +544,8 @@ let popupDraw = (wo) => { /////////////////////////////////////////////////////////////////////////////////////////////////// let filenavOninput = (window, event) => { - let eventName = event[0] - if (eventName == "key_down") { + if (eventName !== "key_down") return let keysym = event[1] let keyJustHit = (1 == event[2]) @@ -474,13 +554,15 @@ let filenavOninput = (window, event) => { let scrollPeek = (LIST_HEIGHT / 3)|0 - if (keyJustHit && keysym == "q") { - exit = true - } - else if (keyJustHit && keysym == "z") { - windowMode = 1 - windowMode - redraw() // this would double-redraw (hence no panel switching) or something if redraw() is not merely a request to do so - } + if (keyJustHit && keysym == "q") actQuit() + else if (keyJustHit && keysym == "z") actSwitchPanel() + else if (keyJustHit && keysym == 'u') actGoUp() + else if (keyJustHit && keysym == 'c') actCopy() + else if (keyJustHit && keysym == 'v') actMove() + else if (keyJustHit && keysym == 'd') actDelete() + else if (keyJustHit && keysym == 'k') actMkdir() + else if (keyJustHit && keysym == 'r') actRename() + else if (keyJustHit && keysym == 'm') actMore() else if (keysym == "") { [cursor[windowMode], scroll[windowMode]] = win.scrollVert(-1, dirFileList[windowMode].length, LIST_HEIGHT, cursor[windowMode], scroll[windowMode], scrollPeek) drawFilePanel() @@ -498,71 +580,7 @@ let filenavOninput = (window, event) => { drawFilePanel() } else if (keyJustHit && keycode == 66) { // enter - let selectedFileCache = filePanelCache[windowMode][cursor[windowMode]] - let selectedFile = selectedFileCache.file - - //serial.println(`selectedFile = ${selectedFile.fullPath}`) - - if (selectedFile.fullPath[1] == ":" && selectedFile.fullPath[2] == "\\" && selectedFile.fullPath.length == 3) { - path[windowMode].push(selectedFile.fullPath) - cursor[windowMode] = 0; scroll[windowMode] = 0 - refreshFilePanelCache(windowMode) - drawFilePanel() - } - else if (selectedFileCache.isDirectory) { - //serial.println(`selectedFile.name = ${selectedFile.name}`) - path[windowMode].push(selectedFileCache.filename) - cursor[windowMode] = 0; scroll[windowMode] = 0 - refreshFilePanelCache(windowMode) - drawFilePanel() - } - else { - let fileext = selectedFileCache.filename.substring(selectedFileCache.filename.lastIndexOf(".") + 1).toLowerCase() - let execfun = EXEC_FUNS[fileext] || ((f) => _G.shell.execute(f)) - let errorlevel = 0 - - con.curs_set(1);clearScr();con.move(1,1) - try { - //serial.println(selectedFile.fullPath) - errorlevel = execfun(selectedFile.fullPath) - //serial.println("1 errorlevel = " + errorlevel) - } - catch (e) { - // TODO popup error - println(e) - errorlevel = 1 - //serial.println("2 errorlevel = " + errorlevel) - } - - if (errorlevel) { - println("Hit Return/Enter key to continue . . . .") - sys.read() - } - - firstRunLatch = true - con.curs_set(0);clearScr() - refreshFilePanelCache(windowMode) - redraw() - } - } - else if (keyJustHit && keysym == 'u') { // no bksp: used as an exit key for playmov/playwav - if (path[windowMode].length >= 1) { - path[windowMode].pop() - cursor[windowMode] = 0; scroll[windowMode] = 0 - refreshFilePanelCache(windowMode) - drawFilePanel() - } - else { - // TODO list of drives - - } - } - else if (keyJustHit && keysym == 'm') { - makePopup(1); redraw() - } - - - + actActivate() } } @@ -592,6 +610,44 @@ let popupInput = (window, event) => { } +/////////////////////////////////////////////////////////////////////////////////////////////////// +// Popup wrappers (delegate to win.showDialog in wintex.mjs) +/////////////////////////////////////////////////////////////////////////////////////////////////// + +function showConfirmPopup(title, message) { + const res = win.showDialog({ + title: title, + message: message, + fields: [], + buttons: [ + { label: 'OK', action: 'ok', default: true }, + { label: 'CANCEL', action: 'cancel' }, + ], + }) + return res.action === 'ok' +} + +function showInputPopup(title, prompt, defaultVal) { + const res = win.showDialog({ + title: title, + fields: [{ label: prompt, initial: defaultVal || '', width: POPUP_WIDTH - 6 }], + buttons: [ + { label: 'OK', action: 'ok', default: true }, + { label: 'CANCEL', action: 'cancel' }, + ], + }) + return res.action === 'ok' ? res.values[0] : null +} + +function showMessagePopup(title, message) { + win.showDialog({ + title: title, + message: message, + fields: [], + buttons: [{ label: 'OK', action: 'ok', default: true }], + }) +} + /////////////////////////////////////////////////////////////////////////////////////////////////// let windows = [ @@ -617,6 +673,11 @@ let currentPopup = 0 function makePopup(index) { currentPopup = index windowFocus.push(currentPopup) + // Push an empty mouse region set so the panel's op-button / file-row regions + // stop receiving clicks while this popup is open. Otherwise the user could + // click a panel button while e.g. the "More" palette is shown and end up + // with two popups stacked on top of each other. + pushMousePopup([]) for (let i = 0; i < windows.length; i++) { windows[i].forEach(it => { it.isHighlighted = (i == index) @@ -626,6 +687,7 @@ function makePopup(index) { function removePopup() { windowFocus.pop() + popMousePopup() const index = windowFocus.last currentPopup = 0 for (let i = 0; i < windows.length; i++) { @@ -701,6 +763,7 @@ function _redraw() { drawFilePanel() drawOpPanel() drawPopupPanel() + setupPanelMouseRegions() } function clearScr() { @@ -710,6 +773,311 @@ function clearScr() { graphics.setGraphicsMode(0) } +/////////////////////////////////////////////////////////////////////////////////////////////////// +// File operations and op-panel actions +/////////////////////////////////////////////////////////////////////////////////////////////////// + +function getCurrentDirStr(side) { + return path[side].concat(['']).join("\\").replaceAll('\\\\', '\\') +} + +function clampCursorAfterChange() { + const len = dirFileList[windowMode].length + if (cursor[windowMode] >= len) cursor[windowMode] = Math.max(0, len - 1) + const maxScroll = Math.max(0, len - LIST_HEIGHT) + if (scroll[windowMode] > maxScroll) scroll[windowMode] = maxScroll + if (scroll[windowMode] < 0) scroll[windowMode] = 0 +} + +function actSwitchPanel() { + windowMode = 1 - windowMode + redraw() +} + +function actGoUp() { + if (path[windowMode].length >= 1) { + path[windowMode].pop() + cursor[windowMode] = 0; scroll[windowMode] = 0 + refreshFilePanelCache(windowMode) + _redraw() + } +} + +function actActivate() { + let selectedFileCache = filePanelCache[windowMode][cursor[windowMode]] + if (!selectedFileCache || !selectedFileCache.file) return + let selectedFile = selectedFileCache.file + + if (selectedFile.fullPath[1] == ":" && selectedFile.fullPath[2] == "\\" && selectedFile.fullPath.length == 3) { + path[windowMode].push(selectedFile.fullPath) + cursor[windowMode] = 0; scroll[windowMode] = 0 + refreshFilePanelCache(windowMode) + _redraw() + } + else if (selectedFileCache.isDirectory) { + path[windowMode].push(selectedFileCache.filename) + cursor[windowMode] = 0; scroll[windowMode] = 0 + refreshFilePanelCache(windowMode) + _redraw() + } + else { + let fileext = selectedFileCache.filename.substring(selectedFileCache.filename.lastIndexOf(".") + 1).toLowerCase() + let execfun = EXEC_FUNS[fileext] || ((f) => _G.shell.execute(f)) + let errorlevel = 0 + + con.curs_set(1); clearScr(); con.move(1,1) + try { + errorlevel = execfun(selectedFile.fullPath) + } + catch (e) { + println(e) + errorlevel = 1 + } + + if (errorlevel) { + println("Hit Return/Enter key to continue . . . .") + sys.read() + } + + firstRunLatch = true + con.curs_set(0); clearScr() + refreshFilePanelCache(windowMode) + redraw() + } +} + +function actCopy() { + if (path[windowMode].length === 0) return + const cache = filePanelCache[windowMode][cursor[windowMode]] + if (!cache || !cache.file) return + if (cache.isDirectory) { showMessagePopup('Copy', 'Directory copy is not supported.'); _redraw(); return } + if (path[1 - windowMode].length === 0) { showMessagePopup('Copy', 'Cannot copy to drive list view.'); _redraw(); return } + + const srcPath = cache.file.fullPath + const dstDir = getCurrentDirStr(1 - windowMode) + const dstPath = dstDir + cache.file.name + if (srcPath === dstPath) { _redraw(); return } // both panels point to same directory + + try { + const srcFile = files.open(srcPath) + const dstFile = files.open(dstPath) + if (!srcFile.exists) { showMessagePopup('Copy', 'Source not found.'); _redraw(); return } + if (dstFile.exists) { + if (!showConfirmPopup('Copy', `Overwrite "${cache.file.name}"?`)) { _redraw(); return } + } + if (!dstFile.exists) dstFile.mkFile() + dstFile.bwrite(srcFile.bread()) + try { dstFile.flush() } catch (e) {} + try { dstFile.close() } catch (e) {} + try { srcFile.close() } catch (e) {} + refreshFilePanelCache(1 - windowMode) + } + catch (e) { + showMessagePopup('Copy failed', e.message || ('' + e)) + } + _redraw() +} + +function actMove() { + if (path[windowMode].length === 0) return + const cache = filePanelCache[windowMode][cursor[windowMode]] + if (!cache || !cache.file) return + if (cache.isDirectory) { showMessagePopup('Move', 'Directory move is not supported.'); _redraw(); return } + if (path[1 - windowMode].length === 0) { showMessagePopup('Move', 'Cannot move to drive list view.'); _redraw(); return } + + const srcPath = cache.file.fullPath + const dstDir = getCurrentDirStr(1 - windowMode) + const dstPath = dstDir + cache.file.name + if (srcPath === dstPath) { _redraw(); return } // no-op + + try { + const srcFile = files.open(srcPath) + const dstFile = files.open(dstPath) + if (!srcFile.exists) { showMessagePopup('Move', 'Source not found.'); _redraw(); return } + if (dstFile.exists) { + if (!showConfirmPopup('Move', `Overwrite "${cache.file.name}"?`)) { _redraw(); return } + } + if (!dstFile.exists) dstFile.mkFile() + dstFile.bwrite(srcFile.bread()) + try { dstFile.flush() } catch (e) {} + try { dstFile.close() } catch (e) {} + srcFile.remove() + refreshFilePanelCache(windowMode) + refreshFilePanelCache(1 - windowMode) + clampCursorAfterChange() + } + catch (e) { + showMessagePopup('Move failed', e.message || ('' + e)) + } + _redraw() +} + +function actDelete() { + if (path[windowMode].length === 0) return + const cache = filePanelCache[windowMode][cursor[windowMode]] + if (!cache || !cache.file) return + + const name = cache.file.name + const kind = cache.isDirectory ? 'directory' : 'file' + if (!showConfirmPopup('Delete', `Delete ${kind} "${name}"?`)) { _redraw(); return } + + try { + const status = cache.file.remove() + if (status !== undefined && status !== 0 && status !== true) { + showMessagePopup('Delete failed', `Cannot delete "${name}" (status ${status}).`) + } + refreshFilePanelCache(windowMode) + clampCursorAfterChange() + } + catch (e) { + showMessagePopup('Delete failed', e.message || ('' + e)) + } + _redraw() +} + +function actMkdir() { + if (path[windowMode].length === 0) { showMessagePopup('Mkdir', 'Choose a directory first.'); _redraw(); return } + const name = showInputPopup('Make Directory', 'Directory name:', '') + if (name === null || name.length === 0) { _redraw(); return } + + const dstPath = getCurrentDirStr(windowMode) + name + try { + const dstFile = files.open(dstPath) + if (dstFile.exists) { + showMessagePopup('Mkdir', `"${name}" already exists.`) + } + else { + const ok = dstFile.mkDir() + if (!ok) showMessagePopup('Mkdir failed', `Cannot create "${name}".`) + else refreshFilePanelCache(windowMode) + } + } + catch (e) { + showMessagePopup('Mkdir failed', e.message || ('' + e)) + } + _redraw() +} + +function actRename() { + if (path[windowMode].length === 0) return + const cache = filePanelCache[windowMode][cursor[windowMode]] + if (!cache || !cache.file) return + if (cache.isDirectory) { showMessagePopup('Rename', 'Directory rename is not supported.'); _redraw(); return } + + const oldName = cache.file.name + const newName = showInputPopup('Rename', 'New name:', oldName) + if (newName === null || newName.length === 0 || newName === oldName) { _redraw(); return } + + const dirStr = getCurrentDirStr(windowMode) + const srcPath = cache.file.fullPath + const dstPath = dirStr + newName + + try { + const srcFile = files.open(srcPath) + const dstFile = files.open(dstPath) + if (dstFile.exists) { + if (!showConfirmPopup('Rename', `Overwrite "${newName}"?`)) { _redraw(); return } + } + if (!dstFile.exists) dstFile.mkFile() + dstFile.bwrite(srcFile.bread()) + try { dstFile.flush() } catch (e) {} + try { dstFile.close() } catch (e) {} + srcFile.remove() + refreshFilePanelCache(windowMode) + clampCursorAfterChange() + } + catch (e) { + showMessagePopup('Rename failed', e.message || ('' + e)) + } + _redraw() +} + +function actMore() { makePopup(1); redraw() } +function actQuit() { exit = true } + +function invokeOpAction(id) { + if (id === 'switch') actSwitchPanel() + else if (id === 'up') actGoUp() + else if (id === 'copy') actCopy() + else if (id === 'move') actMove() + else if (id === 'delete') actDelete() + else if (id === 'mkdir') actMkdir() + else if (id === 'rename') actRename() + else if (id === 'more') actMore() + else if (id === 'quit') actQuit() +} + +/////////////////////////////////////////////////////////////////////////////////////////////////// +// Mouse region setup (file list + op buttons) +/////////////////////////////////////////////////////////////////////////////////////////////////// + +function setupPanelMouseRegions() { + clearPanelMouseRegions() + + const fp = (windowMode === 0) ? LEFTPANEL : RIGHTPANEL + const fpX = fp.x + 1 + const fpW = fp.width - 2 + const fpY = fp.y + 2 // first file row (after frame top + header) + + // Wheel-scroll over the file list. Wheel and keyboard are the only inputs allowed + // to move the scroll position; hover (below) only moves the caret. + addPanelMouseRegion(fpX, fpY, fpW, LIST_HEIGHT, { + onWheel: (cy, cx, dy) => { + const filesCount = dirFileList[windowMode].length + const maxScroll = Math.max(0, filesCount - LIST_HEIGHT) + let s = scroll[windowMode] + dy * 3 + if (s > maxScroll) s = maxScroll + if (s < 0) s = 0 + if (s !== scroll[windowMode]) { + scroll[windowMode] = s + drawFilePanel() + } + } + }) + + // One hover/click region per row so the caret can follow the mouse without + // calling scrollVert (which would re-scroll the list near the upper/lower thirds). + for (let i = 0; i < LIST_HEIGHT; i++) { + const rowIdx = i + addPanelMouseRegion(fpX, fpY + i, fpW, 1, { + onHover: () => { + const target = scroll[windowMode] + rowIdx + if (target < dirFileList[windowMode].length && cursor[windowMode] !== target) { + cursor[windowMode] = target + drawFilePanel() + } + }, + onClick: (cy, cx, btn) => { + if (btn !== 1) return + const target = scroll[windowMode] + rowIdx + if (target >= dirFileList[windowMode].length) return + cursor[windowMode] = target + actActivate() + } + }) + } + + // Op-panel button hover/click. Each button covers its icon row + label row. + const opX = OPPANEL.x + 1 + const opW = SIDEBAR_WIDTH - 2 + for (let i = 0; i < OP_BUTTONS.length; i++) { + const idx = i + const btn = OP_BUTTONS[i] + addPanelMouseRegion(opX, OPPANEL.y + 1 + btn.yOff, opW, btn.hitH || 2, { + onHover: () => { + if (opHover !== idx) { opHover = idx; drawOpPanel() } + }, + onHoverLeave: () => { + if (opHover === idx) { opHover = -1; drawOpPanel() } + }, + onClick: (cy, cx, btnNum) => { + if (btnNum !== 1) return + invokeOpAction(btn.id) + } + }) + } +} + /////////////////////////////////////////////////////////////////////////////////////////////////// con.curs_set(0) @@ -717,6 +1085,12 @@ refreshFilePanelCache(0) refreshFilePanelCache(1) _redraw() +// Drain inherited mouse/key state from whoever launched us. Polling launchers +// like fsh.js can hand off with the mouse button still held; without this, +// input.withEvent's first call edge-detects that as a fresh mouse_down at the +// cursor and activates whichever file row happens to sit there. +input.withEvent(() => {}) + let redrawRequested = false let exit = false let firstRunLatch = true @@ -724,6 +1098,14 @@ let firstRunLatch = true while (!exit) { input.withEvent(event => { + if (dispatchMouseEvent(event)) { + if (redrawRequested) { + redrawRequested = false + _redraw() + } + return + } + let keysym = event[1] let keyJustHit = (1 == event[2]) diff --git a/assets/disk0/tvdos/include/wintex.mjs b/assets/disk0/tvdos/include/wintex.mjs index 40e5131..d478611 100644 --- a/assets/disk0/tvdos/include/wintex.mjs +++ b/assets/disk0/tvdos/include/wintex.mjs @@ -180,4 +180,345 @@ function scrollHorz(dx, stringSize, stringViewSize, currentCursorPos, currentScr return [currentCursorPos, currentScrollPos] } -exports = { WindowObject, scrollVert, scrollHorz } +// --------------------------------------------------------------------------- +// Modal dialog with multiple input fields and OK/Cancel-style buttons. +// +// opts = { +// title: string, +// message: string | string[]? -- optional body text drawn above fields +// fields: [{label, initial?, width}, ...] -- omit / [] for no input field +// buttons: [{label, action, default?}, ...] -- defaults to [OK, Cancel] (+ Delete +// if `allowDelete:true`) +// allowDelete: bool, -- inserts a Delete button (fsh compat) +// colours: {fg?, bg?, fieldBg?, dimFg?, hlFg?, focusBg?} -- per-call overrides +// } +// +// Returns {action, values}: `action` is the chosen button's `action` +// (default "ok"/"cancel"/"delete"), or "cancel" on Esc; `values` is the array +// of field strings in field order. +// +// Behaviour: +// - Tab / Shift+Tab and arrow Down / Up cycle focus across fields and buttons. +// - Left / Right inside a field move the caret; on a button they cycle focus. +// - Home / End jump to start / end of the focused field. +// - Enter on a field jumps to the next field, then to the first button. Enter +// or Space on a button activates it. +// - Insert at caret. Backspace deletes left of caret; Forward-Del deletes right. +// - Blinking caret (`con.curs_set(1)`) is positioned on the focused field and +// hidden when a button has focus. +// - Mouse: left-click on a button activates it; click on a field puts focus +// on that field and positions the caret under the click. Mouse hover on a +// button highlights it. +const _dialogScreen = con.getmaxyx() +const _dialogPixDim = graphics.getPixelDimension() +const _CELL_PW = (_dialogPixDim[0] / _dialogScreen[1]) | 0 +const _CELL_PH = (_dialogPixDim[1] / _dialogScreen[0]) | 0 +function _pxToCell(px, py) { return [(py / _CELL_PH | 0) + 1, (px / _CELL_PW | 0) + 1] } + +function showDialog(opts) { + const fields = opts.fields || [] + const values = fields.map(f => (f.initial == null) ? '' : ('' + f.initial)) + const cursors = values.map(v => v.length) + + let buttons + if (opts.buttons) { + buttons = opts.buttons + } else { + buttons = [{label: 'OK', action: 'ok', default: true}] + if (opts.allowDelete) buttons.push({label: 'Delete', action: 'delete'}) + buttons.push({label: 'Cancel', action: 'cancel'}) + } + + const title = opts.title || '' + const message = opts.message + const messageLines = !message ? [] + : Array.isArray(message) ? message + : ('' + message).split('\n') + + const c = opts.colours || {} + const fg = (c.fg != null) ? c.fg : 254 + const bg = (c.bg != null) ? c.bg : 242 + const fieldBg = (c.fieldBg != null) ? c.fieldBg : 240 + const dimFg = (c.dimFg != null) ? c.dimFg : 249 + const hlFg = (c.hlFg != null) ? c.hlFg : 230 + const focusBg = (c.focusBg != null) ? c.focusBg : bg + + // Layout + const maxFieldW = fields.reduce((m, f) => Math.max(m, f.width), 16) + const longestMsg = messageLines.reduce((m, l) => Math.max(m, l.length), 0) + const titleW = title.length + 4 + const btnRowW = buttons.reduce((s, b) => s + b.label.length + 5, 0) - 1 + const w = Math.max(maxFieldW + 6, titleW + 4, longestMsg + 6, btnRowW + 4, 24) + const msgTopOff = (messageLines.length > 0) ? 1 : 0 + const msgRows = messageLines.length + (messageLines.length > 0 ? 1 : 0) + const fieldsBlockH = fields.length * 4 + const buttonsRowOff = 1 + msgRows + (fields.length > 0 ? fieldsBlockH + 1 : 1) + const h = buttonsRowOff + 2 + const screen = con.getmaxyx() + const row = Math.max(2, Math.floor((screen[0] - h) / 2)) + const col = Math.max(2, Math.floor((screen[1] - w) / 2)) + + // Pick initial focus: explicit default > first field > first button. + let focusIdx = -1 + for (let i = 0; i < buttons.length; i++) { + if (buttons[i].default) { focusIdx = fields.length + i; break } + } + if (focusIdx < 0) focusIdx = fields.length > 0 ? 0 : fields.length + const totalFocus = fields.length + buttons.length + let hoverBtn = -1 + let done = null + + function fieldScroll(cur, fw) { return cur < fw ? 0 : cur - fw + 1 } + function fieldLabelRow(i) { return row + 1 + msgRows + i * 4 } + function fieldBoxRow(i) { return fieldLabelRow(i) + 1 } + function fieldContentRow(i) { return fieldLabelRow(i) + 2 } + function fieldBoxCol() { return col + 2 } + function fieldContentRegion(i) { return { x: fieldBoxCol() + 1, y: fieldContentRow(i), w: fields[i].width } } + + function buttonRegions() { + let bx = col + Math.floor((w - btnRowW) / 2) + return buttons.map(b => { + const r = { x: bx, y: row + buttonsRowOff, w: b.label.length + 4 } + bx += b.label.length + 5 + return r + }) + } + + function drawFrameBox() { + con.color_pair(fg, bg) + for (let r = row; r < row + h; r++) { + con.move(r, col) + print(' '.repeat(w)) + } + const wo = new WindowObject(col, row, w, h, ()=>{}, ()=>{}, title) + wo.isHighlighted = true + wo.titleBack = bg + wo.drawFrame() + con.color_pair(fg, bg) + } + + function drawMessage() { + if (messageLines.length === 0) return + con.color_pair(fg, bg) + for (let i = 0; i < messageLines.length; i++) { + con.move(row + 1 + i, col + 2) + print(messageLines[i].padEnd(w - 4, ' ')) + } + } + + function drawField(i) { + const f = fields[i] + const fbCol = fieldBoxCol() + const fbRow = fieldBoxRow(i) + const fw = f.width + const focused = (focusIdx === i) + const frameFg = focused ? fg : dimFg + + // Label + con.color_pair(fg, bg) + con.move(fieldLabelRow(i), fbCol) + print(f.label + ':') + + // Top border + con.color_pair(frameFg, bg) + con.move(fbRow, fbCol) + print('' + ''.repeat(fw) + '') + + // Side borders + content + con.color_pair(frameFg, bg) + con.move(fbRow + 1, fbCol) + print('') + con.color_pair(fg, fieldBg) + const s = fieldScroll(cursors[i], fw) + const vis = values[i].substring(s, s + fw) + print(vis.padEnd(fw, ' ')) + con.color_pair(frameFg, bg) + con.move(fbRow + 1, fbCol + fw + 1) + print('') + + // Bottom border + con.move(fbRow + 2, fbCol) + print('' + ''.repeat(fw) + '') + con.color_pair(fg, bg) + } + + function drawButton(i, regions) { + const b = buttons[i] + const bIdx = fields.length + i + const focused = (focusIdx === bIdx) + const hovered = (hoverBtn === i) + const r = regions[i] + let useFg, useBg + if (focused && hovered) { useFg = hlFg; useBg = focusBg } + else if (focused) { useFg = hlFg; useBg = focusBg } + else if (hovered) { useFg = hlFg; useBg = bg } + else { useFg = fg; useBg = bg } + con.color_pair(useFg, useBg) + con.move(r.y, r.x) + print('[ ' + b.label + ' ]') + con.color_pair(fg, bg) + } + + function positionCaret() { + if (focusIdx < fields.length) { + const fw = fields[focusIdx].width + const s = fieldScroll(cursors[focusIdx], fw) + con.move(fieldContentRow(focusIdx), fieldBoxCol() + 1 + (cursors[focusIdx] - s)) + con.curs_set(1) + } else { + con.curs_set(0) + } + } + + function render() { + drawFrameBox() + drawMessage() + for (let i = 0; i < fields.length; i++) drawField(i) + const regs = buttonRegions() + for (let i = 0; i < buttons.length; i++) drawButton(i, regs) + positionCaret() + } + + function moveFocus(dir) { + focusIdx = (focusIdx + dir + totalFocus) % totalFocus + render() + } + + function activateButton(i) { + done = { action: buttons[i].action, values: values.slice() } + } + + function hitTestMouse(ev) { + const cell = _pxToCell(ev[1], ev[2]) + const cy = cell[0], cx = cell[1] + const btnRegs = buttonRegions() + for (let i = 0; i < btnRegs.length; i++) { + const r = btnRegs[i] + if (cy === r.y && cx >= r.x && cx < r.x + r.w) return { kind: 'button', idx: i } + } + for (let i = 0; i < fields.length; i++) { + const r = fieldContentRegion(i) + if (cy === r.y && cx >= r.x && cx < r.x + r.w) return { kind: 'field', idx: i, cx: cx, region: r } + } + return null + } + + render() + + let eventJustReceived = true + while (done === null) { + input.withEvent(ev => { + if (eventJustReceived && (ev[0] === 'key_down' || ev[0] === 'mouse_down')) { + eventJustReceived = false; return + } + + if (ev[0] === 'mouse_move') { + const hit = hitTestMouse(ev) + const newHover = (hit && hit.kind === 'button') ? hit.idx : -1 + if (newHover !== hoverBtn) { + hoverBtn = newHover + const regs = buttonRegions() + for (let i = 0; i < buttons.length; i++) drawButton(i, regs) + positionCaret() + } + return + } + if (ev[0] === 'mouse_down') { + if (ev[3] !== 1) return + const hit = hitTestMouse(ev) + if (!hit) return + if (hit.kind === 'button') { + focusIdx = fields.length + hit.idx + render() + activateButton(hit.idx) + return + } + if (hit.kind === 'field') { + focusIdx = hit.idx + const fw = fields[hit.idx].width + const s = fieldScroll(cursors[hit.idx], fw) + const newCur = s + (hit.cx - hit.region.x) + cursors[hit.idx] = Math.min(values[hit.idx].length, Math.max(0, newCur)) + render() + } + return + } + if (ev[0] !== 'key_down') return + if (1 !== ev[2]) return + const ks = ev[1] + const shiftDown = (ev.includes(59) || ev.includes(60)) + + if (ks === '') { done = { action: 'cancel', values: values.slice() }; return } + + if (ks === '\t' || ks === '') { moveFocus(shiftDown ? -1 : 1); return } + if (ks === '') { moveFocus(-1); return } + if (ks === '') { moveFocus(+1); return } + + if (ks === '') { + if (focusIdx < fields.length) { + if (cursors[focusIdx] > 0) { cursors[focusIdx] -= 1; render() } + } else moveFocus(-1) + return + } + if (ks === '') { + if (focusIdx < fields.length) { + if (cursors[focusIdx] < values[focusIdx].length) { cursors[focusIdx] += 1; render() } + } else moveFocus(+1) + return + } + if (ks === '') { + if (focusIdx < fields.length) { cursors[focusIdx] = 0; render() } + return + } + if (ks === '') { + if (focusIdx < fields.length) { cursors[focusIdx] = values[focusIdx].length; render() } + return + } + + if (focusIdx < fields.length) { + if (ks === '\n') { + focusIdx = (focusIdx < fields.length - 1) ? focusIdx + 1 : fields.length + render() + return + } + if (ks === '') { + const cur = cursors[focusIdx] + if (cur > 0) { + const v = values[focusIdx] + values[focusIdx] = v.substring(0, cur - 1) + v.substring(cur) + cursors[focusIdx] = cur - 1 + render() + } + return + } + if (ks === '' || ks === '') { + const cur = cursors[focusIdx] + const v = values[focusIdx] + if (cur < v.length) { + values[focusIdx] = v.substring(0, cur) + v.substring(cur + 1) + render() + } + return + } + if (typeof ks === 'string' && ks.length === 1) { + const code = ks.charCodeAt(0) + if (code >= 32 && code < 256 && values[focusIdx].length < fields[focusIdx].width * 4) { + const v = values[focusIdx] + const cur = cursors[focusIdx] + values[focusIdx] = v.substring(0, cur) + ks + v.substring(cur) + cursors[focusIdx] = cur + 1 + render() + } + return + } + } else { + if (ks === '\n' || ks === ' ') { activateButton(focusIdx - fields.length); return } + } + }) + } + + con.curs_set(0) + return done +} + +exports = { WindowObject, scrollVert, scrollHorz, showDialog }