@fresh-editor/fresh-editor 0.2.21 → 0.2.23

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.
@@ -62,7 +62,8 @@ type ColorValue = RGB | string;
62
62
  // =============================================================================
63
63
 
64
64
  const LEFT_WIDTH = 38;
65
- const RIGHT_WIDTH = 61;
65
+ const RIGHT_WIDTH_CONST = 61;
66
+ function RIGHT_WIDTH(): number { return RIGHT_WIDTH_CONST; }
66
67
 
67
68
  type PickerFocusTarget =
68
69
  | { type: "hex-input" }
@@ -333,8 +334,10 @@ interface ThemeEditorState {
333
334
  themeData: Record<string, unknown>;
334
335
  /** Original theme data (for change detection) */
335
336
  originalThemeData: Record<string, unknown>;
336
- /** Theme name */
337
+ /** Theme display name */
337
338
  themeName: string;
339
+ /** Theme registry key (for lookups) */
340
+ themeKey: string;
338
341
  /** Theme file path (null for new themes) */
339
342
  themePath: string | null;
340
343
  /** Expanded sections */
@@ -345,8 +348,10 @@ interface ThemeEditorState {
345
348
  selectedIndex: number;
346
349
  /** Whether there are unsaved changes */
347
350
  hasChanges: boolean;
348
- /** Available built-in themes */
349
- builtinThemes: string[];
351
+ /** All themes from registry: key → {name, pack} */
352
+ themeRegistry: Map<string, {name: string; pack: string}>;
353
+ /** Keys of builtin themes (empty pack) */
354
+ builtinKeys: Set<string>;
350
355
  /** Pending save name for overwrite confirmation */
351
356
  pendingSaveName: string | null;
352
357
  /** Whether current theme is a built-in (requires Save As) */
@@ -371,6 +376,10 @@ interface ThemeEditorState {
371
376
  viewportHeight: number;
372
377
  /** Cached viewport width */
373
378
  viewportWidth: number;
379
+ /** Buffer group ID (when using buffer groups) */
380
+ groupId: number | null;
381
+ /** Panel buffer IDs keyed by panel name */
382
+ panelBuffers: Record<string, number>;
374
383
  }
375
384
 
376
385
  /**
@@ -411,13 +420,15 @@ const state: ThemeEditorState = {
411
420
  sourceBufferId: null,
412
421
  themeData: {},
413
422
  originalThemeData: {},
414
- themeName: "custom",
423
+ themeName: "",
424
+ themeKey: "",
415
425
  themePath: null,
416
426
  expandedSections: new Set(["editor", "syntax"]),
417
427
  visibleFields: [],
418
428
  selectedIndex: 0,
419
429
  hasChanges: false,
420
- builtinThemes: [],
430
+ themeRegistry: new Map(),
431
+ builtinKeys: new Set(),
421
432
  pendingSaveName: null,
422
433
  isBuiltin: false,
423
434
  savedCursorPath: null,
@@ -430,30 +441,55 @@ const state: ThemeEditorState = {
430
441
  treeScrollOffset: 0,
431
442
  viewportHeight: 40,
432
443
  viewportWidth: 120,
444
+ groupId: null,
445
+ panelBuffers: {},
433
446
  };
434
447
 
435
448
  // =============================================================================
436
449
  // Color Definitions for UI
437
450
  // =============================================================================
438
451
 
452
+ /**
453
+ * UI palette for the theme editor's own chrome.
454
+ *
455
+ * Each role maps to a theme-key path (e.g. `"syntax.keyword"`). The plugin
456
+ * passes these strings straight through to the core as
457
+ * `OverlayColorSpec::ThemeKey`, and the core's renderer resolves them
458
+ * against the *currently-active* theme on every frame
459
+ * (see `OverlayFace::from_options` → `OverlayFace::ThemedStyle`, and the
460
+ * render-time lookup in `split_rendering.rs`). That means the theme editor
461
+ * inherits the host theme's look and automatically picks up theme switches
462
+ * without the plugin having to rebuild its overlays or even be notified —
463
+ * the `resolve_theme_key` lookup runs with the new theme on the next render.
464
+ *
465
+ * Important: we only use keys that the theme defines as readable on
466
+ * `editor.bg` (since that's the background the theme editor draws over).
467
+ * That rules out `ui.menu_*`, `ui.tab_*`, etc. — those are designed for
468
+ * their own bg pairs (e.g. `ui.menu_active_fg` on `ui.menu_active_bg`) and
469
+ * will clash or go invisible when drawn on `editor.bg` (notoriously in
470
+ * high-contrast, where `ui.menu_active_fg` is pure black). So we only pull
471
+ * from `editor.*` and `syntax.*`, and lean on bold + distinct syntax roles
472
+ * to give each UI element its own visual identity.
473
+ *
474
+ * We don't need a client-side fallback chain: the core's `Theme` struct has
475
+ * serde defaults for every field, so `resolve_theme_key` always returns a
476
+ * value for any key listed here — a stub theme file can omit them and the
477
+ * defaults still apply.
478
+ */
439
479
  const colors = {
440
- sectionHeader: [255, 200, 100] as RGB, // Gold
441
- fieldName: [200, 200, 255] as RGB, // Light blue
442
- defaultValue: [150, 150, 150] as RGB, // Gray
443
- customValue: [100, 255, 100] as RGB, // Green
444
- description: [120, 120, 120] as RGB, // Dim gray
445
- modified: [255, 255, 100] as RGB, // Yellow
446
- footer: [100, 100, 100] as RGB, // Gray
447
- colorBlock: [200, 200, 200] as RGB, // Light gray for color swatch outline
448
- selectionBg: [50, 50, 80] as RGB, // Dark blue-gray for selected field
449
- divider: [60, 60, 80] as RGB, // Muted divider color
450
- header: [100, 180, 255] as RGB, // Header blue
451
- pickerLabel: [180, 180, 200] as RGB, // Picker section labels
452
- pickerValue: [255, 255, 255] as RGB, // Picker value text
453
- pickerFocusBg: [40, 60, 100] as RGB, // Picker focused item bg
454
- filterText: [200, 200, 100] as RGB, // Filter input text
455
- previewBg: [25, 25, 30] as RGB, // Preview background
456
- };
480
+ sectionHeader: "syntax.keyword",
481
+ fieldName: "editor.fg",
482
+ customValue: "syntax.string",
483
+ description: "syntax.comment",
484
+ footer: "editor.line_number_fg",
485
+ selectionBg: "editor.selection_bg",
486
+ divider: "editor.line_number_fg",
487
+ header: "syntax.keyword",
488
+ pickerLabel: "editor.fg",
489
+ pickerValue: "syntax.constant",
490
+ pickerFocusBg: "editor.selection_bg",
491
+ filterText: "syntax.function",
492
+ } as const satisfies Record<string, OverlayColorSpec>;
457
493
 
458
494
  // =============================================================================
459
495
  // Keyboard Shortcuts (defined once, used in mode and i18n)
@@ -669,33 +705,50 @@ function findThemesDir(): string {
669
705
  /**
670
706
  * Load list of available built-in themes
671
707
  */
672
- async function loadBuiltinThemes(): Promise<string[]> {
708
+ /**
709
+ * Load all themes from the registry, returning a map of key → display name.
710
+ *
711
+ * The registry is keyed by unique keys (repo URLs, file:// paths, or bare
712
+ * names for builtins). Each value contains a `name` field (display name)
713
+ * and `_key`/`_pack` metadata.
714
+ */
715
+ /**
716
+ * Load theme registry and populate state.themeRegistry + state.builtinKeys.
717
+ */
718
+ async function loadThemeRegistry(): Promise<void> {
673
719
  try {
674
- editor.debug("[theme_editor] loadBuiltinThemes: calling editor.getBuiltinThemes()");
720
+ editor.debug("[theme_editor] loadThemeRegistry: calling editor.getBuiltinThemes()");
675
721
  const rawThemes = editor.getBuiltinThemes();
676
- editor.debug(`[theme_editor] loadBuiltinThemes: got rawThemes type=${typeof rawThemes}`);
677
- // getBuiltinThemes returns a JSON string, need to parse it
678
- const builtinThemes = typeof rawThemes === "string"
679
- ? JSON.parse(rawThemes) as Record<string, string>
680
- : rawThemes as Record<string, string>;
681
- editor.debug(`[theme_editor] loadBuiltinThemes: parsed ${Object.keys(builtinThemes).length} themes`);
682
- return Object.keys(builtinThemes);
722
+ const themes = typeof rawThemes === "string"
723
+ ? JSON.parse(rawThemes) as Record<string, Record<string, unknown>>
724
+ : rawThemes as Record<string, Record<string, unknown>>;
725
+ state.themeRegistry = new Map();
726
+ state.builtinKeys = new Set();
727
+ for (const [key, data] of Object.entries(themes)) {
728
+ const name = (data?.name as string) || key;
729
+ const pack = (data?._pack as string) || "";
730
+ state.themeRegistry.set(key, {name, pack});
731
+ // Builtin themes have an empty pack; user themes start with "user"
732
+ if (!pack || (!pack.startsWith("user") && !pack.startsWith("pkg"))) {
733
+ state.builtinKeys.add(key);
734
+ }
735
+ }
736
+ editor.debug(`[theme_editor] loadThemeRegistry: loaded ${state.themeRegistry.size} themes (${state.builtinKeys.size} builtin)`);
683
737
  } catch (e) {
684
- editor.debug(`[theme_editor] Failed to load built-in themes list: ${e}`);
738
+ editor.debug(`[theme_editor] Failed to load theme registry: ${e}`);
685
739
  throw e;
686
740
  }
687
741
  }
688
742
 
689
743
  /**
690
- * Load theme data by name from the in-memory theme registry.
691
- * Works for all theme types (builtin, user, package) — no file I/O needed.
744
+ * Load theme data by key from the in-memory theme registry.
692
745
  */
693
- function loadThemeFile(name: string): Record<string, unknown> | null {
746
+ function loadThemeFile(key: string): Record<string, unknown> | null {
694
747
  try {
695
- const data = editor.getThemeData(name);
748
+ const data = editor.getThemeData(key);
696
749
  return data as Record<string, unknown> | null;
697
750
  } catch (e) {
698
- editor.debug(`[theme_editor] Failed to load theme data for '${name}': ${e}`);
751
+ editor.debug(`[theme_editor] Failed to load theme data for '${key}': ${e}`);
699
752
  return null;
700
753
  }
701
754
  }
@@ -793,8 +846,8 @@ function buildTreeLines(): TreeLine[] {
793
846
  type: "header",
794
847
  });
795
848
 
796
- // Separator
797
- lines.push({ text: "─".repeat(36), type: "separator" });
849
+ // Separator (adapt to panel width)
850
+ lines.push({ text: "─".repeat(Math.max(10, LEFT_WIDTH - 2)), type: "separator" });
798
851
 
799
852
  // Filter
800
853
  if (state.filterText) {
@@ -802,7 +855,7 @@ function buildTreeLines(): TreeLine[] {
802
855
  text: `Filter: [${state.filterText}]`,
803
856
  type: "filter",
804
857
  });
805
- lines.push({ text: "─".repeat(36), type: "separator" });
858
+ lines.push({ text: "─".repeat(Math.max(10, LEFT_WIDTH - 2)), type: "separator" });
806
859
  }
807
860
 
808
861
  // Build visible fields
@@ -829,11 +882,15 @@ function buildTreeLines(): TreeLine[] {
829
882
  });
830
883
  } else {
831
884
  const sel = isSelected && state.focusPanel === "tree" ? "▸" : " ";
832
- const name = field.def.key.length > 13 ? field.def.key.slice(0, 12) + "…" : field.def.key;
885
+ // Adapt name/value truncation to panel width
886
+ // Layout: " ▸ name.padEnd(nameW) ██ value" = 6 + nameW + 3 + valueW
887
+ const nameW = Math.max(8, LEFT_WIDTH - 18);
888
+ const valueW = Math.max(5, LEFT_WIDTH - nameW - 9);
889
+ const name = field.def.key.length > nameW ? field.def.key.slice(0, nameW - 1) + "…" : field.def.key;
833
890
  const colorStr = formatColorValue(field.value);
834
- const valueStr = colorStr.length > 9 ? colorStr.slice(0, 8) + "…" : colorStr;
891
+ const valueStr = colorStr.length > valueW ? colorStr.slice(0, valueW - 1) + "…" : colorStr;
835
892
  lines.push({
836
- text: ` ${sel} ${name.padEnd(13)} ██ ${valueStr}`,
893
+ text: ` ${sel} ${name.padEnd(nameW)} ██ ${valueStr}`,
837
894
  type: "tree-field",
838
895
  index: i,
839
896
  path: field.path,
@@ -870,7 +927,7 @@ function buildPickerLines(): PickerLine[] {
870
927
  } else {
871
928
  lines.push({ text: "No field selected", type: "picker-title" });
872
929
  }
873
- lines.push({ text: "─".repeat(RIGHT_WIDTH - 2), type: "picker-separator" });
930
+ lines.push({ text: "─".repeat(RIGHT_WIDTH() - 2), type: "picker-separator" });
874
931
  lines.push({ text: "Select a color field to edit", type: "picker-desc" });
875
932
  return lines;
876
933
  }
@@ -878,7 +935,7 @@ function buildPickerLines(): PickerLine[] {
878
935
  // Field title
879
936
  lines.push({ text: `${field.path} - ${field.def.displayName}`, type: "picker-title" });
880
937
  lines.push({ text: `"${field.def.description}"`, type: "picker-desc" });
881
- lines.push({ text: "─".repeat(RIGHT_WIDTH - 2), type: "picker-separator" });
938
+ lines.push({ text: "─".repeat(RIGHT_WIDTH() - 2), type: "picker-separator" });
882
939
 
883
940
  // Color value display
884
941
  const isNamed = typeof field.value === "string" && NAMED_COLORS[field.value] !== undefined;
@@ -919,7 +976,7 @@ function buildPickerLines(): PickerLine[] {
919
976
  lines.push({ text: rowText, type: "picker-palette-row", paletteRow: row });
920
977
  }
921
978
 
922
- lines.push({ text: "─".repeat(RIGHT_WIDTH - 2), type: "picker-separator" });
979
+ lines.push({ text: "─".repeat(RIGHT_WIDTH() - 2), type: "picker-separator" });
923
980
 
924
981
  // Preview section
925
982
  lines.push({ text: "Preview:", type: "picker-label" });
@@ -1218,7 +1275,7 @@ function addBackgroundHighlight(
1218
1275
  bufferId: number,
1219
1276
  start: number,
1220
1277
  end: number,
1221
- bgColor: RGB
1278
+ bgColor: OverlayColorSpec
1222
1279
  ): void {
1223
1280
  editor.addOverlay(bufferId, "theme-sel", start, end, { bg: bgColor });
1224
1281
  }
@@ -1329,10 +1386,99 @@ let isUpdatingDisplay = false;
1329
1386
  * Full display update — rebuilds content and all overlays.
1330
1387
  * Use for structural changes (open, section toggle, color edit, filter).
1331
1388
  */
1389
+ // --- Buffer Group panel content builders ---
1390
+
1391
+ function buildTreePanelEntries(): TextPropertyEntry[] {
1392
+ const entries: TextPropertyEntry[] = [];
1393
+ const allLeftLines = buildTreeLines();
1394
+ for (const item of allLeftLines) {
1395
+ const leftStyle = styleForLeftEntry(item);
1396
+ entries.push({
1397
+ text: " " + item.text + "\n",
1398
+ properties: {
1399
+ type: item.type,
1400
+ index: item.index,
1401
+ path: item.path,
1402
+ selected: item.selected,
1403
+ colorValue: item.colorValue,
1404
+ },
1405
+ style: leftStyle.style,
1406
+ inlineOverlays: leftStyle.inlineOverlays,
1407
+ });
1408
+ }
1409
+ return entries;
1410
+ }
1411
+
1412
+ function buildPickerPanelEntries(): TextPropertyEntry[] {
1413
+ const entries: TextPropertyEntry[] = [];
1414
+ const rightLines = buildPickerLines();
1415
+ for (const item of rightLines) {
1416
+ const rightStyle = styleForRightEntry(item);
1417
+ entries.push({
1418
+ text: " " + item.text + "\n",
1419
+ properties: {
1420
+ type: item.type,
1421
+ namedRow: item.namedRow,
1422
+ paletteRow: item.paletteRow,
1423
+ previewLineIdx: item.previewLineIdx,
1424
+ },
1425
+ style: rightStyle.style,
1426
+ inlineOverlays: rightStyle.inlineOverlays,
1427
+ });
1428
+ }
1429
+ return entries;
1430
+ }
1431
+
1432
+ function buildFooterPanelEntries(): TextPropertyEntry[] {
1433
+ const hintText = " ↑↓ Navigate Tab Switch Panel Enter Edit /Filter Ctrl+S Save Esc Close";
1434
+ return [{ text: hintText + "\n", style: { fg: colors.header } }];
1435
+ }
1436
+
1332
1437
  function updateDisplay(): void {
1333
- if (state.bufferId === null) return;
1334
1438
  isUpdatingDisplay = true;
1335
1439
 
1440
+ // Always refresh viewport dimensions
1441
+ const viewport = editor.getViewport();
1442
+ if (viewport) {
1443
+ state.viewportHeight = viewport.height;
1444
+ state.viewportWidth = viewport.width;
1445
+ }
1446
+
1447
+ // Buffer group mode: write to each panel separately
1448
+ if (state.groupId !== null) {
1449
+ editor.setPanelContent(state.groupId, "tree", buildTreePanelEntries());
1450
+ editor.setPanelContent(state.groupId, "picker", buildPickerPanelEntries());
1451
+ editor.setPanelContent(state.groupId, "footer", buildFooterPanelEntries());
1452
+
1453
+ // Keep the selected tree row in view. The plugin's `selectedIndex`
1454
+ // navigation doesn't move the buffer cursor, so without this the
1455
+ // core-driven panel viewport would stay at the top even after many
1456
+ // Down-arrow presses, causing the `▸` marker to scroll off-screen
1457
+ // for sections with many fields.
1458
+ const treeBufferId = state.panelBuffers["tree"];
1459
+ if (typeof treeBufferId === "number") {
1460
+ const treeLines = buildTreeLines();
1461
+ let selectedLine = -1;
1462
+ for (let i = 0; i < treeLines.length; i++) {
1463
+ if (treeLines[i].index === state.selectedIndex && treeLines[i].selected) {
1464
+ selectedLine = i;
1465
+ break;
1466
+ }
1467
+ }
1468
+ if (selectedLine >= 0) {
1469
+ editor.scrollBufferToLine(treeBufferId, selectedLine);
1470
+ }
1471
+ }
1472
+
1473
+ isUpdatingDisplay = false;
1474
+ return;
1475
+ }
1476
+
1477
+ if (state.bufferId === null) {
1478
+ isUpdatingDisplay = false;
1479
+ return;
1480
+ }
1481
+
1336
1482
  const entries = buildDisplayEntries();
1337
1483
 
1338
1484
  // Clear selection overlays BEFORE replacing content to prevent stale
@@ -1509,11 +1655,7 @@ function onThemeColorPromptConfirmed(args: {
1509
1655
  setNestedValue(state.themeData, path, result.value);
1510
1656
  state.hasChanges = !deepEqual(state.themeData, state.originalThemeData);
1511
1657
 
1512
- const entries = buildDisplayEntries();
1513
- if (state.bufferId !== null) {
1514
- editor.setVirtualBufferContent(state.bufferId, entries);
1515
- applySelectionHighlighting(entries);
1516
- }
1658
+ updateDisplay();
1517
1659
  moveCursorToField(path);
1518
1660
  editor.setStatus(editor.t("status.updated", { path }));
1519
1661
  } else {
@@ -1559,28 +1701,17 @@ async function onThemeOpenPromptConfirmed(args: {
1559
1701
  }): Promise<boolean> {
1560
1702
  if (args.prompt_type !== "theme-open") return true;
1561
1703
 
1562
- const value = args.input.trim();
1563
-
1564
- // Parse the value to determine if it's user or builtin
1565
- let isBuiltin = false;
1566
- let themeName = value;
1704
+ const key = args.input.trim();
1705
+ const isBuiltin = state.builtinKeys.has(key);
1706
+ const entry = state.themeRegistry.get(key);
1707
+ const themeName = entry?.name || key;
1567
1708
 
1568
- if (value.startsWith("user:")) {
1569
- themeName = value.slice(5);
1570
- isBuiltin = false;
1571
- } else if (value.startsWith("builtin:")) {
1572
- themeName = value.slice(8);
1573
- isBuiltin = true;
1574
- } else {
1575
- // Fallback: check if it's a builtin theme
1576
- isBuiltin = state.builtinThemes.includes(value);
1577
- }
1578
-
1579
- const themeData = loadThemeFile(themeName);
1709
+ const themeData = loadThemeFile(key);
1580
1710
  if (themeData) {
1581
1711
  state.themeData = deepClone(themeData);
1582
1712
  state.originalThemeData = deepClone(themeData);
1583
1713
  state.themeName = themeName;
1714
+ state.themeKey = key;
1584
1715
  state.themePath = null;
1585
1716
  state.isBuiltin = isBuiltin;
1586
1717
  state.hasChanges = false;
@@ -1623,7 +1754,7 @@ async function onThemeSaveAsPromptConfirmed(args: {
1623
1754
  state.saveAsPreFilled = false;
1624
1755
 
1625
1756
  // Reject names that match a built-in theme
1626
- if (state.builtinThemes.includes(name)) {
1757
+ if (state.builtinKeys.has(name)) {
1627
1758
  editor.startPromptWithInitial(editor.t("prompt.save_as_builtin_error"), "theme-save-as", name);
1628
1759
  editor.setPromptSuggestions([{
1629
1760
  text: state.themeName,
@@ -1693,30 +1824,19 @@ async function onThemeSelectInitialPromptConfirmed(args: {
1693
1824
  }
1694
1825
  editor.debug(`[theme_editor] prompt_type matched, processing selection...`);
1695
1826
 
1696
- const value = args.input.trim();
1697
-
1698
- // Parse the value to determine if it's user or builtin
1699
- let isBuiltin = false;
1700
- let themeName = value;
1701
-
1702
- if (value.startsWith("user:")) {
1703
- themeName = value.slice(5);
1704
- isBuiltin = false;
1705
- } else if (value.startsWith("builtin:")) {
1706
- themeName = value.slice(8);
1707
- isBuiltin = true;
1708
- } else {
1709
- // Fallback: check if it's a builtin theme
1710
- isBuiltin = state.builtinThemes.includes(value);
1711
- }
1827
+ const key = args.input.trim();
1828
+ const isBuiltin = state.builtinKeys.has(key);
1829
+ const entry = state.themeRegistry.get(key);
1830
+ const themeName = entry?.name || key;
1712
1831
 
1713
1832
  editor.debug(editor.t("status.loading"));
1714
1833
 
1715
- const themeData = loadThemeFile(themeName);
1834
+ const themeData = loadThemeFile(key);
1716
1835
  if (themeData) {
1717
1836
  state.themeData = deepClone(themeData);
1718
1837
  state.originalThemeData = deepClone(themeData);
1719
1838
  state.themeName = themeName;
1839
+ state.themeKey = key;
1720
1840
  state.themePath = null;
1721
1841
  state.isBuiltin = isBuiltin;
1722
1842
  state.hasChanges = false;
@@ -1758,6 +1878,11 @@ async function saveTheme(name?: string, restorePath?: string | null): Promise<bo
1758
1878
  // (must match Rust's normalize_theme_name so config name matches filename)
1759
1879
  const themeName = (name || state.themeName).toLowerCase().replace(/[_ ]/g, "-");
1760
1880
 
1881
+ if (!themeName) {
1882
+ editor.setStatus(editor.t("status.save_failed", { error: "No theme name" }));
1883
+ return false;
1884
+ }
1885
+
1761
1886
  try {
1762
1887
  // Build a complete theme object from all known fields.
1763
1888
  // This ensures we always write every field, even if state.themeData
@@ -1781,16 +1906,13 @@ async function saveTheme(name?: string, restorePath?: string | null): Promise<bo
1781
1906
 
1782
1907
  state.themePath = savedPath;
1783
1908
  state.themeName = themeName;
1909
+ state.themeKey = `file://${savedPath}`;
1784
1910
  state.isBuiltin = false; // After saving, it's now a user theme
1785
1911
  state.originalThemeData = deepClone(state.themeData);
1786
1912
  state.hasChanges = false;
1787
1913
 
1788
1914
  // Update display
1789
- const entries = buildDisplayEntries();
1790
- if (state.bufferId !== null) {
1791
- editor.setVirtualBufferContent(state.bufferId, entries);
1792
- applySelectionHighlighting(entries);
1793
- }
1915
+ updateDisplay();
1794
1916
 
1795
1917
  // Restore cursor position if provided
1796
1918
  if (restorePath) {
@@ -1896,7 +2018,17 @@ function onThemeEditorCursorMoved(data: {
1896
2018
  new_position: number;
1897
2019
  text_properties?: Array<Record<string, any>>;
1898
2020
  }): void {
1899
- if (state.bufferId === null || data.buffer_id !== state.bufferId) return;
2021
+ if (state.bufferId === null) return;
2022
+ // Accept cursor_moved events for any of the buffer group's panels
2023
+ // (tree, picker, footer). With buffer groups each panel is its own
2024
+ // buffer, so clicks in the picker fire cursor_moved for the picker
2025
+ // buffer — not the tree buffer (state.bufferId). We must handle
2026
+ // events for all of them so picker clicks (named colors, palette,
2027
+ // hex) still update selection/colors.
2028
+ const groupBufferIds = Object.values(state.panelBuffers || {});
2029
+ const isGroupBuffer =
2030
+ data.buffer_id === state.bufferId || groupBufferIds.includes(data.buffer_id);
2031
+ if (!isGroupBuffer) return;
1900
2032
  if (isUpdatingDisplay) return;
1901
2033
 
1902
2034
  const props = data.text_properties || [];
@@ -1967,20 +2099,6 @@ function onThemeEditorResize(data: { width: number; height: number }): void {
1967
2099
  registerHandler("onThemeEditorResize", onThemeEditorResize);
1968
2100
  editor.on("resize", "onThemeEditorResize");
1969
2101
 
1970
- function onThemeEditorMouseScroll(data: { buffer_id: number; delta: number; col: number; row: number }): void {
1971
- if (state.bufferId === null || data.buffer_id !== state.bufferId) return;
1972
-
1973
- // Only scroll the tree when mouse is over the left panel area (col < LEFT_WIDTH)
1974
- if (data.col >= LEFT_WIDTH) return;
1975
-
1976
- // delta > 0 = scroll down, delta < 0 = scroll up
1977
- const scrollAmount = data.delta > 0 ? 3 : -3;
1978
- state.treeScrollOffset = Math.max(0, state.treeScrollOffset + scrollAmount);
1979
- updateDisplay();
1980
- }
1981
- registerHandler("onThemeEditorMouseScroll", onThemeEditorMouseScroll);
1982
- editor.on("mouse_scroll", "onThemeEditorMouseScroll");
1983
-
1984
2102
  /**
1985
2103
  * Handle buffer_closed event to reset state when buffer is closed by any means
1986
2104
  */
@@ -2029,20 +2147,24 @@ async function onThemeInspectKey(data: {
2029
2147
  // Save context
2030
2148
  state.sourceSplitId = editor.getActiveSplitId();
2031
2149
  state.sourceBufferId = editor.getActiveBufferId();
2032
- state.builtinThemes = await loadBuiltinThemes();
2033
-
2034
- // Auto-load the current theme (builtin or user)
2035
- const isBuiltin = state.builtinThemes.includes(data.theme_name);
2036
- const themeData = loadThemeFile(data.theme_name);
2150
+ await loadThemeRegistry();
2151
+
2152
+ // Auto-load the current theme (data.theme_name is the config key)
2153
+ const themeKey = data.theme_name;
2154
+ const isBuiltin = state.builtinKeys.has(themeKey);
2155
+ const entry = state.themeRegistry.get(themeKey);
2156
+ const themeName = entry?.name || themeKey;
2157
+ const themeData = loadThemeFile(themeKey);
2037
2158
  if (themeData) {
2038
2159
  state.themeData = deepClone(themeData);
2039
2160
  state.originalThemeData = deepClone(themeData);
2040
- state.themeName = data.theme_name;
2161
+ state.themeName = themeName;
2162
+ state.themeKey = themeKey;
2041
2163
  state.themePath = null;
2042
2164
  state.isBuiltin = isBuiltin;
2043
2165
  state.hasChanges = false;
2044
2166
  } else {
2045
- editor.setStatus(`Failed to load theme '${data.theme_name}'`);
2167
+ editor.setStatus(`Failed to load theme '${themeName}'`);
2046
2168
  return;
2047
2169
  }
2048
2170
 
@@ -2444,46 +2566,35 @@ async function open_theme_editor() : Promise<void> {
2444
2566
 
2445
2567
  editor.debug("[theme_editor] loading builtin themes...");
2446
2568
  // Load available themes
2447
- state.builtinThemes = await loadBuiltinThemes();
2448
- editor.debug(`[theme_editor] loaded ${state.builtinThemes.length} builtin themes`);
2569
+ await loadThemeRegistry();
2570
+ editor.debug(`[theme_editor] loaded ${state.themeRegistry.size} themes (${state.builtinKeys.size} builtin)`);
2449
2571
 
2450
- // Get current theme name from config
2572
+ // Get current theme key from config
2451
2573
  const config = editor.getConfig() as Record<string, unknown>;
2452
- const currentThemeName = (config?.theme as string) || "dark";
2574
+ const currentThemeKey = (config?.theme as string) || "dark";
2453
2575
 
2454
2576
  // Prompt user to select which theme to edit
2455
2577
  editor.startPrompt(editor.t("prompt.select_theme_to_edit"), "theme-select-initial");
2456
2578
 
2457
2579
  const suggestions: PromptSuggestion[] = [];
2458
2580
 
2459
- // Add user themes first (from themes directory)
2460
- const userThemesDir = editor.getThemesDir();
2461
- try {
2462
- const entries = editor.readDir(userThemesDir);
2463
- for (const e of entries) {
2464
- if (e.is_file && e.name.endsWith(".json")) {
2465
- const name = e.name.replace(".json", "");
2466
- const isCurrent = name === currentThemeName;
2467
- suggestions.push({
2468
- text: name,
2469
- description: isCurrent ? editor.t("suggestion.user_theme_current") : editor.t("suggestion.user_theme"),
2470
- value: `user:${name}`,
2471
- });
2472
- }
2581
+ // Build suggestions from theme registry (user themes first, then builtins)
2582
+ const userSuggestions: PromptSuggestion[] = [];
2583
+ const builtinSuggestions: PromptSuggestion[] = [];
2584
+ for (const [key, {name}] of state.themeRegistry) {
2585
+ const isCurrent = key === currentThemeKey || name === currentThemeKey;
2586
+ const isBuiltin = state.builtinKeys.has(key);
2587
+ const desc = isBuiltin
2588
+ ? (isCurrent ? editor.t("suggestion.builtin_theme_current") : editor.t("suggestion.builtin_theme"))
2589
+ : (isCurrent ? editor.t("suggestion.user_theme_current") : editor.t("suggestion.user_theme"));
2590
+ const suggestion = { text: name, description: desc, value: key };
2591
+ if (isBuiltin) {
2592
+ builtinSuggestions.push(suggestion);
2593
+ } else {
2594
+ userSuggestions.push(suggestion);
2473
2595
  }
2474
- } catch {
2475
- // No user themes directory
2476
- }
2477
-
2478
- // Add built-in themes
2479
- for (const name of state.builtinThemes) {
2480
- const isCurrent = name === currentThemeName;
2481
- suggestions.push({
2482
- text: name,
2483
- description: isCurrent ? editor.t("suggestion.builtin_theme_current") : editor.t("suggestion.builtin_theme"),
2484
- value: `builtin:${name}`,
2485
- });
2486
2596
  }
2597
+ suggestions.push(...userSuggestions, ...builtinSuggestions);
2487
2598
 
2488
2599
  // Sort suggestions to put current theme first
2489
2600
  suggestions.sort((a, b) => {
@@ -2510,38 +2621,30 @@ async function doOpenThemeEditor(): Promise<void> {
2510
2621
  }
2511
2622
  state.treeScrollOffset = 0;
2512
2623
 
2513
- editor.debug("[theme_editor] doOpenThemeEditor: building display entries");
2514
- // Build initial entries
2515
- const entries = buildDisplayEntries();
2516
- editor.debug(`[theme_editor] doOpenThemeEditor: built ${entries.length} entries`);
2517
-
2518
- // Create virtual buffer in current split (no new split)
2519
- editor.debug("[theme_editor] doOpenThemeEditor: calling createVirtualBuffer...");
2520
- const result = await editor.createVirtualBuffer({
2521
- name: "*Theme Editor*",
2522
- mode: "theme-editor",
2523
- readOnly: true,
2524
- entries: entries,
2525
- showLineNumbers: false,
2526
- showCursors: false,
2527
- editingDisabled: true,
2624
+ // Create buffer group with layout: horizontal split (tree | picker) + footer
2625
+ const layout = JSON.stringify({
2626
+ type: "split",
2627
+ direction: "v",
2628
+ ratio: 0.95,
2629
+ first: {
2630
+ type: "split",
2631
+ direction: "h",
2632
+ ratio: 0.38,
2633
+ first: { type: "scrollable", id: "tree" },
2634
+ second: { type: "scrollable", id: "picker" },
2635
+ },
2636
+ second: { type: "fixed", id: "footer", height: 1 },
2528
2637
  });
2529
- const bufferId = result.bufferId;
2530
- editor.debug(`[theme_editor] doOpenThemeEditor: createVirtualBuffer returned bufferId=${bufferId}`);
2531
- editor.debug(`[theme_editor] doOpenThemeEditor: checking if bufferId !== null...`);
2532
2638
 
2533
- if (bufferId !== null) {
2534
- editor.debug(`[theme_editor] doOpenThemeEditor: bufferId is not null, setting state...`);
2535
- state.bufferId = bufferId;
2536
- state.splitId = null;
2537
-
2538
- // Disable line wrapping — our layout is fixed-width
2539
- editor.setLineWrap(bufferId, null, false);
2639
+ const groupResult = await editor.createBufferGroup("*Theme Editor*", "theme-editor", layout);
2640
+ state.groupId = groupResult.groupId;
2641
+ state.panelBuffers = groupResult.panels;
2642
+ state.bufferId = groupResult.panels["tree"]; // representative buffer
2643
+ state.splitId = null;
2540
2644
 
2541
- editor.debug(`[theme_editor] doOpenThemeEditor: calling applySelectionHighlighting...`);
2542
- applySelectionHighlighting();
2543
- editor.debug(`[theme_editor] doOpenThemeEditor: applySelectionHighlighting completed`);
2544
- editor.debug(`[theme_editor] doOpenThemeEditor: calling setStatus...`);
2645
+ if (state.bufferId !== null) {
2646
+ // Set initial content for all panels
2647
+ updateDisplay();
2545
2648
  editor.debug(editor.t("status.ready"));
2546
2649
  editor.debug(`[theme_editor] doOpenThemeEditor: completed successfully`);
2547
2650
  } else {
@@ -2575,8 +2678,12 @@ registerHandler("theme_editor_close", theme_editor_close);
2575
2678
  * Actually close the editor (called after confirmation or when no changes)
2576
2679
  */
2577
2680
  function doCloseEditor(): void {
2578
- // Close the buffer (this will switch to another buffer in the same split)
2579
- if (state.bufferId !== null) {
2681
+ // Close the buffer group (or fall back to single buffer close)
2682
+ if (state.groupId !== null) {
2683
+ editor.closeBufferGroup(state.groupId);
2684
+ state.groupId = null;
2685
+ state.panelBuffers = {};
2686
+ } else if (state.bufferId !== null) {
2580
2687
  editor.closeBuffer(state.bufferId);
2581
2688
  }
2582
2689
 
@@ -2667,32 +2774,20 @@ function theme_editor_open() : void {
2667
2774
 
2668
2775
  const suggestions: PromptSuggestion[] = [];
2669
2776
 
2670
- // Add user themes first (from themes directory)
2671
- const userThemesDir = editor.getThemesDir();
2672
- try {
2673
- const entries = editor.readDir(userThemesDir);
2674
- for (const e of entries) {
2675
- if (e.is_file && e.name.endsWith(".json")) {
2676
- const name = e.name.replace(".json", "");
2677
- suggestions.push({
2678
- text: name,
2679
- description: editor.t("suggestion.user_theme"),
2680
- value: `user:${name}`,
2681
- });
2682
- }
2777
+ // Build suggestions from theme registry (user themes first, then builtins)
2778
+ const userSuggestions: PromptSuggestion[] = [];
2779
+ const builtinSuggestions: PromptSuggestion[] = [];
2780
+ for (const [key, {name}] of state.themeRegistry) {
2781
+ const isBuiltin = state.builtinKeys.has(key);
2782
+ const desc = isBuiltin ? editor.t("suggestion.builtin_theme") : editor.t("suggestion.user_theme");
2783
+ const suggestion = { text: name, description: desc, value: key };
2784
+ if (isBuiltin) {
2785
+ builtinSuggestions.push(suggestion);
2786
+ } else {
2787
+ userSuggestions.push(suggestion);
2683
2788
  }
2684
- } catch {
2685
- // No user themes directory
2686
- }
2687
-
2688
- // Add built-in themes
2689
- for (const name of state.builtinThemes) {
2690
- suggestions.push({
2691
- text: name,
2692
- description: editor.t("suggestion.builtin_theme"),
2693
- value: `builtin:${name}`,
2694
- });
2695
2789
  }
2790
+ suggestions.push(...userSuggestions, ...builtinSuggestions);
2696
2791
 
2697
2792
  editor.setPromptSuggestions(suggestions);
2698
2793
  }
@@ -2799,8 +2894,7 @@ registerHandler("theme_editor_save_as", theme_editor_save_as);
2799
2894
  */
2800
2895
  async function theme_editor_reload() : Promise<void> {
2801
2896
  if (state.themePath) {
2802
- const themeName = state.themeName;
2803
- const themeData = loadThemeFile(themeName);
2897
+ const themeData = loadThemeFile(state.themeKey);
2804
2898
  if (themeData) {
2805
2899
  state.themeData = deepClone(themeData);
2806
2900
  state.originalThemeData = deepClone(themeData);
@@ -2867,7 +2961,8 @@ async function onThemeDeletePromptConfirmed(args: {
2867
2961
  // Reset to default theme
2868
2962
  state.themeData = createDefaultTheme();
2869
2963
  state.originalThemeData = deepClone(state.themeData);
2870
- state.themeName = "custom";
2964
+ state.themeName = "";
2965
+ state.themeKey = "";
2871
2966
  state.themePath = null;
2872
2967
  state.hasChanges = false;
2873
2968
  updateDisplay();