@fresh-editor/fresh-editor 0.2.11 → 0.2.13

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 (51) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/README.md +10 -0
  3. package/package.json +1 -1
  4. package/plugins/audit_mode.ts +79 -58
  5. package/plugins/check-types.sh +1 -0
  6. package/plugins/clangd-lsp.ts +9 -6
  7. package/plugins/clangd_support.ts +12 -8
  8. package/plugins/code-tour.ts +15 -10
  9. package/plugins/config-schema.json +70 -3
  10. package/plugins/csharp_support.ts +15 -10
  11. package/plugins/css-lsp.ts +9 -6
  12. package/plugins/diagnostics_panel.ts +25 -18
  13. package/plugins/examples/README.md +1 -2
  14. package/plugins/examples/async_demo.ts +28 -28
  15. package/plugins/examples/bookmarks.ts +34 -32
  16. package/plugins/examples/buffer_query_demo.ts +20 -20
  17. package/plugins/examples/hello_world.ts +46 -10
  18. package/plugins/examples/virtual_buffer_demo.ts +16 -12
  19. package/plugins/find_references.ts +7 -5
  20. package/plugins/git_blame.ts +13 -9
  21. package/plugins/git_explorer.ts +9 -6
  22. package/plugins/git_find_file.ts +7 -5
  23. package/plugins/git_grep.ts +3 -2
  24. package/plugins/git_gutter.ts +15 -10
  25. package/plugins/git_log.ts +27 -18
  26. package/plugins/go-lsp.ts +9 -6
  27. package/plugins/html-lsp.ts +9 -6
  28. package/plugins/java-lsp.ts +9 -6
  29. package/plugins/json-lsp.ts +9 -6
  30. package/plugins/latex-lsp.ts +9 -6
  31. package/plugins/lib/finder.ts +1 -0
  32. package/plugins/lib/fresh.d.ts +141 -16
  33. package/plugins/live_grep.ts +3 -2
  34. package/plugins/markdown_compose.ts +33 -23
  35. package/plugins/markdown_source.ts +24 -10
  36. package/plugins/marksman-lsp.ts +9 -6
  37. package/plugins/merge_conflict.ts +33 -22
  38. package/plugins/odin-lsp.ts +9 -6
  39. package/plugins/path_complete.ts +3 -2
  40. package/plugins/pkg.ts +70 -48
  41. package/plugins/python-lsp.ts +9 -6
  42. package/plugins/rust-lsp.ts +102 -6
  43. package/plugins/search_replace.ts +32 -21
  44. package/plugins/templ-lsp.ts +9 -6
  45. package/plugins/test_i18n.ts +3 -2
  46. package/plugins/theme_editor.i18n.json +28 -14
  47. package/plugins/theme_editor.ts +1230 -495
  48. package/plugins/typescript-lsp.ts +9 -6
  49. package/plugins/vi_mode.ts +487 -297
  50. package/plugins/welcome.ts +9 -6
  51. package/plugins/zig-lsp.ts +9 -6
@@ -57,6 +57,130 @@ const ALL_COLOR_NAMES = [...NAMED_COLOR_LIST, ...SPECIAL_COLORS];
57
57
  */
58
58
  type ColorValue = RGB | string;
59
59
 
60
+ // =============================================================================
61
+ // Layout Constants & Panel Types
62
+ // =============================================================================
63
+
64
+ const LEFT_WIDTH = 38;
65
+ const RIGHT_WIDTH = 61;
66
+
67
+ type PickerFocusTarget =
68
+ | { type: "hex-input" }
69
+ | { type: "named-colors"; index: number }
70
+ | { type: "palette"; row: number; col: number };
71
+
72
+ // =============================================================================
73
+ // Named Color Grid (for picker panel)
74
+ // =============================================================================
75
+
76
+ const NAMED_COLORS_PER_ROW = 6;
77
+ const NAMED_COLOR_GRID: Array<Array<{ display: string; value: string; rgb: RGB | null }>> = [
78
+ [
79
+ { display: "Black", value: "Black", rgb: [0, 0, 0] },
80
+ { display: "Red", value: "Red", rgb: [255, 0, 0] },
81
+ { display: "Green", value: "Green", rgb: [0, 128, 0] },
82
+ { display: "Yellow", value: "Yellow", rgb: [255, 255, 0] },
83
+ { display: "Blue", value: "Blue", rgb: [0, 0, 255] },
84
+ { display: "Magenta", value: "Magenta", rgb: [255, 0, 255] },
85
+ ],
86
+ [
87
+ { display: "Cyan", value: "Cyan", rgb: [0, 255, 255] },
88
+ { display: "Gray", value: "Gray", rgb: [128, 128, 128] },
89
+ { display: "DkGray", value: "DarkGray", rgb: [169, 169, 169] },
90
+ { display: "LtRed", value: "LightRed", rgb: [255, 128, 128] },
91
+ { display: "LtGreen", value: "LightGreen", rgb: [144, 238, 144] },
92
+ { display: "LtYellw", value: "LightYellow", rgb: [255, 255, 224] },
93
+ ],
94
+ [
95
+ { display: "LtBlue", value: "LightBlue", rgb: [173, 216, 230] },
96
+ { display: "LtMag", value: "LightMagenta", rgb: [255, 128, 255] },
97
+ { display: "LtCyan", value: "LightCyan", rgb: [224, 255, 255] },
98
+ { display: "White", value: "White", rgb: [255, 255, 255] },
99
+ { display: "Default", value: "Default", rgb: null },
100
+ { display: "Reset", value: "Reset", rgb: null },
101
+ ],
102
+ ];
103
+
104
+ // =============================================================================
105
+ // Extended Color Palette
106
+ // =============================================================================
107
+
108
+ const PALETTE_COLS = 12;
109
+ const PALETTE_ROWS = 4;
110
+ const PALETTE_LIGHTNESSES = [25, 40, 60, 75];
111
+
112
+ function hslToRgb(h: number, s: number, l: number): RGB {
113
+ s /= 100;
114
+ l /= 100;
115
+ const c = (1 - Math.abs(2 * l - 1)) * s;
116
+ const x = c * (1 - Math.abs((h / 60) % 2 - 1));
117
+ const m = l - c / 2;
118
+ let r = 0, g = 0, b = 0;
119
+ if (h < 60) { r = c; g = x; }
120
+ else if (h < 120) { r = x; g = c; }
121
+ else if (h < 180) { g = c; b = x; }
122
+ else if (h < 240) { g = x; b = c; }
123
+ else if (h < 300) { r = x; b = c; }
124
+ else { r = c; b = x; }
125
+ return [
126
+ Math.round((r + m) * 255),
127
+ Math.round((g + m) * 255),
128
+ Math.round((b + m) * 255),
129
+ ];
130
+ }
131
+
132
+ let cachedPalette: RGB[][] | null = null;
133
+
134
+ function getExtendedPalette(): RGB[][] {
135
+ if (cachedPalette) return cachedPalette;
136
+ const palette: RGB[][] = [];
137
+ for (let row = 0; row < PALETTE_ROWS; row++) {
138
+ const rowColors: RGB[] = [];
139
+ for (let col = 0; col < PALETTE_COLS; col++) {
140
+ const hue = col * 30;
141
+ rowColors.push(hslToRgb(hue, 80, PALETTE_LIGHTNESSES[row]));
142
+ }
143
+ palette.push(rowColors);
144
+ }
145
+ cachedPalette = palette;
146
+ return palette;
147
+ }
148
+
149
+ // =============================================================================
150
+ // Preview Tokens
151
+ // =============================================================================
152
+
153
+ const PREVIEW_LINES: Array<Array<{ text: string; syntaxType: string }>> = [
154
+ [
155
+ { text: "fn", syntaxType: "keyword" },
156
+ { text: " ", syntaxType: "" },
157
+ { text: "main", syntaxType: "function" },
158
+ { text: "() {", syntaxType: "operator" },
159
+ ],
160
+ [
161
+ { text: " ", syntaxType: "" },
162
+ { text: "let", syntaxType: "keyword" },
163
+ { text: " greeting = ", syntaxType: "" },
164
+ { text: "\"Hello\"", syntaxType: "string" },
165
+ { text: ";", syntaxType: "" },
166
+ ],
167
+ [
168
+ { text: " ", syntaxType: "" },
169
+ { text: "// A comment", syntaxType: "comment" },
170
+ ],
171
+ [
172
+ { text: " ", syntaxType: "" },
173
+ { text: "println!", syntaxType: "function" },
174
+ { text: "(", syntaxType: "" },
175
+ { text: "\"{}\", ", syntaxType: "string" },
176
+ { text: "greeting", syntaxType: "variable" },
177
+ { text: ");", syntaxType: "" },
178
+ ],
179
+ [
180
+ { text: "}", syntaxType: "" },
181
+ ],
182
+ ];
183
+
60
184
  /**
61
185
  * Theme section definition
62
186
  */
@@ -229,6 +353,22 @@ interface ThemeEditorState {
229
353
  isBuiltin: boolean;
230
354
  /** Saved cursor field path (for restoring after prompts) */
231
355
  savedCursorPath: string | null;
356
+ /** Whether to close the editor after a successful save */
357
+ closeAfterSave: boolean;
358
+ /** Which panel has focus */
359
+ focusPanel: "tree" | "picker";
360
+ /** Focus target within picker panel */
361
+ pickerFocus: PickerFocusTarget;
362
+ /** Filter text for tree */
363
+ filterText: string;
364
+ /** Whether filter input is active */
365
+ filterActive: boolean;
366
+ /** First visible tree line index for virtual scrolling */
367
+ treeScrollOffset: number;
368
+ /** Cached viewport height */
369
+ viewportHeight: number;
370
+ /** Cached viewport width */
371
+ viewportWidth: number;
232
372
  }
233
373
 
234
374
  /**
@@ -252,6 +392,11 @@ function isThemeEditorOpen(): boolean {
252
392
  state.themeData = {};
253
393
  state.originalThemeData = {};
254
394
  state.hasChanges = false;
395
+ state.focusPanel = "tree";
396
+ state.selectedIndex = 0;
397
+ state.treeScrollOffset = 0;
398
+ state.filterText = "";
399
+ state.filterActive = false;
255
400
  }
256
401
 
257
402
  return exists;
@@ -274,6 +419,14 @@ const state: ThemeEditorState = {
274
419
  pendingSaveName: null,
275
420
  isBuiltin: false,
276
421
  savedCursorPath: null,
422
+ closeAfterSave: false,
423
+ focusPanel: "tree",
424
+ pickerFocus: { type: "hex-input" },
425
+ filterText: "",
426
+ filterActive: false,
427
+ treeScrollOffset: 0,
428
+ viewportHeight: 40,
429
+ viewportWidth: 120,
277
430
  };
278
431
 
279
432
  // =============================================================================
@@ -290,6 +443,13 @@ const colors = {
290
443
  footer: [100, 100, 100] as RGB, // Gray
291
444
  colorBlock: [200, 200, 200] as RGB, // Light gray for color swatch outline
292
445
  selectionBg: [50, 50, 80] as RGB, // Dark blue-gray for selected field
446
+ divider: [60, 60, 80] as RGB, // Muted divider color
447
+ header: [100, 180, 255] as RGB, // Header blue
448
+ pickerLabel: [180, 180, 200] as RGB, // Picker section labels
449
+ pickerValue: [255, 255, 255] as RGB, // Picker value text
450
+ pickerFocusBg: [40, 60, 100] as RGB, // Picker focused item bg
451
+ filterText: [200, 200, 100] as RGB, // Filter input text
452
+ previewBg: [25, 25, 30] as RGB, // Preview background
293
453
  };
294
454
 
295
455
  // =============================================================================
@@ -305,8 +465,8 @@ const SHORTCUTS = {
305
465
  save: "C-s",
306
466
  save_as: "C-S-s",
307
467
  delete: "C-d",
468
+ close: "Esc",
308
469
  reload: "C-r",
309
- close: "C-q",
310
470
  help: "F1",
311
471
  };
312
472
 
@@ -318,14 +478,17 @@ editor.defineMode(
318
478
  "theme-editor",
319
479
  "normal",
320
480
  [
321
- // Navigation (standard keys that don't conflict with typing)
322
- ["Return", "theme_editor_edit_color"],
323
- ["Space", "theme_editor_edit_color"],
324
- ["Tab", "theme_editor_nav_next_section"],
325
- ["S-Tab", "theme_editor_nav_prev_section"],
481
+ // Navigation
482
+ ["Return", "theme_editor_enter"],
483
+ ["Space", "theme_editor_enter"],
484
+ ["Tab", "theme_editor_focus_tab"],
485
+ ["S-Tab", "theme_editor_focus_shift_tab"],
326
486
  ["Up", "theme_editor_nav_up"],
327
487
  ["Down", "theme_editor_nav_down"],
328
- ["Escape", "theme_editor_close"],
488
+ ["Left", "theme_editor_nav_left"],
489
+ ["Right", "theme_editor_nav_right"],
490
+ ["Escape", "theme_editor_escape"],
491
+ ["/", "theme_editor_filter"],
329
492
  [SHORTCUTS.help, "theme_editor_show_help"],
330
493
 
331
494
  // Ctrl+ shortcuts (match common editor conventions)
@@ -334,7 +497,6 @@ editor.defineMode(
334
497
  [SHORTCUTS.save_as, "theme_editor_save_as"],
335
498
  [SHORTCUTS.delete, "theme_editor_delete"],
336
499
  [SHORTCUTS.reload, "theme_editor_reload"],
337
- [SHORTCUTS.close, "theme_editor_close"],
338
500
  ["C-h", "theme_editor_show_help"], // Alternative help key
339
501
  ],
340
502
  true // read-only
@@ -500,82 +662,19 @@ async function loadBuiltinThemes(): Promise<string[]> {
500
662
  }
501
663
 
502
664
  /**
503
- * Load a theme file from built-in themes
665
+ * Load theme data by name from the in-memory theme registry.
666
+ * Works for all theme types (builtin, user, package) — no file I/O needed.
504
667
  */
505
- async function loadThemeFile(name: string): Promise<Record<string, unknown> | null> {
668
+ function loadThemeFile(name: string): Record<string, unknown> | null {
506
669
  try {
507
- const rawThemes = editor.getBuiltinThemes();
508
- // getBuiltinThemes returns a JSON string, need to parse it
509
- const builtinThemes = typeof rawThemes === "string"
510
- ? JSON.parse(rawThemes) as Record<string, string>
511
- : rawThemes as Record<string, string>;
512
- if (name in builtinThemes) {
513
- return JSON.parse(builtinThemes[name]);
514
- }
515
- return null;
670
+ const data = editor.getThemeData(name);
671
+ return data as Record<string, unknown> | null;
516
672
  } catch (e) {
517
673
  editor.debug(`[theme_editor] Failed to load theme data for '${name}': ${e}`);
518
674
  return null;
519
675
  }
520
676
  }
521
677
 
522
- /**
523
- * Load a user theme file
524
- */
525
- async function loadUserThemeFile(name: string): Promise<{ data: Record<string, unknown>; path: string } | null> {
526
- const userThemesDir = getUserThemesDir();
527
- const themePath = editor.pathJoin(userThemesDir, `${name}.json`);
528
-
529
- try {
530
- const content = await editor.readFile(themePath);
531
- return { data: JSON.parse(content), path: themePath };
532
- } catch {
533
- editor.debug(`Failed to load user theme: ${name}`);
534
- return null;
535
- }
536
- }
537
-
538
- /**
539
- * List available user themes
540
- */
541
- function listUserThemes(): string[] {
542
- const userThemesDir = getUserThemesDir();
543
- try {
544
- const entries = editor.readDir(userThemesDir);
545
- return entries
546
- .filter(e => e.is_file && e.name.endsWith(".json"))
547
- .map(e => e.name.replace(".json", ""));
548
- } catch {
549
- return [];
550
- }
551
- }
552
-
553
- /**
554
- * Get user themes directory
555
- * Uses the API to get the correct path
556
- */
557
- function getUserThemesDir(): string {
558
- // Use the API if available (new method)
559
- if (typeof editor.getThemesDir === "function") {
560
- return editor.getThemesDir();
561
- }
562
-
563
- // Fallback for older versions (deprecated)
564
- // Check XDG_CONFIG_HOME first (standard on Linux)
565
- const xdgConfig = editor.getEnv("XDG_CONFIG_HOME");
566
- if (xdgConfig) {
567
- return editor.pathJoin(xdgConfig, "fresh", "themes");
568
- }
569
-
570
- // Fall back to $HOME/.config
571
- const home = editor.getEnv("HOME");
572
- if (home) {
573
- return editor.pathJoin(home, ".config", "fresh", "themes");
574
- }
575
-
576
- return editor.pathJoin(editor.getCwd(), "themes");
577
- }
578
-
579
678
  // =============================================================================
580
679
  // Field Building
581
680
  // =============================================================================
@@ -586,11 +685,21 @@ function getUserThemesDir(): string {
586
685
  function buildVisibleFields(): ThemeField[] {
587
686
  const fields: ThemeField[] = [];
588
687
  const themeSections = getThemeSections();
688
+ const filter = state.filterText.toLowerCase();
589
689
 
590
690
  for (const section of themeSections) {
591
691
  const expanded = state.expandedSections.has(section.name);
592
692
 
593
- // Section header - displayName and description are already translated in getThemeSections()
693
+ // When filtering, check if section or any of its fields match
694
+ if (filter) {
695
+ const sectionMatches = section.name.toLowerCase().includes(filter) ||
696
+ section.displayName.toLowerCase().includes(filter);
697
+ const anyFieldMatches = section.fields.some(f =>
698
+ f.key.toLowerCase().includes(filter) || f.displayName.toLowerCase().includes(filter));
699
+ if (!sectionMatches && !anyFieldMatches) continue;
700
+ }
701
+
702
+ // Section header
594
703
  fields.push({
595
704
  def: {
596
705
  key: section.name,
@@ -608,10 +717,16 @@ function buildVisibleFields(): ThemeField[] {
608
717
  // Section fields
609
718
  if (expanded) {
610
719
  for (const fieldDef of section.fields) {
720
+ // Filter individual fields
721
+ if (filter) {
722
+ const fieldMatches = fieldDef.key.toLowerCase().includes(filter) ||
723
+ fieldDef.displayName.toLowerCase().includes(filter);
724
+ if (!fieldMatches) continue;
725
+ }
726
+
611
727
  const path = `${section.name}.${fieldDef.key}`;
612
728
  const value = getNestedValue(state.themeData, path) as ColorValue || [128, 128, 128];
613
729
 
614
- // fieldDef displayName and description are already translated in getThemeSections()
615
730
  fields.push({
616
731
  def: fieldDef,
617
732
  value,
@@ -630,109 +745,423 @@ function buildVisibleFields(): ThemeField[] {
630
745
  // UI Building
631
746
  // =============================================================================
632
747
 
633
- /**
634
- * Build display entries for virtual buffer
635
- */
636
- function buildDisplayEntries(): TextPropertyEntry[] {
637
- const entries: TextPropertyEntry[] = [];
748
+ // =============================================================================
749
+ // Tree Panel Builder (Left)
750
+ // =============================================================================
638
751
 
639
- // Title
640
- const modifiedMarker = state.hasChanges ? " " + editor.t("panel.modified") : "";
641
- entries.push({
642
- text: `━━━ ${editor.t("panel.title", { name: state.themeName })}${modifiedMarker} ━━━\n`,
643
- properties: { type: "title" },
644
- });
752
+ interface TreeLine {
753
+ text: string;
754
+ type: string;
755
+ index?: number;
756
+ path?: string;
757
+ selected?: boolean;
758
+ colorValue?: ColorValue;
759
+ }
645
760
 
646
- if (state.themePath) {
647
- entries.push({
648
- text: `${editor.t("panel.file", { path: state.themePath })}\n`,
649
- properties: { type: "file-path" },
650
- });
651
- } else {
652
- entries.push({
653
- text: editor.t("panel.new_theme") + "\n",
654
- properties: { type: "file-path" },
655
- });
656
- }
761
+ function buildTreeLines(): TreeLine[] {
762
+ const lines: TreeLine[] = [];
657
763
 
658
- // Key hints at the top (moved from footer)
659
- entries.push({
660
- text: editor.t("panel.nav_hint") + "\n",
661
- properties: { type: "footer" },
662
- });
663
- entries.push({
664
- text: editor.t("panel.action_hint", SHORTCUTS) + "\n",
665
- properties: { type: "footer" },
764
+ // Header
765
+ const modMarker = state.hasChanges ? " [*]" : "";
766
+ lines.push({
767
+ text: `Theme Editor: ${state.themeName}${modMarker}`,
768
+ type: "header",
666
769
  });
667
770
 
668
- entries.push({
669
- text: "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n",
670
- properties: { type: "separator" },
671
- });
771
+ // Separator
772
+ lines.push({ text: "".repeat(36), type: "separator" });
672
773
 
673
- entries.push({
674
- text: "\n",
675
- properties: { type: "blank" },
676
- });
774
+ // Filter
775
+ if (state.filterText) {
776
+ lines.push({
777
+ text: `Filter: [${state.filterText}]`,
778
+ type: "filter",
779
+ });
780
+ lines.push({ text: "─".repeat(36), type: "separator" });
781
+ }
677
782
 
678
- // Fields
783
+ // Build visible fields
679
784
  state.visibleFields = buildVisibleFields();
680
785
 
786
+ // Clamp selectedIndex
787
+ if (state.selectedIndex >= state.visibleFields.length) {
788
+ state.selectedIndex = Math.max(0, state.visibleFields.length - 1);
789
+ }
790
+
681
791
  for (let i = 0; i < state.visibleFields.length; i++) {
682
792
  const field = state.visibleFields[i];
683
- const indent = " ".repeat(field.depth);
793
+ const isSelected = i === state.selectedIndex;
684
794
 
685
795
  if (field.isSection) {
686
- // Section header
687
796
  const icon = field.expanded ? "▼" : ">";
688
- entries.push({
689
- text: `${indent}${icon} ${field.def.displayName}\n`,
690
- properties: {
691
- type: "section",
692
- path: field.path,
693
- index: i,
694
- expanded: field.expanded,
695
- },
696
- });
697
-
698
- // Section description
699
- entries.push({
700
- text: `${indent} // ${field.def.description}\n`,
701
- properties: { type: "description", path: field.path },
797
+ const sel = isSelected && state.focusPanel === "tree" ? "▸" : " ";
798
+ lines.push({
799
+ text: `${sel}${icon} ${field.def.displayName}`,
800
+ type: "tree-section",
801
+ index: i,
802
+ path: field.path,
803
+ selected: isSelected,
702
804
  });
703
805
  } else {
704
- // Field description (before the field)
705
- entries.push({
706
- text: `${indent} // ${field.def.description}\n`,
707
- properties: { type: "description", path: field.path },
806
+ const sel = isSelected && state.focusPanel === "tree" ? "▸" : " ";
807
+ const name = field.def.key.length > 13 ? field.def.key.slice(0, 12) + "…" : field.def.key;
808
+ const colorStr = formatColorValue(field.value);
809
+ const valueStr = colorStr.length > 9 ? colorStr.slice(0, 8) + "" : colorStr;
810
+ lines.push({
811
+ text: ` ${sel} ${name.padEnd(13)} ██ ${valueStr}`,
812
+ type: "tree-field",
813
+ index: i,
814
+ path: field.path,
815
+ selected: isSelected,
816
+ colorValue: field.value,
708
817
  });
818
+ }
819
+ }
709
820
 
710
- // Color field with swatch characters (X for fg preview, space for bg preview)
711
- const colorStr = formatColorValue(field.value);
821
+ return lines;
822
+ }
712
823
 
713
- entries.push({
714
- text: `${indent} ${field.def.displayName}: X ${colorStr}\n`,
715
- properties: {
716
- type: "field",
717
- path: field.path,
718
- index: i,
719
- colorValue: field.value,
720
- },
721
- });
824
+ // =============================================================================
825
+ // Picker Panel Builder (Right)
826
+ // =============================================================================
827
+
828
+ interface PickerLine {
829
+ text: string;
830
+ type: string;
831
+ namedRow?: number;
832
+ paletteRow?: number;
833
+ previewLineIdx?: number;
834
+ }
835
+
836
+ function buildPickerLines(): PickerLine[] {
837
+ const lines: PickerLine[] = [];
838
+ const field = state.visibleFields[state.selectedIndex];
839
+
840
+ if (!field || field.isSection) {
841
+ // Section selected - show section info
842
+ if (field) {
843
+ lines.push({ text: `${field.def.displayName}`, type: "picker-title" });
844
+ lines.push({ text: `"${field.def.description}"`, type: "picker-desc" });
845
+ } else {
846
+ lines.push({ text: "No field selected", type: "picker-title" });
847
+ }
848
+ lines.push({ text: "─".repeat(RIGHT_WIDTH - 2), type: "picker-separator" });
849
+ lines.push({ text: "Select a color field to edit", type: "picker-desc" });
850
+ return lines;
851
+ }
852
+
853
+ // Field title
854
+ lines.push({ text: `${field.path} - ${field.def.displayName}`, type: "picker-title" });
855
+ lines.push({ text: `"${field.def.description}"`, type: "picker-desc" });
856
+ lines.push({ text: "─".repeat(RIGHT_WIDTH - 2), type: "picker-separator" });
857
+
858
+ // Hex / RGB value
859
+ const colorStr = formatColorValue(field.value);
860
+ const rgb = parseColorToRgb(field.value);
861
+ let valueLine = `Hex: ${colorStr}`;
862
+ if (rgb) {
863
+ valueLine += ` RGB: ${rgb[0]}, ${rgb[1]}, ${rgb[2]}`;
864
+ }
865
+ lines.push({ text: valueLine, type: "picker-hex" });
866
+
867
+ lines.push({ text: "", type: "picker-blank" });
868
+
869
+ // Named Colors section
870
+ lines.push({ text: "Named Colors:", type: "picker-label" });
871
+ for (let row = 0; row < NAMED_COLOR_GRID.length; row++) {
872
+ let rowText = "";
873
+ for (const item of NAMED_COLOR_GRID[row]) {
874
+ rowText += " " + item.display.padEnd(8);
875
+ }
876
+ lines.push({ text: rowText, type: "picker-named-row", namedRow: row });
877
+ }
878
+
879
+ lines.push({ text: "", type: "picker-blank" });
880
+
881
+ // Extended Color Palette section
882
+ lines.push({ text: "Color Palette:", type: "picker-label" });
883
+ const palette = getExtendedPalette();
884
+ for (let row = 0; row < PALETTE_ROWS; row++) {
885
+ let rowText = "";
886
+ for (let col = 0; col < PALETTE_COLS; col++) {
887
+ rowText += (col === 0 ? " " : " ") + "██";
888
+ }
889
+ lines.push({ text: rowText, type: "picker-palette-row", paletteRow: row });
890
+ }
891
+
892
+ lines.push({ text: "─".repeat(RIGHT_WIDTH - 2), type: "picker-separator" });
893
+
894
+ // Preview section
895
+ lines.push({ text: "Preview:", type: "picker-label" });
896
+ for (let i = 0; i < PREVIEW_LINES.length; i++) {
897
+ let lineText = " ";
898
+ for (const token of PREVIEW_LINES[i]) {
899
+ lineText += token.text;
900
+ }
901
+ lines.push({ text: lineText, type: "picker-preview-line", previewLineIdx: i });
902
+ }
903
+
904
+ return lines;
905
+ }
906
+
907
+ // =============================================================================
908
+ // UI Building - Two Panel Merge
909
+ // =============================================================================
910
+
911
+ /**
912
+ * Compute inline style for a left-panel tree entry
913
+ */
914
+ function styleForLeftEntry(item: TreeLine | undefined): { style?: Partial<OverlayOptions>; inlineOverlays?: InlineOverlay[] } {
915
+ if (!item) return {};
916
+ const type = item.type;
917
+ if (type === "header") {
918
+ return { style: { fg: colors.header, bold: true } };
919
+ } else if (type === "separator") {
920
+ return { style: { fg: colors.divider } };
921
+ } else if (type === "filter") {
922
+ return { style: { fg: colors.filterText } };
923
+ } else if (type === "tree-section") {
924
+ return { style: { fg: colors.sectionHeader, bold: true } };
925
+ } else if (type === "tree-field") {
926
+ const inlines: InlineOverlay[] = [];
927
+ const text = " " + item.text; // matches leftText construction below
928
+ const paddedLen = getUtf8ByteLength(text.padEnd(LEFT_WIDTH));
929
+ const colorValue = item.colorValue;
930
+ const swatchIdx = colorValue !== undefined ? text.indexOf("██") : -1;
931
+ const rgb = colorValue !== undefined ? parseColorToRgb(colorValue) : null;
932
+
933
+ if (rgb && swatchIdx >= 0) {
934
+ const swatchStart = getUtf8ByteLength(text.substring(0, swatchIdx));
935
+ const swatchEnd = swatchStart + getUtf8ByteLength("██");
936
+ // Non-overlapping segments: fieldName | swatch | value
937
+ inlines.push({ start: 0, end: swatchStart, style: { fg: colors.fieldName } });
938
+ inlines.push({ start: swatchStart, end: swatchEnd, style: { fg: rgb, bg: rgb } });
939
+ const valueStart = swatchEnd + getUtf8ByteLength(" ");
940
+ if (valueStart < paddedLen) {
941
+ inlines.push({ start: valueStart, end: paddedLen, style: { fg: colors.customValue } });
942
+ }
943
+ return { inlineOverlays: inlines };
722
944
  }
945
+ // No swatch — use entry-level style
946
+ return { style: { fg: colors.fieldName } };
947
+ }
948
+ return {};
949
+ }
950
+
951
+ /**
952
+ * Compute inline style for a right-panel picker entry
953
+ */
954
+ function styleForRightEntry(item: PickerLine | undefined): { style?: Partial<OverlayOptions>; inlineOverlays?: InlineOverlay[] } {
955
+ if (!item) return {};
956
+ const type = item.type;
957
+ if (type === "picker-title") {
958
+ return { style: { fg: colors.header, bold: true } };
959
+ } else if (type === "picker-desc") {
960
+ return { style: { fg: colors.description } };
961
+ } else if (type === "picker-separator") {
962
+ return { style: { fg: colors.divider } };
963
+ } else if (type === "picker-hex") {
964
+ return { style: { fg: colors.pickerValue } };
965
+ } else if (type === "picker-label") {
966
+ return { style: { fg: colors.pickerLabel } };
967
+ } else if (type === "picker-named-row") {
968
+ const namedRow = item.namedRow!;
969
+ const gridRow = NAMED_COLOR_GRID[namedRow];
970
+ if (!gridRow) return {};
971
+ const inlines: InlineOverlay[] = [];
972
+ // Entry text is " " + item.text. Byte positions are relative to that.
973
+ const bytePos = getUtf8ByteLength(" "); // the prepended " "
974
+ let innerPos = 0;
975
+ for (let col = 0; col < gridRow.length; col++) {
976
+ const cellItem = gridRow[col];
977
+ const cellText = " " + cellItem.display.padEnd(8);
978
+ const cellLen = getUtf8ByteLength(cellText);
979
+ const cellStart = bytePos + getUtf8ByteLength(item.text.substring(0, innerPos));
980
+ const cellEnd = cellStart + cellLen;
981
+ if (cellItem.rgb) {
982
+ inlines.push({ start: cellStart, end: cellEnd, style: { fg: cellItem.rgb }, properties: { namedCol: col } });
983
+ } else {
984
+ inlines.push({ start: cellStart, end: cellEnd, style: { fg: colors.pickerLabel }, properties: { namedCol: col } });
985
+ }
986
+ innerPos += cellText.length;
987
+ }
988
+ return { inlineOverlays: inlines.length > 0 ? inlines : undefined };
989
+ } else if (type === "picker-palette-row") {
990
+ const paletteRow = item.paletteRow!;
991
+ const palette = getExtendedPalette();
992
+ const rowColors = palette[paletteRow];
993
+ if (!rowColors) return {};
994
+ const inlines: InlineOverlay[] = [];
995
+ // entry text = " " + item.text
996
+ // item.text = " ██ ██ ██..." (starts with " " then "██" pairs)
997
+ let bytePos = getUtf8ByteLength(" "); // prepended " "
998
+ let innerPos = 0;
999
+ for (let col = 0; col < PALETTE_COLS; col++) {
1000
+ const prefix = col === 0 ? " " : " ";
1001
+ innerPos += prefix.length;
1002
+ bytePos = getUtf8ByteLength(" ") + getUtf8ByteLength(item.text.substring(0, innerPos));
1003
+ const swatchLen = getUtf8ByteLength("██");
1004
+ const rgb = rowColors[col];
1005
+ inlines.push({ start: bytePos, end: bytePos + swatchLen, style: { fg: rgb, bg: rgb }, properties: { paletteCol: col } });
1006
+ innerPos += 2; // "██" is 2 JS chars
1007
+ }
1008
+ return { inlineOverlays: inlines.length > 0 ? inlines : undefined };
1009
+ } else if (type === "picker-preview-line") {
1010
+ const previewLineIdx = item.previewLineIdx!;
1011
+ const tokens = PREVIEW_LINES[previewLineIdx];
1012
+ if (!tokens) return {};
1013
+ const inlines: InlineOverlay[] = [];
1014
+ // entry text = " " + item.text
1015
+ // item.text = " " + token texts concatenated
1016
+ const editorBg = getNestedValue(state.themeData, "editor.bg") as ColorValue;
1017
+ const bgRgb = parseColorToRgb(editorBg);
1018
+ const entryText = " " + item.text;
1019
+ const entryLen = getUtf8ByteLength(entryText);
1020
+ const baseStyle: Partial<OverlayOptions> | undefined = bgRgb ? { bg: bgRgb } : undefined;
1021
+
1022
+ // Skip the leading " " + " " (from entry " " prefix + item.text leading " ")
1023
+ let charPos = 2; // " " prefix + " " in item.text
1024
+ let bytePos = getUtf8ByteLength(" ");
1025
+ for (const token of tokens) {
1026
+ const tokenLen = getUtf8ByteLength(token.text);
1027
+ if (token.syntaxType) {
1028
+ const syntaxPath = `syntax.${token.syntaxType}`;
1029
+ const syntaxColor = getNestedValue(state.themeData, syntaxPath) as ColorValue;
1030
+ const syntaxRgb = parseColorToRgb(syntaxColor);
1031
+ if (syntaxRgb) {
1032
+ inlines.push({ start: bytePos, end: bytePos + tokenLen, style: { fg: syntaxRgb } });
1033
+ }
1034
+ } else {
1035
+ const fgColor = getNestedValue(state.themeData, "editor.fg") as ColorValue;
1036
+ const fgRgb = parseColorToRgb(fgColor);
1037
+ if (fgRgb) {
1038
+ inlines.push({ start: bytePos, end: bytePos + tokenLen, style: { fg: fgRgb } });
1039
+ }
1040
+ }
1041
+ bytePos += tokenLen;
1042
+ }
1043
+ return { style: baseStyle, inlineOverlays: inlines.length > 0 ? inlines : undefined };
1044
+ }
1045
+ return {};
1046
+ }
1047
+
1048
+ function buildDisplayEntries(): TextPropertyEntry[] {
1049
+ const entries: TextPropertyEntry[] = [];
1050
+
1051
+ const allLeftLines = buildTreeLines();
1052
+ const rightLines = buildPickerLines();
1053
+
1054
+ // Virtual scrolling: only show a viewport-sized slice of tree lines
1055
+ // Reserve 2 rows for status bar + hints, 1 for possible scroll indicator
1056
+ const treeVisibleRows = Math.max(8, state.viewportHeight - 2);
1057
+
1058
+ // Adjust scroll offset to keep selectedIndex visible
1059
+ // Find which line index in allLeftLines corresponds to selectedIndex
1060
+ let selectedLineIdx = -1;
1061
+ for (let i = 0; i < allLeftLines.length; i++) {
1062
+ if (allLeftLines[i].index === state.selectedIndex && allLeftLines[i].selected) {
1063
+ selectedLineIdx = i;
1064
+ break;
1065
+ }
1066
+ }
1067
+ if (selectedLineIdx >= 0) {
1068
+ if (selectedLineIdx < state.treeScrollOffset) {
1069
+ state.treeScrollOffset = selectedLineIdx;
1070
+ }
1071
+ if (selectedLineIdx >= state.treeScrollOffset + treeVisibleRows) {
1072
+ state.treeScrollOffset = selectedLineIdx - treeVisibleRows + 1;
1073
+ }
1074
+ }
1075
+ // Clamp scroll offset
1076
+ const maxOffset = Math.max(0, allLeftLines.length - treeVisibleRows);
1077
+ if (state.treeScrollOffset > maxOffset) state.treeScrollOffset = maxOffset;
1078
+ if (state.treeScrollOffset < 0) state.treeScrollOffset = 0;
1079
+
1080
+ const leftLines = allLeftLines.slice(state.treeScrollOffset, state.treeScrollOffset + treeVisibleRows);
1081
+
1082
+ // Add scroll indicators if tree is scrollable
1083
+ const canScrollUp = state.treeScrollOffset > 0;
1084
+ const canScrollDown = state.treeScrollOffset + treeVisibleRows < allLeftLines.length;
1085
+
1086
+ const maxRows = Math.max(leftLines.length, rightLines.length, 8);
1087
+ let byteOffset = 0;
1088
+
1089
+ for (let i = 0; i < maxRows; i++) {
1090
+ const leftItem = leftLines[i];
1091
+ const rightItem = rightLines[i];
723
1092
 
1093
+ // Left side (padded to LEFT_WIDTH)
1094
+ const leftText = leftItem ? (" " + leftItem.text) : "";
1095
+ const leftPadded = leftText.padEnd(LEFT_WIDTH);
1096
+ const leftStyle = styleForLeftEntry(leftItem);
724
1097
  entries.push({
725
- text: "\n",
726
- properties: { type: "blank" },
1098
+ text: leftPadded,
1099
+ properties: {
1100
+ type: leftItem?.type || "blank",
1101
+ index: leftItem?.index,
1102
+ path: leftItem?.path,
1103
+ selected: leftItem?.selected,
1104
+ colorValue: leftItem?.colorValue,
1105
+ },
1106
+ style: leftStyle.style,
1107
+ inlineOverlays: leftStyle.inlineOverlays,
1108
+ });
1109
+ byteOffset += getUtf8ByteLength(leftPadded);
1110
+
1111
+ // Divider
1112
+ const dividerText = "│";
1113
+ entries.push({ text: dividerText, properties: { type: "divider" }, style: { fg: colors.divider } });
1114
+ byteOffset += getUtf8ByteLength(dividerText);
1115
+
1116
+ // Right side
1117
+ const rightText = rightItem ? (" " + rightItem.text) : "";
1118
+ const rightStyle = styleForRightEntry(rightItem);
1119
+ entries.push({
1120
+ text: rightText,
1121
+ properties: {
1122
+ type: rightItem?.type || "blank",
1123
+ namedRow: rightItem?.namedRow,
1124
+ paletteRow: rightItem?.paletteRow,
1125
+ previewLineIdx: rightItem?.previewLineIdx,
1126
+ },
1127
+ style: rightStyle.style,
1128
+ inlineOverlays: rightStyle.inlineOverlays,
727
1129
  });
1130
+ byteOffset += getUtf8ByteLength(rightText);
1131
+
1132
+ // Newline
1133
+ entries.push({ text: "\n", properties: { type: "newline" } });
1134
+ byteOffset += 1;
728
1135
  }
729
1136
 
1137
+ // Status bar (full width)
1138
+ entries.push({
1139
+ text: "─".repeat(100) + "\n",
1140
+ properties: { type: "status-separator" },
1141
+ style: { fg: colors.divider },
1142
+ });
1143
+
1144
+ // Context-sensitive key hints
1145
+ let hints: string;
1146
+ const scrollHint = canScrollUp || canScrollDown
1147
+ ? ` [${canScrollUp ? "▲" : " "}${canScrollDown ? "▼" : " "}]`
1148
+ : "";
1149
+ if (state.focusPanel === "tree") {
1150
+ hints = " ↑↓ Navigate Tab Switch Panel Enter Edit /Filter Ctrl+S Save Esc Close" + scrollHint;
1151
+ } else {
1152
+ hints = " ↑↓←→ Navigate Tab Switch Panel Enter Apply Esc Back to Tree" + scrollHint;
1153
+ }
1154
+ entries.push({
1155
+ text: hints + "\n",
1156
+ properties: { type: "status-bar" },
1157
+ style: { fg: colors.footer },
1158
+ });
1159
+
730
1160
  return entries;
731
1161
  }
732
1162
 
733
1163
  /**
734
1164
  * Helper to add a colored overlay (foreground color)
735
- * addOverlay signature: (bufferId, namespace, start, end, r, g, b, underline, bold, italic, bg_r, bg_g, bg_b, extend_to_line_end)
736
1165
  */
737
1166
  function addColorOverlay(
738
1167
  bufferId: number,
@@ -753,7 +1182,7 @@ function addBackgroundHighlight(
753
1182
  end: number,
754
1183
  bgColor: RGB
755
1184
  ): void {
756
- editor.addOverlay(bufferId, "theme-selection", start, end, { bg: bgColor, extendToLineEnd: true });
1185
+ editor.addOverlay(bufferId, "theme-sel", start, end, { bg: bgColor });
757
1186
  }
758
1187
 
759
1188
  /**
@@ -773,96 +1202,110 @@ function isSpecialColor(value: ColorValue): boolean {
773
1202
  }
774
1203
 
775
1204
  /**
776
- * Apply syntax highlighting
1205
+ * Apply selection-only highlighting (cheap — just the selected row background
1206
+ * and picker focus highlights). Only touches the "theme-selection" namespace.
777
1207
  */
778
- function applyHighlighting(): void {
1208
+ function applySelectionHighlighting(cachedEntries?: TextPropertyEntry[]): void {
779
1209
  if (state.bufferId === null) return;
780
1210
 
781
1211
  const bufferId = state.bufferId;
782
- editor.clearNamespace(bufferId, "theme");
783
- editor.clearNamespace(bufferId, "theme-selection");
1212
+ editor.clearNamespace(bufferId, "theme-sel");
784
1213
 
785
- const entries = buildDisplayEntries();
1214
+ const entries = cachedEntries || buildDisplayEntries();
786
1215
  let byteOffset = 0;
787
1216
 
788
- // Get current field at cursor to highlight it
789
- const currentField = getFieldAtCursor();
790
- const currentFieldPath = currentField?.path;
791
-
792
1217
  for (const entry of entries) {
793
1218
  const text = entry.text;
794
1219
  const textLen = getUtf8ByteLength(text);
795
1220
  const props = entry.properties as Record<string, unknown>;
796
1221
  const entryType = props.type as string;
797
- const entryPath = props.path as string | undefined;
798
-
799
- // Add selection highlight for current field/section
800
- if (currentFieldPath && entryPath === currentFieldPath && (entryType === "field" || entryType === "section")) {
801
- addBackgroundHighlight(bufferId, byteOffset, byteOffset + textLen, colors.selectionBg);
802
- }
803
1222
 
804
- if (entryType === "title") {
805
- addColorOverlay(bufferId, byteOffset, byteOffset + textLen, colors.sectionHeader, true);
806
- } else if (entryType === "file-path") {
807
- addColorOverlay(bufferId, byteOffset, byteOffset + textLen, colors.description);
808
- } else if (entryType === "description") {
809
- addColorOverlay(bufferId, byteOffset, byteOffset + textLen, colors.description);
810
- } else if (entryType === "section") {
811
- addColorOverlay(bufferId, byteOffset, byteOffset + textLen, colors.sectionHeader, true);
812
- } else if (entryType === "field") {
813
- // Field name - light blue
814
- const colonPos = text.indexOf(":");
815
- if (colonPos > 0) {
816
- const nameEnd = byteOffset + getUtf8ByteLength(text.substring(0, colonPos));
817
- addColorOverlay(bufferId, byteOffset, nameEnd, colors.fieldName);
818
-
819
- // Color the swatch characters with the field's actual color
820
- // Text format: "FieldName: X #RRGGBB" (X=fg, space=bg)
821
- const colorValue = props.colorValue as ColorValue;
822
- const rgb = parseColorToRgb(colorValue);
823
- if (rgb) {
824
- // "X" is at colon + 2 (": " = 2 bytes), and is 1 byte
825
- const swatchFgStart = nameEnd + getUtf8ByteLength(": ");
826
- const swatchFgEnd = swatchFgStart + 1; // "X" is 1 byte
827
- addColorOverlay(bufferId, swatchFgStart, swatchFgEnd, rgb);
828
-
829
- // First space after "X" is the bg swatch, 1 byte
830
- const swatchBgStart = swatchFgEnd;
831
- const swatchBgEnd = swatchBgStart + 1;
832
- // Use background color for the space
833
- editor.addOverlay(bufferId, "theme", swatchBgStart, swatchBgEnd, { bg: rgb });
1223
+ if (entryType === "tree-section" || entryType === "tree-field") {
1224
+ if (props.selected as boolean) {
1225
+ // For tree-field entries, split the highlight around the swatch (██) bytes
1226
+ // so the inline swatch color overlay (fg+bg) isn't overridden by selection bg
1227
+ const swatchIdx = text.indexOf("██");
1228
+ if (entryType === "tree-field" && swatchIdx >= 0) {
1229
+ const swatchByteStart = byteOffset + getUtf8ByteLength(text.substring(0, swatchIdx));
1230
+ const swatchByteEnd = swatchByteStart + getUtf8ByteLength("██");
1231
+ addBackgroundHighlight(bufferId, byteOffset, swatchByteStart, colors.selectionBg);
1232
+ addBackgroundHighlight(bufferId, swatchByteEnd, byteOffset + textLen, colors.selectionBg);
1233
+ } else {
1234
+ addBackgroundHighlight(bufferId, byteOffset, byteOffset + textLen, colors.selectionBg);
1235
+ }
1236
+ }
1237
+ } else if (entryType === "picker-hex") {
1238
+ if (state.focusPanel === "picker" && state.pickerFocus.type === "hex-input") {
1239
+ addBackgroundHighlight(bufferId, byteOffset, byteOffset + textLen, colors.pickerFocusBg);
1240
+ }
1241
+ } else if (entryType === "picker-named-row") {
1242
+ if (state.focusPanel === "picker" && state.pickerFocus.type === "named-colors") {
1243
+ const namedRow = props.namedRow as number;
1244
+ const gridRow = NAMED_COLOR_GRID[namedRow];
1245
+ if (gridRow) {
1246
+ let pos = byteOffset + getUtf8ByteLength(" ");
1247
+ for (let col = 0; col < gridRow.length; col++) {
1248
+ const item = gridRow[col];
1249
+ const cellText = " " + item.display.padEnd(8);
1250
+ const cellLen = getUtf8ByteLength(cellText);
1251
+ const flatIdx = namedRow * NAMED_COLORS_PER_ROW + col;
1252
+ if (state.pickerFocus.index === flatIdx) {
1253
+ editor.addOverlay(bufferId, "theme-sel", pos, pos + cellLen, { bg: colors.pickerFocusBg });
1254
+ }
1255
+ pos += cellLen;
1256
+ }
1257
+ }
1258
+ }
1259
+ } else if (entryType === "picker-palette-row") {
1260
+ if (state.focusPanel === "picker" && state.pickerFocus.type === "palette") {
1261
+ const paletteRow = props.paletteRow as number;
1262
+ const palette = getExtendedPalette();
1263
+ const rowColors = palette[paletteRow];
1264
+ if (rowColors) {
1265
+ let pos = byteOffset + getUtf8ByteLength(" ");
1266
+ for (let col = 0; col < PALETTE_COLS; col++) {
1267
+ const prefix = col === 0 ? " " : " ";
1268
+ pos += getUtf8ByteLength(prefix);
1269
+ const swatchLen = getUtf8ByteLength("██");
1270
+ if (state.pickerFocus.row === paletteRow && state.pickerFocus.col === col) {
1271
+ editor.addOverlay(bufferId, "theme-sel", pos, pos + swatchLen, {
1272
+ bg: [255, 255, 255],
1273
+ fg: rowColors[col],
1274
+ });
1275
+ }
1276
+ pos += swatchLen;
1277
+ }
834
1278
  }
835
-
836
- // Value (hex code) - custom color (green)
837
- // Format: ": X #RRGGBB" - value starts after "X " (X + 2 spaces)
838
- const valueStart = nameEnd + getUtf8ByteLength(": X ");
839
- addColorOverlay(bufferId, valueStart, byteOffset + textLen, colors.customValue);
840
1279
  }
841
- } else if (entryType === "separator" || entryType === "footer") {
842
- addColorOverlay(bufferId, byteOffset, byteOffset + textLen, colors.footer);
843
1280
  }
844
1281
 
845
1282
  byteOffset += textLen;
846
1283
  }
1284
+
847
1285
  }
848
1286
 
1287
+ // Guard to suppress cursor_moved handler during programmatic updates
1288
+ let isUpdatingDisplay = false;
1289
+
849
1290
  /**
850
- * Update display (preserves cursor position)
1291
+ * Full display update rebuilds content and all overlays.
1292
+ * Use for structural changes (open, section toggle, color edit, filter).
851
1293
  */
852
1294
  function updateDisplay(): void {
853
1295
  if (state.bufferId === null) return;
854
-
855
- // Save current field path before updating
856
- const currentPath = getCurrentFieldPath();
1296
+ isUpdatingDisplay = true;
857
1297
 
858
1298
  const entries = buildDisplayEntries();
1299
+
1300
+ // Clear selection overlays BEFORE replacing content to prevent stale
1301
+ // theme-sel markers from having wrong positions after the buffer replace
1302
+ editor.clearNamespace(state.bufferId, "theme-sel");
1303
+
859
1304
  editor.setVirtualBufferContent(state.bufferId, entries);
860
- applyHighlighting();
861
1305
 
862
- // Restore cursor to the same field if possible
863
- if (currentPath) {
864
- moveCursorToField(currentPath);
865
- }
1306
+ // Selection highlights use a separate namespace via addOverlay (dynamic, position-dependent)
1307
+ applySelectionHighlighting(entries);
1308
+ isUpdatingDisplay = false;
866
1309
  }
867
1310
 
868
1311
  // =============================================================================
@@ -870,19 +1313,12 @@ function updateDisplay(): void {
870
1313
  // =============================================================================
871
1314
 
872
1315
  /**
873
- * Get field at cursor position
1316
+ * Get field at cursor position (uses state.selectedIndex)
874
1317
  */
875
1318
  function getFieldAtCursor(): ThemeField | null {
876
- if (state.bufferId === null) return null;
877
-
878
- const props = editor.getTextPropertiesAtCursor(state.bufferId);
879
- if (props.length > 0 && typeof props[0].index === "number") {
880
- const index = props[0].index as number;
881
- if (index >= 0 && index < state.visibleFields.length) {
882
- return state.visibleFields[index];
883
- }
1319
+ if (state.selectedIndex >= 0 && state.selectedIndex < state.visibleFields.length) {
1320
+ return state.visibleFields[state.selectedIndex];
884
1321
  }
885
-
886
1322
  return null;
887
1323
  }
888
1324
 
@@ -1019,7 +1455,7 @@ function findMatchingColor(input: string): string | null {
1019
1455
  /**
1020
1456
  * Handle color prompt confirmation
1021
1457
  */
1022
- globalThis.onThemeColorPromptConfirmed = function(args: {
1458
+ function onThemeColorPromptConfirmed(args: {
1023
1459
  prompt_type: string;
1024
1460
  selected_index: number | null;
1025
1461
  input: string;
@@ -1040,7 +1476,7 @@ globalThis.onThemeColorPromptConfirmed = function(args: {
1040
1476
  const entries = buildDisplayEntries();
1041
1477
  if (state.bufferId !== null) {
1042
1478
  editor.setVirtualBufferContent(state.bufferId, entries);
1043
- applyHighlighting();
1479
+ applySelectionHighlighting(entries);
1044
1480
  }
1045
1481
  moveCursorToField(path);
1046
1482
  editor.setStatus(editor.t("status.updated", { path }));
@@ -1074,12 +1510,13 @@ globalThis.onThemeColorPromptConfirmed = function(args: {
1074
1510
  }
1075
1511
 
1076
1512
  return true;
1077
- };
1513
+ }
1514
+ registerHandler("onThemeColorPromptConfirmed", onThemeColorPromptConfirmed);
1078
1515
 
1079
1516
  /**
1080
1517
  * Handle open theme prompt (both builtin and user themes)
1081
1518
  */
1082
- globalThis.onThemeOpenPromptConfirmed = async function(args: {
1519
+ async function onThemeOpenPromptConfirmed(args: {
1083
1520
  prompt_type: string;
1084
1521
  selected_index: number | null;
1085
1522
  input: string;
@@ -1103,45 +1540,29 @@ globalThis.onThemeOpenPromptConfirmed = async function(args: {
1103
1540
  isBuiltin = state.builtinThemes.includes(value);
1104
1541
  }
1105
1542
 
1106
- if (isBuiltin) {
1107
- // Load builtin theme
1108
- const themeData = await loadThemeFile(themeName);
1109
- if (themeData) {
1110
- state.themeData = deepClone(themeData);
1111
- state.originalThemeData = deepClone(themeData);
1112
- state.themeName = themeName;
1113
- state.themePath = null; // No user path for builtin
1114
- state.isBuiltin = true;
1115
- state.hasChanges = false;
1116
- updateDisplay();
1117
- editor.setStatus(editor.t("status.opened_builtin", { name: themeName }));
1118
- } else {
1119
- editor.setStatus(editor.t("status.load_failed", { name: themeName }));
1120
- }
1543
+ const themeData = loadThemeFile(themeName);
1544
+ if (themeData) {
1545
+ state.themeData = deepClone(themeData);
1546
+ state.originalThemeData = deepClone(themeData);
1547
+ state.themeName = themeName;
1548
+ state.themePath = null;
1549
+ state.isBuiltin = isBuiltin;
1550
+ state.hasChanges = false;
1551
+ updateDisplay();
1552
+ const statusKey = isBuiltin ? "status.opened_builtin" : "status.loaded";
1553
+ editor.setStatus(editor.t(statusKey, { name: themeName }));
1121
1554
  } else {
1122
- // Load user theme
1123
- const result = await loadUserThemeFile(themeName);
1124
- if (result) {
1125
- state.themeData = deepClone(result.data);
1126
- state.originalThemeData = deepClone(result.data);
1127
- state.themeName = themeName;
1128
- state.themePath = result.path;
1129
- state.isBuiltin = false;
1130
- state.hasChanges = false;
1131
- updateDisplay();
1132
- editor.setStatus(editor.t("status.loaded", { name: themeName }));
1133
- } else {
1134
- editor.setStatus(editor.t("status.load_failed", { name: themeName }));
1135
- }
1555
+ editor.setStatus(editor.t("status.load_failed", { name: themeName }));
1136
1556
  }
1137
1557
 
1138
1558
  return true;
1139
- };
1559
+ }
1560
+ registerHandler("onThemeOpenPromptConfirmed", onThemeOpenPromptConfirmed);
1140
1561
 
1141
1562
  /**
1142
1563
  * Handle save as prompt
1143
1564
  */
1144
- globalThis.onThemeSaveAsPromptConfirmed = async function(args: {
1565
+ async function onThemeSaveAsPromptConfirmed(args: {
1145
1566
  prompt_type: string;
1146
1567
  selected_index: number | null;
1147
1568
  input: string;
@@ -1150,11 +1571,15 @@ globalThis.onThemeSaveAsPromptConfirmed = async function(args: {
1150
1571
 
1151
1572
  const name = args.input.trim();
1152
1573
  if (name) {
1153
- // Check if theme already exists
1154
- const userThemesDir = getUserThemesDir();
1155
- const targetPath = editor.pathJoin(userThemesDir, `${name}.json`);
1574
+ // Reject names that match a built-in theme
1575
+ if (state.builtinThemes.includes(name)) {
1576
+ editor.setStatus(editor.t("error.name_is_builtin", { name }));
1577
+ theme_editor_save_as();
1578
+ return true;
1579
+ }
1156
1580
 
1157
- if (editor.fileExists(targetPath)) {
1581
+ // Check if a user theme file already exists with this name
1582
+ if (editor.themeFileExists(name)) {
1158
1583
  // Store pending save name for overwrite confirmation
1159
1584
  state.pendingSaveName = name;
1160
1585
  editor.startPrompt(editor.t("prompt.overwrite_confirm", { name }), "theme-overwrite-confirm");
@@ -1176,26 +1601,30 @@ globalThis.onThemeSaveAsPromptConfirmed = async function(args: {
1176
1601
  }
1177
1602
 
1178
1603
  return true;
1179
- };
1604
+ }
1605
+ registerHandler("onThemeSaveAsPromptConfirmed", onThemeSaveAsPromptConfirmed);
1180
1606
 
1181
1607
  /**
1182
1608
  * Handle prompt cancellation
1183
1609
  */
1184
- globalThis.onThemePromptCancelled = function(args: { prompt_type: string }): boolean {
1610
+ function onThemePromptCancelled(args: { prompt_type: string }) : boolean {
1185
1611
  if (!args.prompt_type.startsWith("theme-")) return true;
1186
1612
 
1187
- // Clear saved cursor path on cancellation
1613
+ // Clear saved state on cancellation
1188
1614
  state.savedCursorPath = null;
1189
1615
  state.pendingSaveName = null;
1616
+ state.closeAfterSave = false;
1617
+ state.filterActive = false;
1190
1618
 
1191
- editor.setStatus(editor.t("status.cancelled"));
1619
+ editor.debug(editor.t("status.cancelled"));
1192
1620
  return true;
1193
- };
1621
+ }
1622
+ registerHandler("onThemePromptCancelled", onThemePromptCancelled);
1194
1623
 
1195
1624
  /**
1196
1625
  * Handle initial theme selection prompt (when opening editor)
1197
1626
  */
1198
- globalThis.onThemeSelectInitialPromptConfirmed = async function(args: {
1627
+ async function onThemeSelectInitialPromptConfirmed(args: {
1199
1628
  prompt_type: string;
1200
1629
  selected_index: number | null;
1201
1630
  input: string;
@@ -1224,46 +1653,19 @@ globalThis.onThemeSelectInitialPromptConfirmed = async function(args: {
1224
1653
  isBuiltin = state.builtinThemes.includes(value);
1225
1654
  }
1226
1655
 
1227
- editor.setStatus(editor.t("status.loading"));
1656
+ editor.debug(editor.t("status.loading"));
1228
1657
 
1229
- if (isBuiltin) {
1230
- // Load builtin theme
1231
- const themeData = await loadThemeFile(themeName);
1232
- if (themeData) {
1233
- state.themeData = deepClone(themeData);
1234
- state.originalThemeData = deepClone(themeData);
1235
- state.themeName = themeName;
1236
- state.themePath = null; // No user path for builtin
1237
- state.isBuiltin = true;
1238
- state.hasChanges = false;
1239
- } else {
1240
- // Fallback to default theme if load failed
1241
- state.themeData = createDefaultTheme();
1242
- state.originalThemeData = deepClone(state.themeData);
1243
- state.themeName = themeName;
1244
- state.themePath = null;
1245
- state.isBuiltin = true;
1246
- state.hasChanges = false;
1247
- }
1658
+ const themeData = loadThemeFile(themeName);
1659
+ if (themeData) {
1660
+ state.themeData = deepClone(themeData);
1661
+ state.originalThemeData = deepClone(themeData);
1662
+ state.themeName = themeName;
1663
+ state.themePath = null;
1664
+ state.isBuiltin = isBuiltin;
1665
+ state.hasChanges = false;
1248
1666
  } else {
1249
- // Load user theme
1250
- const result = await loadUserThemeFile(themeName);
1251
- if (result) {
1252
- state.themeData = deepClone(result.data);
1253
- state.originalThemeData = deepClone(result.data);
1254
- state.themeName = themeName;
1255
- state.themePath = result.path;
1256
- state.isBuiltin = false;
1257
- state.hasChanges = false;
1258
- } else {
1259
- // Fallback to default theme if load failed
1260
- state.themeData = createDefaultTheme();
1261
- state.originalThemeData = deepClone(state.themeData);
1262
- state.themeName = themeName;
1263
- state.themePath = null;
1264
- state.isBuiltin = false;
1265
- state.hasChanges = false;
1266
- }
1667
+ editor.setStatus(`Failed to load theme '${themeName}'`);
1668
+ return true;
1267
1669
  }
1268
1670
 
1269
1671
  // Now open the editor with loaded theme
@@ -1272,7 +1674,8 @@ globalThis.onThemeSelectInitialPromptConfirmed = async function(args: {
1272
1674
  editor.debug(`[theme_editor] doOpenThemeEditor() completed`);
1273
1675
 
1274
1676
  return true;
1275
- };
1677
+ }
1678
+ registerHandler("onThemeSelectInitialPromptConfirmed", onThemeSelectInitialPromptConfirmed);
1276
1679
 
1277
1680
  // Register prompt handlers
1278
1681
  editor.on("prompt_confirmed", "onThemeSelectInitialPromptConfirmed");
@@ -1294,28 +1697,32 @@ editor.on("prompt_cancelled", "onThemePromptCancelled");
1294
1697
  * @param restorePath - Optional field path to restore cursor to after save
1295
1698
  */
1296
1699
  async function saveTheme(name?: string, restorePath?: string | null): Promise<boolean> {
1297
- const themeName = name || state.themeName;
1298
- const userThemesDir = getUserThemesDir();
1299
-
1300
- // Ensure themes directory exists
1301
- if (!editor.fileExists(userThemesDir)) {
1302
- try {
1303
- // Create directory via shell command
1304
- await editor.spawnProcess("mkdir", ["-p", userThemesDir]);
1305
- } catch (e) {
1306
- editor.setStatus(editor.t("status.mkdir_failed", { error: String(e) }));
1307
- return false;
1308
- }
1309
- }
1310
-
1311
- const themePath = editor.pathJoin(userThemesDir, `${themeName}.json`);
1700
+ // Normalize theme name: lowercase, replace underscores/spaces with hyphens
1701
+ // (must match Rust's normalize_theme_name so config name matches filename)
1702
+ const themeName = (name || state.themeName).toLowerCase().replace(/[_ ]/g, "-");
1312
1703
 
1313
1704
  try {
1314
- state.themeData.name = themeName;
1315
- const content = JSON.stringify(state.themeData, null, 2);
1316
- await editor.writeFile(themePath, content);
1705
+ // Build a complete theme object from all known fields.
1706
+ // This ensures we always write every field, even if state.themeData
1707
+ // is missing some (e.g. package theme that failed to load fully).
1708
+ const completeTheme: Record<string, unknown> = { name: themeName };
1709
+ const sections = getThemeSections();
1710
+ for (const section of sections) {
1711
+ const sectionData: Record<string, unknown> = {};
1712
+ for (const field of section.fields) {
1713
+ const path = `${section.name}.${field.key}`;
1714
+ const value = getNestedValue(state.themeData, path);
1715
+ if (value !== undefined) {
1716
+ sectionData[field.key] = value;
1717
+ }
1718
+ }
1719
+ completeTheme[section.name] = sectionData;
1720
+ }
1721
+
1722
+ const content = JSON.stringify(completeTheme, null, 2);
1723
+ const savedPath = editor.saveThemeFile(themeName, content);
1317
1724
 
1318
- state.themePath = themePath;
1725
+ state.themePath = savedPath;
1319
1726
  state.themeName = themeName;
1320
1727
  state.isBuiltin = false; // After saving, it's now a user theme
1321
1728
  state.originalThemeData = deepClone(state.themeData);
@@ -1325,7 +1732,7 @@ async function saveTheme(name?: string, restorePath?: string | null): Promise<bo
1325
1732
  const entries = buildDisplayEntries();
1326
1733
  if (state.bufferId !== null) {
1327
1734
  editor.setVirtualBufferContent(state.bufferId, entries);
1328
- applyHighlighting();
1735
+ applySelectionHighlighting(entries);
1329
1736
  }
1330
1737
 
1331
1738
  // Restore cursor position if provided
@@ -1333,12 +1740,18 @@ async function saveTheme(name?: string, restorePath?: string | null): Promise<bo
1333
1740
  moveCursorToField(restorePath);
1334
1741
  }
1335
1742
 
1336
- // Reload themes so the new/updated theme is available, then apply it
1337
- editor.reloadThemes();
1338
- editor.applyTheme(themeName);
1743
+ // Reload themes and apply the new/updated theme atomically
1744
+ editor.reloadAndApplyTheme(themeName);
1339
1745
  editor.setStatus(editor.t("status.saved_and_applied", { name: themeName }));
1746
+
1747
+ if (state.closeAfterSave) {
1748
+ state.closeAfterSave = false;
1749
+ doCloseEditor();
1750
+ }
1751
+
1340
1752
  return true;
1341
1753
  } catch (e) {
1754
+ state.closeAfterSave = false;
1342
1755
  editor.setStatus(editor.t("status.save_failed", { error: String(e) }));
1343
1756
  return false;
1344
1757
  }
@@ -1419,28 +1832,102 @@ function createDefaultTheme(): Record<string, unknown> {
1419
1832
  // Cursor Movement Handler
1420
1833
  // =============================================================================
1421
1834
 
1422
- globalThis.onThemeEditorCursorMoved = function(data: {
1835
+ function onThemeEditorCursorMoved(data: {
1423
1836
  buffer_id: number;
1424
1837
  cursor_id: number;
1425
1838
  old_position: number;
1426
1839
  new_position: number;
1840
+ text_properties?: Array<Record<string, any>>;
1427
1841
  }): void {
1428
1842
  if (state.bufferId === null || data.buffer_id !== state.bufferId) return;
1843
+ if (isUpdatingDisplay) return;
1429
1844
 
1430
- applyHighlighting();
1845
+ const props = data.text_properties || [];
1846
+ if (props.length === 0) return;
1431
1847
 
1432
- const field = getFieldAtCursor();
1433
- if (field) {
1434
- editor.setStatus(field.def.description);
1848
+ const entryType = props[0].type as string | undefined;
1849
+
1850
+ // Tree field/section click — update selection and refresh display
1851
+ if ((entryType === "tree-field" || entryType === "tree-section") && typeof props[0].index === "number") {
1852
+ const index = props[0].index as number;
1853
+ if (index >= 0 && index < state.visibleFields.length) {
1854
+ state.selectedIndex = index;
1855
+ state.focusPanel = "tree";
1856
+ // Click on section header always toggles expand/collapse
1857
+ if (entryType === "tree-section") {
1858
+ theme_editor_toggle_section();
1859
+ return;
1860
+ }
1861
+ updateDisplay();
1862
+ return;
1863
+ }
1435
1864
  }
1436
- };
1865
+
1866
+ // Picker named color click — find which column via inline overlay properties
1867
+ if (entryType === "picker-named-row" && typeof props[0].namedRow === "number") {
1868
+ const namedRow = props[0].namedRow as number;
1869
+ // Look for namedCol property from inline overlay
1870
+ const colProp = props.find(p => typeof p.namedCol === "number");
1871
+ const clickedCol = colProp ? (colProp.namedCol as number) : 0;
1872
+ state.focusPanel = "picker";
1873
+ state.pickerFocus = { type: "named-colors", index: namedRow * NAMED_COLORS_PER_ROW + clickedCol };
1874
+ applyPickerColor();
1875
+ return;
1876
+ }
1877
+
1878
+ // Picker palette click — find which column via inline overlay properties
1879
+ if (entryType === "picker-palette-row" && typeof props[0].paletteRow === "number") {
1880
+ const paletteRow = props[0].paletteRow as number;
1881
+ // Look for paletteCol property from inline overlay
1882
+ const colProp = props.find(p => typeof p.paletteCol === "number");
1883
+ const clickedCol = colProp ? (colProp.paletteCol as number) : 0;
1884
+ state.focusPanel = "picker";
1885
+ state.pickerFocus = { type: "palette", row: paletteRow, col: clickedCol };
1886
+ applyPickerColor();
1887
+ return;
1888
+ }
1889
+
1890
+ // Picker hex click — open hex editing prompt
1891
+ if (entryType === "picker-hex") {
1892
+ state.focusPanel = "picker";
1893
+ state.pickerFocus = { type: "hex-input" };
1894
+ applyPickerColor();
1895
+ return;
1896
+ }
1897
+
1898
+ applySelectionHighlighting();
1899
+ }
1900
+ registerHandler("onThemeEditorCursorMoved", onThemeEditorCursorMoved);
1437
1901
 
1438
1902
  editor.on("cursor_moved", "onThemeEditorCursorMoved");
1439
1903
 
1904
+ function onThemeEditorResize(data: { width: number; height: number }): void {
1905
+ if (state.bufferId === null) return;
1906
+ state.viewportHeight = data.height;
1907
+ state.viewportWidth = data.width;
1908
+ updateDisplay();
1909
+ }
1910
+ registerHandler("onThemeEditorResize", onThemeEditorResize);
1911
+ editor.on("resize", "onThemeEditorResize");
1912
+
1913
+ function onThemeEditorMouseScroll(data: { buffer_id: number; delta: number; col: number; row: number }): void {
1914
+ if (state.bufferId === null || data.buffer_id !== state.bufferId) return;
1915
+
1916
+ // Only scroll the tree when mouse is over the left panel area (col < LEFT_WIDTH)
1917
+ if (data.col >= LEFT_WIDTH) return;
1918
+
1919
+ // delta > 0 = scroll down, delta < 0 = scroll up
1920
+ const scrollAmount = data.delta > 0 ? 3 : -3;
1921
+ state.treeScrollOffset = Math.max(0, state.treeScrollOffset + scrollAmount);
1922
+ updateDisplay();
1923
+ }
1924
+ registerHandler("onThemeEditorMouseScroll", onThemeEditorMouseScroll);
1925
+ editor.on("mouse_scroll", "onThemeEditorMouseScroll");
1926
+
1440
1927
  /**
1441
1928
  * Handle buffer_closed event to reset state when buffer is closed by any means
1442
1929
  */
1443
- globalThis.onThemeEditorBufferClosed = function(data: {
1930
+ function onThemeEditorBufferClosed(data: {
1444
1931
  buffer_id: number;
1445
1932
  }): void {
1446
1933
  if (state.bufferId !== null && data.buffer_id === state.bufferId) {
@@ -1450,11 +1937,70 @@ globalThis.onThemeEditorBufferClosed = function(data: {
1450
1937
  state.themeData = {};
1451
1938
  state.originalThemeData = {};
1452
1939
  state.hasChanges = false;
1940
+ state.focusPanel = "tree";
1941
+ state.pickerFocus = { type: "hex-input" };
1942
+ state.filterText = "";
1943
+ state.filterActive = false;
1944
+ state.selectedIndex = 0;
1945
+ state.treeScrollOffset = 0;
1453
1946
  }
1454
- };
1947
+ }
1948
+ registerHandler("onThemeEditorBufferClosed", onThemeEditorBufferClosed);
1455
1949
 
1456
1950
  editor.on("buffer_closed", "onThemeEditorBufferClosed");
1457
1951
 
1952
+ /**
1953
+ * Handle theme_inspect_key hook: open the theme editor at a specific key
1954
+ */
1955
+ async function onThemeInspectKey(data: {
1956
+ theme_name: string;
1957
+ key: string;
1958
+ }): Promise<void> {
1959
+ // If already open, focus and navigate to the key
1960
+ if (isThemeEditorOpen()) {
1961
+ if (state.bufferId !== null) {
1962
+ editor.showBuffer(state.bufferId);
1963
+ }
1964
+ const section = data.key.split(".")[0];
1965
+ if (!state.expandedSections.has(section)) {
1966
+ state.expandedSections.add(section);
1967
+ }
1968
+ moveCursorToField(data.key);
1969
+ return;
1970
+ }
1971
+
1972
+ // Save context
1973
+ state.sourceSplitId = editor.getActiveSplitId();
1974
+ state.sourceBufferId = editor.getActiveBufferId();
1975
+ state.builtinThemes = await loadBuiltinThemes();
1976
+
1977
+ // Auto-load the current theme (builtin or user)
1978
+ const isBuiltin = state.builtinThemes.includes(data.theme_name);
1979
+ const themeData = loadThemeFile(data.theme_name);
1980
+ if (themeData) {
1981
+ state.themeData = deepClone(themeData);
1982
+ state.originalThemeData = deepClone(themeData);
1983
+ state.themeName = data.theme_name;
1984
+ state.themePath = null;
1985
+ state.isBuiltin = isBuiltin;
1986
+ state.hasChanges = false;
1987
+ } else {
1988
+ editor.setStatus(`Failed to load theme '${data.theme_name}'`);
1989
+ return;
1990
+ }
1991
+
1992
+ // Expand the target section
1993
+ const section = data.key.split(".")[0];
1994
+ state.expandedSections.add(section);
1995
+
1996
+ // Open editor and navigate
1997
+ await doOpenThemeEditor();
1998
+ moveCursorToField(data.key);
1999
+ }
2000
+ registerHandler("onThemeInspectKey", onThemeInspectKey);
2001
+
2002
+ editor.on("theme_inspect_key", "onThemeInspectKey");
2003
+
1458
2004
  // =============================================================================
1459
2005
  // Smart Navigation - Skip Non-Selectable Lines
1460
2006
  // =============================================================================
@@ -1480,23 +2026,13 @@ function getSelectableEntries(): SelectableEntry[] {
1480
2026
  const entryType = props.type as string;
1481
2027
  const path = (props.path as string) || "";
1482
2028
 
1483
- // Only fields and sections are selectable (they have index property)
1484
- if ((entryType === "field" || entryType === "section") && typeof props.index === "number") {
1485
- // For fields, calculate position at the color value (after "FieldName: X ")
1486
- let valueByteOffset = byteOffset;
1487
- if (entryType === "field") {
1488
- const colonIdx = entry.text.indexOf(":");
1489
- if (colonIdx >= 0) {
1490
- // Position at the hex value, after ": X " (colon + space + X + 2 spaces = 5 chars)
1491
- valueByteOffset = byteOffset + getUtf8ByteLength(entry.text.substring(0, colonIdx + 5));
1492
- }
1493
- }
1494
-
2029
+ // Only tree fields and sections are selectable (they have index property)
2030
+ if ((entryType === "tree-field" || entryType === "tree-section") && typeof props.index === "number") {
1495
2031
  selectableEntries.push({
1496
2032
  byteOffset,
1497
- valueByteOffset,
2033
+ valueByteOffset: byteOffset,
1498
2034
  index: props.index as number,
1499
- isSection: entryType === "section",
2035
+ isSection: entryType === "tree-section",
1500
2036
  path,
1501
2037
  });
1502
2038
  }
@@ -1539,118 +2075,263 @@ function getCurrentFieldPath(): string | null {
1539
2075
  function moveCursorToField(path: string): void {
1540
2076
  if (state.bufferId === null) return;
1541
2077
 
1542
- const selectableEntries = getSelectableEntries();
1543
- for (const entry of selectableEntries) {
1544
- if (entry.path === path) {
1545
- // Use valueByteOffset for fields, byteOffset for sections
1546
- const targetOffset = entry.isSection ? entry.byteOffset : entry.valueByteOffset;
1547
- editor.setBufferCursor(state.bufferId, targetOffset);
2078
+ // Find the field by path in visibleFields and update selectedIndex
2079
+ for (let i = 0; i < state.visibleFields.length; i++) {
2080
+ if (state.visibleFields[i].path === path) {
2081
+ state.selectedIndex = i;
2082
+ updateDisplay();
1548
2083
  return;
1549
2084
  }
1550
2085
  }
1551
2086
  }
1552
2087
 
1553
2088
  /**
1554
- * Navigate to the next selectable field/section
2089
+ * Navigate up - context-dependent on focus panel
1555
2090
  */
1556
- globalThis.theme_editor_nav_down = function(): void {
2091
+ function theme_editor_nav_down() : void {
1557
2092
  if (state.bufferId === null) return;
1558
2093
 
1559
- const selectableEntries = getSelectableEntries();
1560
- const currentIndex = getCurrentSelectableIndex();
2094
+ if (state.focusPanel === "tree") {
2095
+ if (state.selectedIndex < state.visibleFields.length - 1) {
2096
+ state.selectedIndex++;
2097
+ updateDisplay();
2098
+ }
2099
+ } else {
2100
+ navigatePickerVertical(1);
2101
+ }
2102
+ }
2103
+ registerHandler("theme_editor_nav_down", theme_editor_nav_down);
1561
2104
 
1562
- // Find next selectable entry after current
1563
- for (const entry of selectableEntries) {
1564
- if (entry.index > currentIndex) {
1565
- // Use valueByteOffset for fields, byteOffset for sections
1566
- const targetOffset = entry.isSection ? entry.byteOffset : entry.valueByteOffset;
1567
- editor.setBufferCursor(state.bufferId, targetOffset);
1568
- return;
2105
+ function theme_editor_nav_up() : void {
2106
+ if (state.bufferId === null) return;
2107
+
2108
+ if (state.focusPanel === "tree") {
2109
+ if (state.selectedIndex > 0) {
2110
+ state.selectedIndex--;
2111
+ updateDisplay();
1569
2112
  }
2113
+ } else {
2114
+ navigatePickerVertical(-1);
1570
2115
  }
2116
+ }
2117
+ registerHandler("theme_editor_nav_up", theme_editor_nav_up);
1571
2118
 
1572
- // Already at last selectable, stay there
1573
- editor.setStatus(editor.t("status.at_last_field"));
1574
- };
2119
+ /**
2120
+ * Navigate left/right - for picker grid navigation
2121
+ */
2122
+ function theme_editor_nav_left() : void {
2123
+ if (state.focusPanel === "picker") {
2124
+ navigatePickerHorizontal(-1);
2125
+ }
2126
+ }
2127
+ registerHandler("theme_editor_nav_left", theme_editor_nav_left);
2128
+
2129
+ function theme_editor_nav_right() : void {
2130
+ if (state.focusPanel === "picker") {
2131
+ navigatePickerHorizontal(1);
2132
+ }
2133
+ }
2134
+ registerHandler("theme_editor_nav_right", theme_editor_nav_right);
1575
2135
 
1576
2136
  /**
1577
- * Navigate to the previous selectable field/section
2137
+ * Tab - switch focus between panels
1578
2138
  */
1579
- globalThis.theme_editor_nav_up = function(): void {
1580
- if (state.bufferId === null) return;
2139
+ function theme_editor_focus_tab() : void {
2140
+ if (state.focusPanel === "tree") {
2141
+ state.focusPanel = "picker";
2142
+ state.pickerFocus = { type: "hex-input" };
2143
+ } else {
2144
+ state.focusPanel = "tree";
2145
+ }
2146
+ updateDisplay();
2147
+ }
2148
+ registerHandler("theme_editor_focus_tab", theme_editor_focus_tab);
1581
2149
 
1582
- const selectableEntries = getSelectableEntries();
1583
- const currentIndex = getCurrentSelectableIndex();
2150
+ /**
2151
+ * Shift-Tab - reverse switch focus
2152
+ */
2153
+ function theme_editor_focus_shift_tab() : void {
2154
+ if (state.focusPanel === "picker") {
2155
+ state.focusPanel = "tree";
2156
+ } else {
2157
+ state.focusPanel = "picker";
2158
+ state.pickerFocus = { type: "hex-input" };
2159
+ }
2160
+ updateDisplay();
2161
+ }
2162
+ registerHandler("theme_editor_focus_shift_tab", theme_editor_focus_shift_tab);
1584
2163
 
1585
- // Find previous selectable entry before current
1586
- for (let i = selectableEntries.length - 1; i >= 0; i--) {
1587
- const entry = selectableEntries[i];
1588
- if (entry.index < currentIndex) {
1589
- // Use valueByteOffset for fields, byteOffset for sections
1590
- const targetOffset = entry.isSection ? entry.byteOffset : entry.valueByteOffset;
1591
- editor.setBufferCursor(state.bufferId, targetOffset);
1592
- return;
2164
+ /**
2165
+ * Enter key - context-dependent action
2166
+ */
2167
+ function theme_editor_enter() : void {
2168
+ if (state.focusPanel === "tree") {
2169
+ const field = getFieldAtCursor();
2170
+ if (!field) return;
2171
+ if (field.isSection) {
2172
+ theme_editor_toggle_section();
2173
+ } else {
2174
+ editColorField(field);
1593
2175
  }
2176
+ } else {
2177
+ applyPickerColor();
1594
2178
  }
2179
+ }
2180
+ registerHandler("theme_editor_enter", theme_editor_enter);
1595
2181
 
1596
- // Already at first selectable, stay there
1597
- editor.setStatus(editor.t("status.at_first_field"));
1598
- };
2182
+ /**
2183
+ * Escape - context-dependent
2184
+ */
2185
+ function theme_editor_escape() : void {
2186
+ if (state.focusPanel === "picker") {
2187
+ state.focusPanel = "tree";
2188
+ updateDisplay();
2189
+ } else if (state.filterText) {
2190
+ state.filterText = "";
2191
+ state.filterActive = false;
2192
+ updateDisplay();
2193
+ } else {
2194
+ theme_editor_close();
2195
+ }
2196
+ }
2197
+ registerHandler("theme_editor_escape", theme_editor_escape);
1599
2198
 
1600
2199
  /**
1601
- * Navigate to next element (Tab) - includes both fields and sections
2200
+ * / key - activate filter
1602
2201
  */
1603
- globalThis.theme_editor_nav_next_section = function(): void {
1604
- if (state.bufferId === null) return;
2202
+ function theme_editor_filter() : void {
2203
+ state.filterActive = true;
2204
+ editor.startPromptWithInitial(
2205
+ "Filter fields:",
2206
+ "theme-filter",
2207
+ state.filterText
2208
+ );
2209
+ }
2210
+ registerHandler("theme_editor_filter", theme_editor_filter);
2211
+
2212
+ /**
2213
+ * Handle filter prompt confirmation
2214
+ */
2215
+ function onThemeFilterPromptConfirmed(args: {
2216
+ prompt_type: string;
2217
+ selected_index: number | null;
2218
+ input: string;
2219
+ }): boolean {
2220
+ if (args.prompt_type !== "theme-filter") return true;
1605
2221
 
1606
- const selectableEntries = getSelectableEntries();
1607
- const currentIndex = getCurrentSelectableIndex();
2222
+ state.filterText = args.input.trim();
2223
+ state.filterActive = false;
2224
+ state.selectedIndex = 0;
2225
+ state.treeScrollOffset = 0;
2226
+ updateDisplay();
2227
+ return true;
2228
+ }
2229
+ registerHandler("onThemeFilterPromptConfirmed", onThemeFilterPromptConfirmed);
1608
2230
 
1609
- // Find next selectable entry after current
1610
- for (const entry of selectableEntries) {
1611
- if (entry.index > currentIndex) {
1612
- // Use valueByteOffset for fields, byteOffset for sections
1613
- const targetOffset = entry.isSection ? entry.byteOffset : entry.valueByteOffset;
1614
- editor.setBufferCursor(state.bufferId, targetOffset);
1615
- return;
2231
+ editor.on("prompt_confirmed", "onThemeFilterPromptConfirmed");
2232
+
2233
+ /**
2234
+ * Navigate picker vertically (between sections: hex, named-colors, palette)
2235
+ */
2236
+ function navigatePickerVertical(dir: number): void {
2237
+ const pf = state.pickerFocus;
2238
+ if (dir > 0) {
2239
+ if (pf.type === "hex-input") {
2240
+ state.pickerFocus = { type: "named-colors", index: 0 };
2241
+ } else if (pf.type === "named-colors") {
2242
+ const row = Math.floor(pf.index / NAMED_COLORS_PER_ROW);
2243
+ const col = pf.index % NAMED_COLORS_PER_ROW;
2244
+ if (row < NAMED_COLOR_GRID.length - 1) {
2245
+ state.pickerFocus = { type: "named-colors", index: (row + 1) * NAMED_COLORS_PER_ROW + col };
2246
+ } else {
2247
+ state.pickerFocus = { type: "palette", row: 0, col: Math.min(col, PALETTE_COLS - 1) };
2248
+ }
2249
+ } else if (pf.type === "palette") {
2250
+ if (pf.row < PALETTE_ROWS - 1) {
2251
+ state.pickerFocus = { type: "palette", row: pf.row + 1, col: pf.col };
2252
+ }
2253
+ }
2254
+ } else {
2255
+ if (pf.type === "palette") {
2256
+ if (pf.row > 0) {
2257
+ state.pickerFocus = { type: "palette", row: pf.row - 1, col: pf.col };
2258
+ } else {
2259
+ const col = Math.min(pf.col, NAMED_COLORS_PER_ROW - 1);
2260
+ state.pickerFocus = { type: "named-colors", index: (NAMED_COLOR_GRID.length - 1) * NAMED_COLORS_PER_ROW + col };
2261
+ }
2262
+ } else if (pf.type === "named-colors") {
2263
+ const row = Math.floor(pf.index / NAMED_COLORS_PER_ROW);
2264
+ const col = pf.index % NAMED_COLORS_PER_ROW;
2265
+ if (row > 0) {
2266
+ state.pickerFocus = { type: "named-colors", index: (row - 1) * NAMED_COLORS_PER_ROW + col };
2267
+ } else {
2268
+ state.pickerFocus = { type: "hex-input" };
2269
+ }
1616
2270
  }
1617
2271
  }
2272
+ updateDisplay();
2273
+ }
1618
2274
 
1619
- // Wrap to first entry
1620
- if (selectableEntries.length > 0) {
1621
- const entry = selectableEntries[0];
1622
- const targetOffset = entry.isSection ? entry.byteOffset : entry.valueByteOffset;
1623
- editor.setBufferCursor(state.bufferId, targetOffset);
2275
+ /**
2276
+ * Navigate picker horizontally (within named-colors or palette grids)
2277
+ */
2278
+ function navigatePickerHorizontal(dir: number): void {
2279
+ const pf = state.pickerFocus;
2280
+ if (pf.type === "named-colors") {
2281
+ const row = Math.floor(pf.index / NAMED_COLORS_PER_ROW);
2282
+ const col = pf.index % NAMED_COLORS_PER_ROW;
2283
+ const newCol = col + dir;
2284
+ if (newCol >= 0 && newCol < NAMED_COLORS_PER_ROW) {
2285
+ const totalItems = NAMED_COLOR_GRID.length * NAMED_COLORS_PER_ROW;
2286
+ const newIdx = row * NAMED_COLORS_PER_ROW + newCol;
2287
+ if (newIdx < totalItems) {
2288
+ state.pickerFocus = { type: "named-colors", index: newIdx };
2289
+ }
2290
+ }
2291
+ } else if (pf.type === "palette") {
2292
+ const newCol = pf.col + dir;
2293
+ if (newCol >= 0 && newCol < PALETTE_COLS) {
2294
+ state.pickerFocus = { type: "palette", row: pf.row, col: newCol };
2295
+ }
1624
2296
  }
1625
- };
2297
+ updateDisplay();
2298
+ }
1626
2299
 
1627
2300
  /**
1628
- * Navigate to previous element (Shift+Tab) - includes both fields and sections
2301
+ * Apply the currently focused picker color to the selected field
1629
2302
  */
1630
- globalThis.theme_editor_nav_prev_section = function(): void {
1631
- if (state.bufferId === null) return;
2303
+ function applyPickerColor(): void {
2304
+ const field = getFieldAtCursor();
2305
+ if (!field || field.isSection) return;
1632
2306
 
1633
- const selectableEntries = getSelectableEntries();
1634
- const currentIndex = getCurrentSelectableIndex();
2307
+ const pf = state.pickerFocus;
2308
+ let newColor: ColorValue | null = null;
1635
2309
 
1636
- // Find previous selectable entry before current
1637
- for (let i = selectableEntries.length - 1; i >= 0; i--) {
1638
- const entry = selectableEntries[i];
1639
- if (entry.index < currentIndex) {
1640
- // Use valueByteOffset for fields, byteOffset for sections
1641
- const targetOffset = entry.isSection ? entry.byteOffset : entry.valueByteOffset;
1642
- editor.setBufferCursor(state.bufferId, targetOffset);
1643
- return;
2310
+ if (pf.type === "hex-input") {
2311
+ editColorField(field);
2312
+ return;
2313
+ } else if (pf.type === "named-colors") {
2314
+ const row = Math.floor(pf.index / NAMED_COLORS_PER_ROW);
2315
+ const col = pf.index % NAMED_COLORS_PER_ROW;
2316
+ if (row < NAMED_COLOR_GRID.length && col < NAMED_COLOR_GRID[row].length) {
2317
+ const item = NAMED_COLOR_GRID[row][col];
2318
+ newColor = item.value;
2319
+ }
2320
+ } else if (pf.type === "palette") {
2321
+ const palette = getExtendedPalette();
2322
+ if (pf.row < PALETTE_ROWS && pf.col < PALETTE_COLS) {
2323
+ newColor = palette[pf.row][pf.col];
1644
2324
  }
1645
2325
  }
1646
2326
 
1647
- // Wrap to last entry
1648
- if (selectableEntries.length > 0) {
1649
- const entry = selectableEntries[selectableEntries.length - 1];
1650
- const targetOffset = entry.isSection ? entry.byteOffset : entry.valueByteOffset;
1651
- editor.setBufferCursor(state.bufferId, targetOffset);
2327
+ if (newColor !== null) {
2328
+ setNestedValue(state.themeData, field.path, newColor);
2329
+ state.hasChanges = !deepEqual(state.themeData, state.originalThemeData);
2330
+ updateDisplay();
2331
+ editor.setStatus(editor.t("status.updated", { path: field.path }));
1652
2332
  }
1653
- };
2333
+ }
2334
+
1654
2335
 
1655
2336
  // =============================================================================
1656
2337
  // Public Commands
@@ -1659,7 +2340,7 @@ globalThis.theme_editor_nav_prev_section = function(): void {
1659
2340
  /**
1660
2341
  * Open the theme editor - prompts user to select theme first
1661
2342
  */
1662
- globalThis.open_theme_editor = async function(): Promise<void> {
2343
+ async function open_theme_editor() : Promise<void> {
1663
2344
  editor.debug("[theme_editor] open_theme_editor called");
1664
2345
  if (isThemeEditorOpen()) {
1665
2346
  editor.debug("[theme_editor] already open, focusing");
@@ -1667,7 +2348,7 @@ globalThis.open_theme_editor = async function(): Promise<void> {
1667
2348
  if (state.splitId !== null) {
1668
2349
  editor.focusSplit(state.splitId);
1669
2350
  }
1670
- editor.setStatus(editor.t("status.already_open"));
2351
+ editor.debug(editor.t("status.already_open"));
1671
2352
  return;
1672
2353
  }
1673
2354
 
@@ -1690,15 +2371,23 @@ globalThis.open_theme_editor = async function(): Promise<void> {
1690
2371
 
1691
2372
  const suggestions: PromptSuggestion[] = [];
1692
2373
 
1693
- // Add user themes first
1694
- const userThemes = listUserThemes();
1695
- for (const name of userThemes) {
1696
- const isCurrent = name === currentThemeName;
1697
- suggestions.push({
1698
- text: name,
1699
- description: isCurrent ? editor.t("suggestion.user_theme_current") : editor.t("suggestion.user_theme"),
1700
- value: `user:${name}`,
1701
- });
2374
+ // Add user themes first (from themes directory)
2375
+ const userThemesDir = editor.getThemesDir();
2376
+ try {
2377
+ const entries = editor.readDir(userThemesDir);
2378
+ for (const e of entries) {
2379
+ if (e.is_file && e.name.endsWith(".json")) {
2380
+ const name = e.name.replace(".json", "");
2381
+ const isCurrent = name === currentThemeName;
2382
+ suggestions.push({
2383
+ text: name,
2384
+ description: isCurrent ? editor.t("suggestion.user_theme_current") : editor.t("suggestion.user_theme"),
2385
+ value: `user:${name}`,
2386
+ });
2387
+ }
2388
+ }
2389
+ } catch {
2390
+ // No user themes directory
1702
2391
  }
1703
2392
 
1704
2393
  // Add built-in themes
@@ -1713,20 +2402,29 @@ globalThis.open_theme_editor = async function(): Promise<void> {
1713
2402
 
1714
2403
  // Sort suggestions to put current theme first
1715
2404
  suggestions.sort((a, b) => {
1716
- const aIsCurrent = a.description.includes("current");
1717
- const bIsCurrent = b.description.includes("current");
2405
+ const aIsCurrent = (a.description ?? "").includes("current");
2406
+ const bIsCurrent = (b.description ?? "").includes("current");
1718
2407
  if (aIsCurrent && !bIsCurrent) return -1;
1719
2408
  if (!aIsCurrent && bIsCurrent) return 1;
1720
2409
  return 0;
1721
2410
  });
1722
2411
 
1723
2412
  editor.setPromptSuggestions(suggestions);
1724
- };
2413
+ }
2414
+ registerHandler("open_theme_editor", open_theme_editor);
1725
2415
 
1726
2416
  /**
1727
2417
  * Actually open the theme editor with loaded theme data
1728
2418
  */
1729
2419
  async function doOpenThemeEditor(): Promise<void> {
2420
+ // Initialize viewport dimensions
2421
+ const vp = editor.getViewport();
2422
+ if (vp) {
2423
+ state.viewportHeight = vp.height;
2424
+ state.viewportWidth = vp.width;
2425
+ }
2426
+ state.treeScrollOffset = 0;
2427
+
1730
2428
  editor.debug("[theme_editor] doOpenThemeEditor: building display entries");
1731
2429
  // Build initial entries
1732
2430
  const entries = buildDisplayEntries();
@@ -1740,7 +2438,7 @@ async function doOpenThemeEditor(): Promise<void> {
1740
2438
  readOnly: true,
1741
2439
  entries: entries,
1742
2440
  showLineNumbers: false,
1743
- showCursors: true,
2441
+ showCursors: false,
1744
2442
  editingDisabled: true,
1745
2443
  });
1746
2444
  const bufferId = result.bufferId;
@@ -1752,11 +2450,14 @@ async function doOpenThemeEditor(): Promise<void> {
1752
2450
  state.bufferId = bufferId;
1753
2451
  state.splitId = null;
1754
2452
 
1755
- editor.debug(`[theme_editor] doOpenThemeEditor: calling applyHighlighting...`);
1756
- applyHighlighting();
1757
- editor.debug(`[theme_editor] doOpenThemeEditor: applyHighlighting completed`);
2453
+ // Disable line wrapping — our layout is fixed-width
2454
+ editor.setLineWrap(bufferId, null, false);
2455
+
2456
+ editor.debug(`[theme_editor] doOpenThemeEditor: calling applySelectionHighlighting...`);
2457
+ applySelectionHighlighting();
2458
+ editor.debug(`[theme_editor] doOpenThemeEditor: applySelectionHighlighting completed`);
1758
2459
  editor.debug(`[theme_editor] doOpenThemeEditor: calling setStatus...`);
1759
- editor.setStatus(editor.t("status.ready"));
2460
+ editor.debug(editor.t("status.ready"));
1760
2461
  editor.debug(`[theme_editor] doOpenThemeEditor: completed successfully`);
1761
2462
  } else {
1762
2463
  editor.setStatus(editor.t("status.open_failed"));
@@ -1766,22 +2467,24 @@ async function doOpenThemeEditor(): Promise<void> {
1766
2467
  /**
1767
2468
  * Close the theme editor
1768
2469
  */
1769
- globalThis.theme_editor_close = function(): void {
2470
+ function theme_editor_close() : void {
1770
2471
  if (!isThemeEditorOpen()) return;
1771
2472
 
1772
2473
  if (state.hasChanges) {
1773
2474
  // Show confirmation prompt before closing with unsaved changes
1774
2475
  editor.startPrompt(editor.t("prompt.discard_confirm"), "theme-discard-confirm");
1775
2476
  const suggestions: PromptSuggestion[] = [
1776
- { text: editor.t("prompt.discard_yes"), description: "", value: "discard" },
1777
2477
  { text: editor.t("prompt.discard_no"), description: "", value: "keep" },
2478
+ { text: editor.t("prompt.discard_save"), description: "", value: "save" },
2479
+ { text: editor.t("prompt.discard_yes"), description: "", value: "discard" },
1778
2480
  ];
1779
2481
  editor.setPromptSuggestions(suggestions);
1780
2482
  return;
1781
2483
  }
1782
2484
 
1783
2485
  doCloseEditor();
1784
- };
2486
+ }
2487
+ registerHandler("theme_editor_close", theme_editor_close);
1785
2488
 
1786
2489
  /**
1787
2490
  * Actually close the editor (called after confirmation or when no changes)
@@ -1798,14 +2501,19 @@ function doCloseEditor(): void {
1798
2501
  state.themeData = {};
1799
2502
  state.originalThemeData = {};
1800
2503
  state.hasChanges = false;
2504
+ state.focusPanel = "tree";
2505
+ state.pickerFocus = { type: "hex-input" };
2506
+ state.filterText = "";
2507
+ state.filterActive = false;
2508
+ state.selectedIndex = 0;
1801
2509
 
1802
- editor.setStatus(editor.t("status.closed"));
2510
+ editor.debug(editor.t("status.closed"));
1803
2511
  }
1804
2512
 
1805
2513
  /**
1806
2514
  * Handle discard confirmation prompt
1807
2515
  */
1808
- globalThis.onThemeDiscardPromptConfirmed = function(args: {
2516
+ function onThemeDiscardPromptConfirmed(args: {
1809
2517
  prompt_type: string;
1810
2518
  selected_index: number | null;
1811
2519
  input: string;
@@ -1813,41 +2521,46 @@ globalThis.onThemeDiscardPromptConfirmed = function(args: {
1813
2521
  if (args.prompt_type !== "theme-discard-confirm") return true;
1814
2522
 
1815
2523
  const response = args.input.trim().toLowerCase();
1816
- if (response === "discard" || args.selected_index === 0) {
2524
+ if (response === "discard" || args.selected_index === 2) {
1817
2525
  editor.setStatus(editor.t("status.unsaved_discarded"));
1818
2526
  doCloseEditor();
2527
+ } else if (response === "save" || args.selected_index === 1) {
2528
+ state.closeAfterSave = true;
2529
+ theme_editor_save();
1819
2530
  } else {
1820
- editor.setStatus(editor.t("status.cancelled"));
2531
+ editor.debug(editor.t("status.cancelled"));
1821
2532
  }
1822
2533
 
1823
2534
  return false;
1824
- };
2535
+ }
2536
+ registerHandler("onThemeDiscardPromptConfirmed", onThemeDiscardPromptConfirmed);
1825
2537
 
1826
2538
  /**
1827
2539
  * Edit color at cursor
1828
2540
  */
1829
- globalThis.theme_editor_edit_color = function(): void {
2541
+ function theme_editor_edit_color() : void {
1830
2542
  const field = getFieldAtCursor();
1831
2543
  if (!field) {
1832
- editor.setStatus(editor.t("status.no_field"));
2544
+ editor.debug(editor.t("status.no_field"));
1833
2545
  return;
1834
2546
  }
1835
2547
 
1836
2548
  if (field.isSection) {
1837
- globalThis.theme_editor_toggle_section();
2549
+ theme_editor_toggle_section();
1838
2550
  return;
1839
2551
  }
1840
2552
 
1841
2553
  editColorField(field);
1842
- };
2554
+ }
2555
+ registerHandler("theme_editor_edit_color", theme_editor_edit_color);
1843
2556
 
1844
2557
  /**
1845
2558
  * Toggle section expansion
1846
2559
  */
1847
- globalThis.theme_editor_toggle_section = function(): void {
2560
+ function theme_editor_toggle_section() : void {
1848
2561
  const field = getFieldAtCursor();
1849
2562
  if (!field || !field.isSection) {
1850
- editor.setStatus(editor.t("status.not_section"));
2563
+ editor.debug(editor.t("status.not_section"));
1851
2564
  return;
1852
2565
  }
1853
2566
 
@@ -1858,24 +2571,33 @@ globalThis.theme_editor_toggle_section = function(): void {
1858
2571
  }
1859
2572
 
1860
2573
  updateDisplay();
1861
- };
2574
+ }
2575
+ registerHandler("theme_editor_toggle_section", theme_editor_toggle_section);
1862
2576
 
1863
2577
  /**
1864
2578
  * Open a theme (builtin or user) for editing
1865
2579
  */
1866
- globalThis.theme_editor_open = function(): void {
2580
+ function theme_editor_open() : void {
1867
2581
  editor.startPrompt(editor.t("prompt.open_theme"), "theme-open");
1868
2582
 
1869
2583
  const suggestions: PromptSuggestion[] = [];
1870
2584
 
1871
- // Add user themes first
1872
- const userThemes = listUserThemes();
1873
- for (const name of userThemes) {
1874
- suggestions.push({
1875
- text: name,
1876
- description: editor.t("suggestion.user_theme"),
1877
- value: `user:${name}`,
1878
- });
2585
+ // Add user themes first (from themes directory)
2586
+ const userThemesDir = editor.getThemesDir();
2587
+ try {
2588
+ const entries = editor.readDir(userThemesDir);
2589
+ for (const e of entries) {
2590
+ if (e.is_file && e.name.endsWith(".json")) {
2591
+ const name = e.name.replace(".json", "");
2592
+ suggestions.push({
2593
+ text: name,
2594
+ description: editor.t("suggestion.user_theme"),
2595
+ value: `user:${name}`,
2596
+ });
2597
+ }
2598
+ }
2599
+ } catch {
2600
+ // No user themes directory
1879
2601
  }
1880
2602
 
1881
2603
  // Add built-in themes
@@ -1888,38 +2610,38 @@ globalThis.theme_editor_open = function(): void {
1888
2610
  }
1889
2611
 
1890
2612
  editor.setPromptSuggestions(suggestions);
1891
- };
2613
+ }
2614
+ registerHandler("theme_editor_open", theme_editor_open);
1892
2615
 
1893
2616
  /**
1894
2617
  * Save theme
1895
2618
  */
1896
- globalThis.theme_editor_save = async function(): Promise<void> {
2619
+ async function theme_editor_save() : Promise<void> {
1897
2620
  // Save cursor path for restoration after save
1898
2621
  state.savedCursorPath = getCurrentFieldPath();
1899
2622
 
1900
2623
  // Built-in themes require Save As
1901
2624
  if (state.isBuiltin) {
1902
2625
  editor.setStatus(editor.t("status.builtin_requires_save_as"));
1903
- globalThis.theme_editor_save_as();
2626
+ theme_editor_save_as();
1904
2627
  return;
1905
2628
  }
1906
2629
 
1907
2630
  // If theme has never been saved (no path), trigger "Save As" instead
1908
2631
  if (!state.themePath) {
1909
- globalThis.theme_editor_save_as();
2632
+ theme_editor_save_as();
1910
2633
  return;
1911
2634
  }
1912
2635
 
1913
2636
  if (!state.hasChanges) {
1914
- editor.setStatus(editor.t("status.no_changes"));
2637
+ editor.debug(editor.t("status.no_changes"));
1915
2638
  return;
1916
2639
  }
1917
2640
 
1918
2641
  // Check for name collision if name has changed since last save
1919
- const userThemesDir = getUserThemesDir();
1920
- const targetPath = editor.pathJoin(userThemesDir, `${state.themeName}.json`);
2642
+ const expectedPath = editor.pathJoin(editor.getThemesDir(), `${state.themeName}.json`);
1921
2643
 
1922
- if (state.themePath !== targetPath && editor.fileExists(targetPath)) {
2644
+ if (state.themePath !== expectedPath && editor.themeFileExists(state.themeName)) {
1923
2645
  // File exists with this name - ask for confirmation
1924
2646
  editor.startPrompt(editor.t("prompt.overwrite_confirm", { name: state.themeName }), "theme-overwrite-confirm");
1925
2647
  const suggestions: PromptSuggestion[] = [
@@ -1931,12 +2653,13 @@ globalThis.theme_editor_save = async function(): Promise<void> {
1931
2653
  }
1932
2654
 
1933
2655
  await saveTheme(undefined, state.savedCursorPath);
1934
- };
2656
+ }
2657
+ registerHandler("theme_editor_save", theme_editor_save);
1935
2658
 
1936
2659
  /**
1937
2660
  * Handle overwrite confirmation prompt
1938
2661
  */
1939
- globalThis.onThemeOverwritePromptConfirmed = async function(args: {
2662
+ async function onThemeOverwritePromptConfirmed(args: {
1940
2663
  prompt_type: string;
1941
2664
  selected_index: number | null;
1942
2665
  input: string;
@@ -1956,16 +2679,18 @@ globalThis.onThemeOverwritePromptConfirmed = async function(args: {
1956
2679
  } else {
1957
2680
  state.pendingSaveName = null;
1958
2681
  state.savedCursorPath = null;
1959
- editor.setStatus(editor.t("status.cancelled"));
2682
+ state.closeAfterSave = false;
2683
+ editor.debug(editor.t("status.cancelled"));
1960
2684
  }
1961
2685
 
1962
2686
  return false;
1963
- };
2687
+ }
2688
+ registerHandler("onThemeOverwritePromptConfirmed", onThemeOverwritePromptConfirmed);
1964
2689
 
1965
2690
  /**
1966
2691
  * Save theme as (new name)
1967
2692
  */
1968
- globalThis.theme_editor_save_as = function(): void {
2693
+ function theme_editor_save_as() : void {
1969
2694
  // Save cursor path for restoration after save (if not already saved by theme_editor_save)
1970
2695
  if (!state.savedCursorPath) {
1971
2696
  state.savedCursorPath = getCurrentFieldPath();
@@ -1978,15 +2703,16 @@ globalThis.theme_editor_save_as = function(): void {
1978
2703
  description: editor.t("suggestion.current"),
1979
2704
  value: state.themeName,
1980
2705
  }]);
1981
- };
2706
+ }
2707
+ registerHandler("theme_editor_save_as", theme_editor_save_as);
1982
2708
 
1983
2709
  /**
1984
2710
  * Reload theme
1985
2711
  */
1986
- globalThis.theme_editor_reload = async function(): Promise<void> {
2712
+ async function theme_editor_reload() : Promise<void> {
1987
2713
  if (state.themePath) {
1988
2714
  const themeName = state.themeName;
1989
- const themeData = await loadThemeFile(themeName);
2715
+ const themeData = loadThemeFile(themeName);
1990
2716
  if (themeData) {
1991
2717
  state.themeData = deepClone(themeData);
1992
2718
  state.originalThemeData = deepClone(themeData);
@@ -2001,19 +2727,21 @@ globalThis.theme_editor_reload = async function(): Promise<void> {
2001
2727
  updateDisplay();
2002
2728
  editor.setStatus(editor.t("status.reset"));
2003
2729
  }
2004
- };
2730
+ }
2731
+ registerHandler("theme_editor_reload", theme_editor_reload);
2005
2732
 
2006
2733
  /**
2007
2734
  * Show help
2008
2735
  */
2009
- globalThis.theme_editor_show_help = function(): void {
2010
- editor.setStatus(editor.t("status.help"));
2011
- };
2736
+ function theme_editor_show_help() : void {
2737
+ editor.debug(editor.t("status.help"));
2738
+ }
2739
+ registerHandler("theme_editor_show_help", theme_editor_show_help);
2012
2740
 
2013
2741
  /**
2014
2742
  * Delete the current user theme
2015
2743
  */
2016
- globalThis.theme_editor_delete = function(): void {
2744
+ function theme_editor_delete() : void {
2017
2745
  // Can only delete saved user themes
2018
2746
  if (!state.themePath) {
2019
2747
  editor.setStatus(editor.t("status.cannot_delete_unsaved"));
@@ -2027,12 +2755,13 @@ globalThis.theme_editor_delete = function(): void {
2027
2755
  { text: editor.t("prompt.delete_no"), description: "", value: "cancel" },
2028
2756
  ];
2029
2757
  editor.setPromptSuggestions(suggestions);
2030
- };
2758
+ }
2759
+ registerHandler("theme_editor_delete", theme_editor_delete);
2031
2760
 
2032
2761
  /**
2033
2762
  * Handle delete confirmation prompt
2034
2763
  */
2035
- globalThis.onThemeDeletePromptConfirmed = async function(args: {
2764
+ async function onThemeDeletePromptConfirmed(args: {
2036
2765
  prompt_type: string;
2037
2766
  selected_index: number | null;
2038
2767
  input: string;
@@ -2061,11 +2790,12 @@ globalThis.onThemeDeletePromptConfirmed = async function(args: {
2061
2790
  }
2062
2791
  }
2063
2792
  } else {
2064
- editor.setStatus(editor.t("status.cancelled"));
2793
+ editor.debug(editor.t("status.cancelled"));
2065
2794
  }
2066
2795
 
2067
2796
  return true;
2068
- };
2797
+ }
2798
+ registerHandler("onThemeDeletePromptConfirmed", onThemeDeletePromptConfirmed);
2069
2799
 
2070
2800
  // =============================================================================
2071
2801
  // Command Registration
@@ -2091,8 +2821,13 @@ editor.registerCommand("%cmd.show_help", "%cmd.show_help_desc", "theme_editor_sh
2091
2821
  editor.registerCommand("%cmd.delete_theme", "%cmd.delete_theme_desc", "theme_editor_delete", "theme-editor");
2092
2822
  editor.registerCommand("%cmd.nav_up", "%cmd.nav_up_desc", "theme_editor_nav_up", "theme-editor");
2093
2823
  editor.registerCommand("%cmd.nav_down", "%cmd.nav_down_desc", "theme_editor_nav_down", "theme-editor");
2094
- editor.registerCommand("%cmd.nav_next", "%cmd.nav_next_desc", "theme_editor_nav_next_section", "theme-editor");
2095
- editor.registerCommand("%cmd.nav_prev", "%cmd.nav_prev_desc", "theme_editor_nav_prev_section", "theme-editor");
2824
+ editor.registerCommand("%cmd.nav_left", "%cmd.nav_left_desc", "theme_editor_nav_left", "theme-editor");
2825
+ editor.registerCommand("%cmd.nav_right", "%cmd.nav_right_desc", "theme_editor_nav_right", "theme-editor");
2826
+ editor.registerCommand("%cmd.focus_tab", "%cmd.focus_tab_desc", "theme_editor_focus_tab", "theme-editor");
2827
+ editor.registerCommand("%cmd.focus_shift_tab", "%cmd.focus_shift_tab_desc", "theme_editor_focus_shift_tab", "theme-editor");
2828
+ editor.registerCommand("%cmd.enter", "%cmd.enter_desc", "theme_editor_enter", "theme-editor");
2829
+ editor.registerCommand("%cmd.escape", "%cmd.escape_desc", "theme_editor_escape", "theme-editor");
2830
+ editor.registerCommand("%cmd.filter", "%cmd.filter_desc", "theme_editor_filter", "theme-editor");
2096
2831
 
2097
2832
  // =============================================================================
2098
2833
  // Plugin Initialization