From e27a01dca6f0591a7d9fef47bd4c8cf62ed68705 Mon Sep 17 00:00:00 2001 From: minjaesong Date: Thu, 4 Jun 2026 01:16:56 +0900 Subject: [PATCH] command.js: autocomplete by candidate window --- assets/disk0/tvdos/bin/command.js | 221 +++++++++++++++++++++++ assets/disk0/tvdos/bin/taut.js | 14 +- assets/disk0/tvdos/bin/tautfont.kra | 4 +- assets/disk0/tvdos/bin/tautfont_high.chr | Bin 1920 -> 1920 bytes assets/disk0/tvdos/include/wintex.mjs | 19 +- 5 files changed, 252 insertions(+), 6 deletions(-) diff --git a/assets/disk0/tvdos/bin/command.js b/assets/disk0/tvdos/bin/command.js index e9143de..1c9d80e 100644 --- a/assets/disk0/tvdos/bin/command.js +++ b/assets/disk0/tvdos/bin/command.js @@ -985,6 +985,180 @@ shell.removePipe = function() { Object.freeze(shell) _G.shell = shell +/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// TAB AUTOCOMPLETION +// +// Invoked by TAB at the interactive prompt. Only active when BOTH: +// 1. wintex.mjs is available (provides the selection popup), AND +// 2. goFancy == true. +// One candidate -> expand immediately (no popup). +// Many candidates -> wintex popup; user scrolls and selects, or Esc/Cancel to +// discard. The popup over-draws the screen without saving +// what was beneath it, so we snapshot the text plane before +// and copy it back after (the shell can't just redraw like a +// full-screen TUI — there's scrollback above the prompt). +/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// Lazily-resolved wintex module. undefined = not probed yet, null = unavailable. +let _acWin = undefined +function getAutocompleteWin() { + if (_acWin !== undefined) return _acWin + _acWin = null + try { + let w = require("wintex") // resolved through INCLPATH (\tvdos\include\wintex.mjs) + if (w && typeof w.showDialog === "function") _acWin = w + } catch (e) { + debugprintln("command.js > autocomplete: wintex unavailable: " + e) + } + return _acWin +} + +// List a directory's entries, swallowing any IO error. +function _acListDir(fullPath) { + try { + let f = files.open(fullPath) + if (!f.exists || !f.isDirectory) return [] + return f.list() || [] + } catch (e) { return [] } +} + +// Strip a trailing PATHEXT extension so command names show without ".js" etc. +function _acStripExt(name) { + let lower = name.toLowerCase() + let exts = (_TVDOS.variables.PATHEXT || "").split(';').filter(function(e){ return e.length > 0 }) + for (let i = 0; i < exts.length; i++) { + let e = exts[i].toLowerCase() + if (lower.endsWith(e)) return name.substring(0, name.length - e.length) + } + return name +} + +// Candidates for the command position (first word, no path separators): +// shell built-ins + runnable files found along the current dir, drive root and PATH. +function _acCommandCandidates(prefix) { + let lower = prefix.toLowerCase() + let seen = {} + let out = [] + function add(name) { + let k = name.toLowerCase() + if (seen[k]) return + seen[k] = true + out.push({ label: name, value: name + ' ', isDir: false }) + } + + // shell built-ins (and their aliases) + Object.keys(shell.coreutils).forEach(function(k) { + if (k.toLowerCase().startsWith(lower)) add(k) + }) + + // runnable files: search the same places shell.execute does, in the same order + let exts = (_TVDOS.variables.PATHEXT || "").split(';') + .filter(function(e){ return e.length > 0 }).map(function(e){ return e.toLowerCase() }) + let dirFulls = [shell.resolvePathInput('.').full] // current directory first + _TVDOS.getPath().forEach(function(d) { + dirFulls.push((d === '' || d === undefined) ? `${CURRENT_DRIVE}:\\` : shell.resolvePathInput(d).full) + }) + dirFulls.forEach(function(full) { + _acListDir(full).forEach(function(it) { + if (it.isDirectory) return + let nameLower = (it.name || '').toLowerCase() + if (!exts.some(function(e){ return nameLower.endsWith(e) })) return // only runnables + let stripped = _acStripExt(it.name) + if (stripped.toLowerCase().startsWith(lower)) add(stripped) + }) + }) + return out +} + +// Candidates for a path argument. The word may carry a directory prefix +// (kept verbatim) and a partial basename that we match against the directory. +function _acPathCandidates(word) { + let sepIdx = Math.max(word.lastIndexOf('\\'), word.lastIndexOf('/')) + let dirPart, basePart, listArg + if (sepIdx >= 0) { + dirPart = word.substring(0, sepIdx + 1) // includes the trailing separator + basePart = word.substring(sepIdx + 1) + listArg = dirPart + } else { + dirPart = '' + basePart = word + listArg = '.' + } + let resolved = shell.resolvePathInput(listArg) + if (resolved === undefined) return [] + let sep = (dirPart.length > 0 && dirPart.charAt(dirPart.length - 1) === '/') ? '/' : '\\' + let lower = basePart.toLowerCase() + let out = [] + _acListDir(resolved.full).forEach(function(it) { + let name = it.name || '' + if (!name.toLowerCase().startsWith(lower)) return + out.push({ + // directories get a trailing separator so completion can continue into them; + // files get a trailing space so the next argument can be typed straight away. + label: name + (it.isDirectory ? '\\' : ''), + value: dirPart + name + (it.isDirectory ? sep : ' '), + isDir: it.isDirectory + }) + }) + return out +} + +// Work out what is being completed at `caret` within `line`. +// Returns { wordStart, word, candidates } (candidates sorted by label). +function computeCompletion(line, caret) { + let wordStart = caret + while (wordStart > 0 && line.charAt(wordStart - 1) !== ' ') wordStart -= 1 + let word = line.substring(wordStart, caret) + let isFirstWord = (line.substring(0, wordStart).trim().length === 0) + let hasPathSep = (word.indexOf('\\') >= 0 || word.indexOf('/') >= 0 || word.indexOf(':') >= 0) + let candidates = (isFirstWord && !hasPathSep) ? _acCommandCandidates(word) : _acPathCandidates(word) + candidates.sort(function(a, b) { return (a.label < b.label) ? -1 : (a.label > b.label) ? 1 : 0 }) + return { wordStart: wordStart, word: word, candidates: candidates } +} + +// --- text-plane snapshot/restore (so the popup leaves no artefacts) --------- +// In a vtmgr pane the shimmed con/print draw into the pane buffer +// (globalThis.VT_TEXT_PLANE, forward layout); on the physical console they +// draw into the GPU text area (mapped at getGpuMemBase()-253950). vaddr(0) is +// that base in either case; sys.memcpy reads/writes it forward-native. +// NOTE: 7681, not the full 7682-byte text area: relPtrInDev() bounds-checks +// `from+len` inclusively, so the final byte (bottom-right char cell, never +// touched by a centred popup) is unreachable by a single memcpy. +const _AC_TEXTAREA_BYTES = 7681 +let _acTextBase = null +let _acScratchPtr = 0 +function _acTextAreaBase() { + if (_acTextBase === null) { + _acTextBase = (typeof globalThis.VT_TEXT_PLANE !== 'undefined') + ? globalThis.VT_TEXT_PLANE + : (graphics.getGpuMemBase() - 253950) + } + return _acTextBase +} +function _acSnapshotScreen() { + if (_acScratchPtr === 0) _acScratchPtr = sys.malloc(_AC_TEXTAREA_BYTES) + sys.memcpy(_acTextAreaBase(), _acScratchPtr, _AC_TEXTAREA_BYTES) +} +function _acRestoreScreen() { + if (_acScratchPtr === 0) return + sys.memcpy(_acScratchPtr, _acTextAreaBase(), _AC_TEXTAREA_BYTES) +} + +// Modal popup of candidates. Returns the chosen item, or null if discarded. +function _acShowPopup(win, candidates) { + let res = win.showDialog({ + title: `Complete (${candidates.length})`, + list: { + items: candidates, + height: Math.min(12, candidates.length), + onActivate: function(item, idx, key) { return 'select' } + }, + buttons: [{ label: 'Cancel', action: 'cancel' }] + }) + if (res && res.action === 'select' && res.listItem) return res.listItem + return null +} + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // ensure USERCONFIGPATH directory exists @@ -1080,6 +1254,49 @@ if (goInteractive) { refresh(0, Math.max(0, oldLen - cmdbuf.length)) } + // Replace the word [wordStart, caret) with `value`, keeping any text to + // the right of the caret, then reprint the line from `wordStart`. + function applyCompletion(wordStart, value) { + let oldLen = cmdbuf.length + cmdbuf = cmdbuf.substring(0, wordStart) + value + cmdbuf.substring(caret) + caret = wordStart + value.length + con.color_pair(shell.usrcfg.textCol, 255) + refresh(wordStart, Math.max(0, oldLen - cmdbuf.length)) + } + + // TAB handler. No-op unless fancy mode is on and wintex is installed. + function tryAutocomplete() { + if (!goFancy) return + let win = getAutocompleteWin() + if (!win) return + + let comp = computeCompletion(cmdbuf, caret) + let cands = comp.candidates + if (cands.length === 0) return + if (cands.length === 1) { applyCompletion(comp.wordStart, cands[0].value); return } + + _acSnapshotScreen() + let chosen = _acShowPopup(win, cands) + _acRestoreScreen() + + // The popup drives input through input.withEvent (physical held-key + // state), which bypasses the buffer con.getch reads. Inside a vtmgr + // pane the dispatcher keeps draining physical keystrokes into this + // pane's input ring the whole time the popup is open, so the navigation + // keys (and the closing Enter) would otherwise surface as phantom input + // afterwards. Flush them. (On the physical console readKey self-clears, + // so this is harmless there.) + con.resetkeybuf() + + // The popup hid the caret and clobbered colours; restore the prompt + // editing state. The screen content is already back from the snapshot. + con.curs_set(1) + con.color_pair(shell.usrcfg.textCol, 255) + gotoCaret() + + if (chosen) applyCompletion(comp.wordStart, chosen.value) + } + while (true) { let key = con.getch() @@ -1092,6 +1309,10 @@ if (goInteractive) { if (atEnd) print(s) // fast path: simple append else refresh(caret - 1, 0) } + // TAB: autocomplete (fancy mode + wintex only; otherwise a no-op) + else if (key === con.KEY_TAB) { + tryAutocomplete() + } // backspace: delete the char to the left of the caret else if (key === con.KEY_BACKSPACE && caret > 0) { cmdbuf = cmdbuf.substring(0, caret - 1) + cmdbuf.substring(caret) diff --git a/assets/disk0/tvdos/bin/taut.js b/assets/disk0/tvdos/bin/taut.js index 9978804..4228750 100644 --- a/assets/disk0/tvdos/bin/taut.js +++ b/assets/disk0/tvdos/bin/taut.js @@ -1042,6 +1042,7 @@ const colVoiceHdr = 230 const colVoiceHdrMuted = 249 const colVoiceHdrMutedCursorUp = 180 const colSep = 252 +const colScrollBar = 249 const colPushBtnBack = 143 const colTabBarBack = 187 const colTabBarBack2 = 136 @@ -3282,7 +3283,7 @@ function drawSamplesListColumn() { const indPos = (maxScroll === 0) ? 0 : ((smpListScroll * (SMP_LIST_H - 1) / maxScroll) | 0) for (let r = 0; r < SMP_LIST_H; r++) { con.move(SMP_LIST_Y + r, SMP_LIST_SCROLL_X) - con.color_pair(colStatus, colSmpListBg) + con.color_pair(colScrollBar, colSmpListBg) let scrollChar = (r == 0) ? sym.taut_scrollgutter_top : (r == SMP_LIST_H - 1) ? sym.taut_scrollgutter_bot : sym.taut_scrollgutter_mid if (r == indPos) scrollChar += 3; @@ -3899,7 +3900,7 @@ function drawInstrumentsListColumn() { const indPos = (maxScroll === 0) ? 0 : ((instListScroll * (INST_LIST_H - 1) / maxScroll) | 0) for (let r = 0; r < INST_LIST_H; r++) { con.move(INST_LIST_Y + r, INST_LIST_SCROLL_X) - con.color_pair(colStatus, colInstListBg) + con.color_pair(colScrollBar, colInstListBg) let scrollChar = (r == 0) ? sym.taut_scrollgutter_top : (r == INST_LIST_H - 1) ? sym.taut_scrollgutter_bot : sym.taut_scrollgutter_mid if (r == indPos) scrollChar += 3; @@ -5144,6 +5145,7 @@ function openHelpPopup() { bg: colPopupBack, height: HELP_CONTENT_H, width: HELP_CONTENT_W+4, + scrollbarChars: popupScrollbarChars, selectable: () => false, renderItem: (ctx) => { con.color_pair(colText, ctx.listBg) @@ -5188,6 +5190,12 @@ const popupDrawFrame = (wo) => { } } +// Taut's charset carries dedicated scrollbar glyphs at 0xBA..0xBF (empty +// top/mid/bottom caps 0xBA..0xBC, filled top/mid/bottom thumb 0xBD..0xBF). +// wintex defaults to the CP437-safe 0xBA/0xDB pair, so pass these to every +// list popup to render the scrollbar in taut's style. +const popupScrollbarChars = [0xBA, 0xBB, 0xBC, 0xBD, 0xBE, 0xBF] + // Standard colour palette shared by every taut popup so wintex's defaults blend // with taut's popup chrome. const popupColours = { @@ -5302,6 +5310,7 @@ function openRetunePopup() { height: listH, width: 36, cursor: selIdx, + scrollbarChars: popupScrollbarChars, renderItem: (ctx) => { const e = ctx.item.preset const isCur = (e.index === PITCH_PRESET_IDX) @@ -5373,6 +5382,7 @@ function openFlagsPopup() { width: 22, drawWell: false, showScrollbar: false, + scrollbarChars: popupScrollbarChars, selectable: (it) => it.kind === 'tone' || it.kind === 'intp', renderItem: (ctx) => { const it = ctx.item diff --git a/assets/disk0/tvdos/bin/tautfont.kra b/assets/disk0/tvdos/bin/tautfont.kra index 0541eab..e76b67c 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:dc60baf6cdfdb18af2880e7f1a0ef6f9e9509b9428e4c433b34dc86bfa05ab7e -size 147300 +oid sha256:6ac39b9969061999886544b0b5c59934aa2c123e9164c8b273ff9b4224be79a3 +size 147030 diff --git a/assets/disk0/tvdos/bin/tautfont_high.chr b/assets/disk0/tvdos/bin/tautfont_high.chr index dbabef881e6cb53dcb129d744c755d56f32468f3..8ec9cc89baa74d72e3643858be698485b1a8b1da 100644 GIT binary patch delta 53 hcmZqRZ{Xh$&a7cq4+pW1j= 6) + ? list.scrollbarChars + : [0xBA, 0xBA, 0xBA, 0xDB, 0xDB, 0xDB] function firstSelectable(from, dir) { if (!hasList || listItems.length === 0) return -1 let i = from @@ -531,8 +544,10 @@ function showDialog(opts) { 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) + // seg: 0 = top cap, 1 = middle, 2 = bottom cap; +3 selects the + // filled (thumb) variant over the empty (trough) one. + const seg = (r === 0) ? 0 : (r === listHeight - 1) ? 2 : 1 + con.addch(listScrollbarChars[(r === indPos) ? seg + 3 : seg]) } }