/** * WinTex — TUI window management and renderer * @author CuriousTorvald */ class WindowObject { constructor(x, y, w, h, inputProcessor, drawContents, title, drawFrame) { this.isHighlighted = false this.x = x|0 this.y = y|0 this.width = w|0 this.height = h|0 this.inputProcessorFun = inputProcessor this.drawContentsFun = drawContents this.title = title this.titleLeft = undefined this.titleRight = undefined this.titleBack = 0 // default value this.titleBackLeft = 245 // default value this.titleBackRight = 245 // default value this.drawFrameFun = drawFrame || (() => { let oldFore = con.get_color_fore() let oldBack = con.get_color_back() let charset = (this.isHighlighted) ? [0xC9, 0xBB, 0xC8, 0xBC, 0xCD, 0xBA, 0xB5, 0xC6] : [0xDA, 0xBF, 0xC0, 0xD9, 0xC4, 0xB3, 0xB4, 0xC3] let colour = (this.isHighlighted) ? 230 : 253 let colourText = (this.isHighlighted) ? 230 : 254 // set fore colour print(`\x1B[38;5;${colour}m`) // draw top horz con.mvaddch(this.y, this.x, charset[0]); con.curs_right() print(`\x84${charset[4]}u`.repeat(this.width - 2)) con.addch(charset[1]) // draw vert for (let yp = this.y + 1; yp < this.y + this.height - 1; yp++) { con.mvaddch(yp, this.x , charset[5]) con.mvaddch(yp, this.x + this.width - 1, charset[5]) } // draw bottom horz con.mvaddch(this.y + this.height - 1, this.x, charset[2]); con.curs_right() print(`\x84${charset[4]}u`.repeat(this.width - 2)) con.addch(charset[3]) // draw title if (this.title !== undefined) { let tt = ''+this.title con.move(this.y, this.x + ((this.width - 2 - tt.length) >>> 1)) if (this.titleBack !== undefined) print(`\x1B[48;5;${this.titleBack}m`) print(`\x84${charset[6]}u`) print(`\x1B[38;5;${colourText}m${tt}`) print(`\x1B[38;5;${colour}m\x84${charset[7]}u`) if (this.titleBack !== undefined) print(`\x1B[48;5;${oldBack}m`) } if (this.titleLeft !== undefined) { let tt = ''+this.titleLeft con.move(this.y, this.x) print(`\x84${charset[0]}u`) if (this.titleBackLeft !== undefined) print(`\x1B[48;5;${this.titleBackLeft}m`) print(`\x1B[38;5;${colourText}m`);print(tt) if (this.titleBackLeft !== undefined) print(`\x1B[48;5;${oldBack}m`) print(`\x1B[38;5;${colour}m`);print(`\x84${charset[4]}u`) } if (this.titleRight !== undefined) { let tt = ''+this.titleRight con.move(this.y + this.height - 1, this.x + this.width - tt.length - 2) print(`\x84${charset[4]}u`) if (this.titleBackRight !== undefined) print(`\x1B[48;5;${this.titleBackRight}m`) print(`\x1B[38;5;${colourText}m${tt}`) if (this.titleBackRight !== undefined) print(`\x1B[48;5;${oldBack}m`) print(`\x1B[38;5;${colour}m\x84${charset[3]}u`) } // restore fore colour print(`\x1B[38;5;${oldFore}m`) print(`\x1B[48;5;${oldBack}m`) }) } drawContents() { this.drawContentsFun(this) } drawFrame() { this.drawFrameFun(this) } processInput(event) { this.inputProcessorFun(this, event) } } /** * @param dy cursor change (positive or negative) * @param listSize size of the list to scroll * @param listHeight size of the list window * @param currentCursorPos ABSOLUTE position of the cursor * @param currentScrollPos current scroll position of the list * @param scrollPeek size of the scroll "peek" * @return [new cursor pos, new scroll pos] */ function scrollVert(dy, listSize, listHeight, currentCursorPos, currentScrollPos, scrollPeek) { // clamp dy if (currentCursorPos + dy > listSize - 1) dy = (listSize - 1) - currentCursorPos else if (currentCursorPos + dy < 0) dy = -currentCursorPos let nextRow = currentCursorPos + dy // update vertical scroll stats if (dy != 0) { let visible = listHeight - 1 - scrollPeek if (nextRow - currentScrollPos > visible) { currentScrollPos = nextRow - visible } else if (nextRow - currentScrollPos < 0 + scrollPeek) { currentScrollPos = nextRow - scrollPeek // nextRow is less than zero } // NOTE: future-proofing here -- scroll clamping is moved outside of go-up/go-down // if-statements above because horizontal movements can disrupt vertical scrolls as well because // "normally" when you go right at the end of the line, you appear at the start of the next line // scroll to the bottom? if (listSize > listHeight && currentScrollPos > listSize - listHeight) // to make sure not show buncha empty lines currentScrollPos = listSize - listHeight // scroll to the top? (order is important!) if (currentScrollPos <= -1) currentScrollPos = 0 // scroll of -1 would result to show "Line 0" on screen } // move editor cursor currentCursorPos = nextRow return [currentCursorPos, currentScrollPos] } /** * @param dx cursor change (positive or negative) * @param stringSize length of the string to scroll * @param stringViewSize size of the string view * @param currentCursorPos ABSOLUTE position of the cursor * @param currentScrollPos current scroll position of the list * @param scrollPeek size of the scroll "peek" * @return [new cursor pos, new scroll pos] */ function scrollHorz(dx, stringSize, stringViewSize, currentCursorPos, currentScrollPos, scrollPeek) { // clamp dx if (currentCursorPos + dx > stringSize - 1) dx = (stringSize - 1) - currentCursorPos else if (currentCursorPos + dx < 0) dx = -currentCursorPos let nextCol = currentCursorPos + dx // update vertical scroll stats if (dx != 0) { let visible = stringViewSize - 1 - scrollPeek if (nextCol - currentScrollPos > visible) { currentScrollPos = nextCol - visible } else if (nextCol - currentScrollPos < 0 + scrollPeek) { currentScrollPos = nextCol - scrollPeek // nextCol is less than zero } // NOTE: future-proofing here -- scroll clamping is moved outside of go-up/go-down // if-statements above because horizontal movements can disrupt vertical scrolls as well because // "normally" when you go right at the end of the line, you appear at the start of the next line // scroll to the bottom? if (stringSize > stringViewSize && currentScrollPos > stringSize - stringViewSize) // to make sure not show buncha empty lines currentScrollPos = stringSize - stringViewSize // scroll to the top? (order is important!) if (currentScrollPos <= -1) currentScrollPos = 0 // scroll of -1 would result to show "Line 0" on screen } // move editor cursor currentCursorPos = nextCol return [currentCursorPos, currentScrollPos] } // --------------------------------------------------------------------------- // Modal dialog with multiple input fields and OK/Cancel-style buttons. // // opts = { // title: string, // message: string | string[]? -- optional body text drawn above fields // fields: [{label, initial?, width}, ...] -- omit / [] for no input field. Label does NOT get auto-colon // buttons: [{label, action, default?}, ...] -- defaults to [OK, Cancel] (+ Delete // if `allowDelete:true`) // allowDelete: bool, -- inserts a Delete button (fsh compat) // colours: {fg?, bg?, fieldBg?, dimFg?, hlFg?, focusBg?} -- per-call overrides // } // // Returns {action, values}: `action` is the chosen button's `action` // (default "ok"/"cancel"/"delete"), or "cancel" on Esc; `values` is the array // of field strings in field order. // // Behaviour: // - Tab / Shift+Tab and arrow Down / Up cycle focus across fields and buttons. // - Left / Right inside a field move the caret; on a button they cycle focus. // - Home / End jump to start / end of the focused field. // - Enter on a field jumps to the next field, then to the first button. Enter // or Space on a button activates it. // - Insert at caret. Backspace deletes left of caret; Forward-Del deletes right. // - Blinking caret (`con.curs_set(1)`) is positioned on the focused field and // hidden when a button has focus. // - Mouse: left-click on a button activates it; click on a field puts focus // on that field and positions the caret under the click. Mouse hover on a // button 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 const _CELL_PH = (_dialogPixDim[1] / _dialogScreen[0]) | 0 function _pxToCell(px, py) { return [(py / _CELL_PH | 0) + 1, (px / _CELL_PW | 0) + 1] } function showDialog(opts) { const fields = opts.fields || [] const values = fields.map(f => (f.initial == null) ? '' : ('' + f.initial)) const cursors = values.map(v => v.length) let oldFG = con.get_color_fore() let oldBG = con.get_color_back() let buttons if (opts.buttons) { buttons = opts.buttons } else { buttons = [{label: 'OK', action: 'ok', default: true}] if (opts.allowDelete) buttons.push({label: 'Delete', action: 'delete'}) buttons.push({label: 'Cancel', action: 'cancel'}) } const title = opts.title || '' const message = opts.message const messageLines = !message ? [] : Array.isArray(message) ? message : ('' + message).split('\n') const c = opts.colours || {} const fg = (c.fg != null) ? c.fg : 254 const bg = (c.bg != null) ? c.bg : 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 // 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) 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 fieldsBlockH = fields.length * 4 const buttonsRowOff = 1 + msgRows + (fields.length > 0 ? fieldsBlockH + 1 : 1) const h = buttonsRowOff + 2 const screen = con.getmaxyx() const row = Math.max(2, Math.floor((screen[0] - h) / 2)) const col = Math.max(2, Math.floor((screen[1] - w) / 2)) // Pick initial focus: explicit default > first field > first button. let focusIdx = -1 for (let i = 0; i < buttons.length; i++) { if (buttons[i].default) { focusIdx = fields.length + i; break } } if (focusIdx < 0) focusIdx = fields.length > 0 ? 0 : fields.length const totalFocus = fields.length + buttons.length let done = null function fieldScroll(cur, fw) { return cur < fw ? 0 : cur - fw + 1 } function fieldLabelRow(i) { return row + 1 + msgRows + i * 4 } function fieldBoxRow(i) { return fieldLabelRow(i) + 1 } function fieldContentRow(i) { return fieldLabelRow(i) + 2 } function fieldBoxCol() { return col + 2 } function fieldContentRegion(i) { return { x: fieldBoxCol() + 1, y: fieldContentRow(i), w: fields[i].width } } function buttonRegions() { let bx = col + Math.floor((w - btnRowW) / 2) return buttons.map(b => { const r = { x: bx, y: row + buttonsRowOff, w: b.label.length + 4 } bx += b.label.length + 4 + buttonGap return r }) } function drawFrameBox() { con.color_pair(fg, bg) for (let r = row; r < row + h; r++) { con.move(r, col) print(' '.repeat(w)) } const wo = new WindowObject(col, row, w, h, ()=>{}, ()=>{}, title) wo.isHighlighted = true wo.titleBack = bg wo.drawFrame() con.color_pair(fg, bg) } function drawMessage() { if (messageLines.length === 0) return con.color_pair(fg, bg) for (let i = 0; i < messageLines.length; i++) { con.move(row + 1 + i, col + 2) print(messageLines[i].padEnd(w - 4, ' ')) } } function drawField(i) { const f = fields[i] const fbCol = fieldBoxCol() const fbRow = fieldBoxRow(i) const fw = f.width const focused = (focusIdx === i) const frameFg = focused ? fg : dimFg // Label con.color_pair(fg, bg) con.move(fieldLabelRow(i), fbCol) print(f.label) // Top border (3px padding w/ TSVM chr rom) con.color_pair(fieldBg, bg) con.move(fbRow, fbCol) print('\u00EC' + '\u00A9'.repeat(fw) + '\u00ED') // Left border (3px padding w/ TSVM chr rom) con.move(fbRow + 1, fbCol) print('\u00AB') // the content con.color_pair(fg, fieldBg) const s = fieldScroll(cursors[i], fw) const vis = values[i].substring(s, s + fw) print(vis.padEnd(fw, ' ')) // Right border (3px padding w/ TSVM chr rom) con.color_pair(fieldBg, bg) con.move(fbRow + 1, fbCol + fw + 1) print('\u00AA') // Bottom border (3px padding w/ TSVM chr rom) con.move(fbRow + 2, fbCol) print('\u00F4' + '\u00AC'.repeat(fw) + '\u00F5') con.color_pair(fg, bg) } function drawButton(i, regions) { const b = buttons[i] const bIdx = fields.length + i const focused = (focusIdx === bIdx) const r = regions[i] const useFg = focused ? hlFg : fg const useBg = focused ? focusBg : bg con.color_pair(useFg, useBg) con.move(r.y, r.x-1) if (focused) { con.color_pair(useBg, bg) print('\u00DE') con.color_pair(useFg, useBg) print('[ ' + b.label + ' ]') con.color_pair(useBg, bg) print('\u00DD') } else print(' [ ' + b.label + ' ] ') con.color_pair(fg, bg) } function positionCaret() { if (focusIdx < fields.length) { const fw = fields[focusIdx].width const s = fieldScroll(cursors[focusIdx], fw) con.move(fieldContentRow(focusIdx), fieldBoxCol() + 1 + (cursors[focusIdx] - s)) con.curs_set(1) } else { con.curs_set(0) } } function render() { drawFrameBox() drawMessage() for (let i = 0; i < fields.length; i++) drawField(i) const regs = buttonRegions() for (let i = 0; i < buttons.length; i++) drawButton(i, regs) positionCaret() } function moveFocus(dir) { focusIdx = (focusIdx + dir + totalFocus) % totalFocus render() } function activateButton(i) { done = { action: buttons[i].action, values: values.slice() } } function hitTestMouse(ev) { const cell = _pxToCell(ev[1], ev[2]) const cy = cell[0], cx = cell[1] const btnRegs = buttonRegions() for (let i = 0; i < btnRegs.length; i++) { const r = btnRegs[i] if (cy === r.y && cx >= r.x && cx < r.x + r.w) return { kind: 'button', idx: i } } for (let i = 0; i < fields.length; i++) { const r = fieldContentRegion(i) if (cy === r.y && cx >= r.x && cx < r.x + r.w) return { kind: 'field', idx: i, cx: cx, region: r } } return null } render() let eventJustReceived = true while (done === null) { input.withEvent(ev => { if (eventJustReceived && (ev[0] === 'key_down' || ev[0] === 'mouse_down')) { eventJustReceived = false; return } if (ev[0] === 'mouse_move') { const hit = hitTestMouse(ev) if (hit && hit.kind === 'button') { const newFocus = fields.length + hit.idx if (newFocus !== focusIdx) { focusIdx = newFocus render() } } return } if (ev[0] === 'mouse_down') { if (ev[3] !== 1) return const hit = hitTestMouse(ev) if (!hit) return if (hit.kind === 'button') { focusIdx = fields.length + hit.idx render() activateButton(hit.idx) return } if (hit.kind === 'field') { focusIdx = hit.idx const fw = fields[hit.idx].width const s = fieldScroll(cursors[hit.idx], fw) const newCur = s + (hit.cx - hit.region.x) cursors[hit.idx] = Math.min(values[hit.idx].length, Math.max(0, newCur)) render() } return } if (ev[0] !== 'key_down') return if (1 !== ev[2]) return const ks = ev[1] const shiftDown = (ev.includes(59) || ev.includes(60)) if (ks === '') { done = { action: 'cancel', values: values.slice() }; return } if (ks === '\t' || ks === '') { moveFocus(shiftDown ? -1 : 1); return } if (ks === '') { moveFocus(-1); return } if (ks === '') { moveFocus(+1); return } if (ks === '') { if (focusIdx < fields.length) { if (cursors[focusIdx] > 0) { cursors[focusIdx] -= 1; render() } } else moveFocus(-1) return } if (ks === '') { if (focusIdx < fields.length) { if (cursors[focusIdx] < values[focusIdx].length) { cursors[focusIdx] += 1; render() } } else moveFocus(+1) return } if (ks === '') { if (focusIdx < fields.length) { cursors[focusIdx] = 0; render() } return } if (ks === '') { if (focusIdx < fields.length) { cursors[focusIdx] = values[focusIdx].length; render() } return } if (focusIdx < fields.length) { if (ks === '\n') { focusIdx = (focusIdx < fields.length - 1) ? focusIdx + 1 : fields.length render() return } if (ks === '\x08') { const cur = cursors[focusIdx] if (cur > 0) { const v = values[focusIdx] values[focusIdx] = v.substring(0, cur - 1) + v.substring(cur) cursors[focusIdx] = cur - 1 render() } return } if (ks === '') { const cur = cursors[focusIdx] const v = values[focusIdx] if (cur < v.length) { values[focusIdx] = v.substring(0, cur) + v.substring(cur + 1) render() } return } if (typeof ks === 'string' && ks.length === 1) { const code = ks.charCodeAt(0) if (code >= 32 && code < 256 && values[focusIdx].length < fields[focusIdx].width * 4) { const v = values[focusIdx] const cur = cursors[focusIdx] values[focusIdx] = v.substring(0, cur) + ks + v.substring(cur) cursors[focusIdx] = cur + 1 render() } return } } else { if (ks === '\n' || ks === ' ') { activateButton(focusIdx - fields.length); return } } }) } con.curs_set(0) con.color_pair(oldFG, oldBG) return done } exports = { WindowObject, scrollVert, scrollHorz, showDialog }