more wintex and shuffling things around

This commit is contained in:
minjaesong
2026-05-26 10:42:21 +09:00
parent 5a25d394b9
commit 8d473c223c
5 changed files with 627 additions and 743 deletions

View File

@@ -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 === '<ESC>' || ks === '!' || ks === 'q') { done = true }
else if (ks === '<UP>') { if (scroll > 0) { scroll -= 1; scrollAndRepaint() } }
else if (ks === '<DOWN>') { if (scroll < maxScroll) { scroll += 1; scrollAndRepaint() } }
else if (ks === '<PAGE_UP>') { scroll = Math.max(0, scroll - HELP_CONTENT_H); scrollAndRepaint() }
else if (ks === '<PAGE_DOWN>') { scroll = Math.min(maxScroll, scroll + HELP_CONTENT_H); scrollAndRepaint() }
else if (ks === '<HOME>') { scroll = 0; scrollAndRepaint() }
else if (ks === '<END>') { 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 === '<TAB>') {
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 === '<ESC>') { 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 === '<ESC>' || 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 === '<ESC>') { 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 === '<UP>') {
if (sel > 0) { sel--; scroll = centerScroll(sel, scroll, listH, n); repaint() }
} else if (ks === '<DOWN>') {
if (sel < n - 1) { sel++; scroll = centerScroll(sel, scroll, listH, n); repaint() }
} else if (ks === '<HOME>') {
sel = 0; scroll = centerScroll(sel, scroll, listH, n); repaint()
} else if (ks === '<END>') {
sel = n - 1; scroll = centerScroll(sel, scroll, listH, n); repaint()
} else if (ks === '<PAGE_UP>') {
sel = Math.max(0, sel - listH); scroll = centerScroll(sel, scroll, listH, n); repaint()
} else if (ks === '<PAGE_DOWN>') {
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 === '<ESC>' || ks === 'q' || ks === 'Q') { done = true; return }
if (ks === '<UP>' && sel > 0) { sel--; repaint(); return }
if (ks === '<DOWN>' && 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) {

Binary file not shown.

View File

@@ -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 === '<ESC>') { done = { action: 'close' }; return }
if (ks === '\t' || ks === '<TAB>' || ks === '<DOWN>') { moveFocus(+1); return }
if (ks === '<UP>') { 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 }
}
///////////////////////////////////////////////////////////////////////////////////////////////////

View File

@@ -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 === '<ESC>') { done = { action: 'cancel', values: values.slice() }; return }
if (opts.onKey && opts.onKey(ks, shiftDown, externalCtx)) return
if (ks === '<ESC>') {
done = {
action: 'cancel',
values: values.slice(),
listCursor: listCursor,
listItem: (hasList && listCursor >= 0) ? listItems[listCursor] : null,
}
return
}
if (ks === '\t' || ks === '<TAB>') { moveFocus(shiftDown ? -1 : 1); return }
if (ks === '<UP>') { moveFocus(-1); return }
if (ks === '<DOWN>') { moveFocus(+1); return }
// Vertical movement: arrows operate within the list when it has focus.
if (ks === '<UP>') {
if (focusIdx === listFocusIdx) { moveListCursor(-1); render() }
else moveFocus(-1)
return
}
if (ks === '<DOWN>') {
if (focusIdx === listFocusIdx) { moveListCursor(+1); render() }
else moveFocus(+1)
return
}
if (ks === '<PAGE_UP>') {
if (focusIdx === listFocusIdx) { pageListCursor(-1); render() }
return
}
if (ks === '<PAGE_DOWN>') {
if (focusIdx === listFocusIdx) { pageListCursor(+1); render() }
return
}
if (ks === '<LEFT>') {
if (focusIdx < fields.length) {
@@ -479,16 +832,28 @@ function showDialog(opts) {
}
if (ks === '<HOME>') {
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 === '<END>') {
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