@gjsify/adwaita-web 0.14.0 → 0.16.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 (49) hide show
  1. package/README.md +58 -0
  2. package/dist/adwaita-web.css +617 -0
  3. package/dist/adwaita-web.css.map +1 -1
  4. package/lib/types/adw-data-grid.spec.d.ts +1 -0
  5. package/lib/types/adw-dialog.spec.d.ts +1 -0
  6. package/lib/types/adw-drop-down.spec.d.ts +1 -0
  7. package/lib/types/elements/adw-data-grid.d.ts +60 -0
  8. package/lib/types/elements/adw-dialog.d.ts +51 -0
  9. package/lib/types/elements/adw-drop-down.d.ts +63 -0
  10. package/lib/types/elements/adw-menu-button.d.ts +34 -0
  11. package/lib/types/elements/adw-view-stack.d.ts +37 -0
  12. package/lib/types/elements/adw-view-switcher-bar.d.ts +27 -0
  13. package/lib/types/index.d.ts +11 -0
  14. package/lib/types/source-view/adw-source-view.d.ts +75 -0
  15. package/lib/types/source-view/asm6502.d.ts +28 -0
  16. package/lib/types/source-view/hex.d.ts +27 -0
  17. package/lib/types/source-view/index.d.ts +4 -0
  18. package/lib/types/source-view/theme.d.ts +15 -0
  19. package/lib/types/styles.generated.d.ts +1 -1
  20. package/package.json +24 -12
  21. package/scss/_data_grid.scss +144 -0
  22. package/scss/_dialog.scss +220 -0
  23. package/scss/_drop_down.scss +185 -0
  24. package/scss/_menu_button.scss +151 -0
  25. package/scss/_root.scss +22 -0
  26. package/scss/_theme.scss +5 -0
  27. package/scss/_variables.scss +9 -0
  28. package/scss/_view_stack.scss +29 -0
  29. package/scss/_view_switcher_bar.scss +94 -0
  30. package/scss/adwaita-skin.scss +7 -0
  31. package/src/adw-data-grid.spec.ts +243 -0
  32. package/src/adw-dialog.spec.ts +169 -0
  33. package/src/adw-drop-down.spec.ts +186 -0
  34. package/src/elements/adw-data-grid.ts +335 -0
  35. package/src/elements/adw-dialog.ts +329 -0
  36. package/src/elements/adw-drop-down.ts +405 -0
  37. package/src/elements/adw-menu-button.ts +247 -0
  38. package/src/elements/adw-view-stack.ts +174 -0
  39. package/src/elements/adw-view-switcher-bar.ts +168 -0
  40. package/src/index.ts +16 -0
  41. package/src/source-view/adw-source-view.ts +436 -0
  42. package/src/source-view/asm6502.ts +232 -0
  43. package/src/source-view/hex.ts +43 -0
  44. package/src/source-view/index.ts +28 -0
  45. package/src/source-view/theme.ts +224 -0
  46. package/src/test.browser.mts +14 -0
  47. package/src/index.js +0 -1
  48. package/src/styles.generated.js +0 -2976
  49. package/src/styles2.generated.js +0 -2976
@@ -0,0 +1,436 @@
1
+ // <adw-source-view> — an Adwaita-styled source-code editor Web Component backed
2
+ // by CodeMirror 6. It is the GtkSourceView twin the Learn6502 web rewrite needs:
3
+ // the Editor plus — in read-only, hex-address mode — the hex monitor, hexdump
4
+ // and disassembled panes. It maps 1:1 onto the `SourceViewWidget` interface from
5
+ // `@learn6502/common-ui` (code / lineNumbers / editable / readonly /
6
+ // lineNumberStart / selectable / copyable / copyButtonIcon / copyButtonTooltip).
7
+ //
8
+ // Opt-in subpath: this element lives at `@gjsify/adwaita-web/source-view`, NOT
9
+ // the main bundle, so consumers who do not need an editor never pay for
10
+ // CodeMirror. Importing the subpath registers <adw-source-view> and self-injects
11
+ // its stylesheet.
12
+ //
13
+ // Attributes (kebab-case) mirror the properties (camelCase):
14
+ // code, editable, readonly, selectable, line-numbers, line-number-start,
15
+ // hex-addresses, language, copyable, copy-button-icon, copy-button-tooltip,
16
+ // fill-height.
17
+ // Events:
18
+ // `code-changed` (CustomEvent, bubbles, detail = { code }) — on USER edits
19
+ // only (programmatic `.code =` is suppressed so an EditorView two-way bind
20
+ // does not loop). The consuming SourceViewWidget maps it to `changed`.
21
+ // `copy` (CustomEvent, bubbles, detail = { code }) — when the copy button is
22
+ // pressed; the clipboard receives the whitespace-stripped text in hex mode.
23
+ // Reference: packages/app-gnome/src/widgets/source-view.ts (native parity target)
24
+ // Copyright (c) 2025 gjsify contributors. MIT License.
25
+
26
+ import { Compartment, EditorState, type Extension } from '@codemirror/state';
27
+ import {
28
+ drawSelection,
29
+ EditorView,
30
+ highlightActiveLine,
31
+ highlightActiveLineGutter,
32
+ keymap,
33
+ lineNumbers,
34
+ } from '@codemirror/view';
35
+ import { syntaxHighlighting } from '@codemirror/language';
36
+ import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
37
+
38
+ import { asm6502 } from './asm6502.js';
39
+ import { adwaitaEditorTheme, adwaitaHighlightStyle, ensureSourceViewStyleInjected } from './theme.js';
40
+ import { formatHexAddress, formatLineNumber, stripWhitespace } from './hex.js';
41
+
42
+ /** A recognisable copy glyph rendered inline so the subpath needs no icon CSS. */
43
+ const COPY_SVG =
44
+ '<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">' +
45
+ '<path d="M4 1a2 2 0 0 0-2 2v7h2V3h6V1H4z"/>' +
46
+ '<path d="M7 4a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h5a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H7zm0 2h5v7H7V6z"/>' +
47
+ '</svg>';
48
+
49
+ /** Parse a boolean attribute: present (any value but "false") ⇒ true. */
50
+ function boolAttr(value: string | null): boolean {
51
+ return value !== null && value !== 'false';
52
+ }
53
+
54
+ export class AdwSourceView extends HTMLElement {
55
+ // ─── instance state ──────────────────────────────────────────────────────
56
+ private _view?: EditorView;
57
+ private _editorHost!: HTMLDivElement;
58
+ private _copyButton!: HTMLButtonElement;
59
+ private _initialized = false;
60
+ private _suppressChangeEvent = false;
61
+
62
+ private _code = '';
63
+ private _editable = true;
64
+ private _selectable = true;
65
+ private _lineNumbers = false;
66
+ private _lineNumberStart: number | undefined = undefined;
67
+ private _hexAddresses = false;
68
+ private _language = '6502';
69
+ private _copyable = false;
70
+ private _copyButtonIcon = 'edit-copy';
71
+ private _copyButtonTooltip = 'Copy';
72
+
73
+ // Reconfigurable pieces (CodeMirror compartments).
74
+ private readonly _editableComp = new Compartment();
75
+ private readonly _lineNumbersComp = new Compartment();
76
+ private readonly _selectableComp = new Compartment();
77
+ private readonly _languageComp = new Compartment();
78
+ private readonly _copyComp = new Compartment();
79
+
80
+ static get observedAttributes(): string[] {
81
+ return [
82
+ 'code',
83
+ 'editable',
84
+ 'readonly',
85
+ 'selectable',
86
+ 'line-numbers',
87
+ 'line-number-start',
88
+ 'hex-addresses',
89
+ 'language',
90
+ 'copyable',
91
+ 'copy-button-icon',
92
+ 'copy-button-tooltip',
93
+ 'fill-height',
94
+ ];
95
+ }
96
+
97
+ // ─── SourceViewWidget surface ────────────────────────────────────────────
98
+
99
+ /** The document text. Programmatic assignment does NOT emit `code-changed`. */
100
+ get code(): string {
101
+ return this._view ? this._view.state.doc.toString() : this._code;
102
+ }
103
+
104
+ set code(value: string) {
105
+ const next = value ?? '';
106
+ if (next === this.code) return;
107
+ this._code = next;
108
+ if (this._view) {
109
+ this._suppressChangeEvent = true;
110
+ this._view.dispatch({
111
+ changes: { from: 0, to: this._view.state.doc.length, insert: next },
112
+ });
113
+ this._suppressChangeEvent = false;
114
+ }
115
+ }
116
+
117
+ /** Whether the user can edit the buffer (drives readOnly + active-line). */
118
+ get editable(): boolean {
119
+ return this._editable;
120
+ }
121
+
122
+ set editable(value: boolean) {
123
+ this._editable = !!value;
124
+ this._reconfigure(this._editableComp, this._editableExtension());
125
+ }
126
+
127
+ /** Inverse of `editable` — mirrors the native widget's readonly prop. */
128
+ get readonly(): boolean {
129
+ return !this._editable;
130
+ }
131
+
132
+ set readonly(value: boolean) {
133
+ this.editable = !value;
134
+ }
135
+
136
+ /** Whether the content is selectable by the user. */
137
+ get selectable(): boolean {
138
+ return this._selectable;
139
+ }
140
+
141
+ set selectable(value: boolean) {
142
+ this._selectable = !!value;
143
+ this._reconfigure(this._selectableComp, this._selectableExtension());
144
+ }
145
+
146
+ /** Whether the gutter is shown. */
147
+ get lineNumbers(): boolean {
148
+ return this._lineNumbers;
149
+ }
150
+
151
+ set lineNumbers(value: boolean) {
152
+ this._lineNumbers = !!value;
153
+ this._reconfigure(this._lineNumbersComp, this._lineNumbersExtension());
154
+ }
155
+
156
+ /** First gutter value: decimal offset, or the base ADDRESS in hex mode. */
157
+ get lineNumberStart(): number | undefined {
158
+ return this._lineNumberStart;
159
+ }
160
+
161
+ set lineNumberStart(value: number | undefined) {
162
+ this._lineNumberStart = value === undefined || Number.isNaN(value) ? undefined : Number(value);
163
+ this._reconfigure(this._lineNumbersComp, this._lineNumbersExtension());
164
+ }
165
+
166
+ /** Render the gutter as 4-digit hex addresses (monitor / hexdump panes). */
167
+ get hexAddresses(): boolean {
168
+ return this._hexAddresses;
169
+ }
170
+
171
+ set hexAddresses(value: boolean) {
172
+ this._hexAddresses = !!value;
173
+ this._reconfigure(this._lineNumbersComp, this._lineNumbersExtension());
174
+ this._reconfigure(this._copyComp, this._copyExtension());
175
+ }
176
+
177
+ /** `6502` / `6502-assembler` enables highlighting; anything else = plain. */
178
+ get language(): string {
179
+ return this._language;
180
+ }
181
+
182
+ set language(value: string) {
183
+ this._language = value ?? '';
184
+ this._reconfigure(this._languageComp, this._languageExtension());
185
+ }
186
+
187
+ /** Whether the floating copy button is shown. */
188
+ get copyable(): boolean {
189
+ return this._copyable;
190
+ }
191
+
192
+ set copyable(value: boolean) {
193
+ this._copyable = !!value;
194
+ if (this._copyButton) this._copyButton.hidden = !this._copyable;
195
+ }
196
+
197
+ /** Icon name for the copy button (`edit-copy`/`copy` render an inline SVG). */
198
+ get copyButtonIcon(): string {
199
+ return this._copyButtonIcon;
200
+ }
201
+
202
+ set copyButtonIcon(value: string) {
203
+ this._copyButtonIcon = value || 'edit-copy';
204
+ if (this._copyButton) this._renderCopyIcon();
205
+ }
206
+
207
+ /** Accessible label / tooltip for the copy button. */
208
+ get copyButtonTooltip(): string {
209
+ return this._copyButtonTooltip;
210
+ }
211
+
212
+ set copyButtonTooltip(value: string) {
213
+ this._copyButtonTooltip = value ?? '';
214
+ if (this._copyButton) {
215
+ this._copyButton.title = this._copyButtonTooltip;
216
+ this._copyButton.setAttribute('aria-label', this._copyButtonTooltip || 'Copy');
217
+ }
218
+ }
219
+
220
+ /** Whether the editor fills its host's height (vs. auto-sizing to content). */
221
+ get fillHeight(): boolean {
222
+ return this.classList.contains('adw-source-view--fill');
223
+ }
224
+
225
+ set fillHeight(value: boolean) {
226
+ this.classList.toggle('adw-source-view--fill', !!value);
227
+ }
228
+
229
+ /** The live CodeMirror EditorView (null until connected). Escape hatch. */
230
+ get view(): EditorView | null {
231
+ return this._view ?? null;
232
+ }
233
+
234
+ // ─── lifecycle ───────────────────────────────────────────────────────────
235
+
236
+ connectedCallback(): void {
237
+ if (this._initialized) return;
238
+ this._initialized = true;
239
+
240
+ ensureSourceViewStyleInjected();
241
+ this.classList.add('adw-source-view');
242
+ this.setAttribute('dir', 'ltr');
243
+
244
+ this._editorHost = document.createElement('div');
245
+ this._editorHost.className = 'adw-source-view__editor';
246
+
247
+ this._copyButton = document.createElement('button');
248
+ this._copyButton.type = 'button';
249
+ this._copyButton.className = 'adw-source-view__copy';
250
+ this._copyButton.hidden = !this._copyable;
251
+ this._copyButton.addEventListener('click', () => this._onCopyButtonClick());
252
+
253
+ this.replaceChildren(this._editorHost, this._copyButton);
254
+
255
+ // Fold any declarative attributes into fields (a property set earlier
256
+ // already updated the field, so the attribute is a no-op then).
257
+ this._readDeclarativeAttributes();
258
+ this._renderCopyIcon();
259
+ this.copyButtonTooltip = this._copyButtonTooltip;
260
+
261
+ this._view = new EditorView({
262
+ doc: this._code,
263
+ parent: this._editorHost,
264
+ extensions: this._baseExtensions(),
265
+ });
266
+ }
267
+
268
+ disconnectedCallback(): void {
269
+ // Preserve the last document so a re-attach restores it.
270
+ this._code = this.code;
271
+ this._view?.destroy();
272
+ this._view = undefined;
273
+ this._initialized = false;
274
+ }
275
+
276
+ attributeChangedCallback(name: string, _old: string | null, value: string | null): void {
277
+ switch (name) {
278
+ case 'code':
279
+ this.code = value ?? '';
280
+ break;
281
+ case 'editable':
282
+ this.editable = boolAttr(value);
283
+ break;
284
+ case 'readonly':
285
+ this.readonly = boolAttr(value);
286
+ break;
287
+ case 'selectable':
288
+ this.selectable = value === null ? true : boolAttr(value);
289
+ break;
290
+ case 'line-numbers':
291
+ this.lineNumbers = boolAttr(value);
292
+ break;
293
+ case 'line-number-start':
294
+ this.lineNumberStart = value === null ? undefined : Number(value);
295
+ break;
296
+ case 'hex-addresses':
297
+ this.hexAddresses = boolAttr(value);
298
+ break;
299
+ case 'language':
300
+ this.language = value ?? '';
301
+ break;
302
+ case 'copyable':
303
+ this.copyable = boolAttr(value);
304
+ break;
305
+ case 'copy-button-icon':
306
+ this.copyButtonIcon = value ?? '';
307
+ break;
308
+ case 'copy-button-tooltip':
309
+ this.copyButtonTooltip = value ?? '';
310
+ break;
311
+ case 'fill-height':
312
+ this.fillHeight = boolAttr(value);
313
+ break;
314
+ }
315
+ }
316
+
317
+ // ─── CodeMirror wiring ───────────────────────────────────────────────────
318
+
319
+ private _baseExtensions(): Extension {
320
+ return [
321
+ this._editableComp.of(this._editableExtension()),
322
+ this._lineNumbersComp.of(this._lineNumbersExtension()),
323
+ this._selectableComp.of(this._selectableExtension()),
324
+ this._languageComp.of(this._languageExtension()),
325
+ this._copyComp.of(this._copyExtension()),
326
+ history(),
327
+ drawSelection(),
328
+ keymap.of([...defaultKeymap, ...historyKeymap]),
329
+ adwaitaEditorTheme,
330
+ syntaxHighlighting(adwaitaHighlightStyle),
331
+ EditorView.contentAttributes.of({
332
+ 'aria-label': 'Source code',
333
+ translate: 'no',
334
+ autocorrect: 'off',
335
+ autocapitalize: 'off',
336
+ spellcheck: 'false',
337
+ }),
338
+ EditorView.updateListener.of((update) => {
339
+ if (!update.docChanged) return;
340
+ this._code = update.state.doc.toString();
341
+ if (this._suppressChangeEvent) return;
342
+ this.dispatchEvent(new CustomEvent('code-changed', { bubbles: true, detail: { code: this._code } }));
343
+ }),
344
+ ];
345
+ }
346
+
347
+ private _editableExtension(): Extension {
348
+ const editable = this._editable;
349
+ const extensions: Extension[] = [EditorView.editable.of(editable), EditorState.readOnly.of(!editable)];
350
+ // Only highlight the cursor line while editing — an idle read-only
351
+ // cursor would otherwise read as a stray selection (native parity).
352
+ if (editable) extensions.push(highlightActiveLine(), highlightActiveLineGutter());
353
+ return extensions;
354
+ }
355
+
356
+ private _lineNumbersExtension(): Extension {
357
+ if (!this._lineNumbers) return [];
358
+ const hex = this._hexAddresses;
359
+ const start = this._lineNumberStart;
360
+ return lineNumbers({
361
+ formatNumber: (lineNo) =>
362
+ hex ? formatHexAddress(lineNo, start ?? 0) : formatLineNumber(lineNo, start ?? 1),
363
+ });
364
+ }
365
+
366
+ private _selectableExtension(): Extension {
367
+ if (this._selectable) return [];
368
+ // Non-selectable: suppress user selection + hide the caret.
369
+ return EditorView.theme({
370
+ '.cm-content': {
371
+ userSelect: 'none',
372
+ WebkitUserSelect: 'none',
373
+ caretColor: 'transparent',
374
+ },
375
+ '.cm-cursor, .cm-cursorLayer': { display: 'none' },
376
+ });
377
+ }
378
+
379
+ private _languageExtension(): Extension {
380
+ return this._language === '6502' || this._language === '6502-assembler' ? asm6502() : [];
381
+ }
382
+
383
+ private _copyExtension(): Extension {
384
+ // In hex mode, a Ctrl+C over the selection copies without whitespace,
385
+ // mirroring the native monitor's copy-clipboard override.
386
+ if (!this._hexAddresses) return [];
387
+ return EditorView.domEventHandlers({
388
+ copy: (event, view) => {
389
+ const { from, to } = view.state.selection.main;
390
+ if (from === to) return false;
391
+ const selected = view.state.sliceDoc(from, to);
392
+ event.clipboardData?.setData('text/plain', stripWhitespace(selected));
393
+ event.preventDefault();
394
+ return true;
395
+ },
396
+ });
397
+ }
398
+
399
+ private _reconfigure(compartment: Compartment, extension: Extension): void {
400
+ this._view?.dispatch({ effects: compartment.reconfigure(extension) });
401
+ }
402
+
403
+ // ─── copy button ─────────────────────────────────────────────────────────
404
+
405
+ private _onCopyButtonClick(): void {
406
+ const text = this._hexAddresses ? stripWhitespace(this.code) : this.code;
407
+ const clipboard = (globalThis.navigator as Navigator | undefined)?.clipboard;
408
+ if (clipboard?.writeText) void clipboard.writeText(text).catch(() => undefined);
409
+ this.dispatchEvent(new CustomEvent('copy', { bubbles: true, detail: { code: this.code } }));
410
+ }
411
+
412
+ private _renderCopyIcon(): void {
413
+ const name = this._copyButtonIcon || 'edit-copy';
414
+ if (name === 'edit-copy' || name === 'copy' || name === '') {
415
+ this._copyButton.innerHTML = COPY_SVG;
416
+ return;
417
+ }
418
+ // A non-default icon uses the adwaita-web mask class (requires the main
419
+ // bundle's icon CSS to be present).
420
+ this._copyButton.replaceChildren();
421
+ const icon = document.createElement('span');
422
+ icon.className = `adw-icon adw-icon--${name.replace(/-symbolic$/, '')}`;
423
+ icon.setAttribute('aria-hidden', 'true');
424
+ this._copyButton.appendChild(icon);
425
+ }
426
+
427
+ private _readDeclarativeAttributes(): void {
428
+ for (const name of AdwSourceView.observedAttributes) {
429
+ if (this.hasAttribute(name)) {
430
+ this.attributeChangedCallback(name, null, this.getAttribute(name));
431
+ }
432
+ }
433
+ }
434
+ }
435
+
436
+ customElements.define('adw-source-view', AdwSourceView);
@@ -0,0 +1,232 @@
1
+ // A CodeMirror 6 StreamLanguage mode for 6502 assembly — the web twin of the
2
+ // GtkSourceView `6502-assembler` language definition. A stream tokenizer is
3
+ // enough here: 6502 asm is line-oriented and needs no nested grammar, so we
4
+ // avoid a full Lezer grammar (per the task brief).
5
+ //
6
+ // Token classes (mapped to @lezer/highlight tags via `asm6502TokenTable`):
7
+ // opcode — the 56 official mnemonics (LDA, STA, JMP, …) → keyword
8
+ // register — the accumulator/index registers A, X, Y → atom
9
+ // directive — assembler pseudo-ops (define, dcb, .org, .byte …) → meta
10
+ // number — hex `$1a`, binary `%1010`, decimal `42` → number
11
+ // string — quoted literals used by `.ascii` / `.byte "…"` → string
12
+ // comment — `; …` to end of line → lineComment
13
+ // label — a definition (`loop:` or a bare name at column 0) → labelName
14
+ // operator — the immediate `#`, arithmetic + addressing punct → operator
15
+ // identifier — any other symbol reference (a named operand) → variableName
16
+ //
17
+ // Reference: refs/gjs-guide + the GtkSourceView `.lang` grammar shape.
18
+
19
+ import { StreamLanguage, type StreamParser, type StringStream } from '@codemirror/language';
20
+ import { type Extension } from '@codemirror/state';
21
+ import { tags, type Tag } from '@lezer/highlight';
22
+
23
+ /** The 56 official NMOS 6502 mnemonics (uppercased for case-insensitive match). */
24
+ export const OPCODES: ReadonlySet<string> = new Set([
25
+ 'ADC',
26
+ 'AND',
27
+ 'ASL',
28
+ 'BCC',
29
+ 'BCS',
30
+ 'BEQ',
31
+ 'BIT',
32
+ 'BMI',
33
+ 'BNE',
34
+ 'BPL',
35
+ 'BRK',
36
+ 'BVC',
37
+ 'BVS',
38
+ 'CLC',
39
+ 'CLD',
40
+ 'CLI',
41
+ 'CLV',
42
+ 'CMP',
43
+ 'CPX',
44
+ 'CPY',
45
+ 'DEC',
46
+ 'DEX',
47
+ 'DEY',
48
+ 'EOR',
49
+ 'INC',
50
+ 'INX',
51
+ 'INY',
52
+ 'JMP',
53
+ 'JSR',
54
+ 'LDA',
55
+ 'LDX',
56
+ 'LDY',
57
+ 'LSR',
58
+ 'NOP',
59
+ 'ORA',
60
+ 'PHA',
61
+ 'PHP',
62
+ 'PLA',
63
+ 'PLP',
64
+ 'ROL',
65
+ 'ROR',
66
+ 'RTI',
67
+ 'RTS',
68
+ 'SBC',
69
+ 'SEC',
70
+ 'SED',
71
+ 'SEI',
72
+ 'STA',
73
+ 'STX',
74
+ 'STY',
75
+ 'TAX',
76
+ 'TAY',
77
+ 'TSX',
78
+ 'TXA',
79
+ 'TXS',
80
+ 'TYA',
81
+ ]);
82
+
83
+ /** The registers that appear as bare operands (`ASL A`, `LDA $00,X`). */
84
+ export const REGISTERS: ReadonlySet<string> = new Set(['A', 'X', 'Y']);
85
+
86
+ /** Assembler pseudo-ops. `define`/`dcb` are the easy6502 dialect; the rest are
87
+ * common cross-assembler directives (with the leading dot handled separately). */
88
+ export const DIRECTIVE_WORDS: ReadonlySet<string> = new Set([
89
+ 'DEFINE',
90
+ 'DCB',
91
+ 'ORG',
92
+ 'BYTE',
93
+ 'WORD',
94
+ 'DB',
95
+ 'DW',
96
+ 'DS',
97
+ 'ASCII',
98
+ 'ASCIIZ',
99
+ 'TEXT',
100
+ 'INCLUDE',
101
+ 'INCBIN',
102
+ 'EQU',
103
+ 'END',
104
+ 'PROC',
105
+ 'ENDPROC',
106
+ 'MACRO',
107
+ 'ENDMACRO',
108
+ ]);
109
+
110
+ /**
111
+ * Classify an identifier-shaped token. Pure + side-effect-free so it is unit
112
+ * testable without a CodeMirror stream. `atLineStart` decides whether an
113
+ * unknown word is a label DEFINITION (column 0) or a symbol REFERENCE.
114
+ */
115
+ export function classifyWord(word: string, atLineStart: boolean): string {
116
+ const upper = word.toUpperCase();
117
+ if (OPCODES.has(upper)) return 'opcode';
118
+ if (DIRECTIVE_WORDS.has(upper)) return 'directive';
119
+ if (REGISTERS.has(upper)) return 'register';
120
+ if (atLineStart) return 'label';
121
+ return 'identifier';
122
+ }
123
+
124
+ /**
125
+ * Tokenize one token from the stream. Exported for unit tests (feed it a
126
+ * `StringStream`). Returns a token-class name (see file header) or `null` for
127
+ * whitespace/unclassified punctuation.
128
+ */
129
+ export function token6502(stream: StringStream): string | null {
130
+ const atLineStart = stream.sol();
131
+ if (stream.eatSpace()) return null;
132
+
133
+ const ch = stream.peek();
134
+ if (ch === null || ch === undefined) return null;
135
+
136
+ // `; …` line comment.
137
+ if (ch === ';') {
138
+ stream.skipToEnd();
139
+ return 'comment';
140
+ }
141
+
142
+ // Quoted string (used by `.ascii "…"` / `.byte "…"`).
143
+ if (ch === '"' || ch === "'") {
144
+ const quote = ch;
145
+ stream.next();
146
+ let escaped = false;
147
+ while (!stream.eol()) {
148
+ const c = stream.next();
149
+ if (c === undefined) break;
150
+ if (c === quote && !escaped) break;
151
+ escaped = c === '\\' && !escaped;
152
+ }
153
+ return 'string';
154
+ }
155
+
156
+ // Immediate-addressing prefix.
157
+ if (ch === '#') {
158
+ stream.next();
159
+ return 'operator';
160
+ }
161
+
162
+ // Hex literal `$1a2b`.
163
+ if (ch === '$') {
164
+ stream.next();
165
+ stream.eatWhile(/[0-9a-fA-F]/);
166
+ return 'number';
167
+ }
168
+
169
+ // Binary literal `%1010`.
170
+ if (ch === '%') {
171
+ stream.next();
172
+ stream.eatWhile(/[01]/);
173
+ return 'number';
174
+ }
175
+
176
+ // Decimal literal.
177
+ if (/[0-9]/.test(ch)) {
178
+ stream.eatWhile(/[0-9]/);
179
+ return 'number';
180
+ }
181
+
182
+ // Dot-directive `.org` / `.byte`.
183
+ if (ch === '.') {
184
+ stream.next();
185
+ if (stream.eatWhile(/[A-Za-z0-9_]/)) return 'directive';
186
+ return null;
187
+ }
188
+
189
+ // Identifier → opcode / register / directive / label / reference.
190
+ if (/[A-Za-z_]/.test(ch)) {
191
+ stream.eatWhile(/[A-Za-z0-9_]/);
192
+ const word = stream.current();
193
+ // An explicit `name:` is always a label definition.
194
+ if (stream.peek() === ':') return 'label';
195
+ return classifyWord(word, atLineStart);
196
+ }
197
+
198
+ // Arithmetic / addressing punctuation.
199
+ if ('+-*/=<>,()[]&|^~'.includes(ch)) {
200
+ stream.next();
201
+ return 'operator';
202
+ }
203
+
204
+ stream.next();
205
+ return null;
206
+ }
207
+
208
+ /** Maps this mode's token-class names to @lezer/highlight tags. */
209
+ export const asm6502TokenTable: Record<string, Tag> = {
210
+ opcode: tags.keyword,
211
+ register: tags.atom,
212
+ directive: tags.meta,
213
+ number: tags.number,
214
+ string: tags.string,
215
+ comment: tags.lineComment,
216
+ label: tags.labelName,
217
+ operator: tags.operator,
218
+ identifier: tags.variableName,
219
+ };
220
+
221
+ /** The StreamParser powering the 6502 mode. */
222
+ export const asm6502Parser: StreamParser<unknown> = {
223
+ name: '6502',
224
+ token: (stream) => token6502(stream),
225
+ tokenTable: asm6502TokenTable,
226
+ languageData: { commentTokens: { line: ';' } },
227
+ };
228
+
229
+ /** The 6502 assembly language extension for a CodeMirror EditorState. */
230
+ export function asm6502(): Extension {
231
+ return StreamLanguage.define(asm6502Parser);
232
+ }