Compare commits

...

5 Commits

Author SHA1 Message Date
minjaesong
b103e3c690 zfm: force set bgcol on redraw 2026-05-25 01:30:26 +09:00
minjaesong
7edc3e32b1 zfm: 'more' popup 2026-05-25 01:23:16 +09:00
minjaesong
6db6a2e7ed tsvm: highlighter and popup drawing fix 2026-05-25 01:03:20 +09:00
minjaesong
0d564d5f82 tsvm: more mouse operated stuffs 2026-05-25 00:14:38 +09:00
minjaesong
6d20d346f5 tsvm: more mouse coord fix, taut: mouse support 2026-05-24 19:01:31 +09:00
8 changed files with 1559 additions and 523 deletions

View File

@@ -49,13 +49,6 @@ _fsh.QA_CMD_WIDTH = 60 // command path field width in dialog
_fsh.HL_FG = 230 _fsh.HL_FG = 230
_fsh.HL_BG = 255 _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 // Default Quick Access entries when fshrc is missing or empty
_fsh.DEFAULT_QA = [ _fsh.DEFAULT_QA = [
["Files", "/tvdos/bin/zsh.js"], ["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 // 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. // (xoff, yoff) with `length` existing entries and `maxRows` total rows.
@@ -673,7 +440,7 @@ _fsh.redrawAll = function() {
} }
_fsh.openAddTodoDialog = function() { _fsh.openAddTodoDialog = function() {
let res = _fsh.showDialog({ let res = win.showDialog({
title: "New Todo", title: "New Todo",
fields: [{label: "Text", initial: "", width: _fsh.TODO_TEXT_WIDTH}], fields: [{label: "Text", initial: "", width: _fsh.TODO_TEXT_WIDTH}],
allowDelete: false allowDelete: false
@@ -690,7 +457,7 @@ _fsh.openAddTodoDialog = function() {
_fsh.openEditTodoDialog = function(index) { _fsh.openEditTodoDialog = function(index) {
let entry = todoWidget.todoList[index] let entry = todoWidget.todoList[index]
if (!entry) return if (!entry) return
let res = _fsh.showDialog({ let res = win.showDialog({
title: "Edit Todo", title: "Edit Todo",
fields: [{label: "Text", initial: entry[0], width: _fsh.TODO_TEXT_WIDTH}], fields: [{label: "Text", initial: entry[0], width: _fsh.TODO_TEXT_WIDTH}],
allowDelete: true allowDelete: true
@@ -709,7 +476,7 @@ _fsh.openEditTodoDialog = function(index) {
} }
_fsh.openAddQaDialog = function() { _fsh.openAddQaDialog = function() {
let res = _fsh.showDialog({ let res = win.showDialog({
title: "New Quick Access", title: "New Quick Access",
fields: [ fields: [
{label: "Label", initial: "", width: _fsh.QA_LABEL_WIDTH}, {label: "Label", initial: "", width: _fsh.QA_LABEL_WIDTH},
@@ -730,7 +497,7 @@ _fsh.openAddQaDialog = function() {
_fsh.openEditQaDialog = function(index) { _fsh.openEditQaDialog = function(index) {
let entry = quickAccessWidget.entries[index] let entry = quickAccessWidget.entries[index]
if (!entry) return if (!entry) return
let res = _fsh.showDialog({ let res = win.showDialog({
title: "Edit Quick Access", title: "Edit Quick Access",
fields: [ fields: [
{label: "Label", initial: entry[0], width: _fsh.QA_LABEL_WIDTH}, {label: "Label", initial: entry[0], width: _fsh.QA_LABEL_WIDTH},
@@ -908,9 +675,13 @@ while (true) {
let navRight = edge(KEY_RIGHT) let navRight = edge(KEY_RIGHT)
// -- mouse -- // -- mouse --
// MMIO returns VM-screen pixel coords (origin at the top-left of the framebuffer).
// Widget xoff/yoff are passed straight into con.move(y, x), which is 1-indexed, so
// we offset by +1 here. Without this the click registers one cell up-and-left from
// where the user's pointer is, because pixel 0 = con.move(1, 1).
let pos = readMousePos() let pos = readMousePos()
let charX = (pos[0] / 7) | 0 let charX = (pos[0] / 7 | 0) + 1
let charY = (pos[1] / 14) | 0 let charY = (pos[1] / 14 | 0) + 1
let mouseMoved = (charX !== prevMouseCharX || charY !== prevMouseCharY) let mouseMoved = (charX !== prevMouseCharX || charY !== prevMouseCharY)
prevMouseCharX = charX prevMouseCharX = charX
prevMouseCharY = charY prevMouseCharY = charY

View File

@@ -1114,13 +1114,18 @@ inputwork.repeatCount = 0;
* where: * where:
* "key_down", <key symbol string>, <repeat count>, keycode0, keycode1 .. keycode7 * "key_down", <key symbol string>, <repeat count>, keycode0, keycode1 .. keycode7
* "key_change", <key symbol string (what went up)>, 0, keycode0, keycode1 .. keycode7 (remaining keys that are held down) * "key_change", <key symbol string (what went up)>, 0, keycode0, keycode1 .. keycode7 (remaining keys that are held down)
* "mouse_down", pos-x, pos-y, 1 // yes there's only one mouse button :p * "mouse_down", pos-x, pos-y, <button mask: 1=left, 2=right, 4=middle>, keycode0..keycode7
* "mouse_up", pos-x, pos-y, 0 * "mouse_up", pos-x, pos-y, <button mask of the released button>, keycode0..keycode7
* "mouse_move", pos-x, pos-y, <button down?>, oldpos-x, oldpos-y * "mouse_move", pos-x, pos-y, <currently-held button mask>, oldpos-x, oldpos-y, keycode0..keycode7
* "mouse_wheel", pos-x, pos-y, <-1 for wheel up, +1 for wheel down>, keycode0..keycode7
*
* Button mask values come from MMIO[36] bits 0..2 (terranmon.txt:52-58). The wheel
* bits (6, 7) latch in hardware and clear on read, so a one-shot detent fires once.
* Every mouse event carries the currently-held key buffer (same shape as key_down)
* so handlers can detect modifiers like Shift+wheel via `event.includes(<keysym>)`.
*/ */
input.withEvent = function(callback) { input.withEvent = function(callback) {
// TODO mouse event
function arrayEq(a,b) { function arrayEq(a,b) {
for (let i = 0; i < a.length; ++i) { for (let i = 0; i < a.length; ++i) {
if (a[i] !== b[i]) return false; if (a[i] !== b[i]) return false;
@@ -1141,7 +1146,33 @@ input.withEvent = function(callback) {
sys.poke(-40, 255); sys.poke(-40, 255);
let keys = [sys.peek(-41),sys.peek(-42),sys.peek(-43),sys.peek(-44),sys.peek(-45),sys.peek(-46),sys.peek(-47),sys.peek(-48)]; let keys = [sys.peek(-41),sys.peek(-42),sys.peek(-43),sys.peek(-44),sys.peek(-45),sys.peek(-46),sys.peek(-47),sys.peek(-48)];
let mouse = [sys.peek(-33) | (sys.peek(-34) << 8), sys.peek(-35) | (sys.peek(-36) << 8), sys.peek(-37)]; let mx = (sys.peek(-33) & 0xFF) | ((sys.peek(-34) & 0xFF) << 8);
let my = (sys.peek(-35) & 0xFF) | ((sys.peek(-36) & 0xFF) << 8);
let mb = sys.peek(-37) & 0xFF; // bits 0..2 = L/R/M held, bit 6 = wheel up, bit 7 = wheel down
let mouse = [mx, my, mb];
// --- mouse dispatch ---
let oldMouse = inputwork.oldMouse;
let hasOld = oldMouse && oldMouse.length === 3;
let oldBtns = hasOld ? (oldMouse[2] & 0x07) : 0;
let curBtns = mb & 0x07;
let wheelUp = (mb & 0x40) !== 0;
let wheelDn = (mb & 0x80) !== 0;
if (wheelUp) callback(["mouse_wheel", mx, my, -1].concat(keys));
if (wheelDn) callback(["mouse_wheel", mx, my, 1].concat(keys));
let pressed = curBtns & ~oldBtns;
let released = oldBtns & ~curBtns;
for (let b = 1; b <= 4; b <<= 1) {
if (pressed & b) callback(["mouse_down", mx, my, b].concat(keys));
if (released & b) callback(["mouse_up", mx, my, b].concat(keys));
}
if (hasOld && (mx !== oldMouse[0] || my !== oldMouse[1])) {
callback(["mouse_move", mx, my, curBtns, oldMouse[0], oldMouse[1]].concat(keys));
}
// --- end mouse dispatch ---
let keyChanged = !arrayEq(keys, inputwork.oldKeys) let keyChanged = !arrayEq(keys, inputwork.oldKeys)
let keyDiff = arrayDiff(keys, inputwork.oldKeys) let keyDiff = arrayDiff(keys, inputwork.oldKeys)

View File

@@ -3378,23 +3378,48 @@ function openHelpPopup() {
repaint() repaint()
let done = false 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 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) { while (!done) {
input.withEvent(ev => { 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 if (ev[0] !== 'key_down') return
if (eventJustReceived) { eventJustReceived = false; return }
const ks = ev[1] const ks = ev[1]
const shiftDown = (ev.includes(59) || ev.includes(60))
if (ks === '<ESC>' || ks === '!' || ks === 'q' || ks === '\n') { done = true } if (buttons.keyHandler(ks, shiftDown)) return
else if (ks === '<UP>') { if (scroll > 0) { scroll -= 1; repaint() } } if (ks === '<ESC>' || ks === '!' || ks === 'q') { done = true }
else if (ks === '<DOWN>') { if (scroll < maxScroll) { scroll += 1; repaint() } } else if (ks === '<UP>') { if (scroll > 0) { scroll -= 1; scrollAndRepaint() } }
else if (ks === '<PAGE_UP>') { scroll = Math.max(0, scroll - HELP_CONTENT_H); repaint() } else if (ks === '<DOWN>') { if (scroll < maxScroll) { scroll += 1; scrollAndRepaint() } }
else if (ks === '<PAGE_DOWN>') { scroll = Math.min(maxScroll, scroll + HELP_CONTENT_H); repaint() } else if (ks === '<PAGE_UP>') { scroll = Math.max(0, scroll - HELP_CONTENT_H); scrollAndRepaint() }
else if (ks === '<HOME>') { scroll = 0; repaint() } else if (ks === '<PAGE_DOWN>') { scroll = Math.min(maxScroll, scroll + HELP_CONTENT_H); scrollAndRepaint() }
else if (ks === '<END>') { scroll = maxScroll; repaint() } else if (ks === '<HOME>') { scroll = 0; scrollAndRepaint() }
else if (ks === '<END>') { scroll = maxScroll; scrollAndRepaint() }
}) })
} }
popMousePopup()
drawAll() drawAll()
} }
@@ -3432,6 +3457,76 @@ const popupDrawFrame = (wo) => {
} }
} }
// 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) { function drawGotoPopup(popup, buf) {
con.color_pair(230, colPopupBack) con.color_pair(230, colPopupBack)
popup.drawFrame() popup.drawFrame()
@@ -3463,8 +3558,8 @@ function applyGoto(num) {
} }
function openConfirmQuit() { function openConfirmQuit() {
const pw = 25 + hasUnsavedChanges * 4 const pw = 28 + hasUnsavedChanges * 4
const ph = 5 + hasUnsavedChanges const ph = 6 + hasUnsavedChanges
const px = ((SCRW - pw) / 2 | 0) + 1 const px = ((SCRW - pw) / 2 | 0) + 1
const py = ((SCRH - ph) / 2 | 0) const py = ((SCRH - ph) / 2 | 0)
@@ -3477,9 +3572,7 @@ function openConfirmQuit() {
con.move(py + 2, px + 2) con.move(py + 2, px + 2)
con.color_pair(colWHITE, colPopupBack) con.color_pair(colWHITE, colPopupBack)
print('Exit Microtone? ') print('Exit Microtone?')
con.color_pair(230, 240)
print('[Y/N]')
if (hasUnsavedChanges) { if (hasUnsavedChanges) {
con.move(py + 3, px + 2) con.move(py + 3, px + 2)
@@ -3487,29 +3580,40 @@ function openConfirmQuit() {
print('You have unsaved changes.') print('You have unsaved changes.')
} }
con.color_pair(colStatus, 255) // reset colour
let result = false let result = false
let done = 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 let eventJustReceived = true
while (!done) { while (!done) {
input.withEvent(ev => { input.withEvent(ev => {
if (eventJustReceived && ev[0] === 'mouse_down') { eventJustReceived = false; return }
if (dispatchMouseEvent(ev)) return
if (ev[0] !== 'key_down') return if (ev[0] !== 'key_down') return
if (1 !== ev[2]) return if (1 !== ev[2]) return
const ks = ev[1] const ks = ev[1]
const shiftDown = (ev.includes(59) || ev.includes(60))
if (ks === 'y' || ks === 'Y' || ks === '\n') { result = true; done = true } 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 } else if (ks === 'n' || ks === 'N' || ks === '<ESC>') { done = true }
}) })
} }
popMousePopup()
if (!result) drawAll() if (!result) drawAll()
return result return result
} }
function openGotoPopup() { function openGotoPopup() {
const pw = GOTO_POPUP_W const pw = GOTO_POPUP_W
const ph = GOTO_POPUP_H const ph = GOTO_POPUP_H + 2
const px = ((SCRW - pw) / 2 | 0) + 1 const px = ((SCRW - pw) / 2 | 0) + 1
const py = ((SCRH - ph) / 2 | 0) const py = ((SCRH - ph) / 2 | 0)
@@ -3519,36 +3623,45 @@ function openGotoPopup() {
let buf = '' let buf = ''
let done = false let done = false
drawGotoPopup(popup, buf) 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 let eventJustReceived = true
while (!done) { while (!done) {
input.withEvent(ev => { input.withEvent(ev => {
if (ev[0] !== 'key_down') return if (eventJustReceived && (ev[0] === 'key_down' || ev[0] === 'mouse_down')) {
const ks = ev[1]
if (1 !== ev[2]) return // not key just hit
if (eventJustReceived) { // filter lingering input
eventJustReceived = false eventJustReceived = false
return 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') { if (ks === '<ESC>' || ks === 'x') {
done = true done = true
} else if (ks === '\n') {
if (buf.length > 0) applyGoto(parseInt(buf, 16))
done = true
} else if (ks === '\u0008') { } else if (ks === '\u0008') {
buf = buf.slice(0, -1) buf = buf.slice(0, -1)
drawGotoPopup(popup, buf) repaintAll()
} else if (ks.length === 1 && '0123456789abcdefABCDEF'.includes(ks) && buf.length < 3) { } else if (ks.length === 1 && '0123456789abcdefABCDEF'.includes(ks) && buf.length < 3) {
buf += ks.toUpperCase() buf += ks.toUpperCase()
drawGotoPopup(popup, buf) repaintAll()
} }
}) })
} }
popMousePopup()
if (commit && buf.length > 0) applyGoto(parseInt(buf, 16))
drawAll() drawAll()
} }
@@ -3575,7 +3688,7 @@ function openRetunePopup() {
const pw = 42 const pw = 42
const listH = Math.min(n, 15) const listH = Math.min(n, 15)
const ph = listH + 5 const ph = listH + 7
const px = ((SCRW - pw) / 2 | 0) const px = ((SCRW - pw) / 2 | 0)
const py = ((SCRH - ph) / 2 | 0) const py = ((SCRH - ph) / 2 | 0)
const listX = px + 2 const listX = px + 2
@@ -3590,6 +3703,14 @@ function openRetunePopup() {
if (sel < 0) sel = 0 if (sel < 0) sel = 0
let scroll = centerScroll(sel, 0, listH, n) let scroll = centerScroll(sel, 0, listH, n)
// OK/Cancel button placement (bottom inside row)
const btnRow = py + ph - 2
const labelOK = `[ OK ]`.length
const labelCan = `[ Cancel ]`.length
const totalW = labelOK + 2 + labelCan
const btnXOk = px + ((pw - totalW) >>> 1)
const btnXCan = btnXOk + labelOK + 2
const repaint = () => { const repaint = () => {
con.color_pair(230, colPopupBack) con.color_pair(230, colPopupBack)
popup.drawFrame() popup.drawFrame()
@@ -3637,41 +3758,60 @@ function openRetunePopup() {
} }
} }
con.move(py + ph - 2, px + 2) con.move(py + ph - 3, px + 2)
con.color_pair(colVoiceHdr, colPopupBack) con.color_pair(colVoiceHdr, colPopupBack)
print(`\u008418u `) print(`\u008418u `)
con.color_pair(colStatus, colPopupBack) con.color_pair(colStatus, colPopupBack)
print(`Sel `) print(`Sel `)
con.color_pair(colVoiceHdr, colPopupBack) con.color_pair(colVoiceHdr, colPopupBack)
print(`ent `)
con.color_pair(colStatus, colPopupBack)
print(`OK `)
con.color_pair(colVoiceHdr, colPopupBack)
print(`m `) print(`m `)
con.color_pair(colStatus, colPopupBack) con.color_pair(colStatus, colPopupBack)
print(`Method `) print(`Method`)
con.color_pair(colVoiceHdr, colPopupBack)
print(`Q `) buttons.repaint()
con.color_pair(colStatus, colPopupBack)
print(`Cancel`)
con.color_pair(colStatus, 255) con.color_pair(colStatus, 255)
} }
repaint() repaint()
let done = false
let confirmed = false
let eventJustReceived = true 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) { while (!done) {
input.withEvent(ev => { 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 if (ev[0] !== 'key_down') return
if (eventJustReceived) { eventJustReceived = false; return }
const ks = ev[1] const ks = ev[1]
const shiftDown = (ev.includes(59) || ev.includes(60))
if (ks === 'Q') { done = true } if (buttons.keyHandler(ks, shiftDown)) return
else if (ks === '\n') { confirmed = true; done = true } if (ks === 'Q' || ks === '<ESC>') { done = true }
else if (ks === 'M' || ks === 'm') { else if (ks === 'M' || ks === 'm') {
method = methodCycle[(methodCycle.indexOf(method) + 1) % methodCycle.length] method = methodCycle[(methodCycle.indexOf(method) + 1) % methodCycle.length]
repaint() repaint()
@@ -3692,6 +3832,8 @@ function openRetunePopup() {
}) })
} }
popMousePopup()
if (confirmed) { if (confirmed) {
const target = entries[sel] const target = entries[sel]
if (target && target.index !== PITCH_PRESET_IDX) { if (target && target.index !== PITCH_PRESET_IDX) {
@@ -3729,7 +3871,7 @@ function openFlagsPopup() {
let sel = 0 let sel = 0
const pw = 28 const pw = 28
const ph = items.length + 4 const ph = items.length + 6
const px = ((SCRW - pw) / 2 | 0) + 1 const px = ((SCRW - pw) / 2 | 0) + 1
const py = ((SCRH - ph) / 2 | 0) const py = ((SCRH - ph) / 2 | 0)
@@ -3737,6 +3879,13 @@ function openFlagsPopup() {
popup.isHighlighted = true popup.isHighlighted = true
popup.titleBack = colPopupBack 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 = () => { const repaint = () => {
con.color_pair(230, colPopupBack) con.color_pair(230, colPopupBack)
popup.drawFrame() popup.drawFrame()
@@ -3760,32 +3909,48 @@ function openFlagsPopup() {
} }
} }
con.move(py + ph - 2, px + 2) con.move(py + ph - 3, px + 2)
con.color_pair(colVoiceHdr, colPopupBack); print(`\u008418u `) con.color_pair(colVoiceHdr, colPopupBack); print(`\u008418u `)
con.color_pair(colStatus, colPopupBack); print('Sel ') con.color_pair(colStatus, colPopupBack); print('Sel ')
con.color_pair(colVoiceHdr, colPopupBack); print('sp ') con.color_pair(colVoiceHdr, colPopupBack); print('sp ')
con.color_pair(colStatus, colPopupBack); print('Tick ') con.color_pair(colStatus, colPopupBack); print('Tick')
con.color_pair(colVoiceHdr, colPopupBack); print('ent ')
con.color_pair(colStatus, colPopupBack); print('OK ') buttons.repaint()
con.color_pair(colVoiceHdr, colPopupBack); print('Q ')
con.color_pair(colStatus, colPopupBack); print('X')
con.color_pair(colStatus, 255) con.color_pair(colStatus, 255)
} }
repaint() repaint()
let done = false
let confirmed = false
let eventJustReceived = true 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) { while (!done) {
input.withEvent(ev => { 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 if (ev[0] !== 'key_down') return
const ks = ev[1] const ks = ev[1]
if (eventJustReceived) { eventJustReceived = false; return } 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 === '<ESC>' || ks === 'q' || ks === 'Q') { done = true; return }
if (ks === '\n') { confirmed = true; done = true; return }
if (ks === '<UP>' && sel > 0) { sel--; repaint(); return } if (ks === '<UP>' && sel > 0) { sel--; repaint(); return }
if (ks === '<DOWN>' && sel < selectables.length-1) { sel++; repaint(); return } if (ks === '<DOWN>' && sel < selectables.length-1) { sel++; repaint(); return }
if (ks === ' ') { if (ks === ' ') {
@@ -3798,6 +3963,8 @@ function openFlagsPopup() {
}) })
} }
popMousePopup()
if (confirmed) { if (confirmed) {
const newFlags = (initialTrackerMixerflags & ~0x1F) | const newFlags = (initialTrackerMixerflags & ~0x1F) |
(toneMode & 3) | ((intpMode & 7) << 2) (toneMode & 3) | ((intpMode & 7) << 2)
@@ -3838,12 +4005,37 @@ function openInlineHexEdit(y, x, digits, initialValue) {
repaint() repaint()
let eventJustReceived = true let eventJustReceived = true
// Field spans " $XX " — onClick on a digit moves the cursor there.
// Outside-click commits (Enter); right-click cancels.
// Region order matters: dispatchMouseEvent searches in reverse, so the
// field region (registered last) is tested before the catch-all.
pushMousePopup([
{ x: 1, y: 1, w: SCRW, h: SCRH, onClick: (cy, cx, btn) => {
if (btn === 1) done = true
else if (btn === 2) { cancelled = true; done = true }
}},
{ x: x + 2, y: y, w: digits, h: 1, onClick: (cy, cx, btn) => {
if (btn === 1) { cur = cx - (x + 2); repaint() }
else if (btn === 2) { cancelled = true; done = true }
}, onWheel: (cy, cx, dy) => {
// Wheel adjusts the digit under the cursor.
const digit = parseInt(buf[cur], 16)
const next = (digit + (dy < 0 ? 1 : -1) + 16) & 0xF
buf = buf.substring(0, cur) + next.toString(16).toUpperCase() + buf.substring(cur + 1)
repaint()
}},
])
while (!done) { while (!done) {
input.withEvent(ev => { 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 if (ev[0] !== 'key_down') return
if (1 !== ev[2]) return if (1 !== ev[2]) return
const ks = ev[1] const ks = ev[1]
if (eventJustReceived) { eventJustReceived = false; return }
if (ks === '<ESC>') { cancelled = true; done = true; return } if (ks === '<ESC>') { cancelled = true; done = true; return }
if (ks === '\n') { done = true; return } if (ks === '\n') { done = true; return }
@@ -3861,6 +4053,8 @@ function openInlineHexEdit(y, x, digits, initialValue) {
}) })
} }
popMousePopup()
return cancelled ? null : parseInt(buf, 16) return cancelled ? null : parseInt(buf, 16)
} }
@@ -3879,6 +4073,339 @@ function isExternalPanel(p) {
return p === VIEW_SAMPLES || p === VIEW_INSTRMNT || p === VIEW_FILE return p === VIEW_SAMPLES || p === VIEW_INSTRMNT || p === VIEW_FILE
} }
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// MOUSE INPUT
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Region registry. Coordinates are 1-indexed text cell positions. Each region:
// {x, y, w, h, onClick(cy, cx, btn, ev)?, onWheel(cy, cx, dy, ev)?, onRelease(...)?}
// MOUSE_GLOBAL — tabs + transport, live for the whole session.
// MOUSE_PANEL — per-panel viewport handlers, cleared whenever the panel changes.
// MOUSE_POPUP_STACK — popups push their own region set on open and pop on close;
// while non-empty, only the topmost set receives mouse events.
const MOUSE_GLOBAL = []
const MOUSE_PANEL = []
const MOUSE_POPUP_STACK = []
// Wrap push/pop so closing a popup also drops any onHoverLeave that would otherwise
// be invoked against the popup's stale regions on the next mouse move.
function pushMousePopup(regions) { MOUSE_POPUP_STACK.push(regions); lastHoveredRegion = null }
function popMousePopup() { MOUSE_POPUP_STACK.pop(); lastHoveredRegion = null }
function pixelToCell(px, py) {
return [(py / CELL_PH | 0) + 1, (px / CELL_PW | 0) + 1] // [cy, cx], 1-indexed
}
function regionHits(r, cy, cx) {
return cy >= r.y && cy < r.y + r.h && cx >= r.x && cx < r.x + r.w
}
// Dispatch a mouse event to the topmost matching region. Returns true if handled.
// `mouse_move` also fires onHoverLeave for the previously-hovered region so popups can
// repaint un-hovered buttons without tracking that themselves.
let 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.concat(MOUSE_GLOBAL)
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
}
function clearPanelMouseRegions() { MOUSE_PANEL.length = 0 }
function addPanelMouseRegion(x, y, w, h, handlers) { MOUSE_PANEL.push(Object.assign({x, y, w, h}, handlers)) }
function addGlobalMouseRegion(x, y, w, h, handlers) { MOUSE_GLOBAL.push(Object.assign({x, y, w, h}, handlers)) }
// Apply the same panel-switch logic the Tab key path uses.
function switchToPanel(newPanel) {
if (newPanel === currentPanel) return
const wasTimeline = (currentPanel === VIEW_TIMELINE)
currentPanel = newPanel
applyMuteTransition(currentPanel)
if (wasTimeline && currentPanel !== VIEW_TIMELINE) clearVoiceMeters()
if (isExternalPanel(currentPanel)) {
clearPanelMouseRegions()
con.clear(); drawAlwaysOnElems(); drawControlHint()
pendingExternalDraw = true
} else {
rebuildPanelMouseRegions()
drawAll()
}
}
// --- Tab bar regions (registered once; tab geometry is constant) ---
function registerTabRegions() {
let col = 2 // XOFF, mirrors drawTabBar
for (let i = 0; i < PANEL_NAMES.length; i++) {
const w = 1 + PANEL_NAMES[i].length + 1 // spcL + name + spcR
const tabIdx = i
addGlobalMouseRegion(col, 3, w, 1, {
onClick: (cy, cx, btn) => { if (btn === 1) switchToPanel(tabIdx) }
})
col += w + (i < PANEL_NAMES.length - 1 ? TAB_GAP : 0)
}
}
// --- Transport regions (rows 1-2 on the right edge) ---
// Order j: 0=stop, 1=playrow, 2=playcue, 3=playall — mirrors drawStatusBar's loop.
function registerTransportRegions() {
for (let j = 0; j < 4; j++) {
const glyphCol = SCRW - 5 * (j + 1) + 3
const idx = j
addGlobalMouseRegion(glyphCol - 1, 1, 3, 2, {
onClick: (cy, cx, btn) => {
if (btn !== 1) return
if (idx === 0) {
if (playbackMode !== PLAYMODE_NONE) { stopPlayback(); drawAlwaysOnElems(); redrawPanel() }
return
}
// The play handlers vary by panel — match the keyboard shortcut mapping.
if (currentPanel === VIEW_PATTERN_DETAILS) {
if (idx === 1) startPlayPatternRow()
else startPlayPattern()
drawPatternsContents(panelPatterns)
} else {
if (idx === 1) startPlayRow()
else if (idx === 2) startPlayCue()
else startPlaySong()
redrawPanel()
}
drawAlwaysOnElems()
}
})
}
}
// --- Per-panel viewport regions ---
function rebuildPanelMouseRegions() {
clearPanelMouseRegions()
if (currentPanel === VIEW_TIMELINE) registerTimelineMouse()
else if (currentPanel === VIEW_CUES) registerOrdersMouse()
else if (currentPanel === VIEW_PATTERN_DETAILS) registerPatternsMouse()
else if (currentPanel === VIEW_PROJECT) registerProjectMouse()
}
function registerTimelineMouse() {
addPanelMouseRegion(1, PTNVIEW_OFFSET_Y, SCRW, PTNVIEW_HEIGHT, {
onClick: (cy, cx, btn) => {
if (btn !== 1 || playbackMode !== PLAYMODE_NONE) return
const viewRow = cy - PTNVIEW_OFFSET_Y
const targetRow = scrollRow + viewRow
if (targetRow < 0 || targetRow >= ROWS_PER_PAT) return
const oldCursor = cursorRow
const oldVoxOff = voiceOff
cursorRow = targetRow
const relCol = cx - PTNVIEW_OFFSET_X
if (relCol >= 0) {
const colSlot = (relCol / COLSIZE_TIMELINE_FULL) | 0
const targetVox = voiceOff + colSlot
if (targetVox >= 0 && targetVox < song.numVoices) {
cursorVox = targetVox
const fieldX = relCol - colSlot * COLSIZE_TIMELINE_FULL
let field = 0
for (let k = 0; k < TL_FIELD_OFFSETS.length; k++) if (fieldX >= TL_FIELD_OFFSETS[k]) field = k
timelineColCursor = field
}
}
clampCursor(); clampVoice()
if (voiceOff !== oldVoxOff || Math.abs(cursorRow - oldCursor) >= PTNVIEW_HEIGHT) drawAll()
else {
drawPatternView(); drawVoiceHeaders(); drawSeparators(separatorStyle)
drawAlwaysOnElems(); drawVoiceDetail()
}
},
onWheel: (cy, cx, dy) => {
if (playbackMode !== PLAYMODE_NONE) return
cursorRow += dy * 3
clampCursor()
drawPatternView(); drawSeparators(separatorStyle); drawAlwaysOnElems(); drawVoiceDetail()
}
})
}
function registerOrdersMouse() {
// Layout (1-indexed cells, mirrors drawOrdersRowAt):
// cols 1..3 = row number (no column meaning)
// col 4 = gap
// cols 5..10 = CMD (ordersColCursor = 0)
// col 11 = gap
// cols 12 + s*4 .. 12 + s*4 + 3 = voice slot s on screen
// (ordersColCursor = ordersVoiceOff + s + 1)
//
// Returns the ordersColCursor value for a given cx, or -1 if not on a column.
const colAtX = (cx) => {
if (cx >= ORDERS_CMD_X && cx < ORDERS_CMD_X + 6) return 0
if (cx >= ORDERS_VOICE_X) {
const slot = ((cx - ORDERS_VOICE_X) / ORDERS_VOICE_COL_W) | 0
if (slot < 0 || slot >= VOCSIZE_ORDERS) return -1
const v = ordersVoiceOff + slot
if (v >= song.numVoices) return -1
return v + 1
}
return -1
}
const hscrollBy = (dx) => {
const maxOff = Math.max(0, song.numVoices - VOCSIZE_ORDERS)
const next = Math.max(0, Math.min(maxOff, ordersVoiceOff + dx))
if (next === ordersVoiceOff) return false
ordersVoiceOff = next
return true
}
// Header row: click selects a column without touching the row; wheel scrolls
// voice columns horizontally (it's the natural place for column navigation).
addPanelMouseRegion(1, PTNVIEW_OFFSET_Y - 1, SCRW, 1, {
onClick: (cy, cx, btn) => {
if (btn !== 1 || playbackMode !== PLAYMODE_NONE) return
const col = colAtX(cx)
if (col < 0) return
ordersColCursor = col
clampOrdersHoriz(); redrawPanel(); drawAlwaysOnElems()
},
onWheel: (cy, cx, dy) => {
if (hscrollBy(dy * 3)) { redrawPanel(); drawAlwaysOnElems() }
},
})
// Content rows: click sets the row and (when on a column) the column too;
// wheel scrolls vertically; Shift+wheel scrolls horizontally.
addPanelMouseRegion(1, PTNVIEW_OFFSET_Y, SCRW, PTNVIEW_HEIGHT, {
onClick: (cy, cx, btn, ev) => {
if (btn !== 1 || playbackMode !== PLAYMODE_NONE) return
const viewRow = cy - PTNVIEW_OFFSET_Y
const targetIdx = ordersScroll + viewRow
const maxCue = song.lastActiveCue < 0 ? 0 : song.lastActiveCue
if (targetIdx < 0 || targetIdx > maxCue) return
ordersCursor = targetIdx
const col = colAtX(cx)
if (col >= 0) ordersColCursor = col
scrollOrdersTo(ordersCursor)
clampOrdersHoriz()
redrawPanel(); drawAlwaysOnElems()
},
onWheel: (cy, cx, dy, ev) => {
const shiftDown = (ev.includes(59) || ev.includes(60))
if (shiftDown) {
if (hscrollBy(dy * 3)) { redrawPanel(); drawAlwaysOnElems() }
return
}
const maxCue = song.lastActiveCue < 0 ? 0 : song.lastActiveCue
ordersCursor += dy * 3
if (ordersCursor < 0) ordersCursor = 0
if (ordersCursor > maxCue) ordersCursor = maxCue
scrollOrdersTo(ordersCursor)
redrawPanel(); drawAlwaysOnElems()
}
})
}
function registerPatternsMouse() {
// Left column: pattern list. cx in [PATEDITOR_LIST_X, PATEDITOR_SEP1_X)
addPanelMouseRegion(PATEDITOR_LIST_X, PTNVIEW_OFFSET_Y,
PATEDITOR_SEP1_X - PATEDITOR_LIST_X, PTNVIEW_HEIGHT, {
onClick: (cy, cx, btn) => {
if (btn !== 1 || song.numPats === 0 || playbackMode !== PLAYMODE_NONE) return
const targetIdx = patternListScroll + (cy - PTNVIEW_OFFSET_Y)
if (targetIdx < 0 || targetIdx >= song.numPats) return
patternIdx = targetIdx
clampPatternIdx(); simStateKey = ''
drawPatternsContents(panelPatterns)
},
onWheel: (cy, cx, dy) => {
if (song.numPats === 0) return
patternIdx += dy
clampPatternIdx(); simStateKey = ''
drawPatternsContents(panelPatterns)
}
})
// Middle grid: pattern editor cells. cx in [PATEDITOR_GRID_X, PATEDITOR_DETAIL_X)
addPanelMouseRegion(PATEDITOR_GRID_X, PTNVIEW_OFFSET_Y,
PATEDITOR_DETAIL_X - PATEDITOR_GRID_X, PTNVIEW_HEIGHT, {
onClick: (cy, cx, btn) => {
if (btn !== 1 || song.numPats === 0 || playbackMode !== PLAYMODE_NONE) return
const targetRow = patternGridScroll + (cy - PTNVIEW_OFFSET_Y)
if (targetRow < 0 || targetRow >= ROWS_PER_PAT) return
patternGridRow = targetRow
const cellRel = cx - PATEDITOR_CELL_X
const fieldOffsets = [0, 5, 8, 11, 14, 15]
let field = 0
for (let k = 0; k < fieldOffsets.length; k++) if (cellRel >= fieldOffsets[k]) field = k
if (field < 0) field = 0; if (field > 5) field = 5
patternGridCol = field
clampPatternGrid(); simStateKey = ''
drawPatternsContents(panelPatterns)
},
onWheel: (cy, cx, dy) => {
if (song.numPats === 0) return
patternGridRow += dy * 3
clampPatternGrid(); simStateKey = ''
drawPatternsContents(panelPatterns)
}
})
}
function registerProjectMouse() {
addPanelMouseRegion(1, PTNVIEW_OFFSET_Y, SCRW, PTNVIEW_HEIGHT, {
onClick: (cy, cx, btn) => {
if (btn !== 1 || playbackMode !== PLAYMODE_NONE) return
// Meta rows occupy PTNVIEW_OFFSET_Y .. PTNVIEW_OFFSET_Y + PROJ_META_ROWS_COUNT - 1.
// The song list starts at PROJ_SONGLIST_Y + 1.
const metaRow = cy - PTNVIEW_OFFSET_Y
if (metaRow >= 0 && metaRow < PROJ_META_ROWS_COUNT) {
projectCursor = metaRow
clampProjectCursor(); redrawPanel()
return
}
const songRow = cy - (PROJ_SONGLIST_Y + 1)
if (songRow >= 0) {
const songIdx = projectSongScroll + songRow
if (songIdx >= 0 && songIdx < songsMeta.numSongs) {
projectCursor = PROJ_META_ROWS_COUNT + songIdx
clampProjectCursor(); redrawPanel()
}
}
},
onWheel: (cy, cx, dy) => {
const rowsVis = projectSongListRowsVisible()
const maxScroll = Math.max(0, songsMeta.numSongs - rowsVis)
projectSongScroll += dy * 3
if (projectSongScroll < 0) projectSongScroll = 0
if (projectSongScroll > maxScroll) projectSongScroll = maxScroll
redrawPanel()
}
})
}
registerTabRegions()
registerTransportRegions()
rebuildPanelMouseRegions()
// Launching a sub-program from inside an input.withEvent callback causes the triggering // Launching a sub-program from inside an input.withEvent callback causes the triggering
// Tab event to leak into the sub-program's own withEvent call (the event hasn't been // Tab event to leak into the sub-program's own withEvent call (the event hasn't been
// consumed yet when the callback is still executing). We avoid this by deferring the // consumed yet when the callback is still executing). We avoid this by deferring the
@@ -3888,6 +4415,7 @@ let pendingExternalDraw = false
while (!exitFlag) { while (!exitFlag) {
input.withEvent(event => { input.withEvent(event => {
if (dispatchMouseEvent(event)) return
if (event[0] !== "key_down") return if (event[0] !== "key_down") return
const keysym = event[1] const keysym = event[1]
const keyJustHit = (1 == event[2]) const keyJustHit = (1 == event[2])
@@ -3914,9 +4442,11 @@ while (!exitFlag) {
if (isExternalPanel(currentPanel)) { if (isExternalPanel(currentPanel)) {
// Redraw header now so the tab highlight is visible immediately, // Redraw header now so the tab highlight is visible immediately,
// but defer the actual sub-program launch to after withEvent returns. // but defer the actual sub-program launch to after withEvent returns.
clearPanelMouseRegions()
con.clear(); drawAlwaysOnElems(); drawControlHint() con.clear(); drawAlwaysOnElems(); drawControlHint()
pendingExternalDraw = true pendingExternalDraw = true
} else { } else {
rebuildPanelMouseRegions()
drawAll() drawAll()
} }
return return
@@ -3947,9 +4477,11 @@ while (!exitFlag) {
applyMuteTransition(currentPanel) applyMuteTransition(currentPanel)
if (wasTimeline && currentPanel !== VIEW_TIMELINE) clearVoiceMeters() if (wasTimeline && currentPanel !== VIEW_TIMELINE) clearVoiceMeters()
if (isExternalPanel(currentPanel)) { if (isExternalPanel(currentPanel)) {
clearPanelMouseRegions()
con.clear(); drawAlwaysOnElems(); drawControlHint() con.clear(); drawAlwaysOnElems(); drawControlHint()
redrawPanel() redrawPanel()
} else { } else {
rebuildPanelMouseRegions()
drawAll() drawAll()
} }
} }

View File

@@ -19,7 +19,10 @@ const LIST_HEIGHT = HEIGHT - 3
const FILESIZE_WIDTH = 7 const FILESIZE_WIDTH = 7
const FILELIST_WIDTH = WIDTH - SIDEBAR_WIDTH - 3 - FILESIZE_WIDTH const FILELIST_WIDTH = WIDTH - SIDEBAR_WIDTH - 3 - FILESIZE_WIDTH
const POPUP_WIDTH = 52 // always even number 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 = { const COL_HL_EXT = {
"js": 215, "js": 215,
@@ -69,6 +72,11 @@ const EXEC_FUNS = {
"taud": (f) => _G.shell.execute(`playtaud "${f}"`), "taud": (f) => _G.shell.execute(`playtaud "${f}"`),
} }
const EDIT_FUNS = {
"taud": (f) => _G.shell.execute(`microtone "${f}"`),
}
const DEFAULT_EDITOR = `edit`
function makeExecFun(template) { function makeExecFun(template) {
return (f) => _G.shell.execute(template.replaceAll("{0}", `"${f}"`)) return (f) => _G.shell.execute(template.replaceAll("{0}", `"${f}"`))
} }
@@ -118,8 +126,52 @@ function loadZfmrc() {
loadZfmrc() loadZfmrc()
///////////////////////////////////////////////////////////////////////////////////////////////////
// Mouse region registry
///////////////////////////////////////////////////////////////////////////////////////////////////
const MOUSE_PANEL = []
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 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])
if (t === 'mouse_move') {
let hit = null
for (let i = MOUSE_PANEL.length - 1; i >= 0; i--) {
const r = MOUSE_PANEL[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 = MOUSE_PANEL.length - 1; i >= 0; i--) {
const r = MOUSE_PANEL[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 windowMode = 0 // 0 == left, 1 == right
let windowFocus = [0] // is a stack; 0: files window, 1: palette window, 2: popup window
// window states // window states
let path = [["A:", "home"], ["A:"]] let path = [["A:", "home"], ["A:"]]
@@ -347,11 +399,43 @@ let filesPanelDraw = (wo) => {
con.color_pair(COL_TEXT, COL_BACK) 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) => { let opPanelDraw = (wo) => {
function hr(y) { function hr(i, y) {
// draw horizontal rule...
con.color_pair(COL_TEXT, 255)
con.move(y, xp) con.move(y, xp)
print(`\x84196u`.repeat(SIDEBAR_WIDTH - 2)) print(`\u00C4`.repeat(SIDEBAR_WIDTH - 2))
// if mouse is up, draw the whole box
if (opHover == i) {
let moveBack = (i == 0) ? 6 : 3
con.color_pair(COL_HLTEXT, 255)
con.move(y - moveBack, xp)
print('\u00CD'.repeat(SIDEBAR_WIDTH - 2))
con.move(y, xp)
print('\u00CD'.repeat(SIDEBAR_WIDTH - 2))
}
} }
function labCol(i) { return (opHover === i) ? COL_HLTEXT : COL_TEXT }
con.color_pair(COL_TEXT, COL_BACK) con.color_pair(COL_TEXT, COL_BACK)
@@ -360,112 +444,83 @@ let opPanelDraw = (wo) => {
// other panel // other panel
con.move(yp + 2, xp + 3) con.move(yp + 2, xp + 3)
con.prnch((windowMode) ? 0x11 : 0x10) con.color_pair(labCol(0), 255); con.prnch((windowMode) ? 0x11 : 0x10)
con.move(yp + 3, xp) 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) hr(0, yp+5)
// go up // go up
con.mvaddch(yp + 6, xp + 3, 0x18) con.color_pair(labCol(1), 255); con.mvaddch(yp + 6, xp + 3, 0x18)
con.move(yp + 7, xp) 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) hr(1, yp+8)
// copy // copy
con.move(yp + 9, xp + 2) con.move(yp + 9, xp + 2)
con.prnch(0xDB);con.prnch((windowMode) ? 0x1B : 0x1A);con.prnch(0xDB) con.color_pair(labCol(2), 255); con.prnch(0xDB);con.prnch((windowMode) ? 0x1B : 0x1A);con.prnch(0xDB)
con.move(yp + 10, xp) 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) hr(2, yp+11)
// move // move
con.move(yp + 12, xp + 2) con.move(yp + 12, xp + 2)
if (windowMode) con.prnch([0xDB, 0x1B, 0xB0]); else con.prnch([0xB0, 0x1A, 0xDB]) con.color_pair(labCol(3), 255); if (windowMode) con.prnch([0xDB, 0x1B, 0xB0]); else con.prnch([0xB0, 0x1A, 0xDB])
con.move(yp + 13, xp) 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) hr(3, yp+14)
// delete // delete
con.move(yp + 15, xp + 2) con.move(yp + 15, xp + 2)
if (windowMode) con.prnch([0xDB, 0x1A, 0xF9]); else con.prnch([0xF9, 0x1B, 0xDB]) con.color_pair(labCol(4), 255); if (windowMode) con.prnch([0xDB, 0x1A, 0xF9]); else con.prnch([0xF9, 0x1B, 0xDB])
con.move(yp + 16, xp) 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) hr(4, yp+17)
// mkdir // mkdir
con.move(yp + 18, xp + 2) con.move(yp + 18, xp + 2)
con.color_pair(labCol(5), 255);
con.prnch(0xDB) con.prnch(0xDB)
con.video_reverse();con.prnch(0x2B);con.video_reverse() con.video_reverse();con.prnch(0x2B);con.video_reverse()
con.prnch(0xDF) con.prnch(0xDF)
con.move(yp + 19, xp) 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) hr(5, yp+20)
// rename // rename
con.move(yp + 21, xp + 2) con.move(yp + 21, xp + 2)
con.prnch(0x4E);con.prnch(0x1A);con.prnch(0x52) con.color_pair(labCol(6), 255); con.prnch(0x4E);con.prnch(0x1A);con.prnch(0x52)
con.move(yp + 22, xp) 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) hr(6, yp+23)
// the dreaded hamburger menu // the dreaded hamburger menu
con.move(yp + 24, xp + 3) con.move(yp + 24, xp + 3)
con.prnch(0xf0) con.color_pair(labCol(7), 255); con.prnch(0xf0)
con.move(yp + 25, xp) 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) hr(7, yp+26)
// quit // quit
con.move(yp + 27, xp + 3) con.move(yp + 27, xp + 3)
con.prnch(0x58) con.color_pair(labCol(8), 255); con.prnch(0x58)
con.move(yp + 28, xp) 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`)
con.color_pair(COL_TEXT, 255)
} }
let paletteDraw = (wo) => {
function hr(y) {
con.move(y, xp)
print(`\x84196u`.repeat(POPUP_WIDTH - 2))
}
con.color_pair(COL_TEXT, COL_BACK)
let xp = wo.x + 1
let yp = wo.y + 1
// erase first
for (let y = 0; y <= POPUP_HEIGHT-2; y++) {
con.move(yp + y, xp)
print(" ".repeat(POPUP_WIDTH-2))
}
// finally draw something
con.move(yp, xp)
print("More commands (hit m to return):")
}
let popupDraw = (wo) => {
}
///////////////////////////////////////////////////////////////////////////////////////////////////
let filenavOninput = (window, event) => { let filenavOninput = (window, event) => {
let eventName = event[0] let eventName = event[0]
if (eventName == "key_down") { if (eventName !== "key_down") return
let keysym = event[1] let keysym = event[1]
let keyJustHit = (1 == event[2]) let keyJustHit = (1 == event[2])
@@ -474,13 +529,15 @@ let filenavOninput = (window, event) => {
let scrollPeek = (LIST_HEIGHT / 3)|0 let scrollPeek = (LIST_HEIGHT / 3)|0
if (keyJustHit && keysym == "q") { if (keyJustHit && keysym == "q") actQuit()
exit = true else if (keyJustHit && keysym == "z") actSwitchPanel()
} else if (keyJustHit && keysym == 'u') actGoUp()
else if (keyJustHit && keysym == "z") { else if (keyJustHit && keysym == 'c') actCopy()
windowMode = 1 - windowMode else if (keyJustHit && keysym == 'v') actMove()
redraw() // this would double-redraw (hence no panel switching) or something if redraw() is not merely a request to do so 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>") { else if (keysym == "<UP>") {
[cursor[windowMode], scroll[windowMode]] = win.scrollVert(-1, dirFileList[windowMode].length, LIST_HEIGHT, cursor[windowMode], scroll[windowMode], scrollPeek) [cursor[windowMode], scroll[windowMode]] = win.scrollVert(-1, dirFileList[windowMode].length, LIST_HEIGHT, cursor[windowMode], scroll[windowMode], scrollPeek)
drawFilePanel() drawFilePanel()
@@ -498,142 +555,59 @@ let filenavOninput = (window, event) => {
drawFilePanel() drawFilePanel()
} }
else if (keyJustHit && keycode == 66) { // enter else if (keyJustHit && keycode == 66) { // enter
let selectedFileCache = filePanelCache[windowMode][cursor[windowMode]] actActivate()
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()
}
} }
} }
///////////////////////////////////////////////////////////////////////////////////////////////////
// Popup wrappers (delegate to win.showDialog in wintex.mjs)
///////////////////////////////////////////////////////////////////////////////////////////////////
function showConfirmPopup(title, message) {
let paletteInput = (window, event) => { const res = win.showDialog({
title: title,
let eventName = event[0] message: message,
if (eventName == "key_down") { fields: [],
buttons: [
let keysym = event[1] { label: 'OK', action: 'ok', default: true },
let keyJustHit = (1 == event[2]) { label: 'CANCEL', action: 'cancel' },
let keycodes = [event[3],event[4],event[5],event[6],event[7],event[8],event[9],event[10]] ],
let keycode = keycodes[0] })
return res.action === 'ok'
if (keyJustHit && keysym == 'm') {
removePopup(); redraw()
}
}
} }
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) {
let popupInput = (window, event) => { win.showDialog({
title: title,
message: message,
fields: [],
buttons: [{ label: 'OK', action: 'ok', default: true }],
})
} }
/////////////////////////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////////////////////////
let windows = [ const windows = [
/*index 0: main three panels*/[
new win.WindowObject(1, 2, WIDTH - SIDEBAR_WIDTH, HEIGHT, filenavOninput, filesPanelDraw), // left panel new win.WindowObject(1, 2, WIDTH - SIDEBAR_WIDTH, HEIGHT, filenavOninput, filesPanelDraw), // left panel
new win.WindowObject(WIDTH - SIDEBAR_WIDTH+1, 2, SIDEBAR_WIDTH, HEIGHT, ()=>{}, opPanelDraw), new win.WindowObject(WIDTH - SIDEBAR_WIDTH+1, 2, SIDEBAR_WIDTH, HEIGHT, ()=>{}, opPanelDraw),
// new win.WindowObject(1, 2, SIDEBAR_WIDTH, HEIGHT, ()=>{}, opPanelDraw),
new win.WindowObject(SIDEBAR_WIDTH + 1, 2, WIDTH - SIDEBAR_WIDTH, HEIGHT, filenavOninput, filesPanelDraw), // right panel new win.WindowObject(SIDEBAR_WIDTH + 1, 2, WIDTH - SIDEBAR_WIDTH, HEIGHT, filenavOninput, filesPanelDraw), // right panel
], ]
/*index 1: commands palette*/[
new win.WindowObject((WIDTH - POPUP_WIDTH) / 2, (HEIGHT - POPUP_HEIGHT) / 2, POPUP_WIDTH, POPUP_HEIGHT, paletteInput, paletteDraw, "Commands")
],
/*index 2: popup messages*/[
new win.WindowObject((WIDTH - POPUP_WIDTH) / 2, (HEIGHT - POPUP_HEIGHT) / 2, POPUP_WIDTH, POPUP_HEIGHT, popupInput, popupDraw)
]]
const LEFTPANEL = windows[0][0] const LEFTPANEL = windows[0]
const OPPANEL = windows[0][1] const OPPANEL = windows[1]
const RIGHTPANEL = windows[0][2] const RIGHTPANEL = windows[2]
let currentPopup = 0
function makePopup(index) {
currentPopup = index
windowFocus.push(currentPopup)
for (let i = 0; i < windows.length; i++) {
windows[i].forEach(it => {
it.isHighlighted = (i == index)
})
}
}
function removePopup() {
windowFocus.pop()
const index = windowFocus.last
currentPopup = 0
for (let i = 0; i < windows.length; i++) {
windows[i].forEach(it => {
it.isHighlighted = (i == index)
})
}
}
function drawTitle() { function drawTitle() {
// draw window title // draw window title
@@ -651,18 +625,9 @@ function drawTitle() {
function drawFilePanel() { function drawFilePanel() {
// set highlight status windows.forEach((panel, i) => {
const currentTopPanel = windowFocus.last() panel.isHighlighted = (i == 2 * windowMode)
if (currentTopPanel == 0) { })
windows[0].forEach((panel, i)=>{
panel.isHighlighted = (i == 2 * windowMode)
})
}
else {
windows[0].forEach((panel, i)=>{
panel.isHighlighted = false
})
}
if (windowMode) { if (windowMode) {
RIGHTPANEL.drawContents() RIGHTPANEL.drawContents()
RIGHTPANEL.drawFrame() RIGHTPANEL.drawFrame()
@@ -683,14 +648,6 @@ function drawOpPanel() {
OPPANEL.drawFrame() OPPANEL.drawFrame()
} }
function drawPopupPanel() {
if (currentPopup) {
windows[currentPopup][0].drawContents()
windows[currentPopup][0].drawFrame()
}
}
function redraw() { function redraw() {
redrawRequested = true redrawRequested = true
} }
@@ -700,7 +657,7 @@ function _redraw() {
drawTitle() drawTitle()
drawFilePanel() drawFilePanel()
drawOpPanel() drawOpPanel()
drawPopupPanel() setupPanelMouseRegions()
} }
function clearScr() { function clearScr() {
@@ -708,6 +665,354 @@ function clearScr() {
graphics.setBackground(34,51,68) graphics.setBackground(34,51,68)
graphics.clearPixels(255) graphics.clearPixels(255)
graphics.setGraphicsMode(0) graphics.setGraphicsMode(0)
con.color_pair(COL_TEXT, COL_BACK)
}
///////////////////////////////////////////////////////////////////////////////////////////////////
// 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() {
if (path[windowMode].length === 0) return
const cache = filePanelCache[windowMode][cursor[windowMode]]
if (!cache || !cache.file || cache.isDirectory) return
const res = win.showDialog({
title: 'More',
message: cache.file.name,
fields: [],
buttons: [
{ label: 'Execute', action: 'execute', default: true },
{ label: 'Edit', action: 'edit' },
{ label: 'Close', action: 'close' },
],
})
_redraw()
if (res.action === 'execute') {
actActivate()
return
}
if (res.action === 'edit') {
const editfun = EDIT_FUNS[cache.fileext]
|| ((f) => _G.shell.execute(`${DEFAULT_EDITOR} "${f}"`))
let errorlevel = 0
con.curs_set(1); clearScr(); con.move(1, 1)
try {
errorlevel = editfun(cache.file.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 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)
}
})
}
} }
/////////////////////////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////////////////////////
@@ -717,6 +1022,12 @@ refreshFilePanelCache(0)
refreshFilePanelCache(1) refreshFilePanelCache(1)
_redraw() _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 redrawRequested = false
let exit = false let exit = false
let firstRunLatch = true let firstRunLatch = true
@@ -724,6 +1035,14 @@ let firstRunLatch = true
while (!exit) { while (!exit) {
input.withEvent(event => { input.withEvent(event => {
if (dispatchMouseEvent(event)) {
if (redrawRequested) {
redrawRequested = false
_redraw()
}
return
}
let keysym = event[1] let keysym = event[1]
let keyJustHit = (1 == event[2]) let keyJustHit = (1 == event[2])
@@ -735,7 +1054,7 @@ while (!exit) {
firstRunLatch = false firstRunLatch = false
} }
else { else {
windows[windowFocus.last()].forEach(it => { windows.forEach(it => {
if (it.isHighlighted) { // double input processing without this? wtf?! if (it.isHighlighted) { // double input processing without this? wtf?!
it.processInput(event) it.processInput(event)
} }

View File

@@ -180,4 +180,349 @@ function scrollHorz(dx, stringSize, stringViewSize, currentCursorPos, currentScr
return [currentCursorPos, currentScrollPos] 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 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 : 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('\u00DA' + '\u00C4'.repeat(fw) + '\u00BF')
// Side borders + content
con.color_pair(frameFg, bg)
con.move(fbRow + 1, fbCol)
print('\u00B3')
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('\u00B3')
// Bottom border
con.move(fbRow + 2, fbCol)
print('\u00C0' + '\u00C4'.repeat(fw) + '\u00D9')
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)
con.color_pair(oldFG, oldBG)
return done
}
exports = { WindowObject, scrollVert, scrollHorz, showDialog }

View File

@@ -49,7 +49,13 @@ MMIO
0..31 RO: Raw Keyboard Buffer read. Won't shift the key buffer 0..31 RO: Raw Keyboard Buffer read. Won't shift the key buffer
32..33 RO: Mouse X pos 32..33 RO: Mouse X pos
34..35 RO: Mouse Y pos 34..35 RO: Mouse Y pos
36 RO: Mouse down? (1 for LEFT, 2 for RIGHT, 3 for BOTH) 36 RO: Mouse down?
bit 0: left
bit 1: right
bit 2: middle
bit 6: wheel up
bit 7: wheel down
37 RW: Read/Write single key input. Key buffer will be shifted. Manual writing is 37 RW: Read/Write single key input. Key buffer will be shifted. Manual writing is
usually unnecessary as such action must be automatically managed via LibGDX usually unnecessary as such action must be automatically managed via LibGDX
input processing. input processing.

View File

@@ -12,6 +12,7 @@ import net.torvald.tsvm.CircularArray
import net.torvald.tsvm.VM import net.torvald.tsvm.VM
import net.torvald.tsvm.isNonZero import net.torvald.tsvm.isNonZero
import net.torvald.tsvm.toInt import net.torvald.tsvm.toInt
import java.util.concurrent.atomic.AtomicInteger
import kotlin.experimental.and import kotlin.experimental.and
class IOSpace(val vm: VM) : PeriBase("io"), InputProcessor { class IOSpace(val vm: VM) : PeriBase("io"), InputProcessor {
@@ -32,6 +33,13 @@ class IOSpace(val vm: VM) : PeriBase("io"), InputProcessor {
*/ */
var inputViewport: Viewport? = null var inputViewport: Viewport? = null
private val tmpMouseVec = Vector2() private val tmpMouseVec = Vector2()
// Letterbox offset and renderable area inside the inputViewport, set by the host VMGUI.
// After unproject, mouse pixel coords are shifted by (inputOriginX, inputOriginY) and
// clamped to (inputAreaW, inputAreaH) so apps see VM-screen pixel coords (0..drawWidth).
var inputOriginX: Int = 0
var inputOriginY: Int = 0
var inputAreaW: Int = Int.MAX_VALUE
var inputAreaH: Int = Int.MAX_VALUE
/** Accepts a keycode */ /** Accepts a keycode */
private val keyboardBuffer = CircularArray<Byte>(32, true) private val keyboardBuffer = CircularArray<Byte>(32, true)
@@ -108,7 +116,12 @@ class IOSpace(val vm: VM) : PeriBase("io"), InputProcessor {
in 0..31 -> keyboardBuffer[(addr.toInt())] ?: -1 in 0..31 -> keyboardBuffer[(addr.toInt())] ?: -1
in 32..33 -> (mouseX.toInt() shr (adi - 32).times(8)).toByte() in 32..33 -> (mouseX.toInt() shr (adi - 32).times(8)).toByte()
in 34..35 -> (mouseY.toInt() shr (adi - 34).times(8)).toByte() in 34..35 -> (mouseY.toInt() shr (adi - 34).times(8)).toByte()
36L -> mouseButtons.toByte() // only bits 0..1 used; higher bits intentionally truncated 36L -> {
// bit 0: left, bit 1: right, bit 2: middle, bit 6: wheel up, bit 7: wheel down
// Wheel bits are latched on scrolled() and cleared on read so a one-shot
// detent fires exactly once for the polling app.
(mouseButtons or wheelLatch.getAndSet(0)).toByte()
}
37L -> { 37L -> {
val key = keyboardBuffer.removeTail() ?: -1 val key = keyboardBuffer.removeTail() ?: -1
keyPushed = !keyboardBuffer.isEmpty // Clear flag when buffer becomes empty keyPushed = !keyboardBuffer.isEmpty // Clear flag when buffer becomes empty
@@ -290,7 +303,9 @@ class IOSpace(val vm: VM) : PeriBase("io"), InputProcessor {
private var mouseX: Short = 0 private var mouseX: Short = 0
private var mouseY: Short = 0 private var mouseY: Short = 0
private var mouseButtons: Int = 0 // bit 0 = LEFT, bit 1 = RIGHT private var mouseButtons: Int = 0 // bit 0 = LEFT, bit 1 = RIGHT, bit 2 = MIDDLE
// bits 6 (wheel up) and 7 (wheel down) — set by scrolled(), cleared on MMIO[36] read
private val wheelLatch = AtomicInteger(0)
private var systemUptime = 0L private var systemUptime = 0L
private var rtc = 0L private var rtc = 0L
@@ -310,18 +325,24 @@ class IOSpace(val vm: VM) : PeriBase("io"), InputProcessor {
// VM sees logical framebuffer pixels regardless of window magnification, // VM sees logical framebuffer pixels regardless of window magnification,
// letterboxing or sub-region placement done by an embedding GDX app. // letterboxing or sub-region placement done by an embedding GDX app.
val vp = inputViewport val vp = inputViewport
val rawX: Int
val rawY: Int
if (vp != null) { if (vp != null) {
tmpMouseVec.set(Gdx.input.x.toFloat(), Gdx.input.y.toFloat()) tmpMouseVec.set(Gdx.input.x.toFloat(), Gdx.input.y.toFloat())
vp.unproject(tmpMouseVec) vp.unproject(tmpMouseVec)
mouseX = tmpMouseVec.x.toInt().toShort() rawX = tmpMouseVec.x.toInt()
mouseY = tmpMouseVec.y.toInt().toShort() rawY = tmpMouseVec.y.toInt()
} }
else { else {
mouseX = Gdx.input.x.toShort() rawX = Gdx.input.x
mouseY = Gdx.input.y.toShort() rawY = Gdx.input.y
} }
mouseButtons = (if (Gdx.input.isButtonPressed(Input.Buttons.LEFT)) 1 else 0) or // Subtract the letterbox origin so apps see VM-screen pixel coords (0..drawWidth).
(if (Gdx.input.isButtonPressed(Input.Buttons.RIGHT)) 2 else 0) mouseX = (rawX - inputOriginX).coerceIn(0, inputAreaW - 1).toShort()
mouseY = (rawY - inputOriginY).coerceIn(0, inputAreaH - 1).toShort()
mouseButtons = (if (Gdx.input.isButtonPressed(Input.Buttons.LEFT)) 1 else 0) or
(if (Gdx.input.isButtonPressed(Input.Buttons.RIGHT)) 2 else 0) or
(if (Gdx.input.isButtonPressed(Input.Buttons.MIDDLE)) 4 else 0)
// strobe keys to fill the key read buffer // strobe keys to fill the key read buffer
var keysPushed = 0 var keysPushed = 0
@@ -398,8 +419,15 @@ class IOSpace(val vm: VM) : PeriBase("io"), InputProcessor {
} }
} }
override fun scrolled(p0: Float, p1: Float): Boolean { override fun scrolled(amountX: Float, amountY: Float): Boolean {
return false // LibGDX: amountY > 0 = scroll DOWN (toward user), amountY < 0 = scroll UP.
// Latch bits 6/7 of MMIO[36]; the latch is cleared the next time MMIO[36] is read.
if (Gdx.input.inputProcessor !== this) return false
when {
amountY < 0f -> wheelLatch.updateAndGet { it or 0x40 }
amountY > 0f -> wheelLatch.updateAndGet { it or 0x80 }
}
return true
} }
override fun keyUp(p0: Int): Boolean { override fun keyUp(p0: Int): Boolean {

View File

@@ -170,6 +170,10 @@ class VMGUI(val loaderInfo: EmulInstance, val viewportWidth: Int, val viewportHe
Gdx.input.inputProcessor = vm.getIO() Gdx.input.inputProcessor = vm.getIO()
vm.getIO().inputViewport = inputViewport vm.getIO().inputViewport = inputViewport
vm.getIO().inputOriginX = (viewportWidth - loaderInfo.drawWidth) / 2
vm.getIO().inputOriginY = (viewportHeight - loaderInfo.drawHeight) / 2
vm.getIO().inputAreaW = loaderInfo.drawWidth
vm.getIO().inputAreaH = loaderInfo.drawHeight
if (usememvwr) memvwr = Memvwr(vm) if (usememvwr) memvwr = Memvwr(vm)