@fresh-editor/fresh-editor 0.2.22 → 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" }
@@ -375,6 +376,10 @@ interface ThemeEditorState {
375
376
  viewportHeight: number;
376
377
  /** Cached viewport width */
377
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>;
378
383
  }
379
384
 
380
385
  /**
@@ -436,30 +441,55 @@ const state: ThemeEditorState = {
436
441
  treeScrollOffset: 0,
437
442
  viewportHeight: 40,
438
443
  viewportWidth: 120,
444
+ groupId: null,
445
+ panelBuffers: {},
439
446
  };
440
447
 
441
448
  // =============================================================================
442
449
  // Color Definitions for UI
443
450
  // =============================================================================
444
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
+ */
445
479
  const colors = {
446
- sectionHeader: [255, 200, 100] as RGB, // Gold
447
- fieldName: [200, 200, 255] as RGB, // Light blue
448
- defaultValue: [150, 150, 150] as RGB, // Gray
449
- customValue: [100, 255, 100] as RGB, // Green
450
- description: [120, 120, 120] as RGB, // Dim gray
451
- modified: [255, 255, 100] as RGB, // Yellow
452
- footer: [100, 100, 100] as RGB, // Gray
453
- colorBlock: [200, 200, 200] as RGB, // Light gray for color swatch outline
454
- selectionBg: [50, 50, 80] as RGB, // Dark blue-gray for selected field
455
- divider: [60, 60, 80] as RGB, // Muted divider color
456
- header: [100, 180, 255] as RGB, // Header blue
457
- pickerLabel: [180, 180, 200] as RGB, // Picker section labels
458
- pickerValue: [255, 255, 255] as RGB, // Picker value text
459
- pickerFocusBg: [40, 60, 100] as RGB, // Picker focused item bg
460
- filterText: [200, 200, 100] as RGB, // Filter input text
461
- previewBg: [25, 25, 30] as RGB, // Preview background
462
- };
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>;
463
493
 
464
494
  // =============================================================================
465
495
  // Keyboard Shortcuts (defined once, used in mode and i18n)
@@ -816,8 +846,8 @@ function buildTreeLines(): TreeLine[] {
816
846
  type: "header",
817
847
  });
818
848
 
819
- // Separator
820
- 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" });
821
851
 
822
852
  // Filter
823
853
  if (state.filterText) {
@@ -825,7 +855,7 @@ function buildTreeLines(): TreeLine[] {
825
855
  text: `Filter: [${state.filterText}]`,
826
856
  type: "filter",
827
857
  });
828
- lines.push({ text: "─".repeat(36), type: "separator" });
858
+ lines.push({ text: "─".repeat(Math.max(10, LEFT_WIDTH - 2)), type: "separator" });
829
859
  }
830
860
 
831
861
  // Build visible fields
@@ -852,11 +882,15 @@ function buildTreeLines(): TreeLine[] {
852
882
  });
853
883
  } else {
854
884
  const sel = isSelected && state.focusPanel === "tree" ? "▸" : " ";
855
- 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;
856
890
  const colorStr = formatColorValue(field.value);
857
- const valueStr = colorStr.length > 9 ? colorStr.slice(0, 8) + "…" : colorStr;
891
+ const valueStr = colorStr.length > valueW ? colorStr.slice(0, valueW - 1) + "…" : colorStr;
858
892
  lines.push({
859
- text: ` ${sel} ${name.padEnd(13)} ██ ${valueStr}`,
893
+ text: ` ${sel} ${name.padEnd(nameW)} ██ ${valueStr}`,
860
894
  type: "tree-field",
861
895
  index: i,
862
896
  path: field.path,
@@ -893,7 +927,7 @@ function buildPickerLines(): PickerLine[] {
893
927
  } else {
894
928
  lines.push({ text: "No field selected", type: "picker-title" });
895
929
  }
896
- lines.push({ text: "─".repeat(RIGHT_WIDTH - 2), type: "picker-separator" });
930
+ lines.push({ text: "─".repeat(RIGHT_WIDTH() - 2), type: "picker-separator" });
897
931
  lines.push({ text: "Select a color field to edit", type: "picker-desc" });
898
932
  return lines;
899
933
  }
@@ -901,7 +935,7 @@ function buildPickerLines(): PickerLine[] {
901
935
  // Field title
902
936
  lines.push({ text: `${field.path} - ${field.def.displayName}`, type: "picker-title" });
903
937
  lines.push({ text: `"${field.def.description}"`, type: "picker-desc" });
904
- lines.push({ text: "─".repeat(RIGHT_WIDTH - 2), type: "picker-separator" });
938
+ lines.push({ text: "─".repeat(RIGHT_WIDTH() - 2), type: "picker-separator" });
905
939
 
906
940
  // Color value display
907
941
  const isNamed = typeof field.value === "string" && NAMED_COLORS[field.value] !== undefined;
@@ -942,7 +976,7 @@ function buildPickerLines(): PickerLine[] {
942
976
  lines.push({ text: rowText, type: "picker-palette-row", paletteRow: row });
943
977
  }
944
978
 
945
- lines.push({ text: "─".repeat(RIGHT_WIDTH - 2), type: "picker-separator" });
979
+ lines.push({ text: "─".repeat(RIGHT_WIDTH() - 2), type: "picker-separator" });
946
980
 
947
981
  // Preview section
948
982
  lines.push({ text: "Preview:", type: "picker-label" });
@@ -1241,7 +1275,7 @@ function addBackgroundHighlight(
1241
1275
  bufferId: number,
1242
1276
  start: number,
1243
1277
  end: number,
1244
- bgColor: RGB
1278
+ bgColor: OverlayColorSpec
1245
1279
  ): void {
1246
1280
  editor.addOverlay(bufferId, "theme-sel", start, end, { bg: bgColor });
1247
1281
  }
@@ -1352,10 +1386,99 @@ let isUpdatingDisplay = false;
1352
1386
  * Full display update — rebuilds content and all overlays.
1353
1387
  * Use for structural changes (open, section toggle, color edit, filter).
1354
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
+
1355
1437
  function updateDisplay(): void {
1356
- if (state.bufferId === null) return;
1357
1438
  isUpdatingDisplay = true;
1358
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
+
1359
1482
  const entries = buildDisplayEntries();
1360
1483
 
1361
1484
  // Clear selection overlays BEFORE replacing content to prevent stale
@@ -1532,11 +1655,7 @@ function onThemeColorPromptConfirmed(args: {
1532
1655
  setNestedValue(state.themeData, path, result.value);
1533
1656
  state.hasChanges = !deepEqual(state.themeData, state.originalThemeData);
1534
1657
 
1535
- const entries = buildDisplayEntries();
1536
- if (state.bufferId !== null) {
1537
- editor.setVirtualBufferContent(state.bufferId, entries);
1538
- applySelectionHighlighting(entries);
1539
- }
1658
+ updateDisplay();
1540
1659
  moveCursorToField(path);
1541
1660
  editor.setStatus(editor.t("status.updated", { path }));
1542
1661
  } else {
@@ -1793,11 +1912,7 @@ async function saveTheme(name?: string, restorePath?: string | null): Promise<bo
1793
1912
  state.hasChanges = false;
1794
1913
 
1795
1914
  // Update display
1796
- const entries = buildDisplayEntries();
1797
- if (state.bufferId !== null) {
1798
- editor.setVirtualBufferContent(state.bufferId, entries);
1799
- applySelectionHighlighting(entries);
1800
- }
1915
+ updateDisplay();
1801
1916
 
1802
1917
  // Restore cursor position if provided
1803
1918
  if (restorePath) {
@@ -1903,7 +2018,17 @@ function onThemeEditorCursorMoved(data: {
1903
2018
  new_position: number;
1904
2019
  text_properties?: Array<Record<string, any>>;
1905
2020
  }): void {
1906
- 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;
1907
2032
  if (isUpdatingDisplay) return;
1908
2033
 
1909
2034
  const props = data.text_properties || [];
@@ -1974,20 +2099,6 @@ function onThemeEditorResize(data: { width: number; height: number }): void {
1974
2099
  registerHandler("onThemeEditorResize", onThemeEditorResize);
1975
2100
  editor.on("resize", "onThemeEditorResize");
1976
2101
 
1977
- function onThemeEditorMouseScroll(data: { buffer_id: number; delta: number; col: number; row: number }): void {
1978
- if (state.bufferId === null || data.buffer_id !== state.bufferId) return;
1979
-
1980
- // Only scroll the tree when mouse is over the left panel area (col < LEFT_WIDTH)
1981
- if (data.col >= LEFT_WIDTH) return;
1982
-
1983
- // delta > 0 = scroll down, delta < 0 = scroll up
1984
- const scrollAmount = data.delta > 0 ? 3 : -3;
1985
- state.treeScrollOffset = Math.max(0, state.treeScrollOffset + scrollAmount);
1986
- updateDisplay();
1987
- }
1988
- registerHandler("onThemeEditorMouseScroll", onThemeEditorMouseScroll);
1989
- editor.on("mouse_scroll", "onThemeEditorMouseScroll");
1990
-
1991
2102
  /**
1992
2103
  * Handle buffer_closed event to reset state when buffer is closed by any means
1993
2104
  */
@@ -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
-
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
2638
 
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