diff --git a/assets/disk0/tvdos/bin/taut.js b/assets/disk0/tvdos/bin/taut.js index 29e4c13..35ed03c 100644 --- a/assets/disk0/tvdos/bin/taut.js +++ b/assets/disk0/tvdos/bin/taut.js @@ -16,11 +16,8 @@ const TRACKER_SIGNATURE = "TsvmTaut"+BUILD_DATE // 14-byte string const MIDDOT = "\u00FA" const BIGDOT = "\u00F9" const BULLET = "\u00847u" -const VERTCHAR = "\u00CA" -const TWOVERTCHAR = "\u00DA" const DOTHORZ = "\u00B4\u00B5" -const VERT = 0xCA -const TWOVERT = 0xDA +const VERT = 0xDA // global var for the app _G.TAUT = {}; @@ -45,8 +42,8 @@ quadflat:"\u0096\u0097", // refrain from using (not visible on CRT) csharp:"\u0098", cflat:"\u0098", -cdemisharp:"\u009E", -cdemiflat:"\u009F", +cdemisharp:"\u00A7", +cdemiflat:"\u00A8", uptick:"\u009A", dntick:"\u009B", doubleuptick:"\u009C", @@ -76,10 +73,10 @@ px:'\u00AC', vx:'\u00AD', /* transport control */ -playall:'\u00A8', -playcue:'\u00A9', -playrow:'\u00AA', -stop:'\u00AB', +playall:'\u00E1', +playcue:'\u00E2', +playrow:'\u00E3', +stop:'\u00E4', /* miscellaneous */ unticked:"\u00AE", @@ -88,7 +85,7 @@ middot:MIDDOT, doubledot:"\u008419u", statusstop:"\u008420u\u008421u", statusplay:"\u008422u\u008423u", -playhead:"\u00A7", +playhead:"\u00E0", leftshade:'\u00B0', rightshade:'\u00B2', @@ -992,7 +989,7 @@ const colTabBarBack = 187 const colTabBarBack2 = 136 const colTabBarOrn = 136 const colBrand = 211 -const colPopupBack = 245//57 +const colPopupBack = 244 const colTabActive = 239 const colTabInactive = 45 @@ -3334,102 +3331,40 @@ function openHelpPopup() { const lines = (helpmsg.MSG_BY_TABS && helpmsg.MSG_BY_TABS[currentPanel]) || [''] const colText = helpmsg.COL_TEXT || colWHITE - const popup = new win.WindowObject( - HELP_POPUP_X, HELP_POPUP_Y, HELP_POPUP_W, HELP_POPUP_H, - ()=>{}, ()=>{}, `Help: ${PANEL_NAMES[currentPanel]}`, popupDrawFrame - ) - popup.isHighlighted = true - popup.titleBack = colPopupBack - - let scroll = 0 - const maxScroll = Math.max(0, lines.length - HELP_CONTENT_H) - - const repaint = () => { - con.color_pair(230, colPopupBack) - popup.drawFrame() - - // popupDrawFrame leaves the bottom row unpainted; fill it ourselves. - con.color_pair(colText, colPopupBack) - con.move(HELP_POPUP_Y + HELP_POPUP_H - 1, HELP_POPUP_X) - print(' '.repeat(HELP_POPUP_W)) - - for (let r = 0; r < HELP_CONTENT_H; r++) { - con.move(HELP_CONTENT_Y + r, HELP_CONTENT_X) - con.color_pair(colText, colPopupBack) - const line = lines[scroll + r] - print((line === undefined) ? ' '.repeat(HELP_CONTENT_W) : line) - } - - // scroll indicator on the right inner edge - if (lines.length > HELP_CONTENT_H) { - const trackH = HELP_CONTENT_H - const indPos = (maxScroll === 0) ? 0 : ((scroll * (trackH - 1) / maxScroll) | 0) - con.color_pair(colStatus, colPopupBack) - for (let r = 0; r < trackH; r++) { - con.move(HELP_CONTENT_Y + r, HELP_POPUP_X + HELP_POPUP_W - 2) - let trough = (r == 0) ? 0xBA : (r == trackH - 1) ? 0xBC : 0xBB - print(String.fromCharCode(r === indPos ? (trough + 3) : (trough))) - } - } - - con.color_pair(colStatus, 255) - } - - repaint() - - let done = false - const buttons = makePopupButtonRow(HELP_POPUP_Y + HELP_POPUP_H - 1, HELP_POPUP_X, HELP_POPUP_W, [ - { label: 'OK', action: () => { done = true }, default: true }, - ]) - buttons.repaint() - - let eventJustReceived = true - - pushMousePopup(buttons.regions.concat([ - // Scroll body: wheel scrolls help text. - { x: HELP_CONTENT_X, y: HELP_CONTENT_Y, w: HELP_CONTENT_W, h: HELP_CONTENT_H, onWheel: (cy, cx, dy) => { - scroll += dy * 3 - if (scroll < 0) scroll = 0 - if (scroll > maxScroll) scroll = maxScroll - repaint() - buttons.repaint() - }}, - ])) - - const scrollAndRepaint = () => { repaint(); buttons.repaint() } - - while (!done) { - input.withEvent(ev => { - if (eventJustReceived && (ev[0] === 'key_down' || ev[0] === 'mouse_down')) { - eventJustReceived = false; return - } - if (dispatchMouseEvent(ev)) return - if (ev[0] !== 'key_down') return - const ks = ev[1] - const shiftDown = (ev.includes(59) || ev.includes(60)) - - if (buttons.keyHandler(ks, shiftDown)) return - if (ks === '' || ks === '!' || ks === 'q') { done = true } - else if (ks === '') { if (scroll > 0) { scroll -= 1; scrollAndRepaint() } } - else if (ks === '') { if (scroll < maxScroll) { scroll += 1; scrollAndRepaint() } } - else if (ks === '') { scroll = Math.max(0, scroll - HELP_CONTENT_H); scrollAndRepaint() } - else if (ks === '') { scroll = Math.min(maxScroll, scroll + HELP_CONTENT_H); scrollAndRepaint() } - else if (ks === '') { scroll = 0; scrollAndRepaint() } - else if (ks === '') { scroll = maxScroll; scrollAndRepaint() } - }) - } - - popMousePopup() + win.showDialog({ + title: `Help: ${PANEL_NAMES[currentPanel]}`, + drawFrame: popupDrawFrame, + colours: popupColours, + list: { + items: lines.map(l => ({ label: l })), + bg: colPopupBack, + height: HELP_CONTENT_H, + width: HELP_CONTENT_W+4, + selectable: () => false, + renderItem: (ctx) => { + con.color_pair(colText, ctx.listBg) + con.move(ctx.y, ctx.x) + const line = (ctx.item.label != null ? ctx.item.label : '') + print(line.padEnd(ctx.w, ' ').substring(0, ctx.w)) + }, + }, + buttons: [{ label: 'OK', action: 'ok', default: true }], + onKey: (ks, _shift, ctx) => { + if (ks === '!' || ks === 'q') { ctx.close({ action: 'cancel' }); return true } + return false + }, + }) drawAll() } ///////////////////////////////////////////////////////////////////////////////////////////////////////////// -// GOTO POPUP +// SHARED POPUP CHROME ///////////////////////////////////////////////////////////////////////////////////////////////////////////// -const GOTO_POPUP_W = 26 -const GOTO_POPUP_H = 5 - +// Custom window-frame painter passed to wintex showDialog as `drawFrame`. +// Paints a title bar at the top row, then fills the rest of the popup with +// `colPopupBack` (including the bottom row, so the spacing row below wintex's +// button strip stays painted). const popupDrawFrame = (wo) => { // draw header con.move(wo.y, wo.x) @@ -3439,108 +3374,27 @@ const popupDrawFrame = (wo) => { // imprint title let titleWidth = wo.title.length con.move(wo.y, wo.x + (((wo.width - titleWidth - 2) & 254) >>> 1)) - - /*let colFore = colTabActive - let colBack = colTabBarBack2 - let colFore2 = colTabBarBack2 - let colBack2 = colTabBarBack - con.color_pair(colFore2, colBack2); print(sym.leftshade) - con.color_pair(colFore, colBack); print(wo.title) - con.color_pair(colFore2, colBack2); print(sym.rightshade)*/ con.color_pair(colTabInactive, colTabBarBack); print(` ${wo.title} `) - // fill content area - for (let r = 1; r < wo.height - 1; r++) { + // fill content area (title row already painted above) + for (let r = 1; r < wo.height; r++) { con.move(wo.y + r, wo.x) con.color_pair(230, colPopupBack) print(' '.repeat(wo.width)) } } -// Render a centred button "[ Label ]" at (y, x). State drives the colour scheme so -// the button can appear normal / keyboard-focused / mouse-hovered / both. -// state: 0 = normal, 1 = focused, 2 = hovered, 3 = focused + hovered -function drawPopupButton(y, x, label, state) { - const txt = `[ ${label} ]` - let fore, back - if (state === 1) { fore = colWHITE; back = colTabBarBack2 } // focused - else if (state === 2) { fore = colWHITE; back = colHighlight } // hovered - else if (state === 3) { fore = colBLACK; back = colWHITE } // focused + hovered - else { fore = 230; back = colPopupBack } // normal - con.color_pair(fore, back) - con.move(y, x) - print(txt) - con.color_pair(colStatus, 255) - return { x: x, y: y, w: txt.length, h: 1 } -} - -// Build a row of OK/Cancel-style buttons centred under a popup. Each entry: -// { label, action() } (and an optional `default: true` to pre-focus) -// Returns: -// - `regions`: an array suitable for MOUSE_POPUP_STACK.push (handles hover + click) -// - `keyHandler(ks) -> bool`: feed key symbols here; returns true if it consumed Tab/Enter -// - `repaint()`: redraw all buttons with their current focus/hover state -// - `focus`, `hover`: getters/setters via methods (so popups can drive Esc → Cancel) -function makePopupButtonRow(y, popupX, popupW, defs) { - // Lay out buttons centred along row `y`. Label widths are tracked so we can compute hits. - const labels = defs.map(d => `[ ${d.label} ]`) - const totalW = labels.reduce((s, l) => s + l.length, 0) + 2 * (defs.length - 1) - const startX = popupX + ((popupW - totalW) >>> 1) - let cursor = startX - const buttons = defs.map((d, i) => { - const w = labels[i].length - const b = { x: cursor, y, w, label: d.label, action: d.action } - cursor += w + 2 - return b - }) - let focus = Math.max(0, defs.findIndex(d => d.default)) - if (focus < 0) focus = 0 - let hover = -1 - - const repaint = () => { - buttons.forEach((b, i) => { - const st = (i === focus ? 1 : 0) | (i === hover ? 2 : 0) - drawPopupButton(b.y, b.x, b.label, st) - }) - } - - const regions = buttons.map((b, i) => ({ - x: b.x, y: b.y, w: b.w, h: b.h || 1, - onClick: (cy, cx, btn) => { if (btn === 1) b.action() }, - onHover: () => { if (hover !== i) { hover = i; repaint() } }, - onHoverLeave: () => { if (hover === i) { hover = -1; repaint() } }, - })) - - // Tab/Shift+Tab cycles focus; Enter activates. Returns true if the key was consumed. - const keyHandler = (ks, shiftDown) => { - if (ks === '\t' || ks === '') { - focus = (focus + (shiftDown ? defs.length - 1 : 1)) % defs.length - repaint() - return true - } - if (ks === '\n') { buttons[focus].action(); return true } - return false - } - - return { regions, keyHandler, repaint, - getFocus: () => focus, setFocus: (i) => { focus = i; repaint() }, - activate: (i) => buttons[i].action() } -} - -function drawGotoPopup(popup, buf) { - con.color_pair(230, colPopupBack) - popup.drawFrame() - - const prompts = ['Cue (hex):', 'Cue (hex):', 'Pattern (hex):'] - const promptStr = prompts[currentPanel] || 'Number:' - - con.move(popup.y + 2, popup.x + 2) - con.color_pair(colWHITE, colPopupBack) - print(promptStr + ' ') - con.color_pair(230, 240) - print('[' + buf.padEnd(3, '_') + ']') - - con.color_pair(colStatus, 255) // reset colour +// Standard colour palette shared by every taut popup so wintex's defaults blend +// with taut's popup chrome. +const popupColours = { + // fg: colStatus, + // bg: colPopupBack, + // fieldBg: 240, + // dimFg: colVoiceHdrMuted, + // hlFg: colWHITE, + // focusBg: colHighlight, + // listBg: colPopupBack, + // listSelBg: colHighlight, } function applyGoto(num) { @@ -3558,110 +3412,48 @@ function applyGoto(num) { } function openConfirmQuit() { - const pw = 28 + hasUnsavedChanges * 4 - const ph = 6 + hasUnsavedChanges - const px = ((SCRW - pw) / 2 | 0) + 1 - const py = ((SCRH - ph) / 2 | 0) + const messageLines = ['Exit Microtone?'] + if (hasUnsavedChanges) messageLines.push('You have unsaved changes.') - const popup = new win.WindowObject(px, py, pw, ph, ()=>{}, ()=>{}, 'Quit?', popupDrawFrame) - popup.isHighlighted = true - popup.titleBack = colPopupBack + const res = win.showDialog({ + title: 'Quit?', + drawFrame: popupDrawFrame, + colours: popupColours, + message: messageLines, + buttons: [ + { label: 'Yes', action: 'yes', default: true }, + { label: 'No', action: 'no' }, + ], + onKey: (ks, _shift, ctx) => { + if (ks === 'y' || ks === 'Y') { ctx.close({ action: 'yes' }); return true } + if (ks === 'n' || ks === 'N') { ctx.close({ action: 'no' }); return true } + return false + }, + }) - con.color_pair(230, colPopupBack) - popup.drawFrame() - - con.move(py + 2, px + 2) - con.color_pair(colWHITE, colPopupBack) - print('Exit Microtone?') - - if (hasUnsavedChanges) { - con.move(py + 3, px + 2) - con.color_pair(colWHITE, colPopupBack) - print('You have unsaved changes.') - } - - let result = false - let done = false - - const buttons = makePopupButtonRow(py + ph - 2, px, pw, [ - { label: 'Yes', action: () => { result = true; done = true }, default: true }, - { label: 'No', action: () => { done = true } }, - ]) - buttons.repaint() - pushMousePopup(buttons.regions) - - let eventJustReceived = true - while (!done) { - input.withEvent(ev => { - if (eventJustReceived && ev[0] === 'mouse_down') { eventJustReceived = false; return } - if (dispatchMouseEvent(ev)) 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 (buttons.keyHandler(ks, shiftDown)) return - if (ks === 'y' || ks === 'Y') { result = true; done = true } - else if (ks === 'n' || ks === 'N' || ks === '') { done = true } - }) - } - - popMousePopup() + const result = (res.action === 'yes') if (!result) drawAll() return result } function openGotoPopup() { - const pw = GOTO_POPUP_W - const ph = GOTO_POPUP_H + 2 - const px = ((SCRW - pw) / 2 | 0) + 1 - const py = ((SCRH - ph) / 2 | 0) + const prompts = ['Cue (hex):', 'Cue (hex):', 'Pattern (hex):'] + const promptStr = prompts[currentPanel] || 'Number:' - const popup = new win.WindowObject(px, py, pw, ph, ()=>{}, ()=>{}, 'Go To', popupDrawFrame) - popup.isHighlighted = true - popup.titleBack = colTabBarBack - - let buf = '' - let done = false - let commit = false - - const buttons = makePopupButtonRow(py + ph - 2, px, pw, [ - { label: 'OK', action: () => { commit = true; done = true }, default: true }, - { label: 'Cancel', action: () => { done = true } }, - ]) - const repaintAll = () => { drawGotoPopup(popup, buf); buttons.repaint() } - repaintAll() - pushMousePopup(buttons.regions) - - let eventJustReceived = true - - while (!done) { - input.withEvent(ev => { - if (eventJustReceived && (ev[0] === 'key_down' || ev[0] === 'mouse_down')) { - eventJustReceived = false - return - } - if (dispatchMouseEvent(ev)) return - if (ev[0] !== 'key_down') return - const ks = ev[1] - if (1 !== ev[2]) return // not key just hit - const shiftDown = (ev.includes(59) || ev.includes(60)) - - if (buttons.keyHandler(ks, shiftDown)) return - if (ks === '' || ks === 'x') { - done = true - } else if (ks === '\u0008') { - buf = buf.slice(0, -1) - repaintAll() - } else if (ks.length === 1 && '0123456789abcdefABCDEF'.includes(ks) && buf.length < 3) { - buf += ks.toUpperCase() - repaintAll() - } - }) + const res = win.showDialog({ + title: 'Go To', + drawFrame: popupDrawFrame, + colours: popupColours, + fields: [{ label: promptStr, width: 3, maxLength: 3 }], + buttons: [ + { label: 'OK', action: 'ok', default: true }, + { label: 'Cancel', action: 'cancel' }, + ], + }) + if (res.action === 'ok' && res.values[0]) { + const n = parseInt(res.values[0], 16) + if (!isNaN(n)) applyGoto(n) } - - popMousePopup() - if (commit && buf.length > 0) applyGoto(parseInt(buf, 16)) drawAll() } @@ -3686,155 +3478,58 @@ function openRetunePopup() { const methodCycle = ['pitch', 'harmonic', 'delta'/*, 'cadence'*/] let method = 'pitch' - const pw = 42 - const listH = Math.min(n, 15) - const ph = listH + 7 - const px = ((SCRW - pw) / 2 | 0) - const py = ((SCRH - ph) / 2 | 0) - const listX = px + 2 - const listY = py + 3 - const listW = pw - 4 + let selIdx = entries.findIndex(p => p.index === PITCH_PRESET_IDX) + if (selIdx < 0) selIdx = 0 - const popup = new win.WindowObject(px, py, pw, ph, ()=>{}, ()=>{}, 'Retune', popupDrawFrame) - popup.isHighlighted = true - popup.titleBack = colPopupBack + const items = entries.map(e => ({ label: e.name, preset: e })) + const listH = Math.min(n, 13) + const messageLines = [ + 'Select new tuning preset:', + 'Method: ' + methodLabels[method], + ] - let sel = entries.findIndex(p => p.index === PITCH_PRESET_IDX) - if (sel < 0) sel = 0 - let scroll = centerScroll(sel, 0, listH, n) - - let done = false - let confirmed = false - const buttons = makePopupButtonRow(py + ph - 2, px, pw, [ - { label: 'OK', action: () => { confirmed = true; done = true }, default: true }, - { label: 'Cancel', action: () => { done = true } }, - ]) - - const repaint = () => { - con.color_pair(230, colPopupBack) - popup.drawFrame() - - con.move(py + 1, px + 2) - con.color_pair(colStatus, colPopupBack) - print('Select new tuning preset:') - - con.move(py + 2, px + 2) - con.color_pair(colStatus, colPopupBack) - print('Method: ') - con.color_pair(colWHITE, colPopupBack) - const mLabel = methodLabels[method] - print(mLabel.padEnd(listW - 8)) - - for (let r = 0; r < listH; r++) { - const idx = scroll + r - con.move(listY + r, listX) - if (idx >= n) { - con.color_pair(230, colPopupBack) - print(' '.repeat(listW)) - continue - } - const e = entries[idx] - const isSel = (idx === sel) - const isCur = (e.index === PITCH_PRESET_IDX) - const back = isSel ? colHighlight : colPopupBack - const fore = (e.t in tuningTypeColour) ? tuningTypeColour[e.t] : 230 - const marker = isCur ? sym.playhead : ' ' - let label = `${marker} ${e.index.toString().padStart(5, ' ')} ${e.name}` - if (label.length > listW) label = label.substring(0, listW) - else label = label.padEnd(listW) - con.color_pair(fore, back) - print(label) - } - - if (n > listH) { - const maxScroll = n - listH - const indPos = (maxScroll === 0) ? 0 : ((scroll * (listH - 1) / maxScroll) | 0) - con.color_pair(colStatus, colPopupBack) - for (let r = 0; r < listH; r++) { - con.move(listY + r, px + pw - 2) - let trough = (r === 0) ? 0xBA : (r === listH - 1) ? 0xBC : 0xBB - print(String.fromCharCode(r === indPos ? (trough + 3) : trough)) - } - } - - con.move(py + ph - 3, px + 2) - con.color_pair(colVoiceHdr, colPopupBack) - print(`\u008418u `) - con.color_pair(colStatus, colPopupBack) - print(`Sel `) - con.color_pair(colVoiceHdr, colPopupBack) - print(`m `) - con.color_pair(colStatus, colPopupBack) - print(`Method`) - - buttons.repaint() - - con.color_pair(colStatus, 255) - } - - repaint() - - let eventJustReceived = true - - pushMousePopup(buttons.regions.concat([ - // List rows: click to select, double-click semantics omitted (clarity over speed). - { x: listX, y: listY, w: listW, h: listH, onClick: (cy, cx, btn) => { - if (btn !== 1) return - const r = cy - listY - const idx = scroll + r - if (idx < 0 || idx >= n) return - sel = idx; repaint() - }, onWheel: (cy, cx, dy) => { - sel += dy * 3 - if (sel < 0) sel = 0 - if (sel >= n) sel = n - 1 - scroll = centerScroll(sel, scroll, listH, n) - repaint() - }}, - // Method label clickable - { x: px + 2, y: py + 2, w: listW, h: 1, onClick: (cy, cx, btn) => { - if (btn !== 1) return - method = methodCycle[(methodCycle.indexOf(method) + 1) % methodCycle.length] - repaint() - }}, - ])) - - while (!done) { - input.withEvent(ev => { - if (eventJustReceived && (ev[0] === 'key_down' || ev[0] === 'mouse_down')) { - eventJustReceived = false; return - } - if (dispatchMouseEvent(ev)) return - if (ev[0] !== 'key_down') return - const ks = ev[1] - const shiftDown = (ev.includes(59) || ev.includes(60)) - - if (buttons.keyHandler(ks, shiftDown)) return - if (ks === 'Q' || ks === '') { done = true } - else if (ks === 'M' || ks === 'm') { + const res = win.showDialog({ + title: 'Retune', + drawFrame: popupDrawFrame, + colours: popupColours, + message: messageLines, + list: { + items: items, + height: listH, + width: 36, + cursor: selIdx, + renderItem: (ctx) => { + const e = ctx.item.preset + const isCur = (e.index === PITCH_PRESET_IDX) + const fore = (e.t in tuningTypeColour) ? tuningTypeColour[e.t] : 230 + const useFg = (ctx.isCursor && ctx.focused) ? colWHITE : fore + const useBg = (ctx.isCursor && ctx.focused) ? colHighlight : ctx.listBg + con.color_pair(useFg, useBg) + con.move(ctx.y, ctx.x) + const marker = isCur ? sym.playhead : ' ' + let label = `${marker} ${e.name}` + if (label.length > ctx.w) label = label.substring(0, ctx.w) + else label = label.padEnd(ctx.w, ' ') + print(label) + }, + }, + buttons: [ + { label: 'OK', action: 'ok', default: true }, + { label: 'Cancel', action: 'cancel' }, + ], + onKey: (ks, _shift, ctx) => { + if (ks === 'm' || ks === 'M') { method = methodCycle[(methodCycle.indexOf(method) + 1) % methodCycle.length] - repaint() + messageLines[1] = 'Method: ' + methodLabels[method] + ctx.render() + return true } - else if (ks === '') { - if (sel > 0) { sel--; scroll = centerScroll(sel, scroll, listH, n); repaint() } - } else if (ks === '') { - if (sel < n - 1) { sel++; scroll = centerScroll(sel, scroll, listH, n); repaint() } - } else if (ks === '') { - sel = 0; scroll = centerScroll(sel, scroll, listH, n); repaint() - } else if (ks === '') { - sel = n - 1; scroll = centerScroll(sel, scroll, listH, n); repaint() - } else if (ks === '') { - sel = Math.max(0, sel - listH); scroll = centerScroll(sel, scroll, listH, n); repaint() - } else if (ks === '') { - sel = Math.min(n - 1, sel + listH); scroll = centerScroll(sel, scroll, listH, n); repaint() - } - }) - } + return false + }, + }) - popMousePopup() - - if (confirmed) { - const target = entries[sel] + if (res.action === 'ok' && res.listItem) { + const target = res.listItem.preset if (target && target.index !== PITCH_PRESET_IDX) { retuneAllPatterns(target.index, method) } @@ -3857,114 +3552,63 @@ function openFlagsPopup() { if (intpMode >= intpNames.length) intpMode = 0 // Build list rows: headers + selectable radio options. - // items[].kind: undefined = header, 'tone' | 'intp' = selectable. const items = [] - items.push({ label: 'Tone Mode:' }) - toneNames.forEach((n, i) => items.push({ kind: 'tone', idx: i, label: n })) - items.push({ label: '' }) - items.push({ label: 'Interpolation:' }) - intpNames.forEach((n, i) => items.push({ kind: 'intp', idx: i, label: n })) + items.push({ label: 'Tone Mode:', kind: 'header' }) + toneNames.forEach((nm, i) => items.push({ label: nm, kind: 'tone', idx: i })) + items.push({ label: '', kind: 'spacer' }) + items.push({ label: 'Interpolation:', kind: 'header' }) + intpNames.forEach((nm, i) => items.push({ label: nm, kind: 'intp', idx: i })) - const selectables = [] - items.forEach((it, i) => { if (it.kind) selectables.push(i) }) - let sel = 0 - - const pw = 28 - const ph = items.length + 6 - const px = ((SCRW - pw) / 2 | 0) + 1 - const py = ((SCRH - ph) / 2 | 0) - - const popup = new win.WindowObject(px, py, pw, ph, ()=>{}, ()=>{}, 'Mixer Flags', popupDrawFrame) - popup.isHighlighted = true - popup.titleBack = colPopupBack - - let done = false - let confirmed = false - const buttons = makePopupButtonRow(py + ph - 2, px, pw, [ - { label: 'OK', action: () => { confirmed = true; done = true }, default: true }, - { label: 'Cancel', action: () => { done = true } }, - ]) - - const repaint = () => { - con.color_pair(230, colPopupBack) - popup.drawFrame() - - for (let i = 0; i < items.length; i++) { - const it = items[i] - con.move(py + 1 + i, px + 2) - if (!it.kind) { - con.color_pair(colStatus, colPopupBack) - print(it.label.padEnd(pw - 4)) - } else { - const isSel = (selectables[sel] === i) + const res = win.showDialog({ + title: 'Mixer Flags', + drawFrame: popupDrawFrame, + colours: popupColours, + list: { + items: items, + height: items.length, + width: 22, + showScrollbar: false, + selectable: (it) => it.kind === 'tone' || it.kind === 'intp', + renderItem: (ctx) => { + const it = ctx.item + con.move(ctx.y, ctx.x) + if (it.kind === 'header') { + con.color_pair(colStatus, colPopupBack) + print(it.label.padEnd(ctx.w, ' ').substring(0, ctx.w)) + return + } + if (it.kind === 'spacer') { + con.color_pair(colStatus, colPopupBack) + print(' '.repeat(ctx.w)) + return + } const isChecked = (it.kind === 'tone') ? (toneMode === it.idx) : (intpMode === it.idx) - const back = isSel ? colHighlight : colPopupBack - const fore = isChecked ? colVoiceHdr : colWHITE - con.color_pair(fore, back) + const useBg = (ctx.isCursor && ctx.focused) ? colHighlight : colPopupBack + const useFg = isChecked ? colVoiceHdr : colWHITE + con.color_pair(useFg, useBg) const line = ' ' + (isChecked ? sym.ticked : sym.unticked) + ' ' + it.label - print(line.padEnd(pw - 4)) - } - } + print(line.padEnd(ctx.w, ' ').substring(0, ctx.w)) + }, + // Space and left-click toggle the radio; Enter commits via OK. + onActivate: (item, _idx, key) => { + if (key === ' ' || key === 'click') { + if (item.kind === 'tone') toneMode = item.idx + else if (item.kind === 'intp') intpMode = item.idx + return null + } + if (key === '\n') return 'ok' + return null + }, + }, + buttons: [ + { label: 'OK', action: 'ok', default: true }, + { label: 'Cancel', action: 'cancel' }, + ], + }) - con.move(py + ph - 3, px + 2) - con.color_pair(colVoiceHdr, colPopupBack); print(`\u008418u `) - con.color_pair(colStatus, colPopupBack); print('Sel ') - con.color_pair(colVoiceHdr, colPopupBack); print('sp ') - con.color_pair(colStatus, colPopupBack); print('Tick') - - buttons.repaint() - - con.color_pair(colStatus, 255) - } - - repaint() - - let eventJustReceived = true - - pushMousePopup(buttons.regions.concat([ - // Clickable rows — each maps to a selectable index. - { x: px + 2, y: py + 1, w: pw - 4, h: items.length, onClick: (cy, cx, btn) => { - if (btn !== 1) return - const i = cy - (py + 1) - const it = items[i] - if (!it || !it.kind) return - sel = selectables.indexOf(i) - if (sel < 0) sel = 0 - if (it.kind === 'tone') toneMode = it.idx - else if (it.kind === 'intp') intpMode = it.idx - repaint() - }}, - ])) - - while (!done) { - input.withEvent(ev => { - if (eventJustReceived && (ev[0] === 'key_down' || ev[0] === 'mouse_down')) { - eventJustReceived = false; return - } - if (dispatchMouseEvent(ev)) return - if (ev[0] !== 'key_down') return - const ks = ev[1] - const shiftDown = (ev.includes(59) || ev.includes(60)) - - if (buttons.keyHandler(ks, shiftDown)) return - if (ks === '' || ks === 'q' || ks === 'Q') { done = true; return } - if (ks === '' && sel > 0) { sel--; repaint(); return } - if (ks === '' && sel < selectables.length-1) { sel++; repaint(); return } - if (ks === ' ') { - const it = items[selectables[sel]] - if (it.kind === 'tone') toneMode = it.idx - else if (it.kind === 'intp') intpMode = it.idx - repaint() - return - } - }) - } - - popMousePopup() - - if (confirmed) { + if (res.action === 'ok') { const newFlags = (initialTrackerMixerflags & ~0x1F) | (toneMode & 3) | ((intpMode & 7) << 2) if (newFlags !== initialTrackerMixerflags) { diff --git a/assets/disk0/tvdos/bin/tautfont.kra b/assets/disk0/tvdos/bin/tautfont.kra index c094715..6765b05 100644 --- a/assets/disk0/tvdos/bin/tautfont.kra +++ b/assets/disk0/tvdos/bin/tautfont.kra @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:86fea61b524cca436b6c9125e0ea26b60787fd967cae213b731f72f031292b43 -size 138327 +oid sha256:8a00dd17f453dac23befc326d5e133f01687feddc4696fb57bc64239883e89ed +size 138435 diff --git a/assets/disk0/tvdos/bin/tautfont_high.chr b/assets/disk0/tvdos/bin/tautfont_high.chr index e632e0c..1060e3a 100644 Binary files a/assets/disk0/tvdos/bin/tautfont_high.chr and b/assets/disk0/tvdos/bin/tautfont_high.chr differ diff --git a/assets/disk0/tvdos/bin/zfm.js b/assets/disk0/tvdos/bin/zfm.js index d0560ad..2f8710b 100644 --- a/assets/disk0/tvdos/bin/zfm.js +++ b/assets/disk0/tvdos/bin/zfm.js @@ -600,176 +600,27 @@ function showMessagePopup(title, message) { // Vertical-list popup: items are stacked rows, navigable with arrow keys / // mouse, selection (Enter / left-click on row) returns that item's action. // A single Close button sits below the list; Esc and Close both yield 'close'. +// Thin wrapper over win.showDialog — see wintex.mjs for the underlying schema. function showActionListPopup(opts) { - const title = opts.title || '' const items = opts.items || [] const closeLabel = opts.closeLabel || 'Close' - const message = opts.message - const messageLines = !message ? [] - : Array.isArray(message) ? message - : ('' + message).split('\n') + const defaultIdx = items.findIndex(it => it.default) - const fg = 254 - const bg = 243 - const dimFg = 249 - const hlFg = 230 - const itemSelBg = 81 + const res = win.showDialog({ + title: opts.title || '', + message: opts.message, + list: { + items: items, + height: items.length, + cursor: defaultIdx >= 0 ? defaultIdx : 0, + showScrollbar: false, + onActivate: (item) => item.action, + }, + buttons: [{ label: closeLabel, action: 'close' }], + }) - const longestItem = items.reduce((m, it) => Math.max(m, it.label.length), 0) - const longestMsg = messageLines.reduce((m, l) => Math.max(m, l.length), 0) - const titleW = title.length + 4 - const closeBtnW = closeLabel.length + 4 - const w = Math.max(longestItem + 8, titleW + 4, longestMsg + 6, closeBtnW + 4, 24) - - const msgRows = messageLines.length + (messageLines.length > 0 ? 1 : 0) - const itemsRowOff = 1 + msgRows - const buttonsRowOff = itemsRowOff + items.length + 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)) - - let oldFG = con.get_color_fore() - let oldBG = con.get_color_back() - - // Initial focus: first 'default: true' item, otherwise first item, otherwise close button. - let focusIdx = -1 - for (let i = 0; i < items.length; i++) { - if (items[i].default) { focusIdx = i; break } - } - if (focusIdx < 0) focusIdx = (items.length > 0) ? 0 : items.length - const totalFocus = items.length + 1 - let done = null - - function itemRow(i) { return row + itemsRowOff + i } - function itemCol() { return col + 1 } - function itemWidth() { return w - 2 } - function closeBtnRow(){ return row + buttonsRowOff } - function closeBtnCol(){ return col + Math.floor((w - closeBtnW) / 2) } - - 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 win.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(dimFg, bg) - for (let i = 0; i < messageLines.length; i++) { - con.move(row + 1 + i, col + 2) - print(messageLines[i].padEnd(w - 4, ' ')) - } - con.color_pair(fg, bg) - } - - function drawItem(i) { - const focused = (focusIdx === i) - const useFg = focused ? hlFg : fg - const useBg = focused ? itemSelBg : bg - con.color_pair(useFg, useBg) - con.move(itemRow(i), itemCol()) - print(' ' + items[i].label.padEnd(itemWidth() - 2, ' ')) - con.color_pair(fg, bg) - } - - function drawCloseBtn() { - const focused = (focusIdx === items.length) - const useFg = focused ? hlFg : fg - con.color_pair(useFg, bg) - con.move(closeBtnRow(), closeBtnCol()) - print('[ ' + closeLabel + ' ]') - con.color_pair(fg, bg) - } - - function render() { - drawFrameBox() - drawMessage() - for (let i = 0; i < items.length; i++) drawItem(i) - drawCloseBtn() - con.curs_set(0) - } - - function hitTest(ev) { - const [cy, cx] = pixelToCell(ev[1], ev[2]) - for (let i = 0; i < items.length; i++) { - if (cy === itemRow(i) && cx >= itemCol() && cx < itemCol() + itemWidth()) { - return { kind: 'item', idx: i } - } - } - const cbx = closeBtnCol() - if (cy === closeBtnRow() && cx >= cbx && cx < cbx + closeBtnW) { - return { kind: 'close' } - } - return null - } - - function moveFocus(dir) { - focusIdx = (focusIdx + dir + totalFocus) % totalFocus - render() - } - - 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 = hitTest(ev) - let newFocus = null - if (hit && hit.kind === 'item') newFocus = hit.idx - else if (hit && hit.kind === 'close') newFocus = items.length - if (newFocus !== null && newFocus !== focusIdx) { - focusIdx = newFocus - for (let i = 0; i < items.length; i++) drawItem(i) - drawCloseBtn() - } - return - } - if (ev[0] === 'mouse_down') { - if (ev[3] !== 1) return - const hit = hitTest(ev) - if (!hit) return - if (hit.kind === 'item') { - focusIdx = hit.idx; render() - done = { action: items[hit.idx].action } - return - } - if (hit.kind === 'close') { - focusIdx = items.length; render() - done = { action: 'close' } - return - } - return - } - if (ev[0] !== 'key_down') return - if (1 !== ev[2]) return - const ks = ev[1] - - if (ks === '') { done = { action: 'close' }; return } - if (ks === '\t' || ks === '' || ks === '') { moveFocus(+1); return } - if (ks === '') { moveFocus(-1); return } - if (ks === '\n' || ks === ' ') { - if (focusIdx < items.length) done = { action: items[focusIdx].action } - else done = { action: 'close' } - return - } - }) - } - - con.color_pair(oldFG, oldBG) - return done + if (res.action === 'cancel') return { action: 'close' } + return { action: res.action } } /////////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/assets/disk0/tvdos/include/wintex.mjs b/assets/disk0/tvdos/include/wintex.mjs index df1b6f8..fe69e69 100644 --- a/assets/disk0/tvdos/include/wintex.mjs +++ b/assets/disk0/tvdos/include/wintex.mjs @@ -181,34 +181,83 @@ function scrollHorz(dx, stringSize, stringViewSize, currentCursorPos, currentScr } // --------------------------------------------------------------------------- -// Modal dialog with multiple input fields and OK/Cancel-style buttons. +// Modal dialog with optional body text, input fields, a scrollable selection +// list, and OK/Cancel-style buttons. Layout from top to bottom: +// title bar, message, fields, list, buttons. // // opts = { // title: string, -// message: string | string[]? -- optional body text drawn above fields -// fields: [{label, initial?, width}, ...] -- omit / [] for no input field. Label does NOT get auto-colon +// message: string | string[]?, -- optional body text drawn above fields/list +// drawFrame: function(wo)?, -- override for the window-frame painter; +// same contract as WindowObject's +// `drawFrame` slot. Useful when the caller +// wants its own border / title styling. +// +// fields: [{label, initial?, width, maxLength?}, ...] -- omit / [] for no input +// field. Label does NOT get auto-colon. +// `maxLength` caps insertable chars +// (default: width * 4). +// +// list: { -- optional vertical selection list +// items: [{label, ...}, ...], -- arbitrary user objects; only `label` +// is read by the default renderer. +// height: number, -- visible row count. +// width: number?, -- inner width override (default: popup w-4). +// cursor: number?, -- initial cursor row (default: first selectable). +// selectable: function(item, i)->bool?, -- default: every item selectable. Non- +// selectable rows are skipped by arrow keys. +// When NO row is selectable, arrow / PgUp +// / PgDn scroll the view instead. +// renderItem: function(ctx)?, -- per-row painter; ctx exposes +// { y, x, w, item, idx, isCursor, focused, +// listBg, selBg, fg, hlFg, dimFg }. +// Default prints `item.label`. +// onActivate: function(item, i, key)?, -- fired on Enter ('\n') / Space (' ') +// / left-click ('click'); return an +// action string to close the dialog, +// or null to stay open. +// showScrollbar: bool?, -- default: auto (true when overflowing). +// bg: number?, -- list background colour (default 242). +// }, +// // 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 +// colours: {fg?, bg?, fieldBg?, dimFg?, hlFg?, focusBg?, listBg?, listSelBg?} +// -- per-call overrides +// disableKeyRepeat: bool, -- when true, key won't repeat when held down +// onKey: function(ks, shiftDown, ctx)?, -- escape hatch for callers that need +// extra key bindings. Runs BEFORE the +// built-in handlers. Return true to +// consume the key. `ctx` exposes +// { render, close(result), +// getListCursor, setListCursor }. // } // -// 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. +// Returns {action, values, listCursor, listItem}: `action` is the chosen button's +// `action` or the value returned from `onActivate` (default "ok"/"cancel"/"delete"), +// or "cancel" on Esc; `values` is the array of field strings in field order; +// `listCursor` is the final cursor index (-1 if there is no list); `listItem` is +// the item at that index. // // 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. +// - Tab / Shift+Tab and arrow Down / Up cycle focus across fields, list, and buttons. +// Inside the list, arrow Up / Down move the cursor between selectable rows; +// PgUp/PgDn move a page; Home/End jump to the first/last selectable row. +// - Left / Right inside a field move the caret; on the list or 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. +// or Space on a button activates it. Enter or Space on a list row invokes +// `onActivate(item, idx, key)`; if that returns a string, the dialog closes +// with that action. // - 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. +// hidden when the list or 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 moves focus to it (the same focus the keyboard uses). +// on that field and positions the caret under the click; click on a list row +// moves the cursor (and fires `onActivate` if defined); mouse-wheel inside the +// list scrolls it. Mouse hover on a button moves focus to it (the same focus +// the keyboard uses). const _dialogScreen = con.getmaxyx() const _dialogPixDim = graphics.getPixelDimension() const _CELL_PW = (_dialogPixDim[0] / _dialogScreen[1]) | 0 @@ -238,37 +287,87 @@ function showDialog(opts) { : 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 : 243 - const fieldBg = (c.fieldBg != null) ? c.fieldBg : 240 - const dimFg = (c.dimFg != null) ? c.dimFg : 249 - const hlFg = (c.hlFg != null) ? c.hlFg : 240 - const focusBg = (c.focusBg != null) ? c.focusBg : 253 + const c = opts.colours || {} + const fg = (c.fg != null) ? c.fg : 254 + const bg = (c.bg != null) ? c.bg : 244 + const fieldBg = (c.fieldBg != null) ? c.fieldBg : 240 + const dimFg = (c.dimFg != null) ? c.dimFg : 249 + const hlFg = (c.hlFg != null) ? c.hlFg : 240 + const focusBg = (c.focusBg != null) ? c.focusBg : 253 + const listBg = (c.listBg != null) ? c.listBg : 243 + const listSelBg = (c.listSelBg != null) ? c.listSelBg : focusBg + + // List state + const list = opts.list || null + const listItems = list ? (list.items || []) : [] + const listSelectable = list && list.selectable ? list.selectable : (() => true) + const listHeight = list ? (list.height || Math.min(8, listItems.length)) : 0 + const hasList = !!list + const listOnActivate = list ? list.onActivate : null + const listBgColour = (list && list.bg != null) ? list.bg : listBg + function firstSelectable(from, dir) { + if (!hasList || listItems.length === 0) return -1 + let i = from + for (let n = 0; n < listItems.length; n++) { + if (i >= 0 && i < listItems.length && listSelectable(listItems[i], i)) return i + i += dir + if (i < 0) i = listItems.length - 1 + if (i >= listItems.length) i = 0 + } + return -1 + } + let listCursor = hasList + ? (list.cursor != null ? list.cursor : firstSelectable(0, +1)) + : -1 + let listScroll = 0 // Layout const buttonGap = 3 const maxFieldW = fields.reduce((m, f) => Math.max(m, f.width), 16) const longestMsg = messageLines.reduce((m, l) => Math.max(m, l.length), 0) + // When the caller pins `list.width`, trust it — string `.length` overcounts + // visual width whenever items embed ANSI escapes or TVDOS \x84NNu sequences + // (e.g. taut's help popup, whose rows are pre-typeset with fg-colour escapes). + const longestItem = hasList && list.width == null + ? listItems.reduce((m, it) => Math.max(m, (it.label || '').length), 0) + : 0 const titleW = title.length + 4 const btnRowW = buttons.reduce((s, b) => s + b.label.length + 4, 0) + buttonGap * Math.max(0, buttons.length - 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 listMinW = hasList + ? (list.width != null ? list.width + 4 : longestItem + 6) + : 0 + const w = Math.max(maxFieldW + 6, titleW + 4, longestMsg + 6, btnRowW + 4, listMinW, 24) + + 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 listBlockH = hasList ? listHeight + 2 : 0 // top border + rows + bottom border + + let bodyRows = msgRows + if (fields.length > 0) bodyRows += fieldsBlockH + 1 // +1 spacing after fields + if (hasList) bodyRows += listBlockH + 1 // +1 spacing after list + if (bodyRows === 0) bodyRows = 1 // at least one row above buttons + const buttonsRowOff = 1 + bodyRows 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. + // Focus layout: 0..fields.length-1 = fields, [+1 = list if present], then buttons. + const listFocusIdx = hasList ? fields.length : -1 + const buttonsFocusBase = fields.length + (hasList ? 1 : 0) + const totalFocus = buttonsFocusBase + buttons.length + + // Pick initial focus: explicit default > list > 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 (buttons[i].default) { focusIdx = buttonsFocusBase + i; break } + } + if (focusIdx < 0) { + if (fields.length > 0) focusIdx = 0 + else if (hasList) focusIdx = listFocusIdx + else focusIdx = buttonsFocusBase } - if (focusIdx < 0) focusIdx = fields.length > 0 ? 0 : fields.length - const totalFocus = fields.length + buttons.length let done = null function fieldScroll(cur, fw) { return cur < fw ? 0 : cur - fw + 1 } @@ -278,6 +377,22 @@ function showDialog(opts) { function fieldBoxCol() { return col + 2 } function fieldContentRegion(i) { return { x: fieldBoxCol() + 1, y: fieldContentRow(i), w: fields[i].width } } + function listBlockTopRow() { + return row + 1 + msgRows + (fields.length > 0 ? fieldsBlockH + 1 : 0) + } + function listBlockCol() { return col + 2 } + function listBlockWidth() { return w - 4 } // inner content width incl. borders + function listContentRow(i) { return listBlockTopRow() + 1 + (i - listScroll) } + function listContentCol() { return listBlockCol() + 1 } + function listScrollbarNeeded() { + if (!hasList) return false + if (list.showScrollbar != null) return list.showScrollbar + return listItems.length > listHeight + } + function listContentInnerW() { + return listBlockWidth() - 2 - (listScrollbarNeeded() ? 1 : 0) + } + function buttonRegions() { let bx = col + Math.floor((w - btnRowW) / 2) return buttons.map(b => { @@ -293,7 +408,7 @@ function showDialog(opts) { con.move(r, col) print(' '.repeat(w)) } - const wo = new WindowObject(col, row, w, h, ()=>{}, ()=>{}, title) + const wo = new WindowObject(col, row, w, h, ()=>{}, ()=>{}, title, opts.drawFrame) wo.isHighlighted = true wo.titleBack = bg wo.drawFrame() @@ -348,9 +463,83 @@ function showDialog(opts) { con.color_pair(fg, bg) } + function drawList() { + if (!hasList) return + const lbCol = listBlockCol() + const lbRow = listBlockTopRow() + const lw = listBlockWidth() + const innerW = listContentInnerW() + const focused = (focusIdx === listFocusIdx) + const frameFg = focused ? fg : dimFg + const sbar = listScrollbarNeeded() + + // Top border (drawField style) + con.color_pair(listBgColour, bg) + con.move(lbRow, lbCol) + print('\u00EC' + '\u00A9'.repeat(lw - 2) + '\u00ED') + + // Side borders + rows + for (let r = 0; r < listHeight; r++) { + con.color_pair(listBgColour, bg) + con.move(lbRow + 1 + r, lbCol) + print('\u00AB') + con.move(lbRow + 1 + r, lbCol + lw - 1) + print('\u00AA') + + const idx = listScroll + r + con.move(lbRow + 1 + r, lbCol + 1) + if (idx >= listItems.length) { + con.color_pair(fg, listBgColour) + print(' '.repeat(innerW)) + continue + } + const it = listItems[idx] + const isCursor = (idx === listCursor) + const ctx = { + y: lbRow + 1 + r, + x: lbCol + 1, + w: innerW, + item: it, + idx: idx, + isCursor: isCursor, + focused: focused, + listBg: listBgColour, + selBg: listSelBg, + fg: fg, + hlFg: hlFg, + dimFg: dimFg, + } + if (list.renderItem) { + list.renderItem(ctx) + } else { + const useFg = (isCursor && focused) ? hlFg : fg + const useBg = (isCursor && focused) ? listSelBg : listBgColour + con.color_pair(useFg, useBg) + const label = (it.label || '').substring(0, innerW - 1) + print(' ' + label.padEnd(innerW - 1, ' ')) + } + + // Scrollbar column + if (sbar) { + con.color_pair(dimFg, listBgColour) + con.move(lbRow + 1 + r, lbCol + lw - 2) + const maxScroll = Math.max(1, listItems.length - listHeight) + const indPos = (maxScroll <= 0) ? 0 : ((listScroll * (listHeight - 1) / maxScroll) | 0) + let trough = (r === 0) ? 0xBA : (r === listHeight - 1) ? 0xBC : 0xBB + con.addch(r === indPos ? (trough + 3) : trough) + } + } + + // Bottom border + con.color_pair(listBgColour, bg) + con.move(lbRow + 1 + listHeight, lbCol) + print('\u00F4' + '\u00AC'.repeat(lw - 2) + '\u00F5') + con.color_pair(fg, bg) + } + function drawButton(i, regions) { const b = buttons[i] - const bIdx = fields.length + i + const bIdx = buttonsFocusBase + i const focused = (focusIdx === bIdx) const r = regions[i] const useFg = focused ? hlFg : fg @@ -381,10 +570,59 @@ function showDialog(opts) { } } + function ensureListCursorVisible() { + if (!hasList) return + if (listCursor < 0) return + if (listCursor < listScroll) listScroll = listCursor + else if (listCursor >= listScroll + listHeight) listScroll = listCursor - listHeight + 1 + const maxScroll = Math.max(0, listItems.length - listHeight) + if (listScroll > maxScroll) listScroll = maxScroll + if (listScroll < 0) listScroll = 0 + } + + function scrollListBy(dir) { + const maxScroll = Math.max(0, listItems.length - listHeight) + let s = listScroll + dir + if (s < 0) s = 0 + if (s > maxScroll) s = maxScroll + listScroll = s + } + + function moveListCursor(dir) { + if (!hasList || listItems.length === 0) return + // Scroll the view when nothing in the list is selectable (e.g. a help text body). + if (listCursor < 0) { scrollListBy(dir); return } + let next = listCursor + for (let n = 0; n < listItems.length; n++) { + next += dir + if (next < 0 || next >= listItems.length) return + if (listSelectable(listItems[next], next)) { + listCursor = next + ensureListCursorVisible() + return + } + } + } + + function pageListCursor(dir) { + if (!hasList || listItems.length === 0) return + if (listCursor < 0) { scrollListBy(dir * listHeight); return } + let target = listCursor + dir * listHeight + if (target < 0) target = 0 + if (target >= listItems.length) target = listItems.length - 1 + // Snap to nearest selectable + let probe = target + const step = dir < 0 ? -1 : 1 + while (probe >= 0 && probe < listItems.length && !listSelectable(listItems[probe], probe)) probe += step + if (probe < 0 || probe >= listItems.length) probe = firstSelectable(target, -step) + if (probe >= 0) { listCursor = probe; ensureListCursorVisible() } + } + function render() { drawFrameBox() drawMessage() for (let i = 0; i < fields.length; i++) drawField(i) + drawList() const regs = buttonRegions() for (let i = 0; i < buttons.length; i++) drawButton(i, regs) positionCaret() @@ -396,7 +634,32 @@ function showDialog(opts) { } function activateButton(i) { - done = { action: buttons[i].action, values: values.slice() } + done = { + action: buttons[i].action, + values: values.slice(), + listCursor: listCursor, + listItem: (hasList && listCursor >= 0) ? listItems[listCursor] : null, + } + } + + function activateListItem(idx, key) { + if (!hasList || !listOnActivate) return false + if (idx < 0 || idx >= listItems.length) return false + if (!listSelectable(listItems[idx], idx)) return false + const result = listOnActivate(listItems[idx], idx, key) + if (result == null) { + // Callback consumed the event but kept the dialog open (e.g. radio + // toggle); reflect any state changes it made. + render() + return true + } + done = { + action: result, + values: values.slice(), + listCursor: idx, + listItem: listItems[idx], + } + return true } function hitTestMouse(ev) { @@ -411,9 +674,42 @@ function showDialog(opts) { 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 } } + if (hasList) { + const lbRow = listBlockTopRow() + const lbCol = listBlockCol() + const innerW = listContentInnerW() + if (cy > lbRow && cy <= lbRow + listHeight && cx >= lbCol + 1 && cx < lbCol + 1 + innerW) { + const r = cy - (lbRow + 1) + const idx = listScroll + r + if (idx >= 0 && idx < listItems.length) return { kind: 'list', idx: idx } + } + if (cy > lbRow && cy <= lbRow + listHeight && cx >= lbCol && cx < lbCol + listBlockWidth()) { + return { kind: 'listblank' } + } + } return null } + const externalCtx = { + render: () => render(), + close: (result) => { + done = Object.assign({ + action: 'cancel', + values: values.slice(), + listCursor: listCursor, + listItem: (hasList && listCursor >= 0) ? listItems[listCursor] : null, + }, result || {}) + }, + getListCursor: () => listCursor, + setListCursor: (n) => { + if (!hasList) return + if (n < 0 || n >= listItems.length) return + listCursor = n + ensureListCursorVisible() + }, + } + + ensureListCursorVisible() render() let eventJustReceived = true @@ -426,7 +722,7 @@ function showDialog(opts) { if (ev[0] === 'mouse_move') { const hit = hitTestMouse(ev) if (hit && hit.kind === 'button') { - const newFocus = fields.length + hit.idx + const newFocus = buttonsFocusBase + hit.idx if (newFocus !== focusIdx) { focusIdx = newFocus render() @@ -439,7 +735,7 @@ function showDialog(opts) { const hit = hitTestMouse(ev) if (!hit) return if (hit.kind === 'button') { - focusIdx = fields.length + hit.idx + focusIdx = buttonsFocusBase + hit.idx render() activateButton(hit.idx) return @@ -451,19 +747,76 @@ function showDialog(opts) { const newCur = s + (hit.cx - hit.region.x) cursors[hit.idx] = Math.min(values[hit.idx].length, Math.max(0, newCur)) render() + return + } + if (hit.kind === 'list') { + focusIdx = listFocusIdx + if (listSelectable(listItems[hit.idx], hit.idx)) { + listCursor = hit.idx + ensureListCursorVisible() + render() + if (activateListItem(hit.idx, 'click')) return + } else { + render() + } + return + } + if (hit.kind === 'listblank') { + focusIdx = listFocusIdx + render() + return } return } + if (ev[0] === 'mouse_wheel' && hasList) { + const hit = hitTestMouse(ev) + if (!hit || (hit.kind !== 'list' && hit.kind !== 'listblank')) return + const dy = (ev[3] | 0) * 3 + const maxScroll = Math.max(0, listItems.length - listHeight) + let next = listScroll + dy + if (next < 0) next = 0 + if (next > maxScroll) next = maxScroll + if (next !== listScroll) { listScroll = next; render() } + return + } if (ev[0] !== 'key_down') return - if (1 !== ev[2]) return + if (opts.disableKeyRepeat && 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 (opts.onKey && opts.onKey(ks, shiftDown, externalCtx)) return + + if (ks === '') { + done = { + action: 'cancel', + values: values.slice(), + listCursor: listCursor, + listItem: (hasList && listCursor >= 0) ? listItems[listCursor] : null, + } + return + } if (ks === '\t' || ks === '') { moveFocus(shiftDown ? -1 : 1); return } - if (ks === '') { moveFocus(-1); return } - if (ks === '') { moveFocus(+1); return } + + // Vertical movement: arrows operate within the list when it has focus. + if (ks === '') { + if (focusIdx === listFocusIdx) { moveListCursor(-1); render() } + else moveFocus(-1) + return + } + if (ks === '') { + if (focusIdx === listFocusIdx) { moveListCursor(+1); render() } + else moveFocus(+1) + return + } + if (ks === '') { + if (focusIdx === listFocusIdx) { pageListCursor(-1); render() } + return + } + if (ks === '') { + if (focusIdx === listFocusIdx) { pageListCursor(+1); render() } + return + } if (ks === '') { if (focusIdx < fields.length) { @@ -479,16 +832,28 @@ function showDialog(opts) { } if (ks === '') { if (focusIdx < fields.length) { cursors[focusIdx] = 0; render() } + else if (focusIdx === listFocusIdx) { + const t = firstSelectable(0, +1) + if (t >= 0) { listCursor = t; ensureListCursorVisible(); render() } + else { listScroll = 0; render() } + } return } if (ks === '') { if (focusIdx < fields.length) { cursors[focusIdx] = values[focusIdx].length; render() } + else if (focusIdx === listFocusIdx) { + const t = firstSelectable(listItems.length - 1, -1) + if (t >= 0) { listCursor = t; ensureListCursorVisible(); render() } + else { listScroll = Math.max(0, listItems.length - listHeight); render() } + } return } if (focusIdx < fields.length) { if (ks === '\n') { - focusIdx = (focusIdx < fields.length - 1) ? focusIdx + 1 : fields.length + if (focusIdx < fields.length - 1) focusIdx = focusIdx + 1 + else if (hasList) focusIdx = listFocusIdx + else focusIdx = buttonsFocusBase render() return } @@ -513,7 +878,10 @@ function showDialog(opts) { } 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 cap = fields[focusIdx].maxLength != null + ? fields[focusIdx].maxLength + : fields[focusIdx].width * 4 + if (code >= 32 && code < 256 && values[focusIdx].length < cap) { const v = values[focusIdx] const cur = cursors[focusIdx] values[focusIdx] = v.substring(0, cur) + ks + v.substring(cur) @@ -522,12 +890,33 @@ function showDialog(opts) { } return } + } else if (focusIdx === listFocusIdx) { + if (ks === '\n' || ks === ' ') { + if (listCursor >= 0 && activateListItem(listCursor, ks)) return + } } else { - if (ks === '\n' || ks === ' ') { activateButton(focusIdx - fields.length); return } + if (ks === '\n' || ks === ' ') { activateButton(focusIdx - buttonsFocusBase); return } } }) } + // Modal-dialog convention: wait for the user to release whatever key closed + // the dialog before handing control back. TVDOS's input strobo + // (TVDOS.SYS:input.withEvent) keeps re-firing `key_down` for a held key + // once its ~250 ms initial-press delay elapses; without this drain a brief + // hold on Enter inside a popup would surface as a fresh Enter to whatever + // the popup was covering, e.g. activating the file under zfm's More menu. + // A mouse close (or any path with no key held) leaves the head key at 0 + // and skips the wait. + sys.poke(-40, 255) + const heldHead = sys.peek(-41) + if (heldHead !== 0) { + while (true) { + input.withEvent(() => {}) + if (sys.peek(-41) !== heldHead) break + } + } + con.curs_set(0) con.color_pair(oldFG, oldBG) return done