mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-06 05:28:31 +09:00
tsvm: more mouse operated stuffs
This commit is contained in:
@@ -49,13 +49,6 @@ _fsh.QA_CMD_WIDTH = 60 // command path field width in dialog
|
||||
_fsh.HL_FG = 230
|
||||
_fsh.HL_BG = 255
|
||||
|
||||
// Dialog colour pair. Background MUST be opaque (bg 255 is transparent
|
||||
// in TSVM and lets the pixel-layer wallpaper bleed through dialog cells).
|
||||
_fsh.DIALOG_FG = 254
|
||||
_fsh.DIALOG_BG = 242
|
||||
_fsh.FIELD_BG = 240
|
||||
_fsh.DIALOG_DIM_FG = 249
|
||||
|
||||
// Default Quick Access entries when fshrc is missing or empty
|
||||
_fsh.DEFAULT_QA = [
|
||||
["Files", "/tvdos/bin/zsh.js"],
|
||||
@@ -158,232 +151,6 @@ _fsh.saveConfig = function() {
|
||||
}
|
||||
}
|
||||
|
||||
// Draw the bordered popup background. (row, col) is the top-left, (h, w)
|
||||
// the size. Paints an opaque interior first (otherwise the wallpaper bleeds
|
||||
// through cells with bg 255), then delegates frame drawing to wintex so the
|
||||
// corner/edge glyphs always connect correctly.
|
||||
_fsh.drawDialogFrame = function(row, col, h, w, title) {
|
||||
con.color_pair(_fsh.DIALOG_FG, _fsh.DIALOG_BG)
|
||||
for (let y = 0; y < h; y++) {
|
||||
con.move(row + y, col)
|
||||
print(' '.repeat(w))
|
||||
}
|
||||
let wo = new win.WindowObject(col, row, w, h, function(){}, function(){}, title)
|
||||
wo.isHighlighted = true
|
||||
wo.titleBack = _fsh.DIALOG_BG
|
||||
wo.drawFrame()
|
||||
con.color_pair(_fsh.DIALOG_FG, _fsh.DIALOG_BG)
|
||||
}
|
||||
|
||||
// Slide the visible window so the caret stays inside (cursor at the
|
||||
// rightmost column once it passes the field width).
|
||||
_fsh.fieldScroll = function(cursor, width) {
|
||||
return cursor < width ? 0 : cursor - width + 1
|
||||
}
|
||||
|
||||
// Draw a single-line bordered input field at (row, col) with given width.
|
||||
// content is the current text; cursor is the caret offset within content
|
||||
// focused brightens the border colour.
|
||||
_fsh.drawDialogField = function(row, col, width, content, cursor, focused) {
|
||||
let frameFg = focused ? _fsh.DIALOG_FG : _fsh.DIALOG_DIM_FG
|
||||
// Clear the field area (3 rows × width+2 cols) with FIELD_BG first so any
|
||||
// stale chars from a previous render are wiped before we draw on top.
|
||||
con.color_pair(_fsh.DIALOG_FG, _fsh.FIELD_BG)
|
||||
con.move(row + 1, col + 1)
|
||||
print(' '.repeat(width))
|
||||
// Top border
|
||||
con.color_pair(frameFg, _fsh.DIALOG_BG)
|
||||
con.move(row, col)
|
||||
print('\u00DA') // ┌
|
||||
print('\u00C4'.repeat(width)) // ─
|
||||
print('\u00BF') // ┐
|
||||
// Vertical borders + content
|
||||
con.move(row + 1, col)
|
||||
print('\u00B3') // │
|
||||
con.color_pair(_fsh.DIALOG_FG, _fsh.DIALOG_BG)
|
||||
let scroll = _fsh.fieldScroll(cursor, width)
|
||||
let visible = content.substring(scroll, scroll + width)
|
||||
print(visible)
|
||||
con.color_pair(frameFg, _fsh.DIALOG_BG)
|
||||
con.move(row + 1, col + width + 1)
|
||||
print('\u00B3') // │
|
||||
// Bottom border
|
||||
con.move(row + 2, col)
|
||||
print('\u00C0') // └
|
||||
print('\u00C4'.repeat(width)) // ─
|
||||
print('\u00D9') // ┘
|
||||
con.color_pair(_fsh.DIALOG_FG, _fsh.DIALOG_BG)
|
||||
}
|
||||
|
||||
// Draw a button as "[ Label ]" at the given position; highlights when focused.
|
||||
_fsh.drawDialogButton = function(row, col, label, focused) {
|
||||
if (focused) con.color_pair(_fsh.HL_FG, _fsh.DIALOG_BG)
|
||||
else con.color_pair(_fsh.DIALOG_FG, _fsh.DIALOG_BG)
|
||||
con.move(row, col)
|
||||
print("[ " + label + " ]")
|
||||
con.color_pair(_fsh.DIALOG_FG, _fsh.DIALOG_BG)
|
||||
}
|
||||
|
||||
// Modal dialog. opts = {
|
||||
// title: string,
|
||||
// fields: [{label, initial, width}, ...],
|
||||
// allowDelete: bool,
|
||||
// }
|
||||
// Returns {action: "ok"|"cancel"|"delete", values: [string, ...]}.
|
||||
_fsh.showDialog = function(opts) {
|
||||
let fields = opts.fields
|
||||
let values = fields.map(function(f) { return f.initial || "" })
|
||||
// Caret position per field. Start at end of any pre-filled initial text.
|
||||
let cursors = values.map(function(v) { return v.length })
|
||||
|
||||
// Layout
|
||||
let maxFieldW = fields.reduce(function(m, f) { return Math.max(m, f.width) }, 16)
|
||||
let titleW = (opts.title ? opts.title.length : 0) + 4
|
||||
let w = Math.max(maxFieldW + 6, titleW + 4, 24)
|
||||
let buttonsRow = 2 + fields.length * 4 + 1 // 1 label + 3 field rows per field
|
||||
let h = buttonsRow + 2
|
||||
let screen = con.getmaxyx()
|
||||
let row = Math.max(2, Math.floor((screen[0] - h) / 2))
|
||||
let col = Math.max(2, Math.floor((screen[1] - w) / 2))
|
||||
|
||||
// Buttons list: indices follow Tab order after the last field
|
||||
let buttons = [{label: "OK", action: "ok"}, {label: "Cancel", action: "cancel"}]
|
||||
if (opts.allowDelete) buttons.splice(1, 0, {label: "Delete", action: "delete"})
|
||||
|
||||
let focusIdx = 0 // 0..fields.length-1 = field; then buttons
|
||||
let totalFocus = fields.length + buttons.length
|
||||
let done = null // {action, values} when set
|
||||
|
||||
function render() {
|
||||
_fsh.drawDialogFrame(row, col, h, w, opts.title)
|
||||
// Fields
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
let labelRow = row + 1 + i * 4
|
||||
let fieldRow = labelRow + 1
|
||||
con.color_pair(_fsh.DIALOG_FG, _fsh.DIALOG_BG)
|
||||
con.move(labelRow, col + 2)
|
||||
print(fields[i].label + ":")
|
||||
_fsh.drawDialogField(fieldRow, col + 2, fields[i].width,
|
||||
values[i], cursors[i], i === focusIdx)
|
||||
}
|
||||
// Buttons centred on buttonsRow
|
||||
let totalBtnW = buttons.reduce(function(s, b) { return s + b.label.length + 5 }, 0) - 1
|
||||
let bx = col + Math.floor((w - totalBtnW) / 2)
|
||||
for (let i = 0; i < buttons.length; i++) {
|
||||
let bIdx = fields.length + i
|
||||
_fsh.drawDialogButton(row + buttonsRow, bx, buttons[i].label, bIdx === focusIdx)
|
||||
bx += buttons[i].label.length + 5
|
||||
}
|
||||
// Position the visible caret. Inside a field: place it on the content
|
||||
// row at the cursor offset (corrected for horizontal scroll). On a
|
||||
// button: hide the caret entirely.
|
||||
if (focusIdx < fields.length) {
|
||||
let fldWidth = fields[focusIdx].width
|
||||
let scroll = _fsh.fieldScroll(cursors[focusIdx], fldWidth)
|
||||
let contentRow = row + 1 + focusIdx * 4 + 2
|
||||
let contentCol = col + 2 + 1 + (cursors[focusIdx] - scroll)
|
||||
con.move(contentRow, contentCol)
|
||||
con.curs_set(1)
|
||||
} else {
|
||||
con.curs_set(0)
|
||||
}
|
||||
}
|
||||
|
||||
render()
|
||||
|
||||
// Note: con.getch() returns TSVM scancodes (defined in JS_INIT.js as
|
||||
// con.KEY_UP=200, KEY_DOWN=208, KEY_LEFT=203, KEY_RIGHT=205,
|
||||
// con.KEY_BACKSPACE=8, KEY_TAB=9, KEY_RETURN=10). Esc isn't in JS_INIT's
|
||||
// map — it arrives as ASCII 27 via keyTyped().
|
||||
while (done === null) {
|
||||
let k = con.getch()
|
||||
|
||||
if (k === 27) { // Esc
|
||||
done = {action: "cancel", values: values}
|
||||
break
|
||||
}
|
||||
if (k === con.KEY_TAB) {
|
||||
focusIdx = (focusIdx + 1) % totalFocus
|
||||
render()
|
||||
continue
|
||||
}
|
||||
// Up/Down always cycles focus across fields/buttons.
|
||||
if (k === con.KEY_UP) {
|
||||
focusIdx = (focusIdx - 1 + totalFocus) % totalFocus
|
||||
render()
|
||||
continue
|
||||
}
|
||||
if (k === con.KEY_DOWN) {
|
||||
focusIdx = (focusIdx + 1) % totalFocus
|
||||
render()
|
||||
continue
|
||||
}
|
||||
// Left/Right moves the caret inside a field; on a button it cycles.
|
||||
if (k === con.KEY_LEFT) {
|
||||
if (focusIdx < fields.length) {
|
||||
if (cursors[focusIdx] > 0) {
|
||||
cursors[focusIdx] -= 1
|
||||
render()
|
||||
}
|
||||
} else {
|
||||
focusIdx = (focusIdx - 1 + totalFocus) % totalFocus
|
||||
render()
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (k === con.KEY_RIGHT) {
|
||||
if (focusIdx < fields.length) {
|
||||
if (cursors[focusIdx] < values[focusIdx].length) {
|
||||
cursors[focusIdx] += 1
|
||||
render()
|
||||
}
|
||||
} else {
|
||||
focusIdx = (focusIdx + 1) % totalFocus
|
||||
render()
|
||||
}
|
||||
continue
|
||||
}
|
||||
// On a field
|
||||
if (focusIdx < fields.length) {
|
||||
if (k === con.KEY_RETURN) {
|
||||
if (focusIdx < fields.length - 1) {
|
||||
focusIdx += 1
|
||||
} else {
|
||||
focusIdx = fields.length // move to OK button
|
||||
}
|
||||
render()
|
||||
continue
|
||||
}
|
||||
if (k === con.KEY_BACKSPACE) {
|
||||
let c = cursors[focusIdx]
|
||||
if (c > 0) {
|
||||
let v = values[focusIdx]
|
||||
values[focusIdx] = v.substring(0, c - 1) + v.substring(c)
|
||||
cursors[focusIdx] = c - 1
|
||||
render()
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Printable: insert at the caret.
|
||||
if (k >= 32 && k < 256 && values[focusIdx].length < fields[focusIdx].width * 4) {
|
||||
let v = values[focusIdx]
|
||||
let c = cursors[focusIdx]
|
||||
values[focusIdx] = v.substring(0, c) + String.fromCharCode(k) + v.substring(c)
|
||||
cursors[focusIdx] = c + 1
|
||||
render()
|
||||
}
|
||||
continue
|
||||
}
|
||||
// On a button
|
||||
if (k === con.KEY_RETURN || k === 32) {
|
||||
done = {action: buttons[focusIdx - fields.length].action, values: values}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
con.curs_set(0)
|
||||
return done
|
||||
}
|
||||
|
||||
// Map (mouse char x, mouse char y) to a row index for a widget drawn at
|
||||
// (xoff, yoff) with `length` existing entries and `maxRows` total rows.
|
||||
@@ -673,7 +440,7 @@ _fsh.redrawAll = function() {
|
||||
}
|
||||
|
||||
_fsh.openAddTodoDialog = function() {
|
||||
let res = _fsh.showDialog({
|
||||
let res = win.showDialog({
|
||||
title: "New Todo",
|
||||
fields: [{label: "Text", initial: "", width: _fsh.TODO_TEXT_WIDTH}],
|
||||
allowDelete: false
|
||||
@@ -690,7 +457,7 @@ _fsh.openAddTodoDialog = function() {
|
||||
_fsh.openEditTodoDialog = function(index) {
|
||||
let entry = todoWidget.todoList[index]
|
||||
if (!entry) return
|
||||
let res = _fsh.showDialog({
|
||||
let res = win.showDialog({
|
||||
title: "Edit Todo",
|
||||
fields: [{label: "Text", initial: entry[0], width: _fsh.TODO_TEXT_WIDTH}],
|
||||
allowDelete: true
|
||||
@@ -709,7 +476,7 @@ _fsh.openEditTodoDialog = function(index) {
|
||||
}
|
||||
|
||||
_fsh.openAddQaDialog = function() {
|
||||
let res = _fsh.showDialog({
|
||||
let res = win.showDialog({
|
||||
title: "New Quick Access",
|
||||
fields: [
|
||||
{label: "Label", initial: "", width: _fsh.QA_LABEL_WIDTH},
|
||||
@@ -730,7 +497,7 @@ _fsh.openAddQaDialog = function() {
|
||||
_fsh.openEditQaDialog = function(index) {
|
||||
let entry = quickAccessWidget.entries[index]
|
||||
if (!entry) return
|
||||
let res = _fsh.showDialog({
|
||||
let res = win.showDialog({
|
||||
title: "Edit Quick Access",
|
||||
fields: [
|
||||
{label: "Label", initial: entry[0], width: _fsh.QA_LABEL_WIDTH},
|
||||
|
||||
@@ -21,6 +21,10 @@ const FILELIST_WIDTH = WIDTH - SIDEBAR_WIDTH - 3 - FILESIZE_WIDTH
|
||||
const POPUP_WIDTH = 52 // always even number
|
||||
const POPUP_HEIGHT = 16
|
||||
|
||||
const [SCRPW, SCRPH] = graphics.getPixelDimension()
|
||||
const CELL_PW = (SCRPW / WIDTH) | 0
|
||||
const CELL_PH = (SCRPH / WHEIGHT) | 0
|
||||
|
||||
const COL_HL_EXT = {
|
||||
"js": 215,
|
||||
"bas": 215,
|
||||
@@ -69,6 +73,13 @@ const EXEC_FUNS = {
|
||||
"taud": (f) => _G.shell.execute(`playtaud "${f}"`),
|
||||
}
|
||||
|
||||
const EDIT_FUNS = {
|
||||
"bas": (f) => _G.shell.execute(`edit "${f}"`),
|
||||
"txt": (f) => _G.shell.execute(`edit "${f}"`),
|
||||
"md": (f) => _G.shell.execute(`edit "${f}"`),
|
||||
"taud": (f) => _G.shell.execute(`microtone "${f}"`),
|
||||
}
|
||||
|
||||
function makeExecFun(template) {
|
||||
return (f) => _G.shell.execute(template.replaceAll("{0}", `"${f}"`))
|
||||
}
|
||||
@@ -118,6 +129,57 @@ function loadZfmrc() {
|
||||
|
||||
loadZfmrc()
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// Mouse region registry
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
const MOUSE_PANEL = []
|
||||
const MOUSE_POPUP_STACK = []
|
||||
let lastHoveredRegion = null
|
||||
|
||||
function pixelToCell(px, py) {
|
||||
return [(py / CELL_PH | 0) + 1, (px / CELL_PW | 0) + 1]
|
||||
}
|
||||
function regionHits(r, cy, cx) {
|
||||
return cy >= r.y && cy < r.y + r.h && cx >= r.x && cx < r.x + r.w
|
||||
}
|
||||
function clearPanelMouseRegions() { MOUSE_PANEL.length = 0; lastHoveredRegion = null }
|
||||
function addPanelMouseRegion(x, y, w, h, handlers) { MOUSE_PANEL.push(Object.assign({x, y, w, h}, handlers)) }
|
||||
function pushMousePopup(regions) { MOUSE_POPUP_STACK.push(regions); lastHoveredRegion = null }
|
||||
function popMousePopup() { MOUSE_POPUP_STACK.pop(); lastHoveredRegion = null }
|
||||
|
||||
function dispatchMouseEvent(event) {
|
||||
const t = event[0]
|
||||
if (t !== 'mouse_down' && t !== 'mouse_wheel' && t !== 'mouse_up' && t !== 'mouse_move') return false
|
||||
const [cy, cx] = pixelToCell(event[1], event[2])
|
||||
const pool = (MOUSE_POPUP_STACK.length > 0)
|
||||
? MOUSE_POPUP_STACK[MOUSE_POPUP_STACK.length - 1]
|
||||
: MOUSE_PANEL
|
||||
|
||||
if (t === 'mouse_move') {
|
||||
let hit = null
|
||||
for (let i = pool.length - 1; i >= 0; i--) {
|
||||
const r = pool[i]
|
||||
if (regionHits(r, cy, cx) && (r.onHover || r.onHoverLeave)) { hit = r; break }
|
||||
}
|
||||
if (hit !== lastHoveredRegion) {
|
||||
if (lastHoveredRegion && lastHoveredRegion.onHoverLeave) lastHoveredRegion.onHoverLeave()
|
||||
lastHoveredRegion = hit
|
||||
}
|
||||
if (hit && hit.onHover) { hit.onHover(cy, cx, event); return true }
|
||||
return false
|
||||
}
|
||||
|
||||
for (let i = pool.length - 1; i >= 0; i--) {
|
||||
const r = pool[i]
|
||||
if (!regionHits(r, cy, cx)) continue
|
||||
if (t === 'mouse_down' && r.onClick) { r.onClick(cy, cx, event[3], event); return true }
|
||||
if (t === 'mouse_wheel' && r.onWheel) { r.onWheel(cy, cx, event[3], event); return true }
|
||||
if (t === 'mouse_up' && r.onRelease) { r.onRelease(cy, cx, event[3], event); return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
let windowMode = 0 // 0 == left, 1 == right
|
||||
let windowFocus = [0] // is a stack; 0: files window, 1: palette window, 2: popup window
|
||||
|
||||
@@ -347,11 +409,30 @@ let filesPanelDraw = (wo) => {
|
||||
con.color_pair(COL_TEXT, COL_BACK)
|
||||
|
||||
}
|
||||
// Op panel buttons. yOff is the row offset (icon) inside the op panel frame;
|
||||
// label sits at yOff+1. Hit regions span both rows.
|
||||
// hitH is the row count for the mouse hit-box. The switch button gets a taller
|
||||
// hit-box than the others because the icon glyph above its label leaves extra
|
||||
// whitespace inside the cell above the first horizontal rule.
|
||||
const OP_BUTTONS = [
|
||||
{ id: 'switch', yOff: 0, hitH: 5, key: 'z' },
|
||||
{ id: 'up', yOff: 6, hitH: 2, key: 'u' },
|
||||
{ id: 'copy', yOff: 9, hitH: 2, key: 'c' },
|
||||
{ id: 'move', yOff: 12, hitH: 2, key: 'v' },
|
||||
{ id: 'delete', yOff: 15, hitH: 2, key: 'd' },
|
||||
{ id: 'mkdir', yOff: 18, hitH: 2, key: 'k' },
|
||||
{ id: 'rename', yOff: 21, hitH: 2, key: 'r' },
|
||||
{ id: 'more', yOff: 24, hitH: 2, key: 'm' },
|
||||
{ id: 'quit', yOff: 27, hitH: 2, key: 'q' },
|
||||
]
|
||||
let opHover = -1
|
||||
|
||||
let opPanelDraw = (wo) => {
|
||||
function hr(y) {
|
||||
con.move(y, xp)
|
||||
print(`\x84196u`.repeat(SIDEBAR_WIDTH - 2))
|
||||
}
|
||||
function labCol(i) { return (opHover === i) ? COL_HLTEXT : COL_TEXT }
|
||||
|
||||
con.color_pair(COL_TEXT, COL_BACK)
|
||||
|
||||
@@ -362,14 +443,14 @@ let opPanelDraw = (wo) => {
|
||||
con.move(yp + 2, xp + 3)
|
||||
con.prnch((windowMode) ? 0x11 : 0x10)
|
||||
con.move(yp + 3, xp)
|
||||
print(` \x1B[38;5;${COL_TEXT}m[\x1B[38;5;${COL_HLACTION}mZ\x1B[38;5;${COL_TEXT}m]`)
|
||||
print(` \x1B[38;5;${labCol(0)}m[\x1B[38;5;${COL_HLACTION}mZ\x1B[38;5;${labCol(0)}m]`)
|
||||
|
||||
hr(yp+5)
|
||||
|
||||
// go up
|
||||
con.mvaddch(yp + 6, xp + 3, 0x18)
|
||||
con.move(yp + 7, xp)
|
||||
print(` \x1B[38;5;${COL_TEXT}mGo \x1B[38;5;${COL_HLACTION}mU\x1B[38;5;${COL_TEXT}mp`)
|
||||
print(` \x1B[38;5;${labCol(1)}mGo \x1B[38;5;${COL_HLACTION}mU\x1B[38;5;${labCol(1)}mp`)
|
||||
|
||||
hr(yp+8)
|
||||
|
||||
@@ -377,7 +458,7 @@ let opPanelDraw = (wo) => {
|
||||
con.move(yp + 9, xp + 2)
|
||||
con.prnch(0xDB);con.prnch((windowMode) ? 0x1B : 0x1A);con.prnch(0xDB)
|
||||
con.move(yp + 10, xp)
|
||||
print(` \x1B[38;5;${COL_HLACTION}mC\x1B[38;5;${COL_TEXT}mopy`)
|
||||
print(` \x1B[38;5;${COL_HLACTION}mC\x1B[38;5;${labCol(2)}mopy`)
|
||||
|
||||
hr(yp+11)
|
||||
|
||||
@@ -385,7 +466,7 @@ let opPanelDraw = (wo) => {
|
||||
con.move(yp + 12, xp + 2)
|
||||
if (windowMode) con.prnch([0xDB, 0x1B, 0xB0]); else con.prnch([0xB0, 0x1A, 0xDB])
|
||||
con.move(yp + 13, xp)
|
||||
print(` \x1B[38;5;${COL_TEXT}mMo\x1B[38;5;${COL_HLACTION}mv\x1B[38;5;${COL_TEXT}me`)
|
||||
print(` \x1B[38;5;${labCol(3)}mMo\x1B[38;5;${COL_HLACTION}mv\x1B[38;5;${labCol(3)}me`)
|
||||
|
||||
hr(yp+14)
|
||||
|
||||
@@ -393,7 +474,7 @@ let opPanelDraw = (wo) => {
|
||||
con.move(yp + 15, xp + 2)
|
||||
if (windowMode) con.prnch([0xDB, 0x1A, 0xF9]); else con.prnch([0xF9, 0x1B, 0xDB])
|
||||
con.move(yp + 16, xp)
|
||||
print(` \x1B[38;5;${COL_HLACTION}mD\x1B[38;5;${COL_TEXT}melete`)
|
||||
print(` \x1B[38;5;${COL_HLACTION}mD\x1B[38;5;${labCol(4)}melete`)
|
||||
|
||||
hr(yp+17)
|
||||
|
||||
@@ -403,7 +484,7 @@ let opPanelDraw = (wo) => {
|
||||
con.video_reverse();con.prnch(0x2B);con.video_reverse()
|
||||
con.prnch(0xDF)
|
||||
con.move(yp + 19, xp)
|
||||
print(` \x1B[38;5;${COL_TEXT}mM\x1B[38;5;${COL_HLACTION}mk\x1B[38;5;${COL_TEXT}mDir`)
|
||||
print(` \x1B[38;5;${labCol(5)}mM\x1B[38;5;${COL_HLACTION}mk\x1B[38;5;${labCol(5)}mDir`)
|
||||
|
||||
hr(yp+20)
|
||||
|
||||
@@ -411,7 +492,7 @@ let opPanelDraw = (wo) => {
|
||||
con.move(yp + 21, xp + 2)
|
||||
con.prnch(0x4E);con.prnch(0x1A);con.prnch(0x52)
|
||||
con.move(yp + 22, xp)
|
||||
print(` \x1B[38;5;${COL_HLACTION}mR\x1B[38;5;${COL_TEXT}mename`)
|
||||
print(` \x1B[38;5;${COL_HLACTION}mR\x1B[38;5;${labCol(6)}mename`)
|
||||
|
||||
hr(yp+23)
|
||||
|
||||
@@ -419,7 +500,7 @@ let opPanelDraw = (wo) => {
|
||||
con.move(yp + 24, xp + 3)
|
||||
con.prnch(0xf0)
|
||||
con.move(yp + 25, xp)
|
||||
print(` \x1B[38;5;${COL_HLACTION}mM\x1B[38;5;${COL_TEXT}more`)
|
||||
print(` \x1B[38;5;${COL_HLACTION}mM\x1B[38;5;${labCol(7)}more`)
|
||||
|
||||
hr(yp+26)
|
||||
|
||||
@@ -427,7 +508,7 @@ let opPanelDraw = (wo) => {
|
||||
con.move(yp + 27, xp + 3)
|
||||
con.prnch(0x58)
|
||||
con.move(yp + 28, xp)
|
||||
print(` \x1B[38;5;${COL_HLACTION}mQ\x1B[38;5;${COL_TEXT}muit`)
|
||||
print(` \x1B[38;5;${COL_HLACTION}mQ\x1B[38;5;${labCol(8)}muit`)
|
||||
|
||||
|
||||
}
|
||||
@@ -463,9 +544,8 @@ let popupDraw = (wo) => {
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
let filenavOninput = (window, event) => {
|
||||
|
||||
let eventName = event[0]
|
||||
if (eventName == "key_down") {
|
||||
if (eventName !== "key_down") return
|
||||
|
||||
let keysym = event[1]
|
||||
let keyJustHit = (1 == event[2])
|
||||
@@ -474,13 +554,15 @@ let filenavOninput = (window, event) => {
|
||||
|
||||
let scrollPeek = (LIST_HEIGHT / 3)|0
|
||||
|
||||
if (keyJustHit && keysym == "q") {
|
||||
exit = true
|
||||
}
|
||||
else if (keyJustHit && keysym == "z") {
|
||||
windowMode = 1 - windowMode
|
||||
redraw() // this would double-redraw (hence no panel switching) or something if redraw() is not merely a request to do so
|
||||
}
|
||||
if (keyJustHit && keysym == "q") actQuit()
|
||||
else if (keyJustHit && keysym == "z") actSwitchPanel()
|
||||
else if (keyJustHit && keysym == 'u') actGoUp()
|
||||
else if (keyJustHit && keysym == 'c') actCopy()
|
||||
else if (keyJustHit && keysym == 'v') actMove()
|
||||
else if (keyJustHit && keysym == 'd') actDelete()
|
||||
else if (keyJustHit && keysym == 'k') actMkdir()
|
||||
else if (keyJustHit && keysym == 'r') actRename()
|
||||
else if (keyJustHit && keysym == 'm') actMore()
|
||||
else if (keysym == "<UP>") {
|
||||
[cursor[windowMode], scroll[windowMode]] = win.scrollVert(-1, dirFileList[windowMode].length, LIST_HEIGHT, cursor[windowMode], scroll[windowMode], scrollPeek)
|
||||
drawFilePanel()
|
||||
@@ -498,71 +580,7 @@ let filenavOninput = (window, event) => {
|
||||
drawFilePanel()
|
||||
}
|
||||
else if (keyJustHit && keycode == 66) { // enter
|
||||
let selectedFileCache = filePanelCache[windowMode][cursor[windowMode]]
|
||||
let selectedFile = selectedFileCache.file
|
||||
|
||||
//serial.println(`selectedFile = ${selectedFile.fullPath}`)
|
||||
|
||||
if (selectedFile.fullPath[1] == ":" && selectedFile.fullPath[2] == "\\" && selectedFile.fullPath.length == 3) {
|
||||
path[windowMode].push(selectedFile.fullPath)
|
||||
cursor[windowMode] = 0; scroll[windowMode] = 0
|
||||
refreshFilePanelCache(windowMode)
|
||||
drawFilePanel()
|
||||
}
|
||||
else if (selectedFileCache.isDirectory) {
|
||||
//serial.println(`selectedFile.name = ${selectedFile.name}`)
|
||||
path[windowMode].push(selectedFileCache.filename)
|
||||
cursor[windowMode] = 0; scroll[windowMode] = 0
|
||||
refreshFilePanelCache(windowMode)
|
||||
drawFilePanel()
|
||||
}
|
||||
else {
|
||||
let fileext = selectedFileCache.filename.substring(selectedFileCache.filename.lastIndexOf(".") + 1).toLowerCase()
|
||||
let execfun = EXEC_FUNS[fileext] || ((f) => _G.shell.execute(f))
|
||||
let errorlevel = 0
|
||||
|
||||
con.curs_set(1);clearScr();con.move(1,1)
|
||||
try {
|
||||
//serial.println(selectedFile.fullPath)
|
||||
errorlevel = execfun(selectedFile.fullPath)
|
||||
//serial.println("1 errorlevel = " + errorlevel)
|
||||
}
|
||||
catch (e) {
|
||||
// TODO popup error
|
||||
println(e)
|
||||
errorlevel = 1
|
||||
//serial.println("2 errorlevel = " + errorlevel)
|
||||
}
|
||||
|
||||
if (errorlevel) {
|
||||
println("Hit Return/Enter key to continue . . . .")
|
||||
sys.read()
|
||||
}
|
||||
|
||||
firstRunLatch = true
|
||||
con.curs_set(0);clearScr()
|
||||
refreshFilePanelCache(windowMode)
|
||||
redraw()
|
||||
}
|
||||
}
|
||||
else if (keyJustHit && keysym == 'u') { // no bksp: used as an exit key for playmov/playwav
|
||||
if (path[windowMode].length >= 1) {
|
||||
path[windowMode].pop()
|
||||
cursor[windowMode] = 0; scroll[windowMode] = 0
|
||||
refreshFilePanelCache(windowMode)
|
||||
drawFilePanel()
|
||||
}
|
||||
else {
|
||||
// TODO list of drives
|
||||
|
||||
}
|
||||
}
|
||||
else if (keyJustHit && keysym == 'm') {
|
||||
makePopup(1); redraw()
|
||||
}
|
||||
|
||||
|
||||
|
||||
actActivate()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -592,6 +610,44 @@ let popupInput = (window, event) => {
|
||||
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// Popup wrappers (delegate to win.showDialog in wintex.mjs)
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
function showConfirmPopup(title, message) {
|
||||
const res = win.showDialog({
|
||||
title: title,
|
||||
message: message,
|
||||
fields: [],
|
||||
buttons: [
|
||||
{ label: 'OK', action: 'ok', default: true },
|
||||
{ label: 'CANCEL', action: 'cancel' },
|
||||
],
|
||||
})
|
||||
return res.action === 'ok'
|
||||
}
|
||||
|
||||
function showInputPopup(title, prompt, defaultVal) {
|
||||
const res = win.showDialog({
|
||||
title: title,
|
||||
fields: [{ label: prompt, initial: defaultVal || '', width: POPUP_WIDTH - 6 }],
|
||||
buttons: [
|
||||
{ label: 'OK', action: 'ok', default: true },
|
||||
{ label: 'CANCEL', action: 'cancel' },
|
||||
],
|
||||
})
|
||||
return res.action === 'ok' ? res.values[0] : null
|
||||
}
|
||||
|
||||
function showMessagePopup(title, message) {
|
||||
win.showDialog({
|
||||
title: title,
|
||||
message: message,
|
||||
fields: [],
|
||||
buttons: [{ label: 'OK', action: 'ok', default: true }],
|
||||
})
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
let windows = [
|
||||
@@ -617,6 +673,11 @@ let currentPopup = 0
|
||||
function makePopup(index) {
|
||||
currentPopup = index
|
||||
windowFocus.push(currentPopup)
|
||||
// Push an empty mouse region set so the panel's op-button / file-row regions
|
||||
// stop receiving clicks while this popup is open. Otherwise the user could
|
||||
// click a panel button while e.g. the "More" palette is shown and end up
|
||||
// with two popups stacked on top of each other.
|
||||
pushMousePopup([])
|
||||
for (let i = 0; i < windows.length; i++) {
|
||||
windows[i].forEach(it => {
|
||||
it.isHighlighted = (i == index)
|
||||
@@ -626,6 +687,7 @@ function makePopup(index) {
|
||||
|
||||
function removePopup() {
|
||||
windowFocus.pop()
|
||||
popMousePopup()
|
||||
const index = windowFocus.last
|
||||
currentPopup = 0
|
||||
for (let i = 0; i < windows.length; i++) {
|
||||
@@ -701,6 +763,7 @@ function _redraw() {
|
||||
drawFilePanel()
|
||||
drawOpPanel()
|
||||
drawPopupPanel()
|
||||
setupPanelMouseRegions()
|
||||
}
|
||||
|
||||
function clearScr() {
|
||||
@@ -710,6 +773,311 @@ function clearScr() {
|
||||
graphics.setGraphicsMode(0)
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// File operations and op-panel actions
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
function getCurrentDirStr(side) {
|
||||
return path[side].concat(['']).join("\\").replaceAll('\\\\', '\\')
|
||||
}
|
||||
|
||||
function clampCursorAfterChange() {
|
||||
const len = dirFileList[windowMode].length
|
||||
if (cursor[windowMode] >= len) cursor[windowMode] = Math.max(0, len - 1)
|
||||
const maxScroll = Math.max(0, len - LIST_HEIGHT)
|
||||
if (scroll[windowMode] > maxScroll) scroll[windowMode] = maxScroll
|
||||
if (scroll[windowMode] < 0) scroll[windowMode] = 0
|
||||
}
|
||||
|
||||
function actSwitchPanel() {
|
||||
windowMode = 1 - windowMode
|
||||
redraw()
|
||||
}
|
||||
|
||||
function actGoUp() {
|
||||
if (path[windowMode].length >= 1) {
|
||||
path[windowMode].pop()
|
||||
cursor[windowMode] = 0; scroll[windowMode] = 0
|
||||
refreshFilePanelCache(windowMode)
|
||||
_redraw()
|
||||
}
|
||||
}
|
||||
|
||||
function actActivate() {
|
||||
let selectedFileCache = filePanelCache[windowMode][cursor[windowMode]]
|
||||
if (!selectedFileCache || !selectedFileCache.file) return
|
||||
let selectedFile = selectedFileCache.file
|
||||
|
||||
if (selectedFile.fullPath[1] == ":" && selectedFile.fullPath[2] == "\\" && selectedFile.fullPath.length == 3) {
|
||||
path[windowMode].push(selectedFile.fullPath)
|
||||
cursor[windowMode] = 0; scroll[windowMode] = 0
|
||||
refreshFilePanelCache(windowMode)
|
||||
_redraw()
|
||||
}
|
||||
else if (selectedFileCache.isDirectory) {
|
||||
path[windowMode].push(selectedFileCache.filename)
|
||||
cursor[windowMode] = 0; scroll[windowMode] = 0
|
||||
refreshFilePanelCache(windowMode)
|
||||
_redraw()
|
||||
}
|
||||
else {
|
||||
let fileext = selectedFileCache.filename.substring(selectedFileCache.filename.lastIndexOf(".") + 1).toLowerCase()
|
||||
let execfun = EXEC_FUNS[fileext] || ((f) => _G.shell.execute(f))
|
||||
let errorlevel = 0
|
||||
|
||||
con.curs_set(1); clearScr(); con.move(1,1)
|
||||
try {
|
||||
errorlevel = execfun(selectedFile.fullPath)
|
||||
}
|
||||
catch (e) {
|
||||
println(e)
|
||||
errorlevel = 1
|
||||
}
|
||||
|
||||
if (errorlevel) {
|
||||
println("Hit Return/Enter key to continue . . . .")
|
||||
sys.read()
|
||||
}
|
||||
|
||||
firstRunLatch = true
|
||||
con.curs_set(0); clearScr()
|
||||
refreshFilePanelCache(windowMode)
|
||||
redraw()
|
||||
}
|
||||
}
|
||||
|
||||
function actCopy() {
|
||||
if (path[windowMode].length === 0) return
|
||||
const cache = filePanelCache[windowMode][cursor[windowMode]]
|
||||
if (!cache || !cache.file) return
|
||||
if (cache.isDirectory) { showMessagePopup('Copy', 'Directory copy is not supported.'); _redraw(); return }
|
||||
if (path[1 - windowMode].length === 0) { showMessagePopup('Copy', 'Cannot copy to drive list view.'); _redraw(); return }
|
||||
|
||||
const srcPath = cache.file.fullPath
|
||||
const dstDir = getCurrentDirStr(1 - windowMode)
|
||||
const dstPath = dstDir + cache.file.name
|
||||
if (srcPath === dstPath) { _redraw(); return } // both panels point to same directory
|
||||
|
||||
try {
|
||||
const srcFile = files.open(srcPath)
|
||||
const dstFile = files.open(dstPath)
|
||||
if (!srcFile.exists) { showMessagePopup('Copy', 'Source not found.'); _redraw(); return }
|
||||
if (dstFile.exists) {
|
||||
if (!showConfirmPopup('Copy', `Overwrite "${cache.file.name}"?`)) { _redraw(); return }
|
||||
}
|
||||
if (!dstFile.exists) dstFile.mkFile()
|
||||
dstFile.bwrite(srcFile.bread())
|
||||
try { dstFile.flush() } catch (e) {}
|
||||
try { dstFile.close() } catch (e) {}
|
||||
try { srcFile.close() } catch (e) {}
|
||||
refreshFilePanelCache(1 - windowMode)
|
||||
}
|
||||
catch (e) {
|
||||
showMessagePopup('Copy failed', e.message || ('' + e))
|
||||
}
|
||||
_redraw()
|
||||
}
|
||||
|
||||
function actMove() {
|
||||
if (path[windowMode].length === 0) return
|
||||
const cache = filePanelCache[windowMode][cursor[windowMode]]
|
||||
if (!cache || !cache.file) return
|
||||
if (cache.isDirectory) { showMessagePopup('Move', 'Directory move is not supported.'); _redraw(); return }
|
||||
if (path[1 - windowMode].length === 0) { showMessagePopup('Move', 'Cannot move to drive list view.'); _redraw(); return }
|
||||
|
||||
const srcPath = cache.file.fullPath
|
||||
const dstDir = getCurrentDirStr(1 - windowMode)
|
||||
const dstPath = dstDir + cache.file.name
|
||||
if (srcPath === dstPath) { _redraw(); return } // no-op
|
||||
|
||||
try {
|
||||
const srcFile = files.open(srcPath)
|
||||
const dstFile = files.open(dstPath)
|
||||
if (!srcFile.exists) { showMessagePopup('Move', 'Source not found.'); _redraw(); return }
|
||||
if (dstFile.exists) {
|
||||
if (!showConfirmPopup('Move', `Overwrite "${cache.file.name}"?`)) { _redraw(); return }
|
||||
}
|
||||
if (!dstFile.exists) dstFile.mkFile()
|
||||
dstFile.bwrite(srcFile.bread())
|
||||
try { dstFile.flush() } catch (e) {}
|
||||
try { dstFile.close() } catch (e) {}
|
||||
srcFile.remove()
|
||||
refreshFilePanelCache(windowMode)
|
||||
refreshFilePanelCache(1 - windowMode)
|
||||
clampCursorAfterChange()
|
||||
}
|
||||
catch (e) {
|
||||
showMessagePopup('Move failed', e.message || ('' + e))
|
||||
}
|
||||
_redraw()
|
||||
}
|
||||
|
||||
function actDelete() {
|
||||
if (path[windowMode].length === 0) return
|
||||
const cache = filePanelCache[windowMode][cursor[windowMode]]
|
||||
if (!cache || !cache.file) return
|
||||
|
||||
const name = cache.file.name
|
||||
const kind = cache.isDirectory ? 'directory' : 'file'
|
||||
if (!showConfirmPopup('Delete', `Delete ${kind} "${name}"?`)) { _redraw(); return }
|
||||
|
||||
try {
|
||||
const status = cache.file.remove()
|
||||
if (status !== undefined && status !== 0 && status !== true) {
|
||||
showMessagePopup('Delete failed', `Cannot delete "${name}" (status ${status}).`)
|
||||
}
|
||||
refreshFilePanelCache(windowMode)
|
||||
clampCursorAfterChange()
|
||||
}
|
||||
catch (e) {
|
||||
showMessagePopup('Delete failed', e.message || ('' + e))
|
||||
}
|
||||
_redraw()
|
||||
}
|
||||
|
||||
function actMkdir() {
|
||||
if (path[windowMode].length === 0) { showMessagePopup('Mkdir', 'Choose a directory first.'); _redraw(); return }
|
||||
const name = showInputPopup('Make Directory', 'Directory name:', '')
|
||||
if (name === null || name.length === 0) { _redraw(); return }
|
||||
|
||||
const dstPath = getCurrentDirStr(windowMode) + name
|
||||
try {
|
||||
const dstFile = files.open(dstPath)
|
||||
if (dstFile.exists) {
|
||||
showMessagePopup('Mkdir', `"${name}" already exists.`)
|
||||
}
|
||||
else {
|
||||
const ok = dstFile.mkDir()
|
||||
if (!ok) showMessagePopup('Mkdir failed', `Cannot create "${name}".`)
|
||||
else refreshFilePanelCache(windowMode)
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
showMessagePopup('Mkdir failed', e.message || ('' + e))
|
||||
}
|
||||
_redraw()
|
||||
}
|
||||
|
||||
function actRename() {
|
||||
if (path[windowMode].length === 0) return
|
||||
const cache = filePanelCache[windowMode][cursor[windowMode]]
|
||||
if (!cache || !cache.file) return
|
||||
if (cache.isDirectory) { showMessagePopup('Rename', 'Directory rename is not supported.'); _redraw(); return }
|
||||
|
||||
const oldName = cache.file.name
|
||||
const newName = showInputPopup('Rename', 'New name:', oldName)
|
||||
if (newName === null || newName.length === 0 || newName === oldName) { _redraw(); return }
|
||||
|
||||
const dirStr = getCurrentDirStr(windowMode)
|
||||
const srcPath = cache.file.fullPath
|
||||
const dstPath = dirStr + newName
|
||||
|
||||
try {
|
||||
const srcFile = files.open(srcPath)
|
||||
const dstFile = files.open(dstPath)
|
||||
if (dstFile.exists) {
|
||||
if (!showConfirmPopup('Rename', `Overwrite "${newName}"?`)) { _redraw(); return }
|
||||
}
|
||||
if (!dstFile.exists) dstFile.mkFile()
|
||||
dstFile.bwrite(srcFile.bread())
|
||||
try { dstFile.flush() } catch (e) {}
|
||||
try { dstFile.close() } catch (e) {}
|
||||
srcFile.remove()
|
||||
refreshFilePanelCache(windowMode)
|
||||
clampCursorAfterChange()
|
||||
}
|
||||
catch (e) {
|
||||
showMessagePopup('Rename failed', e.message || ('' + e))
|
||||
}
|
||||
_redraw()
|
||||
}
|
||||
|
||||
function actMore() { makePopup(1); redraw() }
|
||||
function actQuit() { exit = true }
|
||||
|
||||
function invokeOpAction(id) {
|
||||
if (id === 'switch') actSwitchPanel()
|
||||
else if (id === 'up') actGoUp()
|
||||
else if (id === 'copy') actCopy()
|
||||
else if (id === 'move') actMove()
|
||||
else if (id === 'delete') actDelete()
|
||||
else if (id === 'mkdir') actMkdir()
|
||||
else if (id === 'rename') actRename()
|
||||
else if (id === 'more') actMore()
|
||||
else if (id === 'quit') actQuit()
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// Mouse region setup (file list + op buttons)
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
function setupPanelMouseRegions() {
|
||||
clearPanelMouseRegions()
|
||||
|
||||
const fp = (windowMode === 0) ? LEFTPANEL : RIGHTPANEL
|
||||
const fpX = fp.x + 1
|
||||
const fpW = fp.width - 2
|
||||
const fpY = fp.y + 2 // first file row (after frame top + header)
|
||||
|
||||
// Wheel-scroll over the file list. Wheel and keyboard are the only inputs allowed
|
||||
// to move the scroll position; hover (below) only moves the caret.
|
||||
addPanelMouseRegion(fpX, fpY, fpW, LIST_HEIGHT, {
|
||||
onWheel: (cy, cx, dy) => {
|
||||
const filesCount = dirFileList[windowMode].length
|
||||
const maxScroll = Math.max(0, filesCount - LIST_HEIGHT)
|
||||
let s = scroll[windowMode] + dy * 3
|
||||
if (s > maxScroll) s = maxScroll
|
||||
if (s < 0) s = 0
|
||||
if (s !== scroll[windowMode]) {
|
||||
scroll[windowMode] = s
|
||||
drawFilePanel()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// One hover/click region per row so the caret can follow the mouse without
|
||||
// calling scrollVert (which would re-scroll the list near the upper/lower thirds).
|
||||
for (let i = 0; i < LIST_HEIGHT; i++) {
|
||||
const rowIdx = i
|
||||
addPanelMouseRegion(fpX, fpY + i, fpW, 1, {
|
||||
onHover: () => {
|
||||
const target = scroll[windowMode] + rowIdx
|
||||
if (target < dirFileList[windowMode].length && cursor[windowMode] !== target) {
|
||||
cursor[windowMode] = target
|
||||
drawFilePanel()
|
||||
}
|
||||
},
|
||||
onClick: (cy, cx, btn) => {
|
||||
if (btn !== 1) return
|
||||
const target = scroll[windowMode] + rowIdx
|
||||
if (target >= dirFileList[windowMode].length) return
|
||||
cursor[windowMode] = target
|
||||
actActivate()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Op-panel button hover/click. Each button covers its icon row + label row.
|
||||
const opX = OPPANEL.x + 1
|
||||
const opW = SIDEBAR_WIDTH - 2
|
||||
for (let i = 0; i < OP_BUTTONS.length; i++) {
|
||||
const idx = i
|
||||
const btn = OP_BUTTONS[i]
|
||||
addPanelMouseRegion(opX, OPPANEL.y + 1 + btn.yOff, opW, btn.hitH || 2, {
|
||||
onHover: () => {
|
||||
if (opHover !== idx) { opHover = idx; drawOpPanel() }
|
||||
},
|
||||
onHoverLeave: () => {
|
||||
if (opHover === idx) { opHover = -1; drawOpPanel() }
|
||||
},
|
||||
onClick: (cy, cx, btnNum) => {
|
||||
if (btnNum !== 1) return
|
||||
invokeOpAction(btn.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
con.curs_set(0)
|
||||
@@ -717,6 +1085,12 @@ refreshFilePanelCache(0)
|
||||
refreshFilePanelCache(1)
|
||||
_redraw()
|
||||
|
||||
// Drain inherited mouse/key state from whoever launched us. Polling launchers
|
||||
// like fsh.js can hand off with the mouse button still held; without this,
|
||||
// input.withEvent's first call edge-detects that as a fresh mouse_down at the
|
||||
// cursor and activates whichever file row happens to sit there.
|
||||
input.withEvent(() => {})
|
||||
|
||||
let redrawRequested = false
|
||||
let exit = false
|
||||
let firstRunLatch = true
|
||||
@@ -724,6 +1098,14 @@ let firstRunLatch = true
|
||||
while (!exit) {
|
||||
input.withEvent(event => {
|
||||
|
||||
if (dispatchMouseEvent(event)) {
|
||||
if (redrawRequested) {
|
||||
redrawRequested = false
|
||||
_redraw()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let keysym = event[1]
|
||||
let keyJustHit = (1 == event[2])
|
||||
|
||||
|
||||
@@ -180,4 +180,345 @@ function scrollHorz(dx, stringSize, stringViewSize, currentCursorPos, currentScr
|
||||
return [currentCursorPos, currentScrollPos]
|
||||
}
|
||||
|
||||
exports = { WindowObject, scrollVert, scrollHorz }
|
||||
// ---------------------------------------------------------------------------
|
||||
// Modal dialog with multiple input fields and OK/Cancel-style buttons.
|
||||
//
|
||||
// opts = {
|
||||
// title: string,
|
||||
// message: string | string[]? -- optional body text drawn above fields
|
||||
// fields: [{label, initial?, width}, ...] -- omit / [] for no input field
|
||||
// buttons: [{label, action, default?}, ...] -- defaults to [OK, Cancel] (+ Delete
|
||||
// if `allowDelete:true`)
|
||||
// allowDelete: bool, -- inserts a Delete button (fsh compat)
|
||||
// colours: {fg?, bg?, fieldBg?, dimFg?, hlFg?, focusBg?} -- per-call overrides
|
||||
// }
|
||||
//
|
||||
// Returns {action, values}: `action` is the chosen button's `action`
|
||||
// (default "ok"/"cancel"/"delete"), or "cancel" on Esc; `values` is the array
|
||||
// of field strings in field order.
|
||||
//
|
||||
// Behaviour:
|
||||
// - Tab / Shift+Tab and arrow Down / Up cycle focus across fields and buttons.
|
||||
// - Left / Right inside a field move the caret; on a button they cycle focus.
|
||||
// - Home / End jump to start / end of the focused field.
|
||||
// - Enter on a field jumps to the next field, then to the first button. Enter
|
||||
// or Space on a button activates it.
|
||||
// - Insert at caret. Backspace deletes left of caret; Forward-Del deletes right.
|
||||
// - Blinking caret (`con.curs_set(1)`) is positioned on the focused field and
|
||||
// hidden when a button has focus.
|
||||
// - Mouse: left-click on a button activates it; click on a field puts focus
|
||||
// on that field and positions the caret under the click. Mouse hover on a
|
||||
// button highlights it.
|
||||
const _dialogScreen = con.getmaxyx()
|
||||
const _dialogPixDim = graphics.getPixelDimension()
|
||||
const _CELL_PW = (_dialogPixDim[0] / _dialogScreen[1]) | 0
|
||||
const _CELL_PH = (_dialogPixDim[1] / _dialogScreen[0]) | 0
|
||||
function _pxToCell(px, py) { return [(py / _CELL_PH | 0) + 1, (px / _CELL_PW | 0) + 1] }
|
||||
|
||||
function showDialog(opts) {
|
||||
const fields = opts.fields || []
|
||||
const values = fields.map(f => (f.initial == null) ? '' : ('' + f.initial))
|
||||
const cursors = values.map(v => v.length)
|
||||
|
||||
let buttons
|
||||
if (opts.buttons) {
|
||||
buttons = opts.buttons
|
||||
} else {
|
||||
buttons = [{label: 'OK', action: 'ok', default: true}]
|
||||
if (opts.allowDelete) buttons.push({label: 'Delete', action: 'delete'})
|
||||
buttons.push({label: 'Cancel', action: 'cancel'})
|
||||
}
|
||||
|
||||
const title = opts.title || ''
|
||||
const message = opts.message
|
||||
const messageLines = !message ? []
|
||||
: Array.isArray(message) ? message
|
||||
: ('' + message).split('\n')
|
||||
|
||||
const c = opts.colours || {}
|
||||
const fg = (c.fg != null) ? c.fg : 254
|
||||
const bg = (c.bg != null) ? c.bg : 242
|
||||
const fieldBg = (c.fieldBg != null) ? c.fieldBg : 240
|
||||
const dimFg = (c.dimFg != null) ? c.dimFg : 249
|
||||
const hlFg = (c.hlFg != null) ? c.hlFg : 230
|
||||
const focusBg = (c.focusBg != null) ? c.focusBg : bg
|
||||
|
||||
// Layout
|
||||
const maxFieldW = fields.reduce((m, f) => Math.max(m, f.width), 16)
|
||||
const longestMsg = messageLines.reduce((m, l) => Math.max(m, l.length), 0)
|
||||
const titleW = title.length + 4
|
||||
const btnRowW = buttons.reduce((s, b) => s + b.label.length + 5, 0) - 1
|
||||
const w = Math.max(maxFieldW + 6, titleW + 4, longestMsg + 6, btnRowW + 4, 24)
|
||||
const msgTopOff = (messageLines.length > 0) ? 1 : 0
|
||||
const msgRows = messageLines.length + (messageLines.length > 0 ? 1 : 0)
|
||||
const fieldsBlockH = fields.length * 4
|
||||
const buttonsRowOff = 1 + msgRows + (fields.length > 0 ? fieldsBlockH + 1 : 1)
|
||||
const h = buttonsRowOff + 2
|
||||
const screen = con.getmaxyx()
|
||||
const row = Math.max(2, Math.floor((screen[0] - h) / 2))
|
||||
const col = Math.max(2, Math.floor((screen[1] - w) / 2))
|
||||
|
||||
// Pick initial focus: explicit default > first field > first button.
|
||||
let focusIdx = -1
|
||||
for (let i = 0; i < buttons.length; i++) {
|
||||
if (buttons[i].default) { focusIdx = fields.length + i; break }
|
||||
}
|
||||
if (focusIdx < 0) focusIdx = fields.length > 0 ? 0 : fields.length
|
||||
const totalFocus = fields.length + buttons.length
|
||||
let hoverBtn = -1
|
||||
let done = null
|
||||
|
||||
function fieldScroll(cur, fw) { return cur < fw ? 0 : cur - fw + 1 }
|
||||
function fieldLabelRow(i) { return row + 1 + msgRows + i * 4 }
|
||||
function fieldBoxRow(i) { return fieldLabelRow(i) + 1 }
|
||||
function fieldContentRow(i) { return fieldLabelRow(i) + 2 }
|
||||
function fieldBoxCol() { return col + 2 }
|
||||
function fieldContentRegion(i) { return { x: fieldBoxCol() + 1, y: fieldContentRow(i), w: fields[i].width } }
|
||||
|
||||
function buttonRegions() {
|
||||
let bx = col + Math.floor((w - btnRowW) / 2)
|
||||
return buttons.map(b => {
|
||||
const r = { x: bx, y: row + buttonsRowOff, w: b.label.length + 4 }
|
||||
bx += b.label.length + 5
|
||||
return r
|
||||
})
|
||||
}
|
||||
|
||||
function drawFrameBox() {
|
||||
con.color_pair(fg, bg)
|
||||
for (let r = row; r < row + h; r++) {
|
||||
con.move(r, col)
|
||||
print(' '.repeat(w))
|
||||
}
|
||||
const wo = new WindowObject(col, row, w, h, ()=>{}, ()=>{}, title)
|
||||
wo.isHighlighted = true
|
||||
wo.titleBack = bg
|
||||
wo.drawFrame()
|
||||
con.color_pair(fg, bg)
|
||||
}
|
||||
|
||||
function drawMessage() {
|
||||
if (messageLines.length === 0) return
|
||||
con.color_pair(fg, bg)
|
||||
for (let i = 0; i < messageLines.length; i++) {
|
||||
con.move(row + 1 + i, col + 2)
|
||||
print(messageLines[i].padEnd(w - 4, ' '))
|
||||
}
|
||||
}
|
||||
|
||||
function drawField(i) {
|
||||
const f = fields[i]
|
||||
const fbCol = fieldBoxCol()
|
||||
const fbRow = fieldBoxRow(i)
|
||||
const fw = f.width
|
||||
const focused = (focusIdx === i)
|
||||
const frameFg = focused ? fg : dimFg
|
||||
|
||||
// Label
|
||||
con.color_pair(fg, bg)
|
||||
con.move(fieldLabelRow(i), fbCol)
|
||||
print(f.label + ':')
|
||||
|
||||
// Top border
|
||||
con.color_pair(frameFg, bg)
|
||||
con.move(fbRow, fbCol)
|
||||
print('Ú' + 'Ä'.repeat(fw) + '¿')
|
||||
|
||||
// Side borders + content
|
||||
con.color_pair(frameFg, bg)
|
||||
con.move(fbRow + 1, fbCol)
|
||||
print('³')
|
||||
con.color_pair(fg, fieldBg)
|
||||
const s = fieldScroll(cursors[i], fw)
|
||||
const vis = values[i].substring(s, s + fw)
|
||||
print(vis.padEnd(fw, ' '))
|
||||
con.color_pair(frameFg, bg)
|
||||
con.move(fbRow + 1, fbCol + fw + 1)
|
||||
print('³')
|
||||
|
||||
// Bottom border
|
||||
con.move(fbRow + 2, fbCol)
|
||||
print('À' + 'Ä'.repeat(fw) + 'Ù')
|
||||
con.color_pair(fg, bg)
|
||||
}
|
||||
|
||||
function drawButton(i, regions) {
|
||||
const b = buttons[i]
|
||||
const bIdx = fields.length + i
|
||||
const focused = (focusIdx === bIdx)
|
||||
const hovered = (hoverBtn === i)
|
||||
const r = regions[i]
|
||||
let useFg, useBg
|
||||
if (focused && hovered) { useFg = hlFg; useBg = focusBg }
|
||||
else if (focused) { useFg = hlFg; useBg = focusBg }
|
||||
else if (hovered) { useFg = hlFg; useBg = bg }
|
||||
else { useFg = fg; useBg = bg }
|
||||
con.color_pair(useFg, useBg)
|
||||
con.move(r.y, r.x)
|
||||
print('[ ' + b.label + ' ]')
|
||||
con.color_pair(fg, bg)
|
||||
}
|
||||
|
||||
function positionCaret() {
|
||||
if (focusIdx < fields.length) {
|
||||
const fw = fields[focusIdx].width
|
||||
const s = fieldScroll(cursors[focusIdx], fw)
|
||||
con.move(fieldContentRow(focusIdx), fieldBoxCol() + 1 + (cursors[focusIdx] - s))
|
||||
con.curs_set(1)
|
||||
} else {
|
||||
con.curs_set(0)
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
drawFrameBox()
|
||||
drawMessage()
|
||||
for (let i = 0; i < fields.length; i++) drawField(i)
|
||||
const regs = buttonRegions()
|
||||
for (let i = 0; i < buttons.length; i++) drawButton(i, regs)
|
||||
positionCaret()
|
||||
}
|
||||
|
||||
function moveFocus(dir) {
|
||||
focusIdx = (focusIdx + dir + totalFocus) % totalFocus
|
||||
render()
|
||||
}
|
||||
|
||||
function activateButton(i) {
|
||||
done = { action: buttons[i].action, values: values.slice() }
|
||||
}
|
||||
|
||||
function hitTestMouse(ev) {
|
||||
const cell = _pxToCell(ev[1], ev[2])
|
||||
const cy = cell[0], cx = cell[1]
|
||||
const btnRegs = buttonRegions()
|
||||
for (let i = 0; i < btnRegs.length; i++) {
|
||||
const r = btnRegs[i]
|
||||
if (cy === r.y && cx >= r.x && cx < r.x + r.w) return { kind: 'button', idx: i }
|
||||
}
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
const r = fieldContentRegion(i)
|
||||
if (cy === r.y && cx >= r.x && cx < r.x + r.w) return { kind: 'field', idx: i, cx: cx, region: r }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
render()
|
||||
|
||||
let eventJustReceived = true
|
||||
while (done === null) {
|
||||
input.withEvent(ev => {
|
||||
if (eventJustReceived && (ev[0] === 'key_down' || ev[0] === 'mouse_down')) {
|
||||
eventJustReceived = false; return
|
||||
}
|
||||
|
||||
if (ev[0] === 'mouse_move') {
|
||||
const hit = hitTestMouse(ev)
|
||||
const newHover = (hit && hit.kind === 'button') ? hit.idx : -1
|
||||
if (newHover !== hoverBtn) {
|
||||
hoverBtn = newHover
|
||||
const regs = buttonRegions()
|
||||
for (let i = 0; i < buttons.length; i++) drawButton(i, regs)
|
||||
positionCaret()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (ev[0] === 'mouse_down') {
|
||||
if (ev[3] !== 1) return
|
||||
const hit = hitTestMouse(ev)
|
||||
if (!hit) return
|
||||
if (hit.kind === 'button') {
|
||||
focusIdx = fields.length + hit.idx
|
||||
render()
|
||||
activateButton(hit.idx)
|
||||
return
|
||||
}
|
||||
if (hit.kind === 'field') {
|
||||
focusIdx = hit.idx
|
||||
const fw = fields[hit.idx].width
|
||||
const s = fieldScroll(cursors[hit.idx], fw)
|
||||
const newCur = s + (hit.cx - hit.region.x)
|
||||
cursors[hit.idx] = Math.min(values[hit.idx].length, Math.max(0, newCur))
|
||||
render()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (ev[0] !== 'key_down') return
|
||||
if (1 !== ev[2]) return
|
||||
const ks = ev[1]
|
||||
const shiftDown = (ev.includes(59) || ev.includes(60))
|
||||
|
||||
if (ks === '<ESC>') { done = { action: 'cancel', values: values.slice() }; return }
|
||||
|
||||
if (ks === '\t' || ks === '<TAB>') { moveFocus(shiftDown ? -1 : 1); return }
|
||||
if (ks === '<UP>') { moveFocus(-1); return }
|
||||
if (ks === '<DOWN>') { moveFocus(+1); return }
|
||||
|
||||
if (ks === '<LEFT>') {
|
||||
if (focusIdx < fields.length) {
|
||||
if (cursors[focusIdx] > 0) { cursors[focusIdx] -= 1; render() }
|
||||
} else moveFocus(-1)
|
||||
return
|
||||
}
|
||||
if (ks === '<RIGHT>') {
|
||||
if (focusIdx < fields.length) {
|
||||
if (cursors[focusIdx] < values[focusIdx].length) { cursors[focusIdx] += 1; render() }
|
||||
} else moveFocus(+1)
|
||||
return
|
||||
}
|
||||
if (ks === '<HOME>') {
|
||||
if (focusIdx < fields.length) { cursors[focusIdx] = 0; render() }
|
||||
return
|
||||
}
|
||||
if (ks === '<END>') {
|
||||
if (focusIdx < fields.length) { cursors[focusIdx] = values[focusIdx].length; render() }
|
||||
return
|
||||
}
|
||||
|
||||
if (focusIdx < fields.length) {
|
||||
if (ks === '\n') {
|
||||
focusIdx = (focusIdx < fields.length - 1) ? focusIdx + 1 : fields.length
|
||||
render()
|
||||
return
|
||||
}
|
||||
if (ks === '') {
|
||||
const cur = cursors[focusIdx]
|
||||
if (cur > 0) {
|
||||
const v = values[focusIdx]
|
||||
values[focusIdx] = v.substring(0, cur - 1) + v.substring(cur)
|
||||
cursors[focusIdx] = cur - 1
|
||||
render()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (ks === '<FORWARD_DEL>' || ks === '<DEL>') {
|
||||
const cur = cursors[focusIdx]
|
||||
const v = values[focusIdx]
|
||||
if (cur < v.length) {
|
||||
values[focusIdx] = v.substring(0, cur) + v.substring(cur + 1)
|
||||
render()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (typeof ks === 'string' && ks.length === 1) {
|
||||
const code = ks.charCodeAt(0)
|
||||
if (code >= 32 && code < 256 && values[focusIdx].length < fields[focusIdx].width * 4) {
|
||||
const v = values[focusIdx]
|
||||
const cur = cursors[focusIdx]
|
||||
values[focusIdx] = v.substring(0, cur) + ks + v.substring(cur)
|
||||
cursors[focusIdx] = cur + 1
|
||||
render()
|
||||
}
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if (ks === '\n' || ks === ' ') { activateButton(focusIdx - fields.length); return }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
con.curs_set(0)
|
||||
return done
|
||||
}
|
||||
|
||||
exports = { WindowObject, scrollVert, scrollHorz, showDialog }
|
||||
|
||||
Reference in New Issue
Block a user