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.
Files changed (295) hide show
  1. package/.clangd +5 -0
  2. package/.mcp.json +12 -0
  3. package/CLAUDE.md +362 -0
  4. package/CMakeLists.txt +774 -0
  5. package/LICENSE +21 -0
  6. package/README.md +392 -0
  7. package/build-wasm/generated/roms.cpp +2447 -0
  8. package/docker-compose.staging.yml +9 -0
  9. package/docs/basic-rom-disassembly.md +6663 -0
  10. package/docs/softswitch-comparison.md +273 -0
  11. package/docs/thunderclock-debug.md +89 -0
  12. package/examples/cube.bas +72 -0
  13. package/examples/hello.s +55 -0
  14. package/examples/scroll.s +140 -0
  15. package/package.json +18 -0
  16. package/public/assets/apple-logo-old.png +0 -0
  17. package/public/assets/apple-logo.png +0 -0
  18. package/public/assets/drive-closed-light-on.png +0 -0
  19. package/public/assets/drive-closed.png +0 -0
  20. package/public/assets/drive-open-light-on.png +0 -0
  21. package/public/assets/drive-open.png +0 -0
  22. package/public/audio-worklet.js +82 -0
  23. package/public/disks/Apple DOS 3.3 January 1983.dsk +0 -0
  24. package/public/disks/ProDOS 2.4.3.po +0 -0
  25. package/public/disks/h32mb.2mg +0 -0
  26. package/public/disks/library.json +26 -0
  27. package/public/docs/llms/llm-assembler.txt +90 -0
  28. package/public/docs/llms/llm-basic-program.txt +256 -0
  29. package/public/docs/llms/llm-disk-drives.txt +72 -0
  30. package/public/docs/llms/llm-file-explorer.txt +50 -0
  31. package/public/docs/llms/llm-hard-drives.txt +80 -0
  32. package/public/docs/llms/llm-main.txt +51 -0
  33. package/public/docs/llms/llm-slot-configuration.txt +66 -0
  34. package/public/icons/icon-192.svg +4 -0
  35. package/public/icons/icon-512.svg +4 -0
  36. package/public/index.html +661 -0
  37. package/public/llms.txt +49 -0
  38. package/public/manifest.json +29 -0
  39. package/public/shaders/burnin.glsl +22 -0
  40. package/public/shaders/crt.glsl +706 -0
  41. package/public/shaders/edge.glsl +109 -0
  42. package/public/shaders/vertex.glsl +8 -0
  43. package/public/sw.js +186 -0
  44. package/roms/341-0027.bin +0 -0
  45. package/roms/341-0160-A-US-UK.bin +0 -0
  46. package/roms/341-0160-A.bin +0 -0
  47. package/roms/342-0273-A-US-UK.bin +0 -0
  48. package/roms/342-0349-B-C0-FF.bin +0 -0
  49. package/roms/Apple Mouse Interface Card ROM - 342-0270-C.bin +0 -0
  50. package/roms/Thunderclock Plus ROM.bin +0 -0
  51. package/scripts/generate_roms.sh +69 -0
  52. package/src/bindings/wasm_interface.cpp +1940 -0
  53. package/src/core/assembler/assembler.cpp +1239 -0
  54. package/src/core/assembler/assembler.hpp +115 -0
  55. package/src/core/audio/audio.cpp +160 -0
  56. package/src/core/audio/audio.hpp +81 -0
  57. package/src/core/basic/basic_detokenizer.cpp +436 -0
  58. package/src/core/basic/basic_detokenizer.hpp +41 -0
  59. package/src/core/basic/basic_tokenizer.cpp +286 -0
  60. package/src/core/basic/basic_tokenizer.hpp +26 -0
  61. package/src/core/basic/basic_tokens.hpp +295 -0
  62. package/src/core/cards/disk2_card.cpp +568 -0
  63. package/src/core/cards/disk2_card.hpp +316 -0
  64. package/src/core/cards/expansion_card.hpp +185 -0
  65. package/src/core/cards/mockingboard/ay8910.cpp +616 -0
  66. package/src/core/cards/mockingboard/ay8910.hpp +159 -0
  67. package/src/core/cards/mockingboard/via6522.cpp +530 -0
  68. package/src/core/cards/mockingboard/via6522.hpp +163 -0
  69. package/src/core/cards/mockingboard_card.cpp +312 -0
  70. package/src/core/cards/mockingboard_card.hpp +159 -0
  71. package/src/core/cards/mouse_card.cpp +654 -0
  72. package/src/core/cards/mouse_card.hpp +190 -0
  73. package/src/core/cards/smartport/block_device.cpp +202 -0
  74. package/src/core/cards/smartport/block_device.hpp +60 -0
  75. package/src/core/cards/smartport/smartport_card.cpp +603 -0
  76. package/src/core/cards/smartport/smartport_card.hpp +120 -0
  77. package/src/core/cards/thunderclock_card.cpp +237 -0
  78. package/src/core/cards/thunderclock_card.hpp +122 -0
  79. package/src/core/cpu/cpu6502.cpp +1609 -0
  80. package/src/core/cpu/cpu6502.hpp +203 -0
  81. package/src/core/debug/condition_evaluator.cpp +470 -0
  82. package/src/core/debug/condition_evaluator.hpp +87 -0
  83. package/src/core/disassembler/disassembler.cpp +552 -0
  84. package/src/core/disassembler/disassembler.hpp +171 -0
  85. package/src/core/disk-image/disk_image.hpp +267 -0
  86. package/src/core/disk-image/dsk_disk_image.cpp +827 -0
  87. package/src/core/disk-image/dsk_disk_image.hpp +204 -0
  88. package/src/core/disk-image/gcr_encoding.cpp +147 -0
  89. package/src/core/disk-image/gcr_encoding.hpp +78 -0
  90. package/src/core/disk-image/woz_disk_image.cpp +1049 -0
  91. package/src/core/disk-image/woz_disk_image.hpp +343 -0
  92. package/src/core/emulator.cpp +2126 -0
  93. package/src/core/emulator.hpp +434 -0
  94. package/src/core/filesystem/dos33.cpp +178 -0
  95. package/src/core/filesystem/dos33.hpp +66 -0
  96. package/src/core/filesystem/pascal.cpp +262 -0
  97. package/src/core/filesystem/pascal.hpp +87 -0
  98. package/src/core/filesystem/prodos.cpp +369 -0
  99. package/src/core/filesystem/prodos.hpp +119 -0
  100. package/src/core/input/keyboard.cpp +227 -0
  101. package/src/core/input/keyboard.hpp +111 -0
  102. package/src/core/mmu/mmu.cpp +1387 -0
  103. package/src/core/mmu/mmu.hpp +236 -0
  104. package/src/core/types.hpp +196 -0
  105. package/src/core/video/video.cpp +680 -0
  106. package/src/core/video/video.hpp +156 -0
  107. package/src/css/assembler-editor.css +1617 -0
  108. package/src/css/base.css +470 -0
  109. package/src/css/basic-debugger.css +791 -0
  110. package/src/css/basic-editor.css +792 -0
  111. package/src/css/controls.css +783 -0
  112. package/src/css/cpu-debugger.css +1413 -0
  113. package/src/css/debug-base.css +160 -0
  114. package/src/css/debug-windows.css +6455 -0
  115. package/src/css/disk-drives.css +406 -0
  116. package/src/css/documentation.css +392 -0
  117. package/src/css/file-explorer.css +867 -0
  118. package/src/css/hard-drive.css +180 -0
  119. package/src/css/layout.css +217 -0
  120. package/src/css/memory-windows.css +798 -0
  121. package/src/css/modals.css +510 -0
  122. package/src/css/monitor.css +425 -0
  123. package/src/css/release-notes.css +101 -0
  124. package/src/css/responsive.css +400 -0
  125. package/src/css/rule-builder.css +340 -0
  126. package/src/css/save-states.css +201 -0
  127. package/src/css/settings-windows.css +1231 -0
  128. package/src/css/window-switcher.css +150 -0
  129. package/src/js/agent/agent-manager.js +643 -0
  130. package/src/js/agent/agent-tools.js +293 -0
  131. package/src/js/agent/agent-version-tools.js +131 -0
  132. package/src/js/agent/assembler-tools.js +357 -0
  133. package/src/js/agent/basic-program-tools.js +894 -0
  134. package/src/js/agent/disk-tools.js +417 -0
  135. package/src/js/agent/file-explorer-tools.js +269 -0
  136. package/src/js/agent/index.js +13 -0
  137. package/src/js/agent/main-tools.js +222 -0
  138. package/src/js/agent/slot-tools.js +303 -0
  139. package/src/js/agent/smartport-tools.js +257 -0
  140. package/src/js/agent/window-tools.js +80 -0
  141. package/src/js/audio/audio-driver.js +417 -0
  142. package/src/js/audio/audio-worklet.js +85 -0
  143. package/src/js/audio/index.js +8 -0
  144. package/src/js/config/default-layout.js +34 -0
  145. package/src/js/config/version.js +8 -0
  146. package/src/js/data/apple2-rom-routines.js +577 -0
  147. package/src/js/debug/assembler-editor-window.js +2993 -0
  148. package/src/js/debug/basic-breakpoint-manager.js +529 -0
  149. package/src/js/debug/basic-program-parser.js +436 -0
  150. package/src/js/debug/basic-program-window.js +2594 -0
  151. package/src/js/debug/basic-variable-inspector.js +447 -0
  152. package/src/js/debug/breakpoint-manager.js +472 -0
  153. package/src/js/debug/cpu-debugger-window.js +2396 -0
  154. package/src/js/debug/index.js +22 -0
  155. package/src/js/debug/label-manager.js +238 -0
  156. package/src/js/debug/memory-browser-window.js +416 -0
  157. package/src/js/debug/memory-heat-map-window.js +481 -0
  158. package/src/js/debug/memory-map-window.js +206 -0
  159. package/src/js/debug/mockingboard-window.js +882 -0
  160. package/src/js/debug/mouse-card-window.js +355 -0
  161. package/src/js/debug/rule-builder-window.js +648 -0
  162. package/src/js/debug/soft-switch-window.js +458 -0
  163. package/src/js/debug/stack-viewer-window.js +221 -0
  164. package/src/js/debug/symbols.js +416 -0
  165. package/src/js/debug/trace-panel.js +291 -0
  166. package/src/js/debug/zero-page-watch-window.js +297 -0
  167. package/src/js/disk-manager/disk-drives-window.js +212 -0
  168. package/src/js/disk-manager/disk-operations.js +284 -0
  169. package/src/js/disk-manager/disk-persistence.js +301 -0
  170. package/src/js/disk-manager/disk-surface-renderer.js +388 -0
  171. package/src/js/disk-manager/drive-sounds.js +139 -0
  172. package/src/js/disk-manager/hard-drive-manager.js +481 -0
  173. package/src/js/disk-manager/hard-drive-persistence.js +187 -0
  174. package/src/js/disk-manager/hard-drive-window.js +57 -0
  175. package/src/js/disk-manager/index.js +890 -0
  176. package/src/js/display/display-settings-window.js +383 -0
  177. package/src/js/display/index.js +10 -0
  178. package/src/js/display/screen-window.js +342 -0
  179. package/src/js/display/webgl-renderer.js +705 -0
  180. package/src/js/file-explorer/disassembler.js +574 -0
  181. package/src/js/file-explorer/dos33.js +266 -0
  182. package/src/js/file-explorer/file-viewer.js +359 -0
  183. package/src/js/file-explorer/index.js +1261 -0
  184. package/src/js/file-explorer/prodos.js +549 -0
  185. package/src/js/file-explorer/utils.js +67 -0
  186. package/src/js/help/documentation-window.js +1096 -0
  187. package/src/js/help/index.js +10 -0
  188. package/src/js/help/release-notes-window.js +85 -0
  189. package/src/js/help/release-notes.js +612 -0
  190. package/src/js/input/gamepad-handler.js +176 -0
  191. package/src/js/input/index.js +12 -0
  192. package/src/js/input/input-handler.js +396 -0
  193. package/src/js/input/joystick-window.js +404 -0
  194. package/src/js/input/mouse-handler.js +99 -0
  195. package/src/js/input/text-selection.js +462 -0
  196. package/src/js/main.js +653 -0
  197. package/src/js/state/index.js +15 -0
  198. package/src/js/state/save-states-window.js +393 -0
  199. package/src/js/state/state-manager.js +409 -0
  200. package/src/js/state/state-persistence.js +218 -0
  201. package/src/js/ui/confirm.js +43 -0
  202. package/src/js/ui/disk-drive-positioner.js +347 -0
  203. package/src/js/ui/reminder-controller.js +129 -0
  204. package/src/js/ui/slot-configuration-window.js +560 -0
  205. package/src/js/ui/theme-manager.js +61 -0
  206. package/src/js/ui/toast.js +44 -0
  207. package/src/js/ui/ui-controller.js +897 -0
  208. package/src/js/ui/window-switcher.js +275 -0
  209. package/src/js/utils/basic-autocomplete.js +832 -0
  210. package/src/js/utils/basic-highlighting.js +473 -0
  211. package/src/js/utils/basic-tokenizer.js +153 -0
  212. package/src/js/utils/basic-tokens.js +117 -0
  213. package/src/js/utils/constants.js +28 -0
  214. package/src/js/utils/indexeddb-helper.js +225 -0
  215. package/src/js/utils/merlin-editor-support.js +905 -0
  216. package/src/js/utils/merlin-highlighting.js +551 -0
  217. package/src/js/utils/storage.js +125 -0
  218. package/src/js/utils/string-utils.js +19 -0
  219. package/src/js/utils/wasm-memory.js +54 -0
  220. package/src/js/windows/base-window.js +690 -0
  221. package/src/js/windows/index.js +9 -0
  222. package/src/js/windows/window-manager.js +375 -0
  223. package/tests/catch2/catch.hpp +17976 -0
  224. package/tests/common/basic_program_builder.cpp +119 -0
  225. package/tests/common/basic_program_builder.hpp +209 -0
  226. package/tests/common/disk_image_builder.cpp +444 -0
  227. package/tests/common/disk_image_builder.hpp +141 -0
  228. package/tests/common/test_helpers.hpp +118 -0
  229. package/tests/gcr/gcr-test.cpp +142 -0
  230. package/tests/integration/check-rom.js +70 -0
  231. package/tests/integration/compare-boot.js +239 -0
  232. package/tests/integration/crash-trace.js +102 -0
  233. package/tests/integration/disk-boot-test.js +264 -0
  234. package/tests/integration/memory-crash.js +108 -0
  235. package/tests/integration/nibble-read-test.js +249 -0
  236. package/tests/integration/phase-test.js +159 -0
  237. package/tests/integration/test_emulator.cpp +291 -0
  238. package/tests/integration/test_emulator_basic.cpp +91 -0
  239. package/tests/integration/test_emulator_debug.cpp +344 -0
  240. package/tests/integration/test_emulator_disk.cpp +153 -0
  241. package/tests/integration/test_emulator_state.cpp +163 -0
  242. package/tests/klaus/6502_functional_test.bin +0 -0
  243. package/tests/klaus/65C02_extended_opcodes_test.bin +0 -0
  244. package/tests/klaus/klaus_6502_test.cpp +184 -0
  245. package/tests/klaus/klaus_65c02_test.cpp +197 -0
  246. package/tests/thunderclock/thunderclock_mmu_test.cpp +304 -0
  247. package/tests/thunderclock/thunderclock_test.cpp +550 -0
  248. package/tests/unit/test_assembler.cpp +521 -0
  249. package/tests/unit/test_audio.cpp +196 -0
  250. package/tests/unit/test_ay8910.cpp +311 -0
  251. package/tests/unit/test_basic_detokenizer.cpp +265 -0
  252. package/tests/unit/test_basic_tokenizer.cpp +382 -0
  253. package/tests/unit/test_block_device.cpp +259 -0
  254. package/tests/unit/test_condition_evaluator.cpp +219 -0
  255. package/tests/unit/test_cpu6502.cpp +1301 -0
  256. package/tests/unit/test_cpu_addressing.cpp +361 -0
  257. package/tests/unit/test_cpu_cycle_counts.cpp +409 -0
  258. package/tests/unit/test_cpu_decimal.cpp +166 -0
  259. package/tests/unit/test_cpu_interrupts.cpp +285 -0
  260. package/tests/unit/test_disassembler.cpp +323 -0
  261. package/tests/unit/test_disk2_card.cpp +330 -0
  262. package/tests/unit/test_dos33.cpp +273 -0
  263. package/tests/unit/test_dsk_disk_image.cpp +315 -0
  264. package/tests/unit/test_expansion_card.cpp +178 -0
  265. package/tests/unit/test_gcr_encoding.cpp +232 -0
  266. package/tests/unit/test_keyboard.cpp +262 -0
  267. package/tests/unit/test_mmu.cpp +555 -0
  268. package/tests/unit/test_mmu_slots.cpp +323 -0
  269. package/tests/unit/test_mockingboard.cpp +352 -0
  270. package/tests/unit/test_mouse_card.cpp +386 -0
  271. package/tests/unit/test_pascal.cpp +248 -0
  272. package/tests/unit/test_prodos.cpp +259 -0
  273. package/tests/unit/test_smartport_card.cpp +321 -0
  274. package/tests/unit/test_thunderclock.cpp +354 -0
  275. package/tests/unit/test_via6522.cpp +323 -0
  276. package/tests/unit/test_video.cpp +319 -0
  277. package/tests/unit/test_woz_disk_image.cpp +257 -0
  278. package/vite.config.js +96 -0
  279. package/wiki/AI-Agent.md +372 -0
  280. package/wiki/Architecture-Overview.md +303 -0
  281. package/wiki/Audio-System.md +449 -0
  282. package/wiki/CPU-Emulation.md +477 -0
  283. package/wiki/Debugger.md +516 -0
  284. package/wiki/Disk-Drives.md +161 -0
  285. package/wiki/Disk-System-Internals.md +547 -0
  286. package/wiki/Display-Settings.md +88 -0
  287. package/wiki/Expansion-Slots.md +187 -0
  288. package/wiki/File-Explorer.md +259 -0
  289. package/wiki/Getting-Started.md +156 -0
  290. package/wiki/Home.md +69 -0
  291. package/wiki/Input-Devices.md +183 -0
  292. package/wiki/Keyboard-Shortcuts.md +158 -0
  293. package/wiki/Memory-System.md +364 -0
  294. package/wiki/Save-States.md +172 -0
  295. 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
+ }