web-a2e 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.clangd +5 -0
- package/.mcp.json +12 -0
- package/CLAUDE.md +362 -0
- package/CMakeLists.txt +774 -0
- package/LICENSE +21 -0
- package/README.md +392 -0
- package/build-wasm/generated/roms.cpp +2447 -0
- package/docker-compose.staging.yml +9 -0
- package/docs/basic-rom-disassembly.md +6663 -0
- package/docs/softswitch-comparison.md +273 -0
- package/docs/thunderclock-debug.md +89 -0
- package/examples/cube.bas +72 -0
- package/examples/hello.s +55 -0
- package/examples/scroll.s +140 -0
- package/package.json +18 -0
- package/public/assets/apple-logo-old.png +0 -0
- package/public/assets/apple-logo.png +0 -0
- package/public/assets/drive-closed-light-on.png +0 -0
- package/public/assets/drive-closed.png +0 -0
- package/public/assets/drive-open-light-on.png +0 -0
- package/public/assets/drive-open.png +0 -0
- package/public/audio-worklet.js +82 -0
- package/public/disks/Apple DOS 3.3 January 1983.dsk +0 -0
- package/public/disks/ProDOS 2.4.3.po +0 -0
- package/public/disks/h32mb.2mg +0 -0
- package/public/disks/library.json +26 -0
- package/public/docs/llms/llm-assembler.txt +90 -0
- package/public/docs/llms/llm-basic-program.txt +256 -0
- package/public/docs/llms/llm-disk-drives.txt +72 -0
- package/public/docs/llms/llm-file-explorer.txt +50 -0
- package/public/docs/llms/llm-hard-drives.txt +80 -0
- package/public/docs/llms/llm-main.txt +51 -0
- package/public/docs/llms/llm-slot-configuration.txt +66 -0
- package/public/icons/icon-192.svg +4 -0
- package/public/icons/icon-512.svg +4 -0
- package/public/index.html +661 -0
- package/public/llms.txt +49 -0
- package/public/manifest.json +29 -0
- package/public/shaders/burnin.glsl +22 -0
- package/public/shaders/crt.glsl +706 -0
- package/public/shaders/edge.glsl +109 -0
- package/public/shaders/vertex.glsl +8 -0
- package/public/sw.js +186 -0
- package/roms/341-0027.bin +0 -0
- package/roms/341-0160-A-US-UK.bin +0 -0
- package/roms/341-0160-A.bin +0 -0
- package/roms/342-0273-A-US-UK.bin +0 -0
- package/roms/342-0349-B-C0-FF.bin +0 -0
- package/roms/Apple Mouse Interface Card ROM - 342-0270-C.bin +0 -0
- package/roms/Thunderclock Plus ROM.bin +0 -0
- package/scripts/generate_roms.sh +69 -0
- package/src/bindings/wasm_interface.cpp +1940 -0
- package/src/core/assembler/assembler.cpp +1239 -0
- package/src/core/assembler/assembler.hpp +115 -0
- package/src/core/audio/audio.cpp +160 -0
- package/src/core/audio/audio.hpp +81 -0
- package/src/core/basic/basic_detokenizer.cpp +436 -0
- package/src/core/basic/basic_detokenizer.hpp +41 -0
- package/src/core/basic/basic_tokenizer.cpp +286 -0
- package/src/core/basic/basic_tokenizer.hpp +26 -0
- package/src/core/basic/basic_tokens.hpp +295 -0
- package/src/core/cards/disk2_card.cpp +568 -0
- package/src/core/cards/disk2_card.hpp +316 -0
- package/src/core/cards/expansion_card.hpp +185 -0
- package/src/core/cards/mockingboard/ay8910.cpp +616 -0
- package/src/core/cards/mockingboard/ay8910.hpp +159 -0
- package/src/core/cards/mockingboard/via6522.cpp +530 -0
- package/src/core/cards/mockingboard/via6522.hpp +163 -0
- package/src/core/cards/mockingboard_card.cpp +312 -0
- package/src/core/cards/mockingboard_card.hpp +159 -0
- package/src/core/cards/mouse_card.cpp +654 -0
- package/src/core/cards/mouse_card.hpp +190 -0
- package/src/core/cards/smartport/block_device.cpp +202 -0
- package/src/core/cards/smartport/block_device.hpp +60 -0
- package/src/core/cards/smartport/smartport_card.cpp +603 -0
- package/src/core/cards/smartport/smartport_card.hpp +120 -0
- package/src/core/cards/thunderclock_card.cpp +237 -0
- package/src/core/cards/thunderclock_card.hpp +122 -0
- package/src/core/cpu/cpu6502.cpp +1609 -0
- package/src/core/cpu/cpu6502.hpp +203 -0
- package/src/core/debug/condition_evaluator.cpp +470 -0
- package/src/core/debug/condition_evaluator.hpp +87 -0
- package/src/core/disassembler/disassembler.cpp +552 -0
- package/src/core/disassembler/disassembler.hpp +171 -0
- package/src/core/disk-image/disk_image.hpp +267 -0
- package/src/core/disk-image/dsk_disk_image.cpp +827 -0
- package/src/core/disk-image/dsk_disk_image.hpp +204 -0
- package/src/core/disk-image/gcr_encoding.cpp +147 -0
- package/src/core/disk-image/gcr_encoding.hpp +78 -0
- package/src/core/disk-image/woz_disk_image.cpp +1049 -0
- package/src/core/disk-image/woz_disk_image.hpp +343 -0
- package/src/core/emulator.cpp +2126 -0
- package/src/core/emulator.hpp +434 -0
- package/src/core/filesystem/dos33.cpp +178 -0
- package/src/core/filesystem/dos33.hpp +66 -0
- package/src/core/filesystem/pascal.cpp +262 -0
- package/src/core/filesystem/pascal.hpp +87 -0
- package/src/core/filesystem/prodos.cpp +369 -0
- package/src/core/filesystem/prodos.hpp +119 -0
- package/src/core/input/keyboard.cpp +227 -0
- package/src/core/input/keyboard.hpp +111 -0
- package/src/core/mmu/mmu.cpp +1387 -0
- package/src/core/mmu/mmu.hpp +236 -0
- package/src/core/types.hpp +196 -0
- package/src/core/video/video.cpp +680 -0
- package/src/core/video/video.hpp +156 -0
- package/src/css/assembler-editor.css +1617 -0
- package/src/css/base.css +470 -0
- package/src/css/basic-debugger.css +791 -0
- package/src/css/basic-editor.css +792 -0
- package/src/css/controls.css +783 -0
- package/src/css/cpu-debugger.css +1413 -0
- package/src/css/debug-base.css +160 -0
- package/src/css/debug-windows.css +6455 -0
- package/src/css/disk-drives.css +406 -0
- package/src/css/documentation.css +392 -0
- package/src/css/file-explorer.css +867 -0
- package/src/css/hard-drive.css +180 -0
- package/src/css/layout.css +217 -0
- package/src/css/memory-windows.css +798 -0
- package/src/css/modals.css +510 -0
- package/src/css/monitor.css +425 -0
- package/src/css/release-notes.css +101 -0
- package/src/css/responsive.css +400 -0
- package/src/css/rule-builder.css +340 -0
- package/src/css/save-states.css +201 -0
- package/src/css/settings-windows.css +1231 -0
- package/src/css/window-switcher.css +150 -0
- package/src/js/agent/agent-manager.js +643 -0
- package/src/js/agent/agent-tools.js +293 -0
- package/src/js/agent/agent-version-tools.js +131 -0
- package/src/js/agent/assembler-tools.js +357 -0
- package/src/js/agent/basic-program-tools.js +894 -0
- package/src/js/agent/disk-tools.js +417 -0
- package/src/js/agent/file-explorer-tools.js +269 -0
- package/src/js/agent/index.js +13 -0
- package/src/js/agent/main-tools.js +222 -0
- package/src/js/agent/slot-tools.js +303 -0
- package/src/js/agent/smartport-tools.js +257 -0
- package/src/js/agent/window-tools.js +80 -0
- package/src/js/audio/audio-driver.js +417 -0
- package/src/js/audio/audio-worklet.js +85 -0
- package/src/js/audio/index.js +8 -0
- package/src/js/config/default-layout.js +34 -0
- package/src/js/config/version.js +8 -0
- package/src/js/data/apple2-rom-routines.js +577 -0
- package/src/js/debug/assembler-editor-window.js +2993 -0
- package/src/js/debug/basic-breakpoint-manager.js +529 -0
- package/src/js/debug/basic-program-parser.js +436 -0
- package/src/js/debug/basic-program-window.js +2594 -0
- package/src/js/debug/basic-variable-inspector.js +447 -0
- package/src/js/debug/breakpoint-manager.js +472 -0
- package/src/js/debug/cpu-debugger-window.js +2396 -0
- package/src/js/debug/index.js +22 -0
- package/src/js/debug/label-manager.js +238 -0
- package/src/js/debug/memory-browser-window.js +416 -0
- package/src/js/debug/memory-heat-map-window.js +481 -0
- package/src/js/debug/memory-map-window.js +206 -0
- package/src/js/debug/mockingboard-window.js +882 -0
- package/src/js/debug/mouse-card-window.js +355 -0
- package/src/js/debug/rule-builder-window.js +648 -0
- package/src/js/debug/soft-switch-window.js +458 -0
- package/src/js/debug/stack-viewer-window.js +221 -0
- package/src/js/debug/symbols.js +416 -0
- package/src/js/debug/trace-panel.js +291 -0
- package/src/js/debug/zero-page-watch-window.js +297 -0
- package/src/js/disk-manager/disk-drives-window.js +212 -0
- package/src/js/disk-manager/disk-operations.js +284 -0
- package/src/js/disk-manager/disk-persistence.js +301 -0
- package/src/js/disk-manager/disk-surface-renderer.js +388 -0
- package/src/js/disk-manager/drive-sounds.js +139 -0
- package/src/js/disk-manager/hard-drive-manager.js +481 -0
- package/src/js/disk-manager/hard-drive-persistence.js +187 -0
- package/src/js/disk-manager/hard-drive-window.js +57 -0
- package/src/js/disk-manager/index.js +890 -0
- package/src/js/display/display-settings-window.js +383 -0
- package/src/js/display/index.js +10 -0
- package/src/js/display/screen-window.js +342 -0
- package/src/js/display/webgl-renderer.js +705 -0
- package/src/js/file-explorer/disassembler.js +574 -0
- package/src/js/file-explorer/dos33.js +266 -0
- package/src/js/file-explorer/file-viewer.js +359 -0
- package/src/js/file-explorer/index.js +1261 -0
- package/src/js/file-explorer/prodos.js +549 -0
- package/src/js/file-explorer/utils.js +67 -0
- package/src/js/help/documentation-window.js +1096 -0
- package/src/js/help/index.js +10 -0
- package/src/js/help/release-notes-window.js +85 -0
- package/src/js/help/release-notes.js +612 -0
- package/src/js/input/gamepad-handler.js +176 -0
- package/src/js/input/index.js +12 -0
- package/src/js/input/input-handler.js +396 -0
- package/src/js/input/joystick-window.js +404 -0
- package/src/js/input/mouse-handler.js +99 -0
- package/src/js/input/text-selection.js +462 -0
- package/src/js/main.js +653 -0
- package/src/js/state/index.js +15 -0
- package/src/js/state/save-states-window.js +393 -0
- package/src/js/state/state-manager.js +409 -0
- package/src/js/state/state-persistence.js +218 -0
- package/src/js/ui/confirm.js +43 -0
- package/src/js/ui/disk-drive-positioner.js +347 -0
- package/src/js/ui/reminder-controller.js +129 -0
- package/src/js/ui/slot-configuration-window.js +560 -0
- package/src/js/ui/theme-manager.js +61 -0
- package/src/js/ui/toast.js +44 -0
- package/src/js/ui/ui-controller.js +897 -0
- package/src/js/ui/window-switcher.js +275 -0
- package/src/js/utils/basic-autocomplete.js +832 -0
- package/src/js/utils/basic-highlighting.js +473 -0
- package/src/js/utils/basic-tokenizer.js +153 -0
- package/src/js/utils/basic-tokens.js +117 -0
- package/src/js/utils/constants.js +28 -0
- package/src/js/utils/indexeddb-helper.js +225 -0
- package/src/js/utils/merlin-editor-support.js +905 -0
- package/src/js/utils/merlin-highlighting.js +551 -0
- package/src/js/utils/storage.js +125 -0
- package/src/js/utils/string-utils.js +19 -0
- package/src/js/utils/wasm-memory.js +54 -0
- package/src/js/windows/base-window.js +690 -0
- package/src/js/windows/index.js +9 -0
- package/src/js/windows/window-manager.js +375 -0
- package/tests/catch2/catch.hpp +17976 -0
- package/tests/common/basic_program_builder.cpp +119 -0
- package/tests/common/basic_program_builder.hpp +209 -0
- package/tests/common/disk_image_builder.cpp +444 -0
- package/tests/common/disk_image_builder.hpp +141 -0
- package/tests/common/test_helpers.hpp +118 -0
- package/tests/gcr/gcr-test.cpp +142 -0
- package/tests/integration/check-rom.js +70 -0
- package/tests/integration/compare-boot.js +239 -0
- package/tests/integration/crash-trace.js +102 -0
- package/tests/integration/disk-boot-test.js +264 -0
- package/tests/integration/memory-crash.js +108 -0
- package/tests/integration/nibble-read-test.js +249 -0
- package/tests/integration/phase-test.js +159 -0
- package/tests/integration/test_emulator.cpp +291 -0
- package/tests/integration/test_emulator_basic.cpp +91 -0
- package/tests/integration/test_emulator_debug.cpp +344 -0
- package/tests/integration/test_emulator_disk.cpp +153 -0
- package/tests/integration/test_emulator_state.cpp +163 -0
- package/tests/klaus/6502_functional_test.bin +0 -0
- package/tests/klaus/65C02_extended_opcodes_test.bin +0 -0
- package/tests/klaus/klaus_6502_test.cpp +184 -0
- package/tests/klaus/klaus_65c02_test.cpp +197 -0
- package/tests/thunderclock/thunderclock_mmu_test.cpp +304 -0
- package/tests/thunderclock/thunderclock_test.cpp +550 -0
- package/tests/unit/test_assembler.cpp +521 -0
- package/tests/unit/test_audio.cpp +196 -0
- package/tests/unit/test_ay8910.cpp +311 -0
- package/tests/unit/test_basic_detokenizer.cpp +265 -0
- package/tests/unit/test_basic_tokenizer.cpp +382 -0
- package/tests/unit/test_block_device.cpp +259 -0
- package/tests/unit/test_condition_evaluator.cpp +219 -0
- package/tests/unit/test_cpu6502.cpp +1301 -0
- package/tests/unit/test_cpu_addressing.cpp +361 -0
- package/tests/unit/test_cpu_cycle_counts.cpp +409 -0
- package/tests/unit/test_cpu_decimal.cpp +166 -0
- package/tests/unit/test_cpu_interrupts.cpp +285 -0
- package/tests/unit/test_disassembler.cpp +323 -0
- package/tests/unit/test_disk2_card.cpp +330 -0
- package/tests/unit/test_dos33.cpp +273 -0
- package/tests/unit/test_dsk_disk_image.cpp +315 -0
- package/tests/unit/test_expansion_card.cpp +178 -0
- package/tests/unit/test_gcr_encoding.cpp +232 -0
- package/tests/unit/test_keyboard.cpp +262 -0
- package/tests/unit/test_mmu.cpp +555 -0
- package/tests/unit/test_mmu_slots.cpp +323 -0
- package/tests/unit/test_mockingboard.cpp +352 -0
- package/tests/unit/test_mouse_card.cpp +386 -0
- package/tests/unit/test_pascal.cpp +248 -0
- package/tests/unit/test_prodos.cpp +259 -0
- package/tests/unit/test_smartport_card.cpp +321 -0
- package/tests/unit/test_thunderclock.cpp +354 -0
- package/tests/unit/test_via6522.cpp +323 -0
- package/tests/unit/test_video.cpp +319 -0
- package/tests/unit/test_woz_disk_image.cpp +257 -0
- package/vite.config.js +96 -0
- package/wiki/AI-Agent.md +372 -0
- package/wiki/Architecture-Overview.md +303 -0
- package/wiki/Audio-System.md +449 -0
- package/wiki/CPU-Emulation.md +477 -0
- package/wiki/Debugger.md +516 -0
- package/wiki/Disk-Drives.md +161 -0
- package/wiki/Disk-System-Internals.md +547 -0
- package/wiki/Display-Settings.md +88 -0
- package/wiki/Expansion-Slots.md +187 -0
- package/wiki/File-Explorer.md +259 -0
- package/wiki/Getting-Started.md +156 -0
- package/wiki/Home.md +69 -0
- package/wiki/Input-Devices.md +183 -0
- package/wiki/Keyboard-Shortcuts.md +158 -0
- package/wiki/Memory-System.md +364 -0
- package/wiki/Save-States.md +172 -0
- package/wiki/Video-Rendering.md +658 -0
|
@@ -0,0 +1,905 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* merlin-editor-support.js - Merlin assembler editor enhancements
|
|
3
|
+
*
|
|
4
|
+
* Column-aware editing, smart shortcuts, and contextual autocomplete
|
|
5
|
+
* for the 4-column Merlin layout (see COL_* constants for positions)
|
|
6
|
+
*
|
|
7
|
+
* Written by
|
|
8
|
+
* Mike Daley <michael_daley@icloud.com>
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
BRANCH_MNEMONICS, LOAD_MNEMONICS, MATH_MNEMONICS,
|
|
13
|
+
STACK_MNEMONICS, FLAG_MNEMONICS, ALL_MNEMONICS,
|
|
14
|
+
DIRECTIVES, getMnemonicClass,
|
|
15
|
+
} from "./merlin-highlighting.js";
|
|
16
|
+
|
|
17
|
+
// Merlin column positions — change these to adjust the layout
|
|
18
|
+
export const COL_LABEL = 0;
|
|
19
|
+
export const COL_OPCODE = 12;
|
|
20
|
+
export const COL_OPERAND = 17;
|
|
21
|
+
export const COL_COMMENT = 28;
|
|
22
|
+
export const OPCODE_WIDTH = COL_OPERAND - COL_OPCODE; // padEnd width for opcode field
|
|
23
|
+
|
|
24
|
+
// Merlin column stops (derived from the constants above)
|
|
25
|
+
export const MERLIN_COLUMNS = [COL_LABEL, COL_OPCODE, COL_OPERAND, COL_COMMENT];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Get cursor line info from a textarea
|
|
29
|
+
*/
|
|
30
|
+
export function getCursorLineAndCol(textarea) {
|
|
31
|
+
const pos = textarea.selectionStart;
|
|
32
|
+
const text = textarea.value;
|
|
33
|
+
const lineStart = text.lastIndexOf('\n', pos - 1) + 1;
|
|
34
|
+
const col = pos - lineStart;
|
|
35
|
+
const lineEnd = text.indexOf('\n', pos);
|
|
36
|
+
const line = text.substring(lineStart, lineEnd === -1 ? text.length : lineEnd);
|
|
37
|
+
return { lineStart, col, line, pos };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Detect which Merlin column a cursor position falls in
|
|
42
|
+
*/
|
|
43
|
+
export function detectMerlinColumn(line, col) {
|
|
44
|
+
if (col >= COL_COMMENT) return 'comment';
|
|
45
|
+
if (col >= COL_OPERAND) return 'operand';
|
|
46
|
+
if (col >= COL_OPCODE) return 'opcode';
|
|
47
|
+
return 'label';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Mnemonic info for autocomplete hints
|
|
51
|
+
const MNEMONIC_INFO = {
|
|
52
|
+
// Branch / Flow
|
|
53
|
+
JMP: { syntax: "JMP addr", desc: "Jump to address" },
|
|
54
|
+
JSR: { syntax: "JSR addr", desc: "Jump to subroutine" },
|
|
55
|
+
BCC: { syntax: "BCC label", desc: "Branch if carry clear" },
|
|
56
|
+
BCS: { syntax: "BCS label", desc: "Branch if carry set" },
|
|
57
|
+
BEQ: { syntax: "BEQ label", desc: "Branch if equal (Z=1)" },
|
|
58
|
+
BMI: { syntax: "BMI label", desc: "Branch if minus (N=1)" },
|
|
59
|
+
BNE: { syntax: "BNE label", desc: "Branch if not equal (Z=0)" },
|
|
60
|
+
BPL: { syntax: "BPL label", desc: "Branch if plus (N=0)" },
|
|
61
|
+
BRA: { syntax: "BRA label", desc: "Branch always (65C02)" },
|
|
62
|
+
BVC: { syntax: "BVC label", desc: "Branch if overflow clear" },
|
|
63
|
+
BVS: { syntax: "BVS label", desc: "Branch if overflow set" },
|
|
64
|
+
RTS: { syntax: "RTS", desc: "Return from subroutine" },
|
|
65
|
+
RTI: { syntax: "RTI", desc: "Return from interrupt" },
|
|
66
|
+
BRK: { syntax: "BRK", desc: "Software interrupt" },
|
|
67
|
+
// Load / Store
|
|
68
|
+
LDA: { syntax: "LDA #val | addr", desc: "Load accumulator" },
|
|
69
|
+
LDX: { syntax: "LDX #val | addr", desc: "Load X register" },
|
|
70
|
+
LDY: { syntax: "LDY #val | addr", desc: "Load Y register" },
|
|
71
|
+
STA: { syntax: "STA addr", desc: "Store accumulator" },
|
|
72
|
+
STX: { syntax: "STX addr", desc: "Store X register" },
|
|
73
|
+
STY: { syntax: "STY addr", desc: "Store Y register" },
|
|
74
|
+
STZ: { syntax: "STZ addr", desc: "Store zero (65C02)" },
|
|
75
|
+
// Math / Logic
|
|
76
|
+
ADC: { syntax: "ADC #val | addr", desc: "Add with carry" },
|
|
77
|
+
SBC: { syntax: "SBC #val | addr", desc: "Subtract with borrow" },
|
|
78
|
+
AND: { syntax: "AND #val | addr", desc: "Bitwise AND" },
|
|
79
|
+
ORA: { syntax: "ORA #val | addr", desc: "Bitwise OR" },
|
|
80
|
+
EOR: { syntax: "EOR #val | addr", desc: "Bitwise exclusive OR" },
|
|
81
|
+
ASL: { syntax: "ASL [addr]", desc: "Arithmetic shift left" },
|
|
82
|
+
LSR: { syntax: "LSR [addr]", desc: "Logical shift right" },
|
|
83
|
+
ROL: { syntax: "ROL [addr]", desc: "Rotate left through carry" },
|
|
84
|
+
ROR: { syntax: "ROR [addr]", desc: "Rotate right through carry" },
|
|
85
|
+
INC: { syntax: "INC [addr]", desc: "Increment memory" },
|
|
86
|
+
DEC: { syntax: "DEC [addr]", desc: "Decrement memory" },
|
|
87
|
+
INA: { syntax: "INA", desc: "Increment accumulator (65C02)" },
|
|
88
|
+
DEA: { syntax: "DEA", desc: "Decrement accumulator (65C02)" },
|
|
89
|
+
INX: { syntax: "INX", desc: "Increment X" },
|
|
90
|
+
DEX: { syntax: "DEX", desc: "Decrement X" },
|
|
91
|
+
INY: { syntax: "INY", desc: "Increment Y" },
|
|
92
|
+
DEY: { syntax: "DEY", desc: "Decrement Y" },
|
|
93
|
+
CMP: { syntax: "CMP #val | addr", desc: "Compare accumulator" },
|
|
94
|
+
CPX: { syntax: "CPX #val | addr", desc: "Compare X register" },
|
|
95
|
+
CPY: { syntax: "CPY #val | addr", desc: "Compare Y register" },
|
|
96
|
+
BIT: { syntax: "BIT #val | addr", desc: "Bit test" },
|
|
97
|
+
TRB: { syntax: "TRB addr", desc: "Test and reset bits (65C02)" },
|
|
98
|
+
TSB: { syntax: "TSB addr", desc: "Test and set bits (65C02)" },
|
|
99
|
+
// Stack / Transfer
|
|
100
|
+
PHA: { syntax: "PHA", desc: "Push accumulator" },
|
|
101
|
+
PHP: { syntax: "PHP", desc: "Push processor status" },
|
|
102
|
+
PHX: { syntax: "PHX", desc: "Push X (65C02)" },
|
|
103
|
+
PHY: { syntax: "PHY", desc: "Push Y (65C02)" },
|
|
104
|
+
PLA: { syntax: "PLA", desc: "Pull accumulator" },
|
|
105
|
+
PLP: { syntax: "PLP", desc: "Pull processor status" },
|
|
106
|
+
PLX: { syntax: "PLX", desc: "Pull X (65C02)" },
|
|
107
|
+
PLY: { syntax: "PLY", desc: "Pull Y (65C02)" },
|
|
108
|
+
TAX: { syntax: "TAX", desc: "Transfer A to X" },
|
|
109
|
+
TAY: { syntax: "TAY", desc: "Transfer A to Y" },
|
|
110
|
+
TSX: { syntax: "TSX", desc: "Transfer SP to X" },
|
|
111
|
+
TXA: { syntax: "TXA", desc: "Transfer X to A" },
|
|
112
|
+
TXS: { syntax: "TXS", desc: "Transfer X to SP" },
|
|
113
|
+
TYA: { syntax: "TYA", desc: "Transfer Y to A" },
|
|
114
|
+
// Flags
|
|
115
|
+
CLC: { syntax: "CLC", desc: "Clear carry flag" },
|
|
116
|
+
CLD: { syntax: "CLD", desc: "Clear decimal mode" },
|
|
117
|
+
CLI: { syntax: "CLI", desc: "Clear interrupt disable" },
|
|
118
|
+
CLV: { syntax: "CLV", desc: "Clear overflow flag" },
|
|
119
|
+
SEC: { syntax: "SEC", desc: "Set carry flag" },
|
|
120
|
+
SED: { syntax: "SED", desc: "Set decimal mode" },
|
|
121
|
+
SEI: { syntax: "SEI", desc: "Set interrupt disable" },
|
|
122
|
+
NOP: { syntax: "NOP", desc: "No operation" },
|
|
123
|
+
WAI: { syntax: "WAI", desc: "Wait for interrupt (65C02)" },
|
|
124
|
+
STP: { syntax: "STP", desc: "Stop processor (65C02)" },
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// Directive info for autocomplete hints
|
|
128
|
+
const DIRECTIVE_INFO = {
|
|
129
|
+
ORG: { syntax: "ORG addr", desc: "Set origin address" },
|
|
130
|
+
EQU: { syntax: "label EQU value", desc: "Equate label to value" },
|
|
131
|
+
DS: { syntax: "DS count[,fill]", desc: "Define storage (fill bytes)" },
|
|
132
|
+
DFB: { syntax: "DFB byte[,byte]...", desc: "Define byte(s)" },
|
|
133
|
+
DW: { syntax: "DW word[,word]...", desc: "Define word(s)" },
|
|
134
|
+
DA: { syntax: "DA addr[,addr]...", desc: "Define address(es)" },
|
|
135
|
+
DDB: { syntax: "DDB word[,word]...", desc: "Define double byte (big-endian)" },
|
|
136
|
+
ASC: { syntax: 'ASC "text"', desc: "ASCII string" },
|
|
137
|
+
DCI: { syntax: 'DCI "text"', desc: "Dextral char inverted string" },
|
|
138
|
+
HEX: { syntax: "HEX bytes", desc: "Hex data (no $ prefix)" },
|
|
139
|
+
PUT: { syntax: "PUT filename", desc: "Include source file" },
|
|
140
|
+
USE: { syntax: "USE filename", desc: "Include macro file" },
|
|
141
|
+
OBJ: { syntax: "OBJ pathname", desc: "Set object file path" },
|
|
142
|
+
LST: { syntax: "LST ON|OFF", desc: "Listing control" },
|
|
143
|
+
DO: { syntax: "DO expr", desc: "Conditional assembly" },
|
|
144
|
+
ELSE: { syntax: "ELSE", desc: "Conditional else" },
|
|
145
|
+
FIN: { syntax: "FIN", desc: "End conditional" },
|
|
146
|
+
LUP: { syntax: "LUP count", desc: "Loop (repeat) block" },
|
|
147
|
+
'--^': { syntax: "--^", desc: "End of LUP block" },
|
|
148
|
+
REL: { syntax: "REL", desc: "Relocatable code" },
|
|
149
|
+
TYP: { syntax: "TYP type", desc: "Set ProDOS file type" },
|
|
150
|
+
SAV: { syntax: "SAV filename", desc: "Save object file" },
|
|
151
|
+
DSK: { syntax: "DSK filename", desc: "Save as DOS binary" },
|
|
152
|
+
CHN: { syntax: "CHN filename", desc: "Chain to source file" },
|
|
153
|
+
ENT: { syntax: "ENT", desc: "Entry point (export)" },
|
|
154
|
+
EXT: { syntax: "EXT", desc: "External reference (import)" },
|
|
155
|
+
DUM: { syntax: "DUM addr", desc: "Dummy section start" },
|
|
156
|
+
DEND: { syntax: "DEND", desc: "Dummy section end" },
|
|
157
|
+
ERR: { syntax: "ERR expr", desc: "Force error if true" },
|
|
158
|
+
CYC: { syntax: "CYC ON|OFF|AVE", desc: "Cycle count listing" },
|
|
159
|
+
DAT: { syntax: "DAT", desc: "Date stamp" },
|
|
160
|
+
EXP: { syntax: "EXP ON|OFF", desc: "Macro expansion listing" },
|
|
161
|
+
PAU: { syntax: "PAU", desc: "Pause listing" },
|
|
162
|
+
SW: { syntax: "SW", desc: "Sweet-16 mode" },
|
|
163
|
+
USR: { syntax: "USR macro", desc: "User-defined directive" },
|
|
164
|
+
XC: { syntax: "XC [OFF]", desc: "Extended opcodes (65C02)" },
|
|
165
|
+
MX: { syntax: "MX %bits", desc: "Memory/index width (65816)" },
|
|
166
|
+
TR: { syntax: "TR ON|OFF|ADR", desc: "Truncation control" },
|
|
167
|
+
KBD: { syntax: "KBD prompt", desc: "Keyboard input during assembly" },
|
|
168
|
+
PMC: { syntax: "PMC macro[,params]", desc: "Pseudo macro call" },
|
|
169
|
+
PAG: { syntax: "PAG", desc: "Page eject in listing" },
|
|
170
|
+
TTL: { syntax: "TTL text", desc: "Listing title" },
|
|
171
|
+
SKP: { syntax: "SKP count", desc: "Skip lines in listing" },
|
|
172
|
+
CHK: { syntax: "CHK", desc: "Checksum verification" },
|
|
173
|
+
IF: { syntax: "IF expr", desc: "Conditional (alt syntax)" },
|
|
174
|
+
ELUP: { syntax: "ELUP", desc: "End of LUP (alt syntax)" },
|
|
175
|
+
END: { syntax: "END", desc: "End of source" },
|
|
176
|
+
MAC: { syntax: "label MAC", desc: "Macro definition" },
|
|
177
|
+
EOM: { syntax: "EOM", desc: "End of macro" },
|
|
178
|
+
'<<<': { syntax: "<<<", desc: "End of macro (alt syntax)" },
|
|
179
|
+
ADR: { syntax: "ADR addr", desc: "3-byte address" },
|
|
180
|
+
ADRL: { syntax: "ADRL addr", desc: "4-byte address" },
|
|
181
|
+
DB: { syntax: "DB byte[,byte]...", desc: "Define byte (alt)" },
|
|
182
|
+
LNK: { syntax: "LNK filename", desc: "Link object file" },
|
|
183
|
+
STR: { syntax: 'STR "text"', desc: "Length-prefixed string" },
|
|
184
|
+
STRL: { syntax: 'STRL "text"', desc: "2-byte length-prefixed string" },
|
|
185
|
+
REV: { syntax: 'REV "text"', desc: "Reversed string" },
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// Build sorted autocomplete items for mnemonics + directives
|
|
189
|
+
const MNEMONIC_ITEMS = [];
|
|
190
|
+
for (const m of ALL_MNEMONICS) {
|
|
191
|
+
const info = MNEMONIC_INFO[m] || { syntax: m, desc: "" };
|
|
192
|
+
MNEMONIC_ITEMS.push({
|
|
193
|
+
keyword: m,
|
|
194
|
+
syntax: info.syntax,
|
|
195
|
+
desc: info.desc,
|
|
196
|
+
cssClass: getMnemonicClass(m),
|
|
197
|
+
category: 'mnemonic',
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
for (const d of DIRECTIVES) {
|
|
201
|
+
const info = DIRECTIVE_INFO[d] || { syntax: d, desc: "" };
|
|
202
|
+
MNEMONIC_ITEMS.push({
|
|
203
|
+
keyword: d,
|
|
204
|
+
syntax: info.syntax,
|
|
205
|
+
desc: info.desc,
|
|
206
|
+
cssClass: 'mer-directive',
|
|
207
|
+
category: 'directive',
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
MNEMONIC_ITEMS.sort((a, b) => a.keyword.localeCompare(b.keyword));
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* MerlinSourceContext - Parses source to extract labels for autocomplete
|
|
215
|
+
*/
|
|
216
|
+
class MerlinSourceContext {
|
|
217
|
+
constructor() {
|
|
218
|
+
this.labels = []; // { name, line, type: 'global'|'local'|'equ' }
|
|
219
|
+
this.parseTimer = null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Schedule a debounced parse
|
|
224
|
+
*/
|
|
225
|
+
scheduleParse(text) {
|
|
226
|
+
if (this.parseTimer) clearTimeout(this.parseTimer);
|
|
227
|
+
this.parseTimer = setTimeout(() => this.parseSource(text), 300);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Parse source text to extract label definitions
|
|
232
|
+
*/
|
|
233
|
+
parseSource(text) {
|
|
234
|
+
this.labels = [];
|
|
235
|
+
const lines = text.split('\n');
|
|
236
|
+
|
|
237
|
+
for (let i = 0; i < lines.length; i++) {
|
|
238
|
+
const line = lines[i];
|
|
239
|
+
if (!line || /^\s/.test(line)) continue; // no label on this line
|
|
240
|
+
|
|
241
|
+
const trimmed = line.trim();
|
|
242
|
+
if (!trimmed || trimmed[0] === '*' || trimmed[0] === ';') continue;
|
|
243
|
+
|
|
244
|
+
// First non-whitespace token is the label
|
|
245
|
+
const match = line.match(/^(\S+)/);
|
|
246
|
+
if (!match) continue;
|
|
247
|
+
|
|
248
|
+
const name = match[1];
|
|
249
|
+
|
|
250
|
+
// Determine type
|
|
251
|
+
const tokens = line.trim().split(/\s+/);
|
|
252
|
+
const opcode = tokens.length >= 2 ? tokens[1].toUpperCase() : '';
|
|
253
|
+
|
|
254
|
+
if (opcode === 'EQU') {
|
|
255
|
+
this.labels.push({ name, line: i + 1, type: 'equ' });
|
|
256
|
+
} else if (name[0] === ':' || name[0] === ']') {
|
|
257
|
+
this.labels.push({ name, line: i + 1, type: 'local' });
|
|
258
|
+
} else {
|
|
259
|
+
this.labels.push({ name, line: i + 1, type: 'global' });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Get label items matching a filter string
|
|
266
|
+
*/
|
|
267
|
+
getLabels(filter) {
|
|
268
|
+
const upper = filter.toUpperCase();
|
|
269
|
+
return this.labels
|
|
270
|
+
.filter(l => l.name.toUpperCase().startsWith(upper))
|
|
271
|
+
.map(l => ({
|
|
272
|
+
keyword: l.name,
|
|
273
|
+
syntax: `Line ${l.line}`,
|
|
274
|
+
desc: l.type === 'equ' ? 'Equate constant' : l.type === 'local' ? 'Local label' : 'Global label',
|
|
275
|
+
cssClass: l.type === 'local' ? 'mer-local' : 'mer-label',
|
|
276
|
+
category: 'label',
|
|
277
|
+
labelType: l.type,
|
|
278
|
+
}));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
destroy() {
|
|
282
|
+
if (this.parseTimer) clearTimeout(this.parseTimer);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* MerlinAutocomplete - Dropdown UI for mnemonic and label completion
|
|
289
|
+
*/
|
|
290
|
+
class MerlinAutocomplete {
|
|
291
|
+
constructor(textarea, container, sourceContext) {
|
|
292
|
+
this.textarea = textarea;
|
|
293
|
+
this.container = container;
|
|
294
|
+
this.sourceContext = sourceContext;
|
|
295
|
+
this.dropdown = null;
|
|
296
|
+
this.listEl = null;
|
|
297
|
+
this.hintEl = null;
|
|
298
|
+
this.matches = [];
|
|
299
|
+
this.selectedIndex = 0;
|
|
300
|
+
this.isVisible = false;
|
|
301
|
+
this.currentWord = '';
|
|
302
|
+
this.wordStart = 0;
|
|
303
|
+
this.isInserting = false;
|
|
304
|
+
this.onAccept = null; // callback(keyword, column) after accepting
|
|
305
|
+
|
|
306
|
+
this.createDropdown();
|
|
307
|
+
this.bindEvents();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
createDropdown() {
|
|
311
|
+
this.dropdown = document.createElement('div');
|
|
312
|
+
this.dropdown.className = 'asm-autocomplete-dropdown';
|
|
313
|
+
this.dropdown.innerHTML = `
|
|
314
|
+
<div class="autocomplete-list"></div>
|
|
315
|
+
<div class="autocomplete-hint"></div>
|
|
316
|
+
`;
|
|
317
|
+
this.container.appendChild(this.dropdown);
|
|
318
|
+
this.listEl = this.dropdown.querySelector('.autocomplete-list');
|
|
319
|
+
this.hintEl = this.dropdown.querySelector('.autocomplete-hint');
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
bindEvents() {
|
|
323
|
+
// Capture-phase keydown registered BEFORE MerlinEditorSupport's handler
|
|
324
|
+
this.boundOnKeyDown = (e) => this.onKeyDown(e);
|
|
325
|
+
this.textarea.addEventListener('keydown', this.boundOnKeyDown, true);
|
|
326
|
+
|
|
327
|
+
this.textarea.addEventListener('blur', () => {
|
|
328
|
+
setTimeout(() => this.hide(), 200);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
this.listEl.addEventListener('mousedown', (e) => {
|
|
332
|
+
e.preventDefault();
|
|
333
|
+
const item = e.target.closest('.autocomplete-item');
|
|
334
|
+
if (item) {
|
|
335
|
+
this.selectedIndex = parseInt(item.dataset.index, 10);
|
|
336
|
+
this.insertSelected();
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
this.listEl.addEventListener('mouseover', (e) => {
|
|
341
|
+
const item = e.target.closest('.autocomplete-item');
|
|
342
|
+
if (item) {
|
|
343
|
+
this.selectItem(parseInt(item.dataset.index, 10));
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Called on input event from the support class
|
|
350
|
+
*/
|
|
351
|
+
onInput() {
|
|
352
|
+
if (this.isInserting) return;
|
|
353
|
+
|
|
354
|
+
const { col, line, pos } = getCursorLineAndCol(this.textarea);
|
|
355
|
+
const column = detectMerlinColumn(line, col);
|
|
356
|
+
|
|
357
|
+
if (column === 'opcode') {
|
|
358
|
+
this.handleOpcodeAutocomplete(col, line, pos);
|
|
359
|
+
} else if (column === 'operand') {
|
|
360
|
+
this.handleOperandAutocomplete(col, line, pos);
|
|
361
|
+
} else {
|
|
362
|
+
this.hide();
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
handleOpcodeAutocomplete(col, line, pos) {
|
|
367
|
+
// Extract the word being typed in the opcode column
|
|
368
|
+
const word = this.extractWord(col, line);
|
|
369
|
+
if (word.length < 1) {
|
|
370
|
+
this.hide();
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
this.currentWord = word;
|
|
375
|
+
this.wordStart = pos - word.length;
|
|
376
|
+
|
|
377
|
+
const upper = word.toUpperCase();
|
|
378
|
+
this.matches = MNEMONIC_ITEMS
|
|
379
|
+
.filter(item => item.keyword.startsWith(upper))
|
|
380
|
+
.slice(0, 12);
|
|
381
|
+
|
|
382
|
+
this.selectedIndex = 0;
|
|
383
|
+
if (this.matches.length > 0) {
|
|
384
|
+
this.show();
|
|
385
|
+
this.render();
|
|
386
|
+
} else {
|
|
387
|
+
this.hide();
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
handleOperandAutocomplete(col, line, pos) {
|
|
392
|
+
const word = this.extractWord(col, line);
|
|
393
|
+
if (word.length < 1) {
|
|
394
|
+
this.hide();
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
this.currentWord = word;
|
|
399
|
+
this.wordStart = pos - word.length;
|
|
400
|
+
|
|
401
|
+
this.matches = this.sourceContext.getLabels(word).slice(0, 12);
|
|
402
|
+
this.selectedIndex = 0;
|
|
403
|
+
|
|
404
|
+
if (this.matches.length > 0) {
|
|
405
|
+
this.show();
|
|
406
|
+
this.render();
|
|
407
|
+
} else {
|
|
408
|
+
this.hide();
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Extract the current word being typed backwards from cursor col
|
|
414
|
+
*/
|
|
415
|
+
extractWord(col, line) {
|
|
416
|
+
let start = col;
|
|
417
|
+
while (start > 0 && /[A-Za-z0-9_:$\]<>^]/.test(line[start - 1])) {
|
|
418
|
+
start--;
|
|
419
|
+
}
|
|
420
|
+
// Skip leading # for immediate mode
|
|
421
|
+
if (line[start] === '#') start++;
|
|
422
|
+
return line.substring(start, col);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
onKeyDown(e) {
|
|
426
|
+
if (!this.isVisible) return;
|
|
427
|
+
|
|
428
|
+
switch (e.key) {
|
|
429
|
+
case 'ArrowDown':
|
|
430
|
+
e.preventDefault();
|
|
431
|
+
e.stopPropagation();
|
|
432
|
+
this.selectItem(this.selectedIndex + 1);
|
|
433
|
+
break;
|
|
434
|
+
case 'ArrowUp':
|
|
435
|
+
e.preventDefault();
|
|
436
|
+
e.stopPropagation();
|
|
437
|
+
this.selectItem(this.selectedIndex - 1);
|
|
438
|
+
break;
|
|
439
|
+
case 'Tab':
|
|
440
|
+
if (this.matches.length > 0) {
|
|
441
|
+
e.preventDefault();
|
|
442
|
+
e.stopPropagation();
|
|
443
|
+
this.insertSelected(true); // advance = true
|
|
444
|
+
}
|
|
445
|
+
break;
|
|
446
|
+
case 'Enter':
|
|
447
|
+
if (this.matches.length > 0) {
|
|
448
|
+
e.preventDefault();
|
|
449
|
+
e.stopPropagation();
|
|
450
|
+
this.insertSelected(false);
|
|
451
|
+
}
|
|
452
|
+
break;
|
|
453
|
+
case 'Escape':
|
|
454
|
+
e.preventDefault();
|
|
455
|
+
e.stopPropagation();
|
|
456
|
+
this.hide();
|
|
457
|
+
break;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
selectItem(index) {
|
|
462
|
+
if (this.matches.length === 0) return;
|
|
463
|
+
if (index < 0) index = this.matches.length - 1;
|
|
464
|
+
if (index >= this.matches.length) index = 0;
|
|
465
|
+
this.selectedIndex = index;
|
|
466
|
+
this.render();
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
insertSelected(advance) {
|
|
470
|
+
if (this.matches.length === 0) return;
|
|
471
|
+
|
|
472
|
+
const selected = this.matches[this.selectedIndex];
|
|
473
|
+
const text = this.textarea.value;
|
|
474
|
+
const pos = this.textarea.selectionStart;
|
|
475
|
+
const before = text.substring(0, this.wordStart);
|
|
476
|
+
const after = text.substring(pos);
|
|
477
|
+
|
|
478
|
+
this.isInserting = true;
|
|
479
|
+
this.textarea.value = before + selected.keyword + after;
|
|
480
|
+
const newPos = this.wordStart + selected.keyword.length;
|
|
481
|
+
this.textarea.selectionStart = newPos;
|
|
482
|
+
this.textarea.selectionEnd = newPos;
|
|
483
|
+
|
|
484
|
+
this.hide();
|
|
485
|
+
|
|
486
|
+
// Trigger input for highlighting
|
|
487
|
+
this.textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
|
488
|
+
|
|
489
|
+
// Notify support class
|
|
490
|
+
const { col } = getCursorLineAndCol(this.textarea);
|
|
491
|
+
const column = detectMerlinColumn(this.textarea.value.substring(
|
|
492
|
+
this.textarea.value.lastIndexOf('\n', this.textarea.selectionStart - 1) + 1,
|
|
493
|
+
this.textarea.selectionStart
|
|
494
|
+
), col);
|
|
495
|
+
|
|
496
|
+
if (this.onAccept) {
|
|
497
|
+
this.onAccept(selected.keyword, column, advance);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
setTimeout(() => { this.isInserting = false; }, 10);
|
|
501
|
+
this.textarea.focus();
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
render() {
|
|
505
|
+
this.listEl.innerHTML = this.matches.map((item, index) => {
|
|
506
|
+
const sel = index === this.selectedIndex ? ' selected' : '';
|
|
507
|
+
return `
|
|
508
|
+
<div class="autocomplete-item${sel}" data-index="${index}" data-css-class="${item.cssClass}">
|
|
509
|
+
<span class="autocomplete-keyword ${item.cssClass}">${this.highlightMatch(item.keyword)}</span>
|
|
510
|
+
<span class="autocomplete-category">${item.category}</span>
|
|
511
|
+
</div>
|
|
512
|
+
`;
|
|
513
|
+
}).join('');
|
|
514
|
+
|
|
515
|
+
if (this.matches.length > 0) {
|
|
516
|
+
const selected = this.matches[this.selectedIndex];
|
|
517
|
+
this.hintEl.innerHTML = `
|
|
518
|
+
<div class="hint-syntax">${selected.syntax}</div>
|
|
519
|
+
<div class="hint-desc">${selected.desc}</div>
|
|
520
|
+
`;
|
|
521
|
+
this.hintEl.style.display = 'block';
|
|
522
|
+
} else {
|
|
523
|
+
this.hintEl.style.display = 'none';
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const selectedEl = this.listEl.querySelector('.selected');
|
|
527
|
+
if (selectedEl) selectedEl.scrollIntoView({ block: 'nearest' });
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
highlightMatch(keyword) {
|
|
531
|
+
const len = this.currentWord.length;
|
|
532
|
+
return `<span class="match">${keyword.substring(0, len)}</span>${keyword.substring(len)}`;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
show() {
|
|
536
|
+
if (this.isVisible) return;
|
|
537
|
+
this.isVisible = true;
|
|
538
|
+
this.dropdown.classList.add('visible');
|
|
539
|
+
this.positionDropdown();
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
hide() {
|
|
543
|
+
if (!this.isVisible) return;
|
|
544
|
+
this.isVisible = false;
|
|
545
|
+
this.dropdown.classList.remove('visible');
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
positionDropdown() {
|
|
549
|
+
const text = this.textarea.value.substring(0, this.textarea.selectionStart);
|
|
550
|
+
const lines = text.split('\n');
|
|
551
|
+
const currentLine = lines.length - 1;
|
|
552
|
+
const currentCol = lines[lines.length - 1].length;
|
|
553
|
+
|
|
554
|
+
const style = getComputedStyle(this.textarea);
|
|
555
|
+
const fontSize = parseFloat(style.fontSize) || 12;
|
|
556
|
+
const charWidth = fontSize * 0.6;
|
|
557
|
+
const lineHeight = parseFloat(style.lineHeight) || fontSize * 1.4;
|
|
558
|
+
const paddingLeft = parseFloat(style.paddingLeft) || 8;
|
|
559
|
+
const paddingTop = parseFloat(style.paddingTop) || 8;
|
|
560
|
+
|
|
561
|
+
let left = paddingLeft + currentCol * charWidth;
|
|
562
|
+
let top = paddingTop + (currentLine + 1) * lineHeight - this.textarea.scrollTop;
|
|
563
|
+
|
|
564
|
+
const dropdownWidth = 260;
|
|
565
|
+
const dropdownHeight = 280;
|
|
566
|
+
|
|
567
|
+
if (left + dropdownWidth > this.container.clientWidth - 10) {
|
|
568
|
+
left = this.container.clientWidth - dropdownWidth - 10;
|
|
569
|
+
}
|
|
570
|
+
left = Math.max(5, left);
|
|
571
|
+
|
|
572
|
+
if (top + dropdownHeight > this.container.clientHeight) {
|
|
573
|
+
top = Math.max(5, top - dropdownHeight - lineHeight);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
this.dropdown.style.left = left + 'px';
|
|
577
|
+
this.dropdown.style.top = top + 'px';
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
destroy() {
|
|
581
|
+
if (this.boundOnKeyDown) {
|
|
582
|
+
this.textarea.removeEventListener('keydown', this.boundOnKeyDown, true);
|
|
583
|
+
}
|
|
584
|
+
if (this.dropdown && this.dropdown.parentNode) {
|
|
585
|
+
this.dropdown.parentNode.removeChild(this.dropdown);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* MerlinEditorSupport - Main coordinator for editor enhancements
|
|
593
|
+
*
|
|
594
|
+
* Provides: Tab/Shift+Tab column navigation, Smart Enter, auto-uppercase,
|
|
595
|
+
* comment toggle, duplicate line, column indicator, autocomplete
|
|
596
|
+
*/
|
|
597
|
+
export class MerlinEditorSupport {
|
|
598
|
+
constructor(textarea, container, options = {}) {
|
|
599
|
+
this.textarea = textarea;
|
|
600
|
+
this.container = container;
|
|
601
|
+
this.onColumnChange = options.onColumnChange || null;
|
|
602
|
+
this.onAssemble = options.onAssemble || null;
|
|
603
|
+
|
|
604
|
+
this.sourceContext = new MerlinSourceContext();
|
|
605
|
+
this.autocomplete = new MerlinAutocomplete(textarea, container, this.sourceContext);
|
|
606
|
+
|
|
607
|
+
// Wire autocomplete accept callback
|
|
608
|
+
this.autocomplete.onAccept = (keyword, column, advance) => {
|
|
609
|
+
if (advance) {
|
|
610
|
+
// After accepting in opcode column, advance to operand
|
|
611
|
+
this.advanceToColumn(COL_OPERAND);
|
|
612
|
+
}
|
|
613
|
+
// Re-fire column change
|
|
614
|
+
this.fireColumnChange();
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
this.bindEvents();
|
|
618
|
+
|
|
619
|
+
// Initial parse
|
|
620
|
+
this.sourceContext.parseSource(textarea.value);
|
|
621
|
+
this.fireColumnChange();
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
bindEvents() {
|
|
625
|
+
// Keydown handler (capture phase, registered AFTER autocomplete's)
|
|
626
|
+
this.boundOnKeyDown = (e) => this.onKeyDown(e);
|
|
627
|
+
this.textarea.addEventListener('keydown', this.boundOnKeyDown, true);
|
|
628
|
+
|
|
629
|
+
// Input handler for auto-uppercase and autocomplete trigger
|
|
630
|
+
this.boundOnInput = () => this.onInputEvent();
|
|
631
|
+
this.textarea.addEventListener('input', this.boundOnInput);
|
|
632
|
+
|
|
633
|
+
// Track cursor movement for column indicator
|
|
634
|
+
this.textarea.addEventListener('click', () => this.fireColumnChange());
|
|
635
|
+
this.textarea.addEventListener('keyup', () => this.fireColumnChange());
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
onKeyDown(e) {
|
|
639
|
+
const isMod = e.ctrlKey || e.metaKey;
|
|
640
|
+
|
|
641
|
+
// Ctrl/Cmd + Enter: assemble
|
|
642
|
+
if (e.key === 'Enter' && isMod && !e.shiftKey) {
|
|
643
|
+
e.preventDefault();
|
|
644
|
+
if (this.onAssemble) this.onAssemble();
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Ctrl/Cmd + /: comment toggle
|
|
649
|
+
if (e.key === '/' && isMod) {
|
|
650
|
+
e.preventDefault();
|
|
651
|
+
this.toggleComment();
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Ctrl/Cmd + D: duplicate line
|
|
656
|
+
if ((e.key === 'd' || e.key === 'D') && isMod && !e.shiftKey) {
|
|
657
|
+
e.preventDefault();
|
|
658
|
+
this.duplicateLine();
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Tab / Shift+Tab: column navigation (only if autocomplete not visible)
|
|
663
|
+
if (e.key === 'Tab' && !this.autocomplete.isVisible) {
|
|
664
|
+
e.preventDefault();
|
|
665
|
+
if (e.shiftKey) {
|
|
666
|
+
this.tabBackward();
|
|
667
|
+
} else {
|
|
668
|
+
this.tabForward();
|
|
669
|
+
}
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Enter: smart indent (only if autocomplete not visible)
|
|
674
|
+
if (e.key === 'Enter' && !isMod && !this.autocomplete.isVisible) {
|
|
675
|
+
e.preventDefault();
|
|
676
|
+
this.smartEnter();
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
onInputEvent() {
|
|
682
|
+
// Auto-uppercase in opcode column
|
|
683
|
+
this.autoUppercase();
|
|
684
|
+
|
|
685
|
+
// Trigger autocomplete
|
|
686
|
+
this.autocomplete.onInput();
|
|
687
|
+
|
|
688
|
+
// Schedule source context parse
|
|
689
|
+
this.sourceContext.scheduleParse(this.textarea.value);
|
|
690
|
+
|
|
691
|
+
// Fire column change
|
|
692
|
+
this.fireColumnChange();
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Tab forward to next column stop
|
|
697
|
+
*/
|
|
698
|
+
tabForward() {
|
|
699
|
+
const { lineStart, col, line } = getCursorLineAndCol(this.textarea);
|
|
700
|
+
const nextCol = MERLIN_COLUMNS.find(c => c > col);
|
|
701
|
+
const target = nextCol !== undefined ? nextCol : col + 4; // past comment, just add spaces
|
|
702
|
+
|
|
703
|
+
this.padToColumn(lineStart, col, line, target);
|
|
704
|
+
this.fireColumnChange();
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Shift+Tab backward to previous column stop
|
|
709
|
+
*/
|
|
710
|
+
tabBackward() {
|
|
711
|
+
const { lineStart, col } = getCursorLineAndCol(this.textarea);
|
|
712
|
+
|
|
713
|
+
// Find previous column stop
|
|
714
|
+
let target = 0;
|
|
715
|
+
for (let i = MERLIN_COLUMNS.length - 1; i >= 0; i--) {
|
|
716
|
+
if (MERLIN_COLUMNS[i] < col) {
|
|
717
|
+
target = MERLIN_COLUMNS[i];
|
|
718
|
+
break;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const newPos = lineStart + target;
|
|
723
|
+
this.textarea.selectionStart = newPos;
|
|
724
|
+
this.textarea.selectionEnd = newPos;
|
|
725
|
+
this.fireColumnChange();
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Pad the current line with spaces to reach target column
|
|
730
|
+
*/
|
|
731
|
+
padToColumn(lineStart, currentCol, line, targetCol) {
|
|
732
|
+
const pos = this.textarea.selectionStart;
|
|
733
|
+
const text = this.textarea.value;
|
|
734
|
+
|
|
735
|
+
if (currentCol < targetCol) {
|
|
736
|
+
// Insert spaces to reach target
|
|
737
|
+
const spaces = ' '.repeat(targetCol - currentCol);
|
|
738
|
+
const before = text.substring(0, pos);
|
|
739
|
+
const after = text.substring(pos);
|
|
740
|
+
this.textarea.value = before + spaces + after;
|
|
741
|
+
const newPos = pos + spaces.length;
|
|
742
|
+
this.textarea.selectionStart = newPos;
|
|
743
|
+
this.textarea.selectionEnd = newPos;
|
|
744
|
+
this.textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
|
745
|
+
} else {
|
|
746
|
+
// Already at or past target, just position cursor
|
|
747
|
+
const newPos = lineStart + targetCol;
|
|
748
|
+
this.textarea.selectionStart = newPos;
|
|
749
|
+
this.textarea.selectionEnd = newPos;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* Advance cursor to a specific column on the current line
|
|
755
|
+
*/
|
|
756
|
+
advanceToColumn(targetCol) {
|
|
757
|
+
const { lineStart, col, line } = getCursorLineAndCol(this.textarea);
|
|
758
|
+
this.padToColumn(lineStart, col, line, targetCol);
|
|
759
|
+
this.fireColumnChange();
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Smart Enter: new line pre-indented to col 9 (opcode), unless at col 0 or empty line
|
|
764
|
+
*/
|
|
765
|
+
smartEnter() {
|
|
766
|
+
const { lineStart, col, line, pos } = getCursorLineAndCol(this.textarea);
|
|
767
|
+
const text = this.textarea.value;
|
|
768
|
+
const trimmed = line.trim();
|
|
769
|
+
|
|
770
|
+
// If on an empty line or cursor is at column 0 typing a label, new line at col 0
|
|
771
|
+
const startAtZero = !trimmed || col === 0;
|
|
772
|
+
const indent = startAtZero ? '' : ' '.repeat(COL_OPCODE);
|
|
773
|
+
|
|
774
|
+
const before = text.substring(0, pos);
|
|
775
|
+
const after = text.substring(pos);
|
|
776
|
+
this.textarea.value = before + '\n' + indent + after;
|
|
777
|
+
|
|
778
|
+
const newPos = pos + 1 + indent.length;
|
|
779
|
+
this.textarea.selectionStart = newPos;
|
|
780
|
+
this.textarea.selectionEnd = newPos;
|
|
781
|
+
this.textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
|
782
|
+
this.fireColumnChange();
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Auto-uppercase characters typed in the opcode column (cols 9-13)
|
|
787
|
+
*/
|
|
788
|
+
autoUppercase() {
|
|
789
|
+
if (this.autocomplete.isInserting) return;
|
|
790
|
+
|
|
791
|
+
const { col, line, pos } = getCursorLineAndCol(this.textarea);
|
|
792
|
+
// Only auto-uppercase in opcode column range
|
|
793
|
+
if (col < COL_OPCODE + 1 || col > COL_OPERAND) return;
|
|
794
|
+
if (detectMerlinColumn(line, col - 1) !== 'opcode') return;
|
|
795
|
+
|
|
796
|
+
const ch = this.textarea.value[pos - 1];
|
|
797
|
+
if (!ch || !/[a-z]/.test(ch)) return;
|
|
798
|
+
|
|
799
|
+
// Replace the character with uppercase
|
|
800
|
+
const text = this.textarea.value;
|
|
801
|
+
this.textarea.value = text.substring(0, pos - 1) + ch.toUpperCase() + text.substring(pos);
|
|
802
|
+
this.textarea.selectionStart = pos;
|
|
803
|
+
this.textarea.selectionEnd = pos;
|
|
804
|
+
// Don't dispatch input here - we're already in the input handler
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Toggle comment (;) at column 0 of current or selected lines
|
|
809
|
+
*/
|
|
810
|
+
toggleComment() {
|
|
811
|
+
const text = this.textarea.value;
|
|
812
|
+
const start = this.textarea.selectionStart;
|
|
813
|
+
const end = this.textarea.selectionEnd;
|
|
814
|
+
|
|
815
|
+
// Find line range
|
|
816
|
+
const lineStartIdx = text.lastIndexOf('\n', start - 1) + 1;
|
|
817
|
+
let lineEndIdx = text.indexOf('\n', end);
|
|
818
|
+
if (lineEndIdx === -1) lineEndIdx = text.length;
|
|
819
|
+
|
|
820
|
+
const block = text.substring(lineStartIdx, lineEndIdx);
|
|
821
|
+
const lines = block.split('\n');
|
|
822
|
+
|
|
823
|
+
// Check if all lines are commented
|
|
824
|
+
const allCommented = lines.every(l => l.trimStart().startsWith(';') || !l.trim());
|
|
825
|
+
|
|
826
|
+
let newLines;
|
|
827
|
+
if (allCommented) {
|
|
828
|
+
// Remove leading ;
|
|
829
|
+
newLines = lines.map(l => {
|
|
830
|
+
if (!l.trim()) return l;
|
|
831
|
+
const idx = l.indexOf(';');
|
|
832
|
+
return l.substring(0, idx) + l.substring(idx + 1);
|
|
833
|
+
});
|
|
834
|
+
} else {
|
|
835
|
+
// Add ; at column 0
|
|
836
|
+
newLines = lines.map(l => {
|
|
837
|
+
if (!l.trim()) return l;
|
|
838
|
+
return ';' + l;
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
const newBlock = newLines.join('\n');
|
|
843
|
+
const before = text.substring(0, lineStartIdx);
|
|
844
|
+
const after = text.substring(lineEndIdx);
|
|
845
|
+
this.textarea.value = before + newBlock + after;
|
|
846
|
+
|
|
847
|
+
// Adjust selection
|
|
848
|
+
const diff = newBlock.length - block.length;
|
|
849
|
+
this.textarea.selectionStart = lineStartIdx;
|
|
850
|
+
this.textarea.selectionEnd = lineStartIdx + newBlock.length;
|
|
851
|
+
|
|
852
|
+
this.textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
|
853
|
+
this.fireColumnChange();
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* Duplicate current line or selected lines below
|
|
858
|
+
*/
|
|
859
|
+
duplicateLine() {
|
|
860
|
+
const text = this.textarea.value;
|
|
861
|
+
const start = this.textarea.selectionStart;
|
|
862
|
+
const end = this.textarea.selectionEnd;
|
|
863
|
+
|
|
864
|
+
const lineStartIdx = text.lastIndexOf('\n', start - 1) + 1;
|
|
865
|
+
let lineEndIdx = text.indexOf('\n', end);
|
|
866
|
+
if (lineEndIdx === -1) lineEndIdx = text.length;
|
|
867
|
+
|
|
868
|
+
const block = text.substring(lineStartIdx, lineEndIdx);
|
|
869
|
+
|
|
870
|
+
// Insert duplicate after the line(s)
|
|
871
|
+
const before = text.substring(0, lineEndIdx);
|
|
872
|
+
const after = text.substring(lineEndIdx);
|
|
873
|
+
this.textarea.value = before + '\n' + block + after;
|
|
874
|
+
|
|
875
|
+
// Move cursor to the duplicate
|
|
876
|
+
const newStart = lineEndIdx + 1 + (start - lineStartIdx);
|
|
877
|
+
const newEnd = lineEndIdx + 1 + (end - lineStartIdx);
|
|
878
|
+
this.textarea.selectionStart = newStart;
|
|
879
|
+
this.textarea.selectionEnd = newEnd;
|
|
880
|
+
|
|
881
|
+
this.textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
|
882
|
+
this.fireColumnChange();
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Fire column change callback
|
|
887
|
+
*/
|
|
888
|
+
fireColumnChange() {
|
|
889
|
+
if (!this.onColumnChange) return;
|
|
890
|
+
const { col, line } = getCursorLineAndCol(this.textarea);
|
|
891
|
+
const name = detectMerlinColumn(line, col);
|
|
892
|
+
this.onColumnChange(name, col);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
destroy() {
|
|
896
|
+
if (this.boundOnKeyDown) {
|
|
897
|
+
this.textarea.removeEventListener('keydown', this.boundOnKeyDown, true);
|
|
898
|
+
}
|
|
899
|
+
if (this.boundOnInput) {
|
|
900
|
+
this.textarea.removeEventListener('input', this.boundOnInput);
|
|
901
|
+
}
|
|
902
|
+
this.autocomplete.destroy();
|
|
903
|
+
this.sourceContext.destroy();
|
|
904
|
+
}
|
|
905
|
+
}
|