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,832 @@
1
+ /*
2
+ * basic-autocomplete.js - BASIC keyword autocomplete
3
+ *
4
+ * Written by
5
+ * Mike Daley <michael_daley@icloud.com>
6
+ */
7
+
8
+ import { APPLESOFT_TOKENS } from "./basic-tokens.js";
9
+
10
+ /**
11
+ * ProgramContext - Parses BASIC code to extract variables, line numbers, etc.
12
+ */
13
+ class ProgramContext {
14
+ constructor() {
15
+ this.variables = new Map(); // name -> { type: 'numeric'|'string', isArray: boolean }
16
+ this.lineNumbers = new Set();
17
+ this.functions = new Map(); // name -> { param: string }
18
+ }
19
+
20
+ /**
21
+ * Parse the entire program text
22
+ */
23
+ parse(text) {
24
+ this.variables.clear();
25
+ this.lineNumbers.clear();
26
+ this.functions.clear();
27
+
28
+ const lines = text.split(/\r?\n/);
29
+ for (const line of lines) {
30
+ this.parseLine(line.trim().toUpperCase());
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Parse a single line of BASIC
36
+ */
37
+ parseLine(line) {
38
+ if (!line) return;
39
+
40
+ // Extract line number
41
+ const lineMatch = line.match(/^(\d+)\s*(.*)/);
42
+ if (lineMatch) {
43
+ const lineNum = parseInt(lineMatch[1], 10);
44
+ if (lineNum >= 0 && lineNum <= 63999) {
45
+ this.lineNumbers.add(lineNum);
46
+ }
47
+ line = lineMatch[2];
48
+ }
49
+
50
+ // Extract DEF FN functions: DEF FN NAME(VAR) = expr
51
+ const fnMatch = line.match(/DEF\s+FN\s*([A-Z][A-Z0-9]*)\s*\(\s*([A-Z][A-Z0-9]*)\s*\)/);
52
+ if (fnMatch) {
53
+ this.functions.set(fnMatch[1], { param: fnMatch[2] });
54
+ // The parameter is a numeric variable
55
+ this.addVariable(fnMatch[2], "numeric", false);
56
+ }
57
+
58
+ // Extract DIM arrays: DIM A(10), B$(5,3), ...
59
+ const dimMatch = line.match(/DIM\s+(.*)/);
60
+ if (dimMatch) {
61
+ const dimPart = dimMatch[1];
62
+ const arrayPattern = /([A-Z][A-Z0-9]*\$?)\s*\(/g;
63
+ let match;
64
+ while ((match = arrayPattern.exec(dimPart)) !== null) {
65
+ const name = match[1];
66
+ const isString = name.endsWith("$");
67
+ this.addVariable(name, isString ? "string" : "numeric", true);
68
+ }
69
+ }
70
+
71
+ // Extract FOR loop variables: FOR I = 1 TO 10
72
+ const forMatch = line.match(/FOR\s+([A-Z][A-Z0-9]*)\s*=/);
73
+ if (forMatch) {
74
+ this.addVariable(forMatch[1], "numeric", false);
75
+ }
76
+
77
+ // Extract INPUT variables: INPUT A, B$, C
78
+ const inputMatch = line.match(/INPUT\s+(?:"[^"]*"\s*[;,])?\s*(.*)/);
79
+ if (inputMatch) {
80
+ this.extractVariableList(inputMatch[1]);
81
+ }
82
+
83
+ // Extract READ variables: READ A, B$, C
84
+ const readMatch = line.match(/READ\s+(.*)/);
85
+ if (readMatch) {
86
+ this.extractVariableList(readMatch[1]);
87
+ }
88
+
89
+ // Extract GET variable: GET A$
90
+ const getMatch = line.match(/GET\s+([A-Z][A-Z0-9]*\$?)/);
91
+ if (getMatch) {
92
+ const name = getMatch[1];
93
+ this.addVariable(name, name.endsWith("$") ? "string" : "numeric", false);
94
+ }
95
+
96
+ // Extract LET assignments: LET A = 5 or A = 5
97
+ // Also handles A$ = "HELLO"
98
+ const letMatch = line.match(/(?:LET\s+)?([A-Z][A-Z0-9]*\$?)\s*=/);
99
+ if (letMatch) {
100
+ const name = letMatch[1];
101
+ // Don't add if it looks like part of FOR statement (already handled)
102
+ if (!line.match(/FOR\s+/)) {
103
+ this.addVariable(name, name.endsWith("$") ? "string" : "numeric", false);
104
+ }
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Extract variables from a comma-separated list
110
+ */
111
+ extractVariableList(text) {
112
+ // Remove anything after a colon (next statement)
113
+ text = text.split(":")[0];
114
+
115
+ const varPattern = /([A-Z][A-Z0-9]*\$?)(?:\s*\([^)]*\))?/g;
116
+ let match;
117
+ while ((match = varPattern.exec(text)) !== null) {
118
+ const name = match[1];
119
+ // Skip keywords
120
+ if (APPLESOFT_TOKENS.includes(name)) continue;
121
+ this.addVariable(name, name.endsWith("$") ? "string" : "numeric", false);
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Add a variable to the context
127
+ */
128
+ addVariable(name, type, isArray) {
129
+ // Skip single-letter tokens that are keywords
130
+ if (APPLESOFT_TOKENS.includes(name)) return;
131
+
132
+ // Store with array info (arrays can be accessed both ways)
133
+ const existing = this.variables.get(name);
134
+ if (existing) {
135
+ // If we see it as array somewhere, mark it as array
136
+ if (isArray) existing.isArray = true;
137
+ } else {
138
+ this.variables.set(name, { type, isArray });
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Get variables as autocomplete items
144
+ */
145
+ getVariableItems(filter = null) {
146
+ const items = [];
147
+ for (const [name, info] of this.variables) {
148
+ if (filter === "string" && info.type !== "string") continue;
149
+ if (filter === "numeric" && info.type !== "numeric") continue;
150
+
151
+ items.push({
152
+ keyword: name,
153
+ syntax: info.isArray ? `${name}(...)` : name,
154
+ category: "program",
155
+ categoryName: info.type === "string" ? "String Var" : "Variable",
156
+ desc: info.isArray ? "Array variable" : "Variable",
157
+ isVariable: true,
158
+ });
159
+ }
160
+ return items.sort((a, b) => a.keyword.localeCompare(b.keyword));
161
+ }
162
+
163
+ /**
164
+ * Get line numbers as autocomplete items
165
+ */
166
+ getLineNumberItems() {
167
+ return Array.from(this.lineNumbers)
168
+ .sort((a, b) => a - b)
169
+ .map(num => ({
170
+ keyword: String(num),
171
+ syntax: `Line ${num}`,
172
+ category: "program",
173
+ categoryName: "Line",
174
+ desc: "Program line",
175
+ isLineNumber: true,
176
+ }));
177
+ }
178
+
179
+ /**
180
+ * Get user functions as autocomplete items
181
+ */
182
+ getFunctionItems() {
183
+ const items = [];
184
+ for (const [name, info] of this.functions) {
185
+ items.push({
186
+ keyword: name,
187
+ syntax: `FN ${name}(${info.param})`,
188
+ category: "program",
189
+ categoryName: "Function",
190
+ desc: "User-defined function",
191
+ isFunction: true,
192
+ });
193
+ }
194
+ return items.sort((a, b) => a.keyword.localeCompare(b.keyword));
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Detect what context we're in based on what precedes the cursor
200
+ */
201
+ function detectContext(text, position) {
202
+ // Get text from start of line to cursor
203
+ const lineStart = text.lastIndexOf("\n", position - 1) + 1;
204
+ const lineToCursor = text.substring(lineStart, position).toUpperCase();
205
+
206
+ // Check if we're after GOTO, GOSUB, or THEN (expecting line number)
207
+ if (/(?:GOTO|GOSUB|THEN)\s*$/.test(lineToCursor)) {
208
+ return { type: "lineNumber" };
209
+ }
210
+
211
+ // Check if we're in ON ... GOTO/GOSUB line list
212
+ if (/ON\s+.*(?:GOTO|GOSUB)\s+[\d,\s]*$/.test(lineToCursor)) {
213
+ return { type: "lineNumber" };
214
+ }
215
+
216
+ // Check if we're after ONERR GOTO
217
+ if (/ONERR\s+GOTO\s*$/.test(lineToCursor)) {
218
+ return { type: "lineNumber" };
219
+ }
220
+
221
+ // Check if we're after FN (expecting function name)
222
+ if (/FN\s*$/.test(lineToCursor)) {
223
+ return { type: "function" };
224
+ }
225
+
226
+ // Check if we're in a string context (after quotes)
227
+ const beforeCursor = lineToCursor.substring(0, lineToCursor.length);
228
+ const quoteCount = (beforeCursor.match(/"/g) || []).length;
229
+ if (quoteCount % 2 !== 0) {
230
+ return { type: "string" }; // Inside a string - no suggestions
231
+ }
232
+
233
+ // Default: general context (keywords + variables)
234
+ return { type: "general" };
235
+ }
236
+
237
+ // Keywords with their syntax hints and categories
238
+ const KEYWORD_INFO = {
239
+ // Control Flow
240
+ "END": { syntax: "END", category: "control", desc: "End program execution" },
241
+ "FOR": { syntax: "FOR var = start TO end [STEP n]", category: "control", desc: "Start FOR loop" },
242
+ "NEXT": { syntax: "NEXT [var]", category: "control", desc: "End FOR loop" },
243
+ "IF": { syntax: "IF expr THEN statement", category: "control", desc: "Conditional execution" },
244
+ "THEN": { syntax: "THEN statement", category: "control", desc: "Part of IF statement" },
245
+ "GOTO": { syntax: "GOTO linenum", category: "control", desc: "Jump to line" },
246
+ "GOSUB": { syntax: "GOSUB linenum", category: "control", desc: "Call subroutine" },
247
+ "RETURN": { syntax: "RETURN", category: "control", desc: "Return from subroutine" },
248
+ "ON": { syntax: "ON expr GOTO/GOSUB line1,line2,...", category: "control", desc: "Computed branch" },
249
+ "STOP": { syntax: "STOP", category: "control", desc: "Stop execution" },
250
+ "CONT": { syntax: "CONT", category: "control", desc: "Continue after STOP" },
251
+ "RUN": { syntax: "RUN [linenum]", category: "control", desc: "Run program" },
252
+ "ONERR": { syntax: "ONERR GOTO linenum", category: "control", desc: "Error handler" },
253
+ "RESUME": { syntax: "RESUME", category: "control", desc: "Resume after error" },
254
+ "POP": { syntax: "POP", category: "control", desc: "Pop GOSUB return address" },
255
+
256
+ // I/O
257
+ "PRINT": { syntax: "PRINT [expr][;,][expr]...", category: "io", desc: "Print to screen" },
258
+ "INPUT": { syntax: "INPUT [\"prompt\";]var[,var]...", category: "io", desc: "Get user input" },
259
+ "GET": { syntax: "GET var", category: "io", desc: "Get single keypress" },
260
+ "HOME": { syntax: "HOME", category: "io", desc: "Clear screen" },
261
+ "HTAB": { syntax: "HTAB col", category: "io", desc: "Set horizontal position" },
262
+ "VTAB": { syntax: "VTAB row", category: "io", desc: "Set vertical position" },
263
+ "INVERSE": { syntax: "INVERSE", category: "io", desc: "Inverse text mode" },
264
+ "NORMAL": { syntax: "NORMAL", category: "io", desc: "Normal text mode" },
265
+ "FLASH": { syntax: "FLASH", category: "io", desc: "Flashing text mode" },
266
+ "TEXT": { syntax: "TEXT", category: "io", desc: "Text mode" },
267
+ "PR#": { syntax: "PR# slot", category: "io", desc: "Output to slot" },
268
+ "IN#": { syntax: "IN# slot", category: "io", desc: "Input from slot" },
269
+ "TAB(": { syntax: "TAB(col)", category: "io", desc: "Tab to column" },
270
+ "SPC(": { syntax: "SPC(n)", category: "io", desc: "Print n spaces" },
271
+ "POS": { syntax: "POS(0)", category: "io", desc: "Current cursor column" },
272
+
273
+ // Lo-res Graphics
274
+ "GR": { syntax: "GR", category: "lores", desc: "Lo-res graphics mode" },
275
+ "COLOR=": { syntax: "COLOR= n", category: "lores", desc: "Set lo-res color (0-15)" },
276
+ "PLOT": { syntax: "PLOT x,y", category: "lores", desc: "Plot lo-res point" },
277
+ "HLIN": { syntax: "HLIN x1,x2 AT y", category: "lores", desc: "Draw horizontal line" },
278
+ "VLIN": { syntax: "VLIN y1,y2 AT x", category: "lores", desc: "Draw vertical line" },
279
+ "SCRN(": { syntax: "SCRN(x,y)", category: "lores", desc: "Get color at point" },
280
+
281
+ // Hi-res Graphics
282
+ "HGR": { syntax: "HGR", category: "hires", desc: "Hi-res page 1" },
283
+ "HGR2": { syntax: "HGR2", category: "hires", desc: "Hi-res page 2" },
284
+ "HCOLOR=": { syntax: "HCOLOR= n", category: "hires", desc: "Set hi-res color (0-7)" },
285
+ "HPLOT": { syntax: "HPLOT x,y [TO x2,y2]...", category: "hires", desc: "Plot/draw hi-res" },
286
+ "DRAW": { syntax: "DRAW shape AT x,y", category: "hires", desc: "Draw shape" },
287
+ "XDRAW": { syntax: "XDRAW shape AT x,y", category: "hires", desc: "XOR draw shape" },
288
+ "ROT=": { syntax: "ROT= angle", category: "hires", desc: "Set shape rotation" },
289
+ "SCALE=": { syntax: "SCALE= n", category: "hires", desc: "Set shape scale" },
290
+ "SHLOAD": { syntax: "SHLOAD", category: "hires", desc: "Load shape table" },
291
+
292
+ // Variables & Data
293
+ "LET": { syntax: "LET var = expr", category: "vars", desc: "Assign variable" },
294
+ "DIM": { syntax: "DIM var(size)[,var(size)]...", category: "vars", desc: "Dimension array" },
295
+ "DATA": { syntax: "DATA value,value,...", category: "vars", desc: "Define data" },
296
+ "READ": { syntax: "READ var[,var]...", category: "vars", desc: "Read DATA values" },
297
+ "RESTORE": { syntax: "RESTORE", category: "vars", desc: "Reset DATA pointer" },
298
+ "DEF": { syntax: "DEF FN name(var) = expr", category: "vars", desc: "Define function" },
299
+ "FN": { syntax: "FN name(expr)", category: "vars", desc: "Call user function" },
300
+ "CLEAR": { syntax: "CLEAR", category: "vars", desc: "Clear variables" },
301
+ "NEW": { syntax: "NEW", category: "vars", desc: "Clear program" },
302
+
303
+ // Math Functions
304
+ "ABS": { syntax: "ABS(n)", category: "math", desc: "Absolute value" },
305
+ "SGN": { syntax: "SGN(n)", category: "math", desc: "Sign (-1,0,1)" },
306
+ "INT": { syntax: "INT(n)", category: "math", desc: "Integer part" },
307
+ "SQR": { syntax: "SQR(n)", category: "math", desc: "Square root" },
308
+ "RND": { syntax: "RND(n)", category: "math", desc: "Random number" },
309
+ "SIN": { syntax: "SIN(n)", category: "math", desc: "Sine" },
310
+ "COS": { syntax: "COS(n)", category: "math", desc: "Cosine" },
311
+ "TAN": { syntax: "TAN(n)", category: "math", desc: "Tangent" },
312
+ "ATN": { syntax: "ATN(n)", category: "math", desc: "Arctangent" },
313
+ "LOG": { syntax: "LOG(n)", category: "math", desc: "Natural log" },
314
+ "EXP": { syntax: "EXP(n)", category: "math", desc: "e^n" },
315
+
316
+ // String Functions
317
+ "LEN": { syntax: "LEN(str$)", category: "string", desc: "String length" },
318
+ "LEFT$": { syntax: "LEFT$(str$,n)", category: "string", desc: "Left n chars" },
319
+ "RIGHT$": { syntax: "RIGHT$(str$,n)", category: "string", desc: "Right n chars" },
320
+ "MID$": { syntax: "MID$(str$,start[,len])", category: "string", desc: "Substring" },
321
+ "STR$": { syntax: "STR$(n)", category: "string", desc: "Number to string" },
322
+ "VAL": { syntax: "VAL(str$)", category: "string", desc: "String to number" },
323
+ "ASC": { syntax: "ASC(str$)", category: "string", desc: "ASCII code" },
324
+ "CHR$": { syntax: "CHR$(n)", category: "string", desc: "ASCII to char" },
325
+
326
+ // Memory & System
327
+ "PEEK": { syntax: "PEEK(addr)", category: "system", desc: "Read memory byte" },
328
+ "POKE": { syntax: "POKE addr,value", category: "system", desc: "Write memory byte" },
329
+ "CALL": { syntax: "CALL addr", category: "system", desc: "Call machine code" },
330
+ "USR": { syntax: "USR(n)", category: "system", desc: "Call user routine" },
331
+ "WAIT": { syntax: "WAIT addr,mask[,xor]", category: "system", desc: "Wait for memory" },
332
+ "HIMEM:": { syntax: "HIMEM: addr", category: "system", desc: "Set memory top" },
333
+ "LOMEM:": { syntax: "LOMEM: addr", category: "system", desc: "Set variables start" },
334
+ "FRE": { syntax: "FRE(0)", category: "system", desc: "Free memory" },
335
+ "PDL": { syntax: "PDL(n)", category: "system", desc: "Read paddle (0-255)" },
336
+ "SPEED=": { syntax: "SPEED= n", category: "system", desc: "Set output speed" },
337
+
338
+ // File I/O
339
+ "LOAD": { syntax: "LOAD", category: "file", desc: "Load from tape" },
340
+ "SAVE": { syntax: "SAVE", category: "file", desc: "Save to tape" },
341
+ "STORE": { syntax: "STORE", category: "file", desc: "Store array" },
342
+ "RECALL": { syntax: "RECALL", category: "file", desc: "Recall array" },
343
+
344
+ // Other
345
+ "REM": { syntax: "REM comment", category: "other", desc: "Comment" },
346
+ "LIST": { syntax: "LIST [start[-end]]", category: "other", desc: "List program" },
347
+ "DEL": { syntax: "DEL start,end", category: "other", desc: "Delete lines" },
348
+ "TRACE": { syntax: "TRACE", category: "other", desc: "Enable tracing" },
349
+ "NOTRACE": { syntax: "NOTRACE", category: "other", desc: "Disable tracing" },
350
+ "&": { syntax: "& [params]", category: "other", desc: "Machine language hook" },
351
+
352
+ // Operators
353
+ "TO": { syntax: "TO", category: "operator", desc: "Range separator" },
354
+ "STEP": { syntax: "STEP n", category: "operator", desc: "Loop increment" },
355
+ "AT": { syntax: "AT", category: "operator", desc: "Position specifier" },
356
+ "AND": { syntax: "expr AND expr", category: "operator", desc: "Logical AND" },
357
+ "OR": { syntax: "expr OR expr", category: "operator", desc: "Logical OR" },
358
+ "NOT": { syntax: "NOT expr", category: "operator", desc: "Logical NOT" },
359
+ };
360
+
361
+ // Category display names and order
362
+ const CATEGORIES = {
363
+ control: "Control",
364
+ io: "I/O",
365
+ lores: "Lo-Res",
366
+ hires: "Hi-Res",
367
+ vars: "Variables",
368
+ math: "Math",
369
+ string: "Strings",
370
+ system: "System",
371
+ file: "File",
372
+ other: "Other",
373
+ operator: "Operator",
374
+ };
375
+
376
+ // Build list of autocomplete keywords (exclude single-char operators)
377
+ const AUTOCOMPLETE_KEYWORDS = APPLESOFT_TOKENS
378
+ .filter(k => k && k.length > 1 && !/^[+\-*/^<>=]$/.test(k))
379
+ .map(keyword => {
380
+ const info = KEYWORD_INFO[keyword] || {
381
+ syntax: keyword,
382
+ category: "other",
383
+ desc: ""
384
+ };
385
+ return {
386
+ keyword,
387
+ ...info,
388
+ categoryName: CATEGORIES[info.category] || "Other",
389
+ };
390
+ })
391
+ .sort((a, b) => a.keyword.localeCompare(b.keyword));
392
+
393
+ /**
394
+ * BasicAutocomplete - Handles autocomplete UI and logic
395
+ */
396
+ export class BasicAutocomplete {
397
+ constructor(textarea, container) {
398
+ this.textarea = textarea;
399
+ this.container = container;
400
+ this.dropdown = null;
401
+ this.selectedIndex = 0;
402
+ this.matches = [];
403
+ this.isVisible = false;
404
+ this.currentWord = "";
405
+ this.wordStart = 0;
406
+ this.isInserting = false; // Flag to prevent re-triggering on insert
407
+ this.context = new ProgramContext();
408
+ this.parseDebounceTimer = null;
409
+
410
+ this.createDropdown();
411
+ this.bindEvents();
412
+ // Initial parse
413
+ this.parseProgram();
414
+ }
415
+
416
+ /**
417
+ * Parse the program (debounced)
418
+ */
419
+ parseProgram() {
420
+ this.context.parse(this.textarea.value);
421
+ }
422
+
423
+ /**
424
+ * Schedule a program parse (debounced to avoid parsing on every keystroke)
425
+ */
426
+ scheduleParse() {
427
+ if (this.parseDebounceTimer) {
428
+ clearTimeout(this.parseDebounceTimer);
429
+ }
430
+ this.parseDebounceTimer = setTimeout(() => {
431
+ this.parseProgram();
432
+ }, 300);
433
+ }
434
+
435
+ /**
436
+ * Create the autocomplete dropdown element
437
+ */
438
+ createDropdown() {
439
+ this.dropdown = document.createElement("div");
440
+ this.dropdown.className = "basic-autocomplete-dropdown";
441
+ this.dropdown.innerHTML = `
442
+ <div class="autocomplete-list"></div>
443
+ <div class="autocomplete-hint"></div>
444
+ `;
445
+ this.container.appendChild(this.dropdown);
446
+
447
+ this.listEl = this.dropdown.querySelector(".autocomplete-list");
448
+ this.hintEl = this.dropdown.querySelector(".autocomplete-hint");
449
+ }
450
+
451
+ /**
452
+ * Bind event listeners
453
+ */
454
+ bindEvents() {
455
+ // Handle input changes - use a named function so we can identify it
456
+ this.boundOnInput = () => {
457
+ if (this.isInserting) return; // Skip if we're inserting
458
+ this.scheduleParse(); // Update program context
459
+ this.onInput();
460
+ };
461
+ this.textarea.addEventListener("input", this.boundOnInput);
462
+
463
+ // Handle keyboard navigation - use capture phase to intercept before other handlers
464
+ this.boundOnKeyDown = (e) => this.onKeyDown(e);
465
+ this.textarea.addEventListener("keydown", this.boundOnKeyDown, true);
466
+
467
+ // Hide on blur (with delay to allow click on dropdown)
468
+ this.textarea.addEventListener("blur", () => {
469
+ setTimeout(() => this.hide(), 200);
470
+ });
471
+
472
+ // Handle click on dropdown item
473
+ this.listEl.addEventListener("mousedown", (e) => {
474
+ // Use mousedown instead of click to fire before blur
475
+ e.preventDefault(); // Prevent blur
476
+ const item = e.target.closest(".autocomplete-item");
477
+ if (item) {
478
+ const index = parseInt(item.dataset.index, 10);
479
+ this.selectedIndex = index;
480
+ this.insertSelected();
481
+ }
482
+ });
483
+
484
+ // Handle hover on dropdown item
485
+ this.listEl.addEventListener("mouseover", (e) => {
486
+ const item = e.target.closest(".autocomplete-item");
487
+ if (item) {
488
+ const index = parseInt(item.dataset.index, 10);
489
+ this.selectItem(index);
490
+ }
491
+ });
492
+ }
493
+
494
+ /**
495
+ * Handle input changes
496
+ */
497
+ onInput() {
498
+ const { word, start, valid } = this.getCurrentWord();
499
+
500
+ if (valid && word.length >= 2) {
501
+ this.currentWord = word;
502
+ this.wordStart = start;
503
+ this.updateMatches(word);
504
+
505
+ if (this.matches.length > 0) {
506
+ this.show();
507
+ this.render();
508
+ } else {
509
+ this.hide();
510
+ }
511
+ } else {
512
+ this.hide();
513
+ }
514
+ }
515
+
516
+ /**
517
+ * Get the current word being typed
518
+ */
519
+ getCurrentWord() {
520
+ const pos = this.textarea.selectionStart;
521
+ const text = this.textarea.value;
522
+
523
+ // Find word start (go back until we hit a non-word character)
524
+ let start = pos;
525
+ while (start > 0) {
526
+ const char = text[start - 1];
527
+ // Word characters for BASIC: letters, numbers, $
528
+ if (/[A-Za-z0-9$]/.test(char)) {
529
+ start--;
530
+ } else {
531
+ break;
532
+ }
533
+ }
534
+
535
+ // Check if we're inside a string (don't autocomplete)
536
+ const lineStart = text.lastIndexOf('\n', start - 1) + 1;
537
+ const lineToStart = text.substring(lineStart, start);
538
+ const quoteCount = (lineToStart.match(/"/g) || []).length;
539
+ if (quoteCount % 2 !== 0) {
540
+ return { word: "", start: pos, valid: false };
541
+ }
542
+
543
+ // Check if the character before the word is valid for starting autocomplete
544
+ // (space, colon, operator, or start of line)
545
+ if (start > 0 && start > lineStart) {
546
+ const prevChar = text[start - 1];
547
+ if (/[A-Za-z0-9$]/.test(prevChar)) {
548
+ // Previous char is alphanumeric - we're in the middle of something
549
+ return { word: "", start: pos, valid: false };
550
+ }
551
+ }
552
+
553
+ const word = text.substring(start, pos).toUpperCase();
554
+ return { word, start, valid: true };
555
+ }
556
+
557
+ /**
558
+ * Update matches based on typed word and context
559
+ */
560
+ updateMatches(word) {
561
+ const upperWord = word.toUpperCase();
562
+ const pos = this.textarea.selectionStart;
563
+ const ctx = detectContext(this.textarea.value, this.wordStart);
564
+
565
+ let items = [];
566
+
567
+ if (ctx.type === "lineNumber") {
568
+ // Only suggest line numbers
569
+ items = this.context.getLineNumberItems().filter(item =>
570
+ item.keyword.startsWith(upperWord)
571
+ );
572
+ } else if (ctx.type === "function") {
573
+ // Only suggest user-defined functions
574
+ items = this.context.getFunctionItems().filter(item =>
575
+ item.keyword.startsWith(upperWord)
576
+ );
577
+ } else if (ctx.type === "string") {
578
+ // Inside a string - no suggestions
579
+ items = [];
580
+ } else {
581
+ // General context: keywords + variables
582
+ // Get matching keywords
583
+ const keywords = AUTOCOMPLETE_KEYWORDS.filter(item =>
584
+ item.keyword.startsWith(upperWord)
585
+ );
586
+
587
+ // Get matching variables
588
+ const variables = this.context.getVariableItems().filter(item =>
589
+ item.keyword.startsWith(upperWord)
590
+ );
591
+
592
+ // Get matching functions (for when typing FN prefix isn't there)
593
+ const functions = this.context.getFunctionItems().filter(item =>
594
+ item.keyword.startsWith(upperWord)
595
+ );
596
+
597
+ // Combine: variables first (they're more specific), then keywords/functions
598
+ items = [...variables, ...functions, ...keywords];
599
+ }
600
+
601
+ this.matches = items.slice(0, 12); // Limit to 12 matches
602
+ this.selectedIndex = 0;
603
+ }
604
+
605
+ /**
606
+ * Handle keyboard events
607
+ */
608
+ onKeyDown(e) {
609
+ if (!this.isVisible) return;
610
+
611
+ switch (e.key) {
612
+ case "ArrowDown":
613
+ e.preventDefault();
614
+ e.stopPropagation();
615
+ this.selectItem(this.selectedIndex + 1);
616
+ break;
617
+
618
+ case "ArrowUp":
619
+ e.preventDefault();
620
+ e.stopPropagation();
621
+ this.selectItem(this.selectedIndex - 1);
622
+ break;
623
+
624
+ case "Tab":
625
+ if (this.matches.length > 0) {
626
+ e.preventDefault();
627
+ e.stopPropagation();
628
+ this.insertSelected();
629
+ }
630
+ break;
631
+
632
+ case "Enter":
633
+ if (this.matches.length > 0) {
634
+ e.preventDefault();
635
+ e.stopPropagation();
636
+ this.insertSelected();
637
+ }
638
+ break;
639
+
640
+ case "Escape":
641
+ e.preventDefault();
642
+ e.stopPropagation();
643
+ this.hide();
644
+ break;
645
+ }
646
+ }
647
+
648
+ /**
649
+ * Select an item by index
650
+ */
651
+ selectItem(index) {
652
+ if (this.matches.length === 0) return;
653
+
654
+ // Wrap around
655
+ if (index < 0) index = this.matches.length - 1;
656
+ if (index >= this.matches.length) index = 0;
657
+
658
+ this.selectedIndex = index;
659
+ this.render();
660
+ }
661
+
662
+ /**
663
+ * Insert the selected keyword
664
+ */
665
+ insertSelected() {
666
+ if (this.matches.length === 0) return;
667
+
668
+ const selected = this.matches[this.selectedIndex];
669
+ const text = this.textarea.value;
670
+ const pos = this.textarea.selectionStart;
671
+
672
+ // Replace the current word with the selected keyword
673
+ const before = text.substring(0, this.wordStart);
674
+ const after = text.substring(pos);
675
+
676
+ // Set flag to prevent re-triggering autocomplete
677
+ this.isInserting = true;
678
+
679
+ this.textarea.value = before + selected.keyword + after;
680
+
681
+ // Position cursor after inserted keyword
682
+ const newPos = this.wordStart + selected.keyword.length;
683
+ this.textarea.selectionStart = newPos;
684
+ this.textarea.selectionEnd = newPos;
685
+
686
+ this.hide();
687
+
688
+ // Trigger input event for highlighting update
689
+ this.textarea.dispatchEvent(new Event("input", { bubbles: true }));
690
+
691
+ // Clear flag after a small delay
692
+ setTimeout(() => {
693
+ this.isInserting = false;
694
+ }, 10);
695
+
696
+ this.textarea.focus();
697
+ }
698
+
699
+ /**
700
+ * Get the item type for styling
701
+ */
702
+ getItemType(item) {
703
+ if (item.isVariable) return "variable";
704
+ if (item.isLineNumber) return "line";
705
+ if (item.isFunction) return "function";
706
+ return "keyword";
707
+ }
708
+
709
+ /**
710
+ * Render the dropdown
711
+ */
712
+ render() {
713
+ // Render list items
714
+ this.listEl.innerHTML = this.matches.map((item, index) => {
715
+ const itemType = this.getItemType(item);
716
+ return `
717
+ <div class="autocomplete-item${index === this.selectedIndex ? " selected" : ""}" data-index="${index}" data-type="${itemType}">
718
+ <span class="autocomplete-keyword">${this.highlightMatch(item.keyword)}</span>
719
+ <span class="autocomplete-category">${item.categoryName}</span>
720
+ </div>
721
+ `;
722
+ }).join("");
723
+
724
+ // Render hint for selected item
725
+ if (this.matches.length > 0) {
726
+ const selected = this.matches[this.selectedIndex];
727
+ this.hintEl.innerHTML = `
728
+ <div class="hint-syntax">${selected.syntax}</div>
729
+ <div class="hint-desc">${selected.desc}</div>
730
+ `;
731
+ this.hintEl.style.display = "block";
732
+ } else {
733
+ this.hintEl.style.display = "none";
734
+ }
735
+
736
+ // Scroll selected item into view
737
+ const selectedEl = this.listEl.querySelector(".selected");
738
+ if (selectedEl) {
739
+ selectedEl.scrollIntoView({ block: "nearest" });
740
+ }
741
+ }
742
+
743
+ /**
744
+ * Highlight the matching part of the keyword
745
+ */
746
+ highlightMatch(keyword) {
747
+ const matchLen = this.currentWord.length;
748
+ const matched = keyword.substring(0, matchLen);
749
+ const rest = keyword.substring(matchLen);
750
+ return `<span class="match">${matched}</span>${rest}`;
751
+ }
752
+
753
+ /**
754
+ * Show the dropdown
755
+ */
756
+ show() {
757
+ if (this.isVisible) return;
758
+
759
+ this.isVisible = true;
760
+ this.dropdown.classList.add("visible");
761
+ this.positionDropdown();
762
+ }
763
+
764
+ /**
765
+ * Hide the dropdown
766
+ */
767
+ hide() {
768
+ if (!this.isVisible) return;
769
+ this.isVisible = false;
770
+ this.dropdown.classList.remove("visible");
771
+ }
772
+
773
+ /**
774
+ * Position the dropdown near the cursor
775
+ * Uses a simpler approach - position at bottom of textarea, aligned left
776
+ */
777
+ positionDropdown() {
778
+ // Simple positioning: below the textarea at the left
779
+ // This avoids complex cursor position calculations
780
+ const textareaRect = this.textarea.getBoundingClientRect();
781
+ const containerRect = this.container.getBoundingClientRect();
782
+
783
+ // Calculate approximate cursor position based on text
784
+ const text = this.textarea.value.substring(0, this.textarea.selectionStart);
785
+ const lines = text.split('\n');
786
+ const currentLine = lines.length - 1;
787
+ const currentCol = lines[lines.length - 1].length;
788
+
789
+ // Approximate character dimensions (monospace font)
790
+ const charWidth = 7.2; // Approximate for 12px monospace
791
+ const lineHeight = 18; // Approximate line height
792
+
793
+ // Calculate position
794
+ let left = currentCol * charWidth;
795
+ let top = (currentLine + 1) * lineHeight - this.textarea.scrollTop;
796
+
797
+ // Constrain to container bounds
798
+ const dropdownWidth = 260;
799
+ const dropdownHeight = 280;
800
+
801
+ if (left + dropdownWidth > this.container.clientWidth - 10) {
802
+ left = this.container.clientWidth - dropdownWidth - 10;
803
+ }
804
+ left = Math.max(5, left);
805
+
806
+ // If dropdown would go below container, show it above the cursor
807
+ if (top + dropdownHeight > this.container.clientHeight) {
808
+ top = Math.max(5, top - dropdownHeight - lineHeight);
809
+ }
810
+
811
+ this.dropdown.style.left = left + "px";
812
+ this.dropdown.style.top = top + "px";
813
+ }
814
+
815
+ /**
816
+ * Clean up
817
+ */
818
+ destroy() {
819
+ if (this.parseDebounceTimer) {
820
+ clearTimeout(this.parseDebounceTimer);
821
+ }
822
+ if (this.boundOnInput) {
823
+ this.textarea.removeEventListener("input", this.boundOnInput);
824
+ }
825
+ if (this.boundOnKeyDown) {
826
+ this.textarea.removeEventListener("keydown", this.boundOnKeyDown, true);
827
+ }
828
+ if (this.dropdown && this.dropdown.parentNode) {
829
+ this.dropdown.parentNode.removeChild(this.dropdown);
830
+ }
831
+ }
832
+ }