@involvex/fresh-editor 0.1.76 → 0.1.78
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.
- package/bin/CHANGELOG.md +1017 -0
- package/bin/LICENSE +117 -0
- package/bin/README.md +248 -0
- package/bin/fresh.exe +0 -0
- package/bin/plugins/README.md +71 -0
- package/bin/plugins/audit_mode.i18n.json +821 -0
- package/bin/plugins/audit_mode.ts +1810 -0
- package/bin/plugins/buffer_modified.i18n.json +67 -0
- package/bin/plugins/buffer_modified.ts +281 -0
- package/bin/plugins/calculator.i18n.json +93 -0
- package/bin/plugins/calculator.ts +770 -0
- package/bin/plugins/clangd-lsp.ts +168 -0
- package/bin/plugins/clangd_support.i18n.json +223 -0
- package/bin/plugins/clangd_support.md +20 -0
- package/bin/plugins/clangd_support.ts +325 -0
- package/bin/plugins/color_highlighter.i18n.json +145 -0
- package/bin/plugins/color_highlighter.ts +304 -0
- package/bin/plugins/config-schema.json +768 -0
- package/bin/plugins/csharp-lsp.ts +147 -0
- package/bin/plugins/csharp_support.i18n.json +80 -0
- package/bin/plugins/csharp_support.ts +170 -0
- package/bin/plugins/css-lsp.ts +143 -0
- package/bin/plugins/diagnostics_panel.i18n.json +236 -0
- package/bin/plugins/diagnostics_panel.ts +642 -0
- package/bin/plugins/examples/README.md +85 -0
- package/bin/plugins/examples/async_demo.ts +165 -0
- package/bin/plugins/examples/bookmarks.ts +329 -0
- package/bin/plugins/examples/buffer_query_demo.ts +110 -0
- package/bin/plugins/examples/git_grep.ts +262 -0
- package/bin/plugins/examples/hello_world.ts +93 -0
- package/bin/plugins/examples/virtual_buffer_demo.ts +116 -0
- package/bin/plugins/find_references.i18n.json +275 -0
- package/bin/plugins/find_references.ts +359 -0
- package/bin/plugins/git_blame.i18n.json +496 -0
- package/bin/plugins/git_blame.ts +707 -0
- package/bin/plugins/git_find_file.i18n.json +314 -0
- package/bin/plugins/git_find_file.ts +300 -0
- package/bin/plugins/git_grep.i18n.json +171 -0
- package/bin/plugins/git_grep.ts +191 -0
- package/bin/plugins/git_gutter.i18n.json +93 -0
- package/bin/plugins/git_gutter.ts +477 -0
- package/bin/plugins/git_log.i18n.json +481 -0
- package/bin/plugins/git_log.ts +1285 -0
- package/bin/plugins/go-lsp.ts +143 -0
- package/bin/plugins/html-lsp.ts +145 -0
- package/bin/plugins/json-lsp.ts +145 -0
- package/bin/plugins/lib/fresh.d.ts +1321 -0
- package/bin/plugins/lib/index.ts +24 -0
- package/bin/plugins/lib/navigation-controller.ts +214 -0
- package/bin/plugins/lib/panel-manager.ts +220 -0
- package/bin/plugins/lib/types.ts +72 -0
- package/bin/plugins/lib/virtual-buffer-factory.ts +130 -0
- package/bin/plugins/live_grep.i18n.json +171 -0
- package/bin/plugins/live_grep.ts +422 -0
- package/bin/plugins/markdown_compose.i18n.json +223 -0
- package/bin/plugins/markdown_compose.ts +630 -0
- package/bin/plugins/merge_conflict.i18n.json +821 -0
- package/bin/plugins/merge_conflict.ts +1810 -0
- package/bin/plugins/path_complete.i18n.json +80 -0
- package/bin/plugins/path_complete.ts +165 -0
- package/bin/plugins/python-lsp.ts +162 -0
- package/bin/plugins/rust-lsp.ts +166 -0
- package/bin/plugins/search_replace.i18n.json +405 -0
- package/bin/plugins/search_replace.ts +484 -0
- package/bin/plugins/test_i18n.i18n.json +67 -0
- package/bin/plugins/test_i18n.ts +18 -0
- package/bin/plugins/theme_editor.i18n.json +3746 -0
- package/bin/plugins/theme_editor.ts +2063 -0
- package/bin/plugins/todo_highlighter.i18n.json +184 -0
- package/bin/plugins/todo_highlighter.ts +206 -0
- package/bin/plugins/typescript-lsp.ts +167 -0
- package/bin/plugins/vi_mode.i18n.json +1549 -0
- package/bin/plugins/vi_mode.ts +2747 -0
- package/bin/plugins/welcome.i18n.json +236 -0
- package/bin/plugins/welcome.ts +76 -0
- package/bin/themes/dark.json +102 -0
- package/bin/themes/dracula.json +62 -0
- package/bin/themes/high-contrast.json +102 -0
- package/bin/themes/light.json +102 -0
- package/bin/themes/nord.json +62 -0
- package/bin/themes/nostalgia.json +102 -0
- package/bin/themes/solarized-dark.json +62 -0
- package/binary-install.js +1 -1
- package/dist/bin/fresh.js +9 -0
- package/dist/binary-install.js +149 -0
- package/dist/binary.js +30 -0
- package/dist/fresh-6yhknp07.exe +0 -0
- package/dist/install.js +158 -0
- package/dist/run-fresh.js +43 -0
- package/package.json +7 -2
|
@@ -0,0 +1,2063 @@
|
|
|
1
|
+
/// <reference path="../types/fresh.d.ts" />
|
|
2
|
+
const editor = getEditor();
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Theme Editor Plugin - Interactive color theme editor
|
|
7
|
+
*
|
|
8
|
+
* Provides a visual interface for editing Fresh's color themes with:
|
|
9
|
+
* - Organized display of all theme color fields by section
|
|
10
|
+
* - Inline color swatches showing the actual colors
|
|
11
|
+
* - Color picker supporting both RGB values and named colors
|
|
12
|
+
* - Copy from built-in themes to use as starting point
|
|
13
|
+
* - Save as new theme name
|
|
14
|
+
* - Easy option to set as default theme
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// =============================================================================
|
|
18
|
+
// Types and Schema
|
|
19
|
+
// =============================================================================
|
|
20
|
+
|
|
21
|
+
type RGB = [number, number, number];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Named colors supported by Fresh themes
|
|
25
|
+
*/
|
|
26
|
+
const NAMED_COLORS: Record<string, RGB> = {
|
|
27
|
+
"Black": [0, 0, 0],
|
|
28
|
+
"Red": [255, 0, 0],
|
|
29
|
+
"Green": [0, 128, 0],
|
|
30
|
+
"Yellow": [255, 255, 0],
|
|
31
|
+
"Blue": [0, 0, 255],
|
|
32
|
+
"Magenta": [255, 0, 255],
|
|
33
|
+
"Cyan": [0, 255, 255],
|
|
34
|
+
"Gray": [128, 128, 128],
|
|
35
|
+
"DarkGray": [169, 169, 169],
|
|
36
|
+
"LightRed": [255, 128, 128],
|
|
37
|
+
"LightGreen": [144, 238, 144],
|
|
38
|
+
"LightYellow": [255, 255, 224],
|
|
39
|
+
"LightBlue": [173, 216, 230],
|
|
40
|
+
"LightMagenta": [255, 128, 255],
|
|
41
|
+
"LightCyan": [224, 255, 255],
|
|
42
|
+
"White": [255, 255, 255],
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Special colors that use the terminal's default (preserves transparency)
|
|
47
|
+
* These don't have RGB values - they tell the terminal to use its native color
|
|
48
|
+
*/
|
|
49
|
+
const SPECIAL_COLORS = ["Default", "Reset"];
|
|
50
|
+
|
|
51
|
+
const NAMED_COLOR_LIST = Object.keys(NAMED_COLORS);
|
|
52
|
+
const ALL_COLOR_NAMES = [...NAMED_COLOR_LIST, ...SPECIAL_COLORS];
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Color value - either RGB array or named color string
|
|
56
|
+
*/
|
|
57
|
+
type ColorValue = RGB | string;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Theme section definition
|
|
61
|
+
*/
|
|
62
|
+
interface ThemeSection {
|
|
63
|
+
name: string;
|
|
64
|
+
displayName: string;
|
|
65
|
+
description: string;
|
|
66
|
+
fields: ThemeFieldDef[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Theme field definition with metadata
|
|
71
|
+
*/
|
|
72
|
+
interface ThemeFieldDef {
|
|
73
|
+
key: string;
|
|
74
|
+
displayName: string;
|
|
75
|
+
description: string;
|
|
76
|
+
section: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Theme field with current value
|
|
81
|
+
*/
|
|
82
|
+
interface ThemeField {
|
|
83
|
+
def: ThemeFieldDef;
|
|
84
|
+
value: ColorValue;
|
|
85
|
+
path: string;
|
|
86
|
+
depth: number;
|
|
87
|
+
isSection: boolean;
|
|
88
|
+
expanded?: boolean;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// =============================================================================
|
|
92
|
+
// Theme Schema (loaded dynamically from Rust)
|
|
93
|
+
// =============================================================================
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Cached theme sections loaded from the API.
|
|
97
|
+
* This is populated on first use and reflects the actual theme structure from Rust.
|
|
98
|
+
*/
|
|
99
|
+
let cachedThemeSections: ThemeSection[] | null = null;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Load theme sections from the Rust API.
|
|
103
|
+
* Parses the raw JSON Schema and resolves $ref references.
|
|
104
|
+
* Uses i18n keys for localized display names.
|
|
105
|
+
*/
|
|
106
|
+
function loadThemeSections(): ThemeSection[] {
|
|
107
|
+
if (cachedThemeSections !== null) {
|
|
108
|
+
return cachedThemeSections;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const schema = editor.getThemeSchema();
|
|
112
|
+
const defs = schema.$defs || {};
|
|
113
|
+
|
|
114
|
+
// Helper to resolve $ref and get the referenced schema
|
|
115
|
+
const resolveRef = (refStr: string): Record<string, unknown> | null => {
|
|
116
|
+
// $ref format: "#/$defs/TypeName"
|
|
117
|
+
const prefix = "#/$defs/";
|
|
118
|
+
if (refStr.startsWith(prefix)) {
|
|
119
|
+
const typeName = refStr.slice(prefix.length);
|
|
120
|
+
return defs[typeName] as Record<string, unknown> || null;
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const sections: ThemeSection[] = [];
|
|
126
|
+
const properties = schema.properties || {};
|
|
127
|
+
|
|
128
|
+
// Section ordering
|
|
129
|
+
const sectionOrder = ["editor", "ui", "search", "diagnostic", "syntax"];
|
|
130
|
+
|
|
131
|
+
for (const [sectionName, sectionSchema] of Object.entries(properties)) {
|
|
132
|
+
// Skip "name" field - it's not a color section
|
|
133
|
+
if (sectionName === "name") continue;
|
|
134
|
+
|
|
135
|
+
const sectionObj = sectionSchema as Record<string, unknown>;
|
|
136
|
+
const sectionDesc = (sectionObj.description as string) || "";
|
|
137
|
+
|
|
138
|
+
// Resolve $ref to get the actual type definition
|
|
139
|
+
const refStr = sectionObj.$ref as string | undefined;
|
|
140
|
+
const resolvedSchema = refStr ? resolveRef(refStr) : sectionObj;
|
|
141
|
+
if (!resolvedSchema) continue;
|
|
142
|
+
|
|
143
|
+
const sectionProps = resolvedSchema.properties as Record<string, unknown> || {};
|
|
144
|
+
const fields: ThemeFieldDef[] = [];
|
|
145
|
+
|
|
146
|
+
for (const [fieldName, fieldSchema] of Object.entries(sectionProps)) {
|
|
147
|
+
const fieldObj = fieldSchema as Record<string, unknown>;
|
|
148
|
+
const fieldDesc = (fieldObj.description as string) || "";
|
|
149
|
+
|
|
150
|
+
// Generate i18n keys from field names
|
|
151
|
+
const i18nName = `field.${fieldName}`;
|
|
152
|
+
const i18nDesc = `field.${fieldName}_desc`;
|
|
153
|
+
|
|
154
|
+
fields.push({
|
|
155
|
+
key: fieldName,
|
|
156
|
+
displayName: editor.t(i18nName) || fieldDesc || fieldName,
|
|
157
|
+
description: editor.t(i18nDesc) || fieldDesc,
|
|
158
|
+
section: sectionName,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Sort fields alphabetically (use simple comparison to avoid ICU issues in Deno)
|
|
163
|
+
fields.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
164
|
+
|
|
165
|
+
// Generate i18n keys for section
|
|
166
|
+
const sectionI18nName = `section.${sectionName}`;
|
|
167
|
+
const sectionI18nDesc = `section.${sectionName}_desc`;
|
|
168
|
+
|
|
169
|
+
sections.push({
|
|
170
|
+
name: sectionName,
|
|
171
|
+
displayName: editor.t(sectionI18nName) || sectionDesc || sectionName,
|
|
172
|
+
description: editor.t(sectionI18nDesc) || sectionDesc,
|
|
173
|
+
fields,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Sort sections in logical order
|
|
178
|
+
sections.sort((a, b) => {
|
|
179
|
+
const aIdx = sectionOrder.indexOf(a.name);
|
|
180
|
+
const bIdx = sectionOrder.indexOf(b.name);
|
|
181
|
+
return (aIdx === -1 ? 99 : aIdx) - (bIdx === -1 ? 99 : bIdx);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
cachedThemeSections = sections;
|
|
185
|
+
return cachedThemeSections;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Get theme sections (loads from API if not cached)
|
|
190
|
+
*/
|
|
191
|
+
function getThemeSections(): ThemeSection[] {
|
|
192
|
+
return loadThemeSections();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// =============================================================================
|
|
196
|
+
// State Management
|
|
197
|
+
// =============================================================================
|
|
198
|
+
|
|
199
|
+
interface ThemeEditorState {
|
|
200
|
+
bufferId: number | null;
|
|
201
|
+
splitId: number | null;
|
|
202
|
+
sourceSplitId: number | null;
|
|
203
|
+
sourceBufferId: number | null;
|
|
204
|
+
/** Current theme data */
|
|
205
|
+
themeData: Record<string, unknown>;
|
|
206
|
+
/** Original theme data (for change detection) */
|
|
207
|
+
originalThemeData: Record<string, unknown>;
|
|
208
|
+
/** Theme name */
|
|
209
|
+
themeName: string;
|
|
210
|
+
/** Theme file path (null for new themes) */
|
|
211
|
+
themePath: string | null;
|
|
212
|
+
/** Expanded sections */
|
|
213
|
+
expandedSections: Set<string>;
|
|
214
|
+
/** Visible fields */
|
|
215
|
+
visibleFields: ThemeField[];
|
|
216
|
+
/** Selected field index */
|
|
217
|
+
selectedIndex: number;
|
|
218
|
+
/** Whether there are unsaved changes */
|
|
219
|
+
hasChanges: boolean;
|
|
220
|
+
/** Available built-in themes */
|
|
221
|
+
builtinThemes: string[];
|
|
222
|
+
/** Pending save name for overwrite confirmation */
|
|
223
|
+
pendingSaveName: string | null;
|
|
224
|
+
/** Whether current theme is a built-in (requires Save As) */
|
|
225
|
+
isBuiltin: boolean;
|
|
226
|
+
/** Saved cursor field path (for restoring after prompts) */
|
|
227
|
+
savedCursorPath: string | null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Check if the theme editor is currently open.
|
|
232
|
+
* Uses a stateless approach by checking if the buffer actually exists.
|
|
233
|
+
* This handles cases where the buffer was closed externally (e.g., Ctrl+W).
|
|
234
|
+
*/
|
|
235
|
+
function isThemeEditorOpen(): boolean {
|
|
236
|
+
if (state.bufferId === null) {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
// Check if the buffer actually exists
|
|
240
|
+
const buffers = editor.listBuffers();
|
|
241
|
+
const exists = buffers.some(b => b.id === state.bufferId);
|
|
242
|
+
|
|
243
|
+
// If buffer doesn't exist, reset our stale state
|
|
244
|
+
if (!exists) {
|
|
245
|
+
editor.debug(`Theme editor buffer ${state.bufferId} no longer exists, resetting state`);
|
|
246
|
+
state.bufferId = null;
|
|
247
|
+
state.splitId = null;
|
|
248
|
+
state.themeData = {};
|
|
249
|
+
state.originalThemeData = {};
|
|
250
|
+
state.hasChanges = false;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return exists;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const state: ThemeEditorState = {
|
|
257
|
+
bufferId: null,
|
|
258
|
+
splitId: null,
|
|
259
|
+
sourceSplitId: null,
|
|
260
|
+
sourceBufferId: null,
|
|
261
|
+
themeData: {},
|
|
262
|
+
originalThemeData: {},
|
|
263
|
+
themeName: "custom",
|
|
264
|
+
themePath: null,
|
|
265
|
+
expandedSections: new Set(["editor", "syntax"]),
|
|
266
|
+
visibleFields: [],
|
|
267
|
+
selectedIndex: 0,
|
|
268
|
+
hasChanges: false,
|
|
269
|
+
builtinThemes: [],
|
|
270
|
+
pendingSaveName: null,
|
|
271
|
+
isBuiltin: false,
|
|
272
|
+
savedCursorPath: null,
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
// =============================================================================
|
|
276
|
+
// Color Definitions for UI
|
|
277
|
+
// =============================================================================
|
|
278
|
+
|
|
279
|
+
const colors = {
|
|
280
|
+
sectionHeader: [255, 200, 100] as RGB, // Gold
|
|
281
|
+
fieldName: [200, 200, 255] as RGB, // Light blue
|
|
282
|
+
defaultValue: [150, 150, 150] as RGB, // Gray
|
|
283
|
+
customValue: [100, 255, 100] as RGB, // Green
|
|
284
|
+
description: [120, 120, 120] as RGB, // Dim gray
|
|
285
|
+
modified: [255, 255, 100] as RGB, // Yellow
|
|
286
|
+
footer: [100, 100, 100] as RGB, // Gray
|
|
287
|
+
colorBlock: [200, 200, 200] as RGB, // Light gray for color swatch outline
|
|
288
|
+
selectionBg: [50, 50, 80] as RGB, // Dark blue-gray for selected field
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
// =============================================================================
|
|
292
|
+
// Keyboard Shortcuts (defined once, used in mode and i18n)
|
|
293
|
+
// =============================================================================
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Keyboard shortcuts for the theme editor.
|
|
297
|
+
* These are defined once and used both in the mode definition and in the UI hints.
|
|
298
|
+
*/
|
|
299
|
+
const SHORTCUTS = {
|
|
300
|
+
open: "C-o",
|
|
301
|
+
save: "C-s",
|
|
302
|
+
save_as: "C-S-s",
|
|
303
|
+
delete: "C-d",
|
|
304
|
+
reload: "C-r",
|
|
305
|
+
close: "C-q",
|
|
306
|
+
help: "F1",
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
// =============================================================================
|
|
310
|
+
// Mode Definition
|
|
311
|
+
// =============================================================================
|
|
312
|
+
|
|
313
|
+
editor.defineMode(
|
|
314
|
+
"theme-editor",
|
|
315
|
+
"normal",
|
|
316
|
+
[
|
|
317
|
+
// Navigation (standard keys that don't conflict with typing)
|
|
318
|
+
["Return", "theme_editor_edit_color"],
|
|
319
|
+
["Space", "theme_editor_edit_color"],
|
|
320
|
+
["Tab", "theme_editor_nav_next_section"],
|
|
321
|
+
["S-Tab", "theme_editor_nav_prev_section"],
|
|
322
|
+
["Up", "theme_editor_nav_up"],
|
|
323
|
+
["Down", "theme_editor_nav_down"],
|
|
324
|
+
["Escape", "theme_editor_close"],
|
|
325
|
+
[SHORTCUTS.help, "theme_editor_show_help"],
|
|
326
|
+
|
|
327
|
+
// Ctrl+ shortcuts (match common editor conventions)
|
|
328
|
+
[SHORTCUTS.open, "theme_editor_open"],
|
|
329
|
+
[SHORTCUTS.save, "theme_editor_save"],
|
|
330
|
+
[SHORTCUTS.save_as, "theme_editor_save_as"],
|
|
331
|
+
[SHORTCUTS.delete, "theme_editor_delete"],
|
|
332
|
+
[SHORTCUTS.reload, "theme_editor_reload"],
|
|
333
|
+
[SHORTCUTS.close, "theme_editor_close"],
|
|
334
|
+
["C-h", "theme_editor_show_help"], // Alternative help key
|
|
335
|
+
],
|
|
336
|
+
true // read-only
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
// =============================================================================
|
|
340
|
+
// Utility Functions
|
|
341
|
+
// =============================================================================
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Calculate UTF-8 byte length of a string
|
|
345
|
+
*/
|
|
346
|
+
function getUtf8ByteLength(str: string): number {
|
|
347
|
+
let length = 0;
|
|
348
|
+
for (let i = 0; i < str.length; i++) {
|
|
349
|
+
const code = str.charCodeAt(i);
|
|
350
|
+
if (code < 0x80) {
|
|
351
|
+
length += 1;
|
|
352
|
+
} else if (code < 0x800) {
|
|
353
|
+
length += 2;
|
|
354
|
+
} else if (code < 0xD800 || code >= 0xE000) {
|
|
355
|
+
length += 3;
|
|
356
|
+
} else {
|
|
357
|
+
i++;
|
|
358
|
+
length += 4;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return length;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Deep clone an object
|
|
366
|
+
*/
|
|
367
|
+
function deepClone<T>(obj: T): T {
|
|
368
|
+
return JSON.parse(JSON.stringify(obj));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Check if two values are deeply equal
|
|
373
|
+
*/
|
|
374
|
+
function deepEqual(a: unknown, b: unknown): boolean {
|
|
375
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Parse a color value to RGB
|
|
380
|
+
*/
|
|
381
|
+
function parseColorToRgb(value: ColorValue): RGB | null {
|
|
382
|
+
if (Array.isArray(value) && value.length === 3) {
|
|
383
|
+
return value as RGB;
|
|
384
|
+
}
|
|
385
|
+
if (typeof value === "string") {
|
|
386
|
+
return NAMED_COLORS[value] || null;
|
|
387
|
+
}
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Convert RGB to hex string
|
|
393
|
+
*/
|
|
394
|
+
function rgbToHex(r: number, g: number, b: number): string {
|
|
395
|
+
const toHex = (n: number) => n.toString(16).padStart(2, '0').toUpperCase();
|
|
396
|
+
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Parse hex string to RGB
|
|
401
|
+
*/
|
|
402
|
+
function hexToRgb(hex: string): RGB | null {
|
|
403
|
+
const match = hex.match(/^#?([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})$/);
|
|
404
|
+
if (match) {
|
|
405
|
+
return [
|
|
406
|
+
parseInt(match[1], 16),
|
|
407
|
+
parseInt(match[2], 16),
|
|
408
|
+
parseInt(match[3], 16),
|
|
409
|
+
];
|
|
410
|
+
}
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Format a color value for display (as hex)
|
|
416
|
+
*/
|
|
417
|
+
function formatColorValue(value: ColorValue): string {
|
|
418
|
+
if (Array.isArray(value)) {
|
|
419
|
+
return rgbToHex(value[0], value[1], value[2]);
|
|
420
|
+
}
|
|
421
|
+
return String(value);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Check if a color is a named color (including special colors like Default/Reset)
|
|
426
|
+
*/
|
|
427
|
+
function isNamedColor(value: ColorValue): boolean {
|
|
428
|
+
return typeof value === "string" && (value in NAMED_COLORS || SPECIAL_COLORS.includes(value));
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Get nested value from object
|
|
433
|
+
*/
|
|
434
|
+
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
|
435
|
+
const parts = path.split(".");
|
|
436
|
+
let current: unknown = obj;
|
|
437
|
+
for (const part of parts) {
|
|
438
|
+
if (current === null || current === undefined) return undefined;
|
|
439
|
+
current = (current as Record<string, unknown>)[part];
|
|
440
|
+
}
|
|
441
|
+
return current;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Set nested value in object
|
|
446
|
+
*/
|
|
447
|
+
function setNestedValue(obj: Record<string, unknown>, path: string, value: unknown): void {
|
|
448
|
+
const parts = path.split(".");
|
|
449
|
+
let current: Record<string, unknown> = obj;
|
|
450
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
451
|
+
const part = parts[i];
|
|
452
|
+
if (!(part in current) || typeof current[part] !== "object") {
|
|
453
|
+
current[part] = {};
|
|
454
|
+
}
|
|
455
|
+
current = current[part] as Record<string, unknown>;
|
|
456
|
+
}
|
|
457
|
+
current[parts[parts.length - 1]] = value;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Find themes directory
|
|
462
|
+
*/
|
|
463
|
+
function findThemesDir(): string {
|
|
464
|
+
const cwd = editor.getCwd();
|
|
465
|
+
const candidates = [
|
|
466
|
+
editor.pathJoin(cwd, "themes"),
|
|
467
|
+
];
|
|
468
|
+
|
|
469
|
+
for (const path of candidates) {
|
|
470
|
+
if (editor.fileExists(path)) {
|
|
471
|
+
return path;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return candidates[0];
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Load list of available built-in themes
|
|
480
|
+
*/
|
|
481
|
+
async function loadBuiltinThemes(): Promise<string[]> {
|
|
482
|
+
try {
|
|
483
|
+
const builtinThemes = editor.getBuiltinThemes();
|
|
484
|
+
return Object.keys(builtinThemes);
|
|
485
|
+
} catch (e) {
|
|
486
|
+
editor.debug(`Failed to load built-in themes list: ${e}`);
|
|
487
|
+
throw e;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Load a theme file from built-in themes
|
|
493
|
+
*/
|
|
494
|
+
async function loadThemeFile(name: string): Promise<Record<string, unknown> | null> {
|
|
495
|
+
try {
|
|
496
|
+
const builtinThemes = editor.getBuiltinThemes();
|
|
497
|
+
if (name in builtinThemes) {
|
|
498
|
+
return JSON.parse(builtinThemes[name]);
|
|
499
|
+
}
|
|
500
|
+
return null;
|
|
501
|
+
} catch (e) {
|
|
502
|
+
editor.debug(`Failed to load theme data for '${name}': ${e}`);
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Load a user theme file
|
|
509
|
+
*/
|
|
510
|
+
async function loadUserThemeFile(name: string): Promise<{ data: Record<string, unknown>; path: string } | null> {
|
|
511
|
+
const userThemesDir = getUserThemesDir();
|
|
512
|
+
const themePath = editor.pathJoin(userThemesDir, `${name}.json`);
|
|
513
|
+
|
|
514
|
+
try {
|
|
515
|
+
const content = await editor.readFile(themePath);
|
|
516
|
+
return { data: JSON.parse(content), path: themePath };
|
|
517
|
+
} catch {
|
|
518
|
+
editor.debug(`Failed to load user theme: ${name}`);
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* List available user themes
|
|
525
|
+
*/
|
|
526
|
+
function listUserThemes(): string[] {
|
|
527
|
+
const userThemesDir = getUserThemesDir();
|
|
528
|
+
try {
|
|
529
|
+
const entries = editor.readDir(userThemesDir);
|
|
530
|
+
return entries
|
|
531
|
+
.filter(e => e.is_file && e.name.endsWith(".json"))
|
|
532
|
+
.map(e => e.name.replace(".json", ""));
|
|
533
|
+
} catch {
|
|
534
|
+
return [];
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Get user themes directory
|
|
540
|
+
* Uses the API to get the correct path
|
|
541
|
+
*/
|
|
542
|
+
function getUserThemesDir(): string {
|
|
543
|
+
// Use the API if available (new method)
|
|
544
|
+
if (typeof editor.getThemesDir === "function") {
|
|
545
|
+
return editor.getThemesDir();
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Fallback for older versions (deprecated)
|
|
549
|
+
// Check XDG_CONFIG_HOME first (standard on Linux)
|
|
550
|
+
const xdgConfig = editor.getEnv("XDG_CONFIG_HOME");
|
|
551
|
+
if (xdgConfig) {
|
|
552
|
+
return editor.pathJoin(xdgConfig, "fresh", "themes");
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Fall back to $HOME/.config
|
|
556
|
+
const home = editor.getEnv("HOME");
|
|
557
|
+
if (home) {
|
|
558
|
+
return editor.pathJoin(home, ".config", "fresh", "themes");
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
return editor.pathJoin(editor.getCwd(), "themes");
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// =============================================================================
|
|
565
|
+
// Field Building
|
|
566
|
+
// =============================================================================
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Build visible fields list based on expanded sections
|
|
570
|
+
*/
|
|
571
|
+
function buildVisibleFields(): ThemeField[] {
|
|
572
|
+
const fields: ThemeField[] = [];
|
|
573
|
+
const themeSections = getThemeSections();
|
|
574
|
+
|
|
575
|
+
for (const section of themeSections) {
|
|
576
|
+
const expanded = state.expandedSections.has(section.name);
|
|
577
|
+
|
|
578
|
+
// Section header - displayName and description are already translated in getThemeSections()
|
|
579
|
+
fields.push({
|
|
580
|
+
def: {
|
|
581
|
+
key: section.name,
|
|
582
|
+
displayName: section.displayName,
|
|
583
|
+
description: section.description,
|
|
584
|
+
section: section.name,
|
|
585
|
+
},
|
|
586
|
+
value: [0, 0, 0], // Placeholder
|
|
587
|
+
path: section.name,
|
|
588
|
+
depth: 0,
|
|
589
|
+
isSection: true,
|
|
590
|
+
expanded,
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
// Section fields
|
|
594
|
+
if (expanded) {
|
|
595
|
+
for (const fieldDef of section.fields) {
|
|
596
|
+
const path = `${section.name}.${fieldDef.key}`;
|
|
597
|
+
const value = getNestedValue(state.themeData, path) as ColorValue || [128, 128, 128];
|
|
598
|
+
|
|
599
|
+
// fieldDef displayName and description are already translated in getThemeSections()
|
|
600
|
+
fields.push({
|
|
601
|
+
def: fieldDef,
|
|
602
|
+
value,
|
|
603
|
+
path,
|
|
604
|
+
depth: 1,
|
|
605
|
+
isSection: false,
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
return fields;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// =============================================================================
|
|
615
|
+
// UI Building
|
|
616
|
+
// =============================================================================
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Build display entries for virtual buffer
|
|
620
|
+
*/
|
|
621
|
+
function buildDisplayEntries(): TextPropertyEntry[] {
|
|
622
|
+
const entries: TextPropertyEntry[] = [];
|
|
623
|
+
|
|
624
|
+
// Title
|
|
625
|
+
const modifiedMarker = state.hasChanges ? " " + editor.t("panel.modified") : "";
|
|
626
|
+
entries.push({
|
|
627
|
+
text: `━━━ ${editor.t("panel.title", { name: state.themeName })}${modifiedMarker} ━━━\n`,
|
|
628
|
+
properties: { type: "title" },
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
if (state.themePath) {
|
|
632
|
+
entries.push({
|
|
633
|
+
text: `${editor.t("panel.file", { path: state.themePath })}\n`,
|
|
634
|
+
properties: { type: "file-path" },
|
|
635
|
+
});
|
|
636
|
+
} else {
|
|
637
|
+
entries.push({
|
|
638
|
+
text: editor.t("panel.new_theme") + "\n",
|
|
639
|
+
properties: { type: "file-path" },
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Key hints at the top (moved from footer)
|
|
644
|
+
entries.push({
|
|
645
|
+
text: editor.t("panel.nav_hint") + "\n",
|
|
646
|
+
properties: { type: "footer" },
|
|
647
|
+
});
|
|
648
|
+
entries.push({
|
|
649
|
+
text: editor.t("panel.action_hint", SHORTCUTS) + "\n",
|
|
650
|
+
properties: { type: "footer" },
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
entries.push({
|
|
654
|
+
text: "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n",
|
|
655
|
+
properties: { type: "separator" },
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
entries.push({
|
|
659
|
+
text: "\n",
|
|
660
|
+
properties: { type: "blank" },
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
// Fields
|
|
664
|
+
state.visibleFields = buildVisibleFields();
|
|
665
|
+
|
|
666
|
+
for (let i = 0; i < state.visibleFields.length; i++) {
|
|
667
|
+
const field = state.visibleFields[i];
|
|
668
|
+
const indent = " ".repeat(field.depth);
|
|
669
|
+
|
|
670
|
+
if (field.isSection) {
|
|
671
|
+
// Section header
|
|
672
|
+
const icon = field.expanded ? "▼" : ">";
|
|
673
|
+
entries.push({
|
|
674
|
+
text: `${indent}${icon} ${field.def.displayName}\n`,
|
|
675
|
+
properties: {
|
|
676
|
+
type: "section",
|
|
677
|
+
path: field.path,
|
|
678
|
+
index: i,
|
|
679
|
+
expanded: field.expanded,
|
|
680
|
+
},
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
// Section description
|
|
684
|
+
entries.push({
|
|
685
|
+
text: `${indent} // ${field.def.description}\n`,
|
|
686
|
+
properties: { type: "description", path: field.path },
|
|
687
|
+
});
|
|
688
|
+
} else {
|
|
689
|
+
// Field description (before the field)
|
|
690
|
+
entries.push({
|
|
691
|
+
text: `${indent} // ${field.def.description}\n`,
|
|
692
|
+
properties: { type: "description", path: field.path },
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
// Color field with swatch characters (X for fg preview, space for bg preview)
|
|
696
|
+
const colorStr = formatColorValue(field.value);
|
|
697
|
+
|
|
698
|
+
entries.push({
|
|
699
|
+
text: `${indent} ${field.def.displayName}: X ${colorStr}\n`,
|
|
700
|
+
properties: {
|
|
701
|
+
type: "field",
|
|
702
|
+
path: field.path,
|
|
703
|
+
index: i,
|
|
704
|
+
colorValue: field.value,
|
|
705
|
+
},
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
entries.push({
|
|
710
|
+
text: "\n",
|
|
711
|
+
properties: { type: "blank" },
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
return entries;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Helper to add a colored overlay (foreground color)
|
|
720
|
+
* addOverlay signature: (bufferId, namespace, start, end, r, g, b, underline, bold, italic, bg_r, bg_g, bg_b, extend_to_line_end)
|
|
721
|
+
*/
|
|
722
|
+
function addColorOverlay(
|
|
723
|
+
bufferId: number,
|
|
724
|
+
start: number,
|
|
725
|
+
end: number,
|
|
726
|
+
color: RGB,
|
|
727
|
+
bold: boolean = false
|
|
728
|
+
): void {
|
|
729
|
+
editor.addOverlay(bufferId, "theme", start, end, color[0], color[1], color[2], false, bold, false, -1, -1, -1, false);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Helper to add a background highlight overlay
|
|
734
|
+
* addOverlay signature: (bufferId, namespace, start, end, r, g, b, underline, bold, italic, bg_r, bg_g, bg_b, extend_to_line_end)
|
|
735
|
+
*/
|
|
736
|
+
function addBackgroundHighlight(
|
|
737
|
+
bufferId: number,
|
|
738
|
+
start: number,
|
|
739
|
+
end: number,
|
|
740
|
+
bgColor: RGB
|
|
741
|
+
): void {
|
|
742
|
+
editor.addOverlay(bufferId, "theme-selection", start, end, -1, -1, -1, false, false, false, bgColor[0], bgColor[1], bgColor[2], true);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Check if a field path represents a background color
|
|
747
|
+
*/
|
|
748
|
+
function isBackgroundColorField(path: string): boolean {
|
|
749
|
+
// Check if path ends with .bg or contains _bg
|
|
750
|
+
// e.g., "editor.bg", "editor.selection_bg", "ui.tab_active_bg"
|
|
751
|
+
return path.endsWith(".bg") || path.includes("_bg");
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Check if a color is a special color (Default/Reset)
|
|
756
|
+
*/
|
|
757
|
+
function isSpecialColor(value: ColorValue): boolean {
|
|
758
|
+
return typeof value === "string" && SPECIAL_COLORS.includes(value);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Apply syntax highlighting
|
|
763
|
+
*/
|
|
764
|
+
function applyHighlighting(): void {
|
|
765
|
+
if (state.bufferId === null) return;
|
|
766
|
+
|
|
767
|
+
const bufferId = state.bufferId;
|
|
768
|
+
editor.clearNamespace(bufferId, "theme");
|
|
769
|
+
editor.clearNamespace(bufferId, "theme-selection");
|
|
770
|
+
|
|
771
|
+
const entries = buildDisplayEntries();
|
|
772
|
+
let byteOffset = 0;
|
|
773
|
+
|
|
774
|
+
// Get current field at cursor to highlight it
|
|
775
|
+
const currentField = getFieldAtCursor();
|
|
776
|
+
const currentFieldPath = currentField?.path;
|
|
777
|
+
|
|
778
|
+
for (const entry of entries) {
|
|
779
|
+
const text = entry.text;
|
|
780
|
+
const textLen = getUtf8ByteLength(text);
|
|
781
|
+
const props = entry.properties as Record<string, unknown>;
|
|
782
|
+
const entryType = props.type as string;
|
|
783
|
+
const entryPath = props.path as string | undefined;
|
|
784
|
+
|
|
785
|
+
// Add selection highlight for current field/section
|
|
786
|
+
if (currentFieldPath && entryPath === currentFieldPath && (entryType === "field" || entryType === "section")) {
|
|
787
|
+
addBackgroundHighlight(bufferId, byteOffset, byteOffset + textLen, colors.selectionBg);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
if (entryType === "title") {
|
|
791
|
+
addColorOverlay(bufferId, byteOffset, byteOffset + textLen, colors.sectionHeader, true);
|
|
792
|
+
} else if (entryType === "file-path") {
|
|
793
|
+
addColorOverlay(bufferId, byteOffset, byteOffset + textLen, colors.description);
|
|
794
|
+
} else if (entryType === "description") {
|
|
795
|
+
addColorOverlay(bufferId, byteOffset, byteOffset + textLen, colors.description);
|
|
796
|
+
} else if (entryType === "section") {
|
|
797
|
+
addColorOverlay(bufferId, byteOffset, byteOffset + textLen, colors.sectionHeader, true);
|
|
798
|
+
} else if (entryType === "field") {
|
|
799
|
+
// Field name - light blue
|
|
800
|
+
const colonPos = text.indexOf(":");
|
|
801
|
+
if (colonPos > 0) {
|
|
802
|
+
const nameEnd = byteOffset + getUtf8ByteLength(text.substring(0, colonPos));
|
|
803
|
+
addColorOverlay(bufferId, byteOffset, nameEnd, colors.fieldName);
|
|
804
|
+
|
|
805
|
+
// Color the swatch characters with the field's actual color
|
|
806
|
+
// Text format: "FieldName: X #RRGGBB" (X=fg, space=bg)
|
|
807
|
+
const colorValue = props.colorValue as ColorValue;
|
|
808
|
+
const rgb = parseColorToRgb(colorValue);
|
|
809
|
+
if (rgb) {
|
|
810
|
+
// "X" is at colon + 2 (": " = 2 bytes), and is 1 byte
|
|
811
|
+
const swatchFgStart = nameEnd + getUtf8ByteLength(": ");
|
|
812
|
+
const swatchFgEnd = swatchFgStart + 1; // "X" is 1 byte
|
|
813
|
+
addColorOverlay(bufferId, swatchFgStart, swatchFgEnd, rgb);
|
|
814
|
+
|
|
815
|
+
// First space after "X" is the bg swatch, 1 byte
|
|
816
|
+
const swatchBgStart = swatchFgEnd;
|
|
817
|
+
const swatchBgEnd = swatchBgStart + 1;
|
|
818
|
+
// Use background color for the space
|
|
819
|
+
editor.addOverlay(bufferId, "theme", swatchBgStart, swatchBgEnd, -1, -1, -1, false, false, false, rgb[0], rgb[1], rgb[2], false);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// Value (hex code) - custom color (green)
|
|
823
|
+
// Format: ": X #RRGGBB" - value starts after "X " (X + 2 spaces)
|
|
824
|
+
const valueStart = nameEnd + getUtf8ByteLength(": X ");
|
|
825
|
+
addColorOverlay(bufferId, valueStart, byteOffset + textLen, colors.customValue);
|
|
826
|
+
}
|
|
827
|
+
} else if (entryType === "separator" || entryType === "footer") {
|
|
828
|
+
addColorOverlay(bufferId, byteOffset, byteOffset + textLen, colors.footer);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
byteOffset += textLen;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* Update display (preserves cursor position)
|
|
837
|
+
*/
|
|
838
|
+
function updateDisplay(): void {
|
|
839
|
+
if (state.bufferId === null) return;
|
|
840
|
+
|
|
841
|
+
// Save current field path before updating
|
|
842
|
+
const currentPath = getCurrentFieldPath();
|
|
843
|
+
|
|
844
|
+
const entries = buildDisplayEntries();
|
|
845
|
+
editor.setVirtualBufferContent(state.bufferId, entries);
|
|
846
|
+
applyHighlighting();
|
|
847
|
+
|
|
848
|
+
// Restore cursor to the same field if possible
|
|
849
|
+
if (currentPath) {
|
|
850
|
+
moveCursorToField(currentPath);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// =============================================================================
|
|
855
|
+
// Field Editing
|
|
856
|
+
// =============================================================================
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Get field at cursor position
|
|
860
|
+
*/
|
|
861
|
+
function getFieldAtCursor(): ThemeField | null {
|
|
862
|
+
if (state.bufferId === null) return null;
|
|
863
|
+
|
|
864
|
+
const props = editor.getTextPropertiesAtCursor(state.bufferId);
|
|
865
|
+
if (props.length > 0 && typeof props[0].index === "number") {
|
|
866
|
+
const index = props[0].index as number;
|
|
867
|
+
if (index >= 0 && index < state.visibleFields.length) {
|
|
868
|
+
return state.visibleFields[index];
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
return null;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Get field by path
|
|
877
|
+
*/
|
|
878
|
+
function getFieldByPath(path: string): ThemeField | null {
|
|
879
|
+
return state.visibleFields.find(f => f.path === path) || null;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* Build color suggestions for a field
|
|
884
|
+
*/
|
|
885
|
+
function buildColorSuggestions(field: ThemeField): PromptSuggestion[] {
|
|
886
|
+
const currentValue = formatColorValue(field.value);
|
|
887
|
+
const suggestions: PromptSuggestion[] = [
|
|
888
|
+
{ text: currentValue, description: editor.t("suggestion.current"), value: currentValue },
|
|
889
|
+
];
|
|
890
|
+
|
|
891
|
+
// Add special colors (Default/Reset for terminal transparency)
|
|
892
|
+
for (const name of SPECIAL_COLORS) {
|
|
893
|
+
suggestions.push({ text: name, description: editor.t("suggestion.terminal_native"), value: name });
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// Add named colors with hex format
|
|
897
|
+
for (const name of NAMED_COLOR_LIST) {
|
|
898
|
+
const rgb = NAMED_COLORS[name];
|
|
899
|
+
const hexValue = rgbToHex(rgb[0], rgb[1], rgb[2]);
|
|
900
|
+
suggestions.push({ text: name, description: hexValue, value: name });
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
return suggestions;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* Start color editing prompt
|
|
908
|
+
*/
|
|
909
|
+
function editColorField(field: ThemeField): void {
|
|
910
|
+
const currentValue = formatColorValue(field.value);
|
|
911
|
+
editor.startPromptWithInitial(
|
|
912
|
+
editor.t("prompt.color_input", { field: field.def.displayName }),
|
|
913
|
+
`theme-color-${field.path}`,
|
|
914
|
+
currentValue
|
|
915
|
+
);
|
|
916
|
+
editor.setPromptSuggestions(buildColorSuggestions(field));
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
interface ParseColorResult {
|
|
920
|
+
value?: ColorValue;
|
|
921
|
+
error?: string;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Parse color input from user with detailed error messages
|
|
926
|
+
*/
|
|
927
|
+
function parseColorInput(input: string): ParseColorResult {
|
|
928
|
+
input = input.trim();
|
|
929
|
+
|
|
930
|
+
if (!input) {
|
|
931
|
+
return { error: "empty" };
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// Check for special colors (Default/Reset - use terminal's native color)
|
|
935
|
+
if (SPECIAL_COLORS.includes(input)) {
|
|
936
|
+
return { value: input };
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// Check for named color
|
|
940
|
+
if (input in NAMED_COLORS) {
|
|
941
|
+
return { value: input };
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// Try to parse as hex color #RRGGBB
|
|
945
|
+
if (input.startsWith("#")) {
|
|
946
|
+
const hex = input.slice(1);
|
|
947
|
+
if (hex.length !== 6) {
|
|
948
|
+
return { error: "hex_length" };
|
|
949
|
+
}
|
|
950
|
+
if (!/^[0-9A-Fa-f]{6}$/.test(hex)) {
|
|
951
|
+
return { error: "hex_invalid" };
|
|
952
|
+
}
|
|
953
|
+
const hexResult = hexToRgb(input);
|
|
954
|
+
if (hexResult) {
|
|
955
|
+
return { value: hexResult };
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// Try to parse as RGB array [r, g, b]
|
|
960
|
+
const rgbMatch = input.match(/^\[?\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\]?$/);
|
|
961
|
+
if (rgbMatch) {
|
|
962
|
+
const r = parseInt(rgbMatch[1], 10);
|
|
963
|
+
const g = parseInt(rgbMatch[2], 10);
|
|
964
|
+
const b = parseInt(rgbMatch[3], 10);
|
|
965
|
+
if (r > 255 || g > 255 || b > 255) {
|
|
966
|
+
return { error: "rgb_range" };
|
|
967
|
+
}
|
|
968
|
+
return { value: [r, g, b] };
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// Unknown format
|
|
972
|
+
return { error: "unknown" };
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// =============================================================================
|
|
976
|
+
// Prompt Handlers
|
|
977
|
+
// =============================================================================
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* Find best matching color name for partial input
|
|
981
|
+
*/
|
|
982
|
+
function findMatchingColor(input: string): string | null {
|
|
983
|
+
const lower = input.toLowerCase();
|
|
984
|
+
// First try exact match
|
|
985
|
+
for (const name of Object.keys(NAMED_COLORS)) {
|
|
986
|
+
if (name.toLowerCase() === lower) return name;
|
|
987
|
+
}
|
|
988
|
+
for (const name of SPECIAL_COLORS) {
|
|
989
|
+
if (name.toLowerCase() === lower) return name;
|
|
990
|
+
}
|
|
991
|
+
// Then try prefix match
|
|
992
|
+
for (const name of Object.keys(NAMED_COLORS)) {
|
|
993
|
+
if (name.toLowerCase().startsWith(lower)) return name;
|
|
994
|
+
}
|
|
995
|
+
for (const name of SPECIAL_COLORS) {
|
|
996
|
+
if (name.toLowerCase().startsWith(lower)) return name;
|
|
997
|
+
}
|
|
998
|
+
// Then try contains match
|
|
999
|
+
for (const name of Object.keys(NAMED_COLORS)) {
|
|
1000
|
+
if (name.toLowerCase().includes(lower)) return name;
|
|
1001
|
+
}
|
|
1002
|
+
return null;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* Handle color prompt confirmation
|
|
1007
|
+
*/
|
|
1008
|
+
globalThis.onThemeColorPromptConfirmed = function(args: {
|
|
1009
|
+
prompt_type: string;
|
|
1010
|
+
selected_index: number | null;
|
|
1011
|
+
input: string;
|
|
1012
|
+
}): boolean {
|
|
1013
|
+
if (!args.prompt_type.startsWith("theme-color-")) return true;
|
|
1014
|
+
|
|
1015
|
+
const path = args.prompt_type.replace("theme-color-", "");
|
|
1016
|
+
const field = getFieldByPath(path);
|
|
1017
|
+
if (!field) return true;
|
|
1018
|
+
|
|
1019
|
+
const result = parseColorInput(args.input);
|
|
1020
|
+
|
|
1021
|
+
if (result.value !== undefined) {
|
|
1022
|
+
// Valid color - apply it
|
|
1023
|
+
setNestedValue(state.themeData, path, result.value);
|
|
1024
|
+
state.hasChanges = !deepEqual(state.themeData, state.originalThemeData);
|
|
1025
|
+
|
|
1026
|
+
const entries = buildDisplayEntries();
|
|
1027
|
+
if (state.bufferId !== null) {
|
|
1028
|
+
editor.setVirtualBufferContent(state.bufferId, entries);
|
|
1029
|
+
applyHighlighting();
|
|
1030
|
+
}
|
|
1031
|
+
moveCursorToField(path);
|
|
1032
|
+
editor.setStatus(editor.t("status.updated", { path }));
|
|
1033
|
+
} else {
|
|
1034
|
+
// Invalid input - try to find a matching color name
|
|
1035
|
+
const matchedColor = findMatchingColor(args.input);
|
|
1036
|
+
if (matchedColor) {
|
|
1037
|
+
// Found a match - reopen prompt with the matched value
|
|
1038
|
+
editor.startPromptWithInitial(
|
|
1039
|
+
editor.t("prompt.color_input", { field: field.def.displayName }),
|
|
1040
|
+
`theme-color-${path}`,
|
|
1041
|
+
matchedColor
|
|
1042
|
+
);
|
|
1043
|
+
// Rebuild suggestions
|
|
1044
|
+
const suggestions: PromptSuggestion[] = buildColorSuggestions(field);
|
|
1045
|
+
editor.setPromptSuggestions(suggestions);
|
|
1046
|
+
editor.setStatus(editor.t("status.autocompleted", { value: matchedColor }));
|
|
1047
|
+
} else {
|
|
1048
|
+
// No match found - reopen prompt with original input
|
|
1049
|
+
editor.startPromptWithInitial(
|
|
1050
|
+
editor.t("prompt.color_input", { field: field.def.displayName }),
|
|
1051
|
+
`theme-color-${path}`,
|
|
1052
|
+
args.input
|
|
1053
|
+
);
|
|
1054
|
+
const suggestions: PromptSuggestion[] = buildColorSuggestions(field);
|
|
1055
|
+
editor.setPromptSuggestions(suggestions);
|
|
1056
|
+
|
|
1057
|
+
const errorKey = `error.color_${result.error}`;
|
|
1058
|
+
editor.setStatus(editor.t(errorKey, { input: args.input }));
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
return true;
|
|
1063
|
+
};
|
|
1064
|
+
|
|
1065
|
+
/**
|
|
1066
|
+
* Handle open theme prompt (both builtin and user themes)
|
|
1067
|
+
*/
|
|
1068
|
+
globalThis.onThemeOpenPromptConfirmed = async function(args: {
|
|
1069
|
+
prompt_type: string;
|
|
1070
|
+
selected_index: number | null;
|
|
1071
|
+
input: string;
|
|
1072
|
+
}): Promise<boolean> {
|
|
1073
|
+
if (args.prompt_type !== "theme-open") return true;
|
|
1074
|
+
|
|
1075
|
+
const value = args.input.trim();
|
|
1076
|
+
|
|
1077
|
+
// Parse the value to determine if it's user or builtin
|
|
1078
|
+
let isBuiltin = false;
|
|
1079
|
+
let themeName = value;
|
|
1080
|
+
|
|
1081
|
+
if (value.startsWith("user:")) {
|
|
1082
|
+
themeName = value.slice(5);
|
|
1083
|
+
isBuiltin = false;
|
|
1084
|
+
} else if (value.startsWith("builtin:")) {
|
|
1085
|
+
themeName = value.slice(8);
|
|
1086
|
+
isBuiltin = true;
|
|
1087
|
+
} else {
|
|
1088
|
+
// Fallback: check if it's a builtin theme
|
|
1089
|
+
isBuiltin = state.builtinThemes.includes(value);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
if (isBuiltin) {
|
|
1093
|
+
// Load builtin theme
|
|
1094
|
+
const themeData = await loadThemeFile(themeName);
|
|
1095
|
+
if (themeData) {
|
|
1096
|
+
state.themeData = deepClone(themeData);
|
|
1097
|
+
state.originalThemeData = deepClone(themeData);
|
|
1098
|
+
state.themeName = themeName;
|
|
1099
|
+
state.themePath = null; // No user path for builtin
|
|
1100
|
+
state.isBuiltin = true;
|
|
1101
|
+
state.hasChanges = false;
|
|
1102
|
+
updateDisplay();
|
|
1103
|
+
editor.setStatus(editor.t("status.opened_builtin", { name: themeName }));
|
|
1104
|
+
} else {
|
|
1105
|
+
editor.setStatus(editor.t("status.load_failed", { name: themeName }));
|
|
1106
|
+
}
|
|
1107
|
+
} else {
|
|
1108
|
+
// Load user theme
|
|
1109
|
+
const result = await loadUserThemeFile(themeName);
|
|
1110
|
+
if (result) {
|
|
1111
|
+
state.themeData = deepClone(result.data);
|
|
1112
|
+
state.originalThemeData = deepClone(result.data);
|
|
1113
|
+
state.themeName = themeName;
|
|
1114
|
+
state.themePath = result.path;
|
|
1115
|
+
state.isBuiltin = false;
|
|
1116
|
+
state.hasChanges = false;
|
|
1117
|
+
updateDisplay();
|
|
1118
|
+
editor.setStatus(editor.t("status.loaded", { name: themeName }));
|
|
1119
|
+
} else {
|
|
1120
|
+
editor.setStatus(editor.t("status.load_failed", { name: themeName }));
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
return true;
|
|
1125
|
+
};
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* Handle save as prompt
|
|
1129
|
+
*/
|
|
1130
|
+
globalThis.onThemeSaveAsPromptConfirmed = async function(args: {
|
|
1131
|
+
prompt_type: string;
|
|
1132
|
+
selected_index: number | null;
|
|
1133
|
+
input: string;
|
|
1134
|
+
}): Promise<boolean> {
|
|
1135
|
+
if (args.prompt_type !== "theme-save-as") return true;
|
|
1136
|
+
|
|
1137
|
+
const name = args.input.trim();
|
|
1138
|
+
if (name) {
|
|
1139
|
+
// Check if theme already exists
|
|
1140
|
+
const userThemesDir = getUserThemesDir();
|
|
1141
|
+
const targetPath = editor.pathJoin(userThemesDir, `${name}.json`);
|
|
1142
|
+
|
|
1143
|
+
if (editor.fileExists(targetPath)) {
|
|
1144
|
+
// Store pending save name for overwrite confirmation
|
|
1145
|
+
state.pendingSaveName = name;
|
|
1146
|
+
editor.startPrompt(editor.t("prompt.overwrite_confirm", { name }), "theme-overwrite-confirm");
|
|
1147
|
+
const suggestions: PromptSuggestion[] = [
|
|
1148
|
+
{ text: editor.t("prompt.overwrite_yes"), description: "", value: "overwrite" },
|
|
1149
|
+
{ text: editor.t("prompt.overwrite_no"), description: "", value: "cancel" },
|
|
1150
|
+
];
|
|
1151
|
+
editor.setPromptSuggestions(suggestions);
|
|
1152
|
+
return true;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
state.themeName = name;
|
|
1156
|
+
state.themeData.name = name;
|
|
1157
|
+
const restorePath = state.savedCursorPath;
|
|
1158
|
+
state.savedCursorPath = null;
|
|
1159
|
+
await saveTheme(name, restorePath);
|
|
1160
|
+
} else {
|
|
1161
|
+
state.savedCursorPath = null;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
return true;
|
|
1165
|
+
};
|
|
1166
|
+
|
|
1167
|
+
/**
|
|
1168
|
+
* Handle prompt cancellation
|
|
1169
|
+
*/
|
|
1170
|
+
globalThis.onThemePromptCancelled = function(args: { prompt_type: string }): boolean {
|
|
1171
|
+
if (!args.prompt_type.startsWith("theme-")) return true;
|
|
1172
|
+
|
|
1173
|
+
// Clear saved cursor path on cancellation
|
|
1174
|
+
state.savedCursorPath = null;
|
|
1175
|
+
state.pendingSaveName = null;
|
|
1176
|
+
|
|
1177
|
+
editor.setStatus(editor.t("status.cancelled"));
|
|
1178
|
+
return true;
|
|
1179
|
+
};
|
|
1180
|
+
|
|
1181
|
+
/**
|
|
1182
|
+
* Handle initial theme selection prompt (when opening editor)
|
|
1183
|
+
*/
|
|
1184
|
+
globalThis.onThemeSelectInitialPromptConfirmed = async function(args: {
|
|
1185
|
+
prompt_type: string;
|
|
1186
|
+
selected_index: number | null;
|
|
1187
|
+
input: string;
|
|
1188
|
+
}): Promise<boolean> {
|
|
1189
|
+
if (args.prompt_type !== "theme-select-initial") return true;
|
|
1190
|
+
|
|
1191
|
+
const value = args.input.trim();
|
|
1192
|
+
|
|
1193
|
+
// Parse the value to determine if it's user or builtin
|
|
1194
|
+
let isBuiltin = false;
|
|
1195
|
+
let themeName = value;
|
|
1196
|
+
|
|
1197
|
+
if (value.startsWith("user:")) {
|
|
1198
|
+
themeName = value.slice(5);
|
|
1199
|
+
isBuiltin = false;
|
|
1200
|
+
} else if (value.startsWith("builtin:")) {
|
|
1201
|
+
themeName = value.slice(8);
|
|
1202
|
+
isBuiltin = true;
|
|
1203
|
+
} else {
|
|
1204
|
+
// Fallback: check if it's a builtin theme
|
|
1205
|
+
isBuiltin = state.builtinThemes.includes(value);
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
editor.setStatus(editor.t("status.loading"));
|
|
1209
|
+
|
|
1210
|
+
if (isBuiltin) {
|
|
1211
|
+
// Load builtin theme
|
|
1212
|
+
const themeData = await loadThemeFile(themeName);
|
|
1213
|
+
if (themeData) {
|
|
1214
|
+
state.themeData = deepClone(themeData);
|
|
1215
|
+
state.originalThemeData = deepClone(themeData);
|
|
1216
|
+
state.themeName = themeName;
|
|
1217
|
+
state.themePath = null; // No user path for builtin
|
|
1218
|
+
state.isBuiltin = true;
|
|
1219
|
+
state.hasChanges = false;
|
|
1220
|
+
} else {
|
|
1221
|
+
// Fallback to default theme if load failed
|
|
1222
|
+
state.themeData = createDefaultTheme();
|
|
1223
|
+
state.originalThemeData = deepClone(state.themeData);
|
|
1224
|
+
state.themeName = themeName;
|
|
1225
|
+
state.themePath = null;
|
|
1226
|
+
state.isBuiltin = true;
|
|
1227
|
+
state.hasChanges = false;
|
|
1228
|
+
}
|
|
1229
|
+
} else {
|
|
1230
|
+
// Load user theme
|
|
1231
|
+
const result = await loadUserThemeFile(themeName);
|
|
1232
|
+
if (result) {
|
|
1233
|
+
state.themeData = deepClone(result.data);
|
|
1234
|
+
state.originalThemeData = deepClone(result.data);
|
|
1235
|
+
state.themeName = themeName;
|
|
1236
|
+
state.themePath = result.path;
|
|
1237
|
+
state.isBuiltin = false;
|
|
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 = false;
|
|
1246
|
+
state.hasChanges = false;
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// Now open the editor with loaded theme
|
|
1251
|
+
await doOpenThemeEditor();
|
|
1252
|
+
|
|
1253
|
+
return true;
|
|
1254
|
+
};
|
|
1255
|
+
|
|
1256
|
+
// Register prompt handlers
|
|
1257
|
+
editor.on("prompt_confirmed", "onThemeSelectInitialPromptConfirmed");
|
|
1258
|
+
editor.on("prompt_confirmed", "onThemeColorPromptConfirmed");
|
|
1259
|
+
editor.on("prompt_confirmed", "onThemeOpenPromptConfirmed");
|
|
1260
|
+
editor.on("prompt_confirmed", "onThemeSaveAsPromptConfirmed");
|
|
1261
|
+
editor.on("prompt_confirmed", "onThemeDiscardPromptConfirmed");
|
|
1262
|
+
editor.on("prompt_confirmed", "onThemeOverwritePromptConfirmed");
|
|
1263
|
+
editor.on("prompt_confirmed", "onThemeDeletePromptConfirmed");
|
|
1264
|
+
editor.on("prompt_cancelled", "onThemePromptCancelled");
|
|
1265
|
+
|
|
1266
|
+
// =============================================================================
|
|
1267
|
+
// Theme Operations
|
|
1268
|
+
// =============================================================================
|
|
1269
|
+
|
|
1270
|
+
/**
|
|
1271
|
+
* Save theme to file
|
|
1272
|
+
* @param name - Theme name to save as
|
|
1273
|
+
* @param restorePath - Optional field path to restore cursor to after save
|
|
1274
|
+
*/
|
|
1275
|
+
async function saveTheme(name?: string, restorePath?: string | null): Promise<boolean> {
|
|
1276
|
+
const themeName = name || state.themeName;
|
|
1277
|
+
const userThemesDir = getUserThemesDir();
|
|
1278
|
+
|
|
1279
|
+
// Ensure themes directory exists
|
|
1280
|
+
if (!editor.fileExists(userThemesDir)) {
|
|
1281
|
+
try {
|
|
1282
|
+
// Create directory via shell command
|
|
1283
|
+
await editor.spawnProcess("mkdir", ["-p", userThemesDir]);
|
|
1284
|
+
} catch (e) {
|
|
1285
|
+
editor.setStatus(editor.t("status.mkdir_failed", { error: String(e) }));
|
|
1286
|
+
return false;
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
const themePath = editor.pathJoin(userThemesDir, `${themeName}.json`);
|
|
1291
|
+
|
|
1292
|
+
try {
|
|
1293
|
+
state.themeData.name = themeName;
|
|
1294
|
+
const content = JSON.stringify(state.themeData, null, 2);
|
|
1295
|
+
await editor.writeFile(themePath, content);
|
|
1296
|
+
|
|
1297
|
+
state.themePath = themePath;
|
|
1298
|
+
state.themeName = themeName;
|
|
1299
|
+
state.isBuiltin = false; // After saving, it's now a user theme
|
|
1300
|
+
state.originalThemeData = deepClone(state.themeData);
|
|
1301
|
+
state.hasChanges = false;
|
|
1302
|
+
|
|
1303
|
+
// Update display
|
|
1304
|
+
const entries = buildDisplayEntries();
|
|
1305
|
+
if (state.bufferId !== null) {
|
|
1306
|
+
editor.setVirtualBufferContent(state.bufferId, entries);
|
|
1307
|
+
applyHighlighting();
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// Restore cursor position if provided
|
|
1311
|
+
if (restorePath) {
|
|
1312
|
+
moveCursorToField(restorePath);
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
// Automatically apply the saved theme
|
|
1316
|
+
editor.applyTheme(themeName);
|
|
1317
|
+
editor.setStatus(editor.t("status.saved_and_applied", { name: themeName }));
|
|
1318
|
+
return true;
|
|
1319
|
+
} catch (e) {
|
|
1320
|
+
editor.setStatus(editor.t("status.save_failed", { error: String(e) }));
|
|
1321
|
+
return false;
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
/**
|
|
1326
|
+
* Create a default/empty theme
|
|
1327
|
+
*/
|
|
1328
|
+
function createDefaultTheme(): Record<string, unknown> {
|
|
1329
|
+
return {
|
|
1330
|
+
name: "custom",
|
|
1331
|
+
editor: {
|
|
1332
|
+
bg: [30, 30, 30],
|
|
1333
|
+
fg: [212, 212, 212],
|
|
1334
|
+
cursor: [82, 139, 255],
|
|
1335
|
+
inactive_cursor: [100, 100, 100],
|
|
1336
|
+
selection_bg: [38, 79, 120],
|
|
1337
|
+
current_line_bg: [40, 40, 40],
|
|
1338
|
+
line_number_fg: [100, 100, 100],
|
|
1339
|
+
line_number_bg: [30, 30, 30],
|
|
1340
|
+
},
|
|
1341
|
+
ui: {
|
|
1342
|
+
tab_active_fg: "Yellow",
|
|
1343
|
+
tab_active_bg: "Blue",
|
|
1344
|
+
tab_inactive_fg: "White",
|
|
1345
|
+
tab_inactive_bg: "DarkGray",
|
|
1346
|
+
tab_separator_bg: "Black",
|
|
1347
|
+
status_bar_fg: "White",
|
|
1348
|
+
status_bar_bg: "DarkGray",
|
|
1349
|
+
prompt_fg: "White",
|
|
1350
|
+
prompt_bg: "Black",
|
|
1351
|
+
prompt_selection_fg: "White",
|
|
1352
|
+
prompt_selection_bg: [58, 79, 120],
|
|
1353
|
+
popup_border_fg: "Gray",
|
|
1354
|
+
popup_bg: [30, 30, 30],
|
|
1355
|
+
popup_selection_bg: [58, 79, 120],
|
|
1356
|
+
popup_text_fg: "White",
|
|
1357
|
+
suggestion_bg: [30, 30, 30],
|
|
1358
|
+
suggestion_selected_bg: [58, 79, 120],
|
|
1359
|
+
help_bg: "Black",
|
|
1360
|
+
help_fg: "White",
|
|
1361
|
+
help_key_fg: "Cyan",
|
|
1362
|
+
help_separator_fg: "DarkGray",
|
|
1363
|
+
help_indicator_fg: "Red",
|
|
1364
|
+
help_indicator_bg: "Black",
|
|
1365
|
+
split_separator_fg: [100, 100, 100],
|
|
1366
|
+
terminal_bg: "Default",
|
|
1367
|
+
terminal_fg: "Default",
|
|
1368
|
+
},
|
|
1369
|
+
search: {
|
|
1370
|
+
match_bg: [100, 100, 20],
|
|
1371
|
+
match_fg: [255, 255, 255],
|
|
1372
|
+
},
|
|
1373
|
+
diagnostic: {
|
|
1374
|
+
error_fg: "Red",
|
|
1375
|
+
error_bg: [60, 20, 20],
|
|
1376
|
+
warning_fg: "Yellow",
|
|
1377
|
+
warning_bg: [60, 50, 0],
|
|
1378
|
+
info_fg: "Blue",
|
|
1379
|
+
info_bg: [0, 30, 60],
|
|
1380
|
+
hint_fg: "Gray",
|
|
1381
|
+
hint_bg: [30, 30, 30],
|
|
1382
|
+
},
|
|
1383
|
+
syntax: {
|
|
1384
|
+
keyword: [86, 156, 214],
|
|
1385
|
+
string: [206, 145, 120],
|
|
1386
|
+
comment: [106, 153, 85],
|
|
1387
|
+
function: [220, 220, 170],
|
|
1388
|
+
type: [78, 201, 176],
|
|
1389
|
+
variable: [156, 220, 254],
|
|
1390
|
+
constant: [79, 193, 255],
|
|
1391
|
+
operator: [212, 212, 212],
|
|
1392
|
+
},
|
|
1393
|
+
};
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
// =============================================================================
|
|
1397
|
+
// Cursor Movement Handler
|
|
1398
|
+
// =============================================================================
|
|
1399
|
+
|
|
1400
|
+
globalThis.onThemeEditorCursorMoved = function(data: {
|
|
1401
|
+
buffer_id: number;
|
|
1402
|
+
cursor_id: number;
|
|
1403
|
+
old_position: number;
|
|
1404
|
+
new_position: number;
|
|
1405
|
+
}): void {
|
|
1406
|
+
if (state.bufferId === null || data.buffer_id !== state.bufferId) return;
|
|
1407
|
+
|
|
1408
|
+
applyHighlighting();
|
|
1409
|
+
|
|
1410
|
+
const field = getFieldAtCursor();
|
|
1411
|
+
if (field) {
|
|
1412
|
+
editor.setStatus(field.def.description);
|
|
1413
|
+
}
|
|
1414
|
+
};
|
|
1415
|
+
|
|
1416
|
+
editor.on("cursor_moved", "onThemeEditorCursorMoved");
|
|
1417
|
+
|
|
1418
|
+
/**
|
|
1419
|
+
* Handle buffer_closed event to reset state when buffer is closed by any means
|
|
1420
|
+
*/
|
|
1421
|
+
globalThis.onThemeEditorBufferClosed = function(data: {
|
|
1422
|
+
buffer_id: number;
|
|
1423
|
+
}): void {
|
|
1424
|
+
if (state.bufferId !== null && data.buffer_id === state.bufferId) {
|
|
1425
|
+
// Reset state when our buffer is closed
|
|
1426
|
+
state.bufferId = null;
|
|
1427
|
+
state.splitId = null;
|
|
1428
|
+
state.themeData = {};
|
|
1429
|
+
state.originalThemeData = {};
|
|
1430
|
+
state.hasChanges = false;
|
|
1431
|
+
}
|
|
1432
|
+
};
|
|
1433
|
+
|
|
1434
|
+
editor.on("buffer_closed", "onThemeEditorBufferClosed");
|
|
1435
|
+
|
|
1436
|
+
// =============================================================================
|
|
1437
|
+
// Smart Navigation - Skip Non-Selectable Lines
|
|
1438
|
+
// =============================================================================
|
|
1439
|
+
|
|
1440
|
+
interface SelectableEntry {
|
|
1441
|
+
byteOffset: number;
|
|
1442
|
+
valueByteOffset: number; // Position at the value (after "field: ")
|
|
1443
|
+
index: number;
|
|
1444
|
+
isSection: boolean;
|
|
1445
|
+
path: string;
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
/**
|
|
1449
|
+
* Get byte offsets for all selectable entries (fields and sections)
|
|
1450
|
+
*/
|
|
1451
|
+
function getSelectableEntries(): SelectableEntry[] {
|
|
1452
|
+
const entries = buildDisplayEntries();
|
|
1453
|
+
const selectableEntries: SelectableEntry[] = [];
|
|
1454
|
+
let byteOffset = 0;
|
|
1455
|
+
|
|
1456
|
+
for (const entry of entries) {
|
|
1457
|
+
const props = entry.properties as Record<string, unknown>;
|
|
1458
|
+
const entryType = props.type as string;
|
|
1459
|
+
const path = (props.path as string) || "";
|
|
1460
|
+
|
|
1461
|
+
// Only fields and sections are selectable (they have index property)
|
|
1462
|
+
if ((entryType === "field" || entryType === "section") && typeof props.index === "number") {
|
|
1463
|
+
// For fields, calculate position at the color value (after "FieldName: X ")
|
|
1464
|
+
let valueByteOffset = byteOffset;
|
|
1465
|
+
if (entryType === "field") {
|
|
1466
|
+
const colonIdx = entry.text.indexOf(":");
|
|
1467
|
+
if (colonIdx >= 0) {
|
|
1468
|
+
// Position at the hex value, after ": X " (colon + space + X + 2 spaces = 5 chars)
|
|
1469
|
+
valueByteOffset = byteOffset + getUtf8ByteLength(entry.text.substring(0, colonIdx + 5));
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
selectableEntries.push({
|
|
1474
|
+
byteOffset,
|
|
1475
|
+
valueByteOffset,
|
|
1476
|
+
index: props.index as number,
|
|
1477
|
+
isSection: entryType === "section",
|
|
1478
|
+
path,
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
byteOffset += getUtf8ByteLength(entry.text);
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
return selectableEntries;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
/**
|
|
1489
|
+
* Get the current selectable entry index based on cursor position
|
|
1490
|
+
*/
|
|
1491
|
+
function getCurrentSelectableIndex(): number {
|
|
1492
|
+
if (state.bufferId === null) return -1;
|
|
1493
|
+
|
|
1494
|
+
const props = editor.getTextPropertiesAtCursor(state.bufferId);
|
|
1495
|
+
if (props.length > 0 && typeof props[0].index === "number") {
|
|
1496
|
+
return props[0].index as number;
|
|
1497
|
+
}
|
|
1498
|
+
return -1;
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
/**
|
|
1502
|
+
* Get the current field path at cursor
|
|
1503
|
+
*/
|
|
1504
|
+
function getCurrentFieldPath(): string | null {
|
|
1505
|
+
if (state.bufferId === null) return null;
|
|
1506
|
+
|
|
1507
|
+
const props = editor.getTextPropertiesAtCursor(state.bufferId);
|
|
1508
|
+
if (props.length > 0 && typeof props[0].path === "string") {
|
|
1509
|
+
return props[0].path as string;
|
|
1510
|
+
}
|
|
1511
|
+
return null;
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
/**
|
|
1515
|
+
* Move cursor to a field by path (positions at value for fields)
|
|
1516
|
+
*/
|
|
1517
|
+
function moveCursorToField(path: string): void {
|
|
1518
|
+
if (state.bufferId === null) return;
|
|
1519
|
+
|
|
1520
|
+
const selectableEntries = getSelectableEntries();
|
|
1521
|
+
for (const entry of selectableEntries) {
|
|
1522
|
+
if (entry.path === path) {
|
|
1523
|
+
// Use valueByteOffset for fields, byteOffset for sections
|
|
1524
|
+
const targetOffset = entry.isSection ? entry.byteOffset : entry.valueByteOffset;
|
|
1525
|
+
editor.setBufferCursor(state.bufferId, targetOffset);
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
/**
|
|
1532
|
+
* Navigate to the next selectable field/section
|
|
1533
|
+
*/
|
|
1534
|
+
globalThis.theme_editor_nav_down = function(): void {
|
|
1535
|
+
if (state.bufferId === null) return;
|
|
1536
|
+
|
|
1537
|
+
const selectableEntries = getSelectableEntries();
|
|
1538
|
+
const currentIndex = getCurrentSelectableIndex();
|
|
1539
|
+
|
|
1540
|
+
// Find next selectable entry after current
|
|
1541
|
+
for (const entry of selectableEntries) {
|
|
1542
|
+
if (entry.index > currentIndex) {
|
|
1543
|
+
// Use valueByteOffset for fields, byteOffset for sections
|
|
1544
|
+
const targetOffset = entry.isSection ? entry.byteOffset : entry.valueByteOffset;
|
|
1545
|
+
editor.setBufferCursor(state.bufferId, targetOffset);
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
// Already at last selectable, stay there
|
|
1551
|
+
editor.setStatus(editor.t("status.at_last_field"));
|
|
1552
|
+
};
|
|
1553
|
+
|
|
1554
|
+
/**
|
|
1555
|
+
* Navigate to the previous selectable field/section
|
|
1556
|
+
*/
|
|
1557
|
+
globalThis.theme_editor_nav_up = function(): void {
|
|
1558
|
+
if (state.bufferId === null) return;
|
|
1559
|
+
|
|
1560
|
+
const selectableEntries = getSelectableEntries();
|
|
1561
|
+
const currentIndex = getCurrentSelectableIndex();
|
|
1562
|
+
|
|
1563
|
+
// Find previous selectable entry before current
|
|
1564
|
+
for (let i = selectableEntries.length - 1; i >= 0; i--) {
|
|
1565
|
+
const entry = selectableEntries[i];
|
|
1566
|
+
if (entry.index < currentIndex) {
|
|
1567
|
+
// Use valueByteOffset for fields, byteOffset for sections
|
|
1568
|
+
const targetOffset = entry.isSection ? entry.byteOffset : entry.valueByteOffset;
|
|
1569
|
+
editor.setBufferCursor(state.bufferId, targetOffset);
|
|
1570
|
+
return;
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
// Already at first selectable, stay there
|
|
1575
|
+
editor.setStatus(editor.t("status.at_first_field"));
|
|
1576
|
+
};
|
|
1577
|
+
|
|
1578
|
+
/**
|
|
1579
|
+
* Navigate to next element (Tab) - includes both fields and sections
|
|
1580
|
+
*/
|
|
1581
|
+
globalThis.theme_editor_nav_next_section = function(): void {
|
|
1582
|
+
if (state.bufferId === null) return;
|
|
1583
|
+
|
|
1584
|
+
const selectableEntries = getSelectableEntries();
|
|
1585
|
+
const currentIndex = getCurrentSelectableIndex();
|
|
1586
|
+
|
|
1587
|
+
// Find next selectable entry after current
|
|
1588
|
+
for (const entry of selectableEntries) {
|
|
1589
|
+
if (entry.index > currentIndex) {
|
|
1590
|
+
// Use valueByteOffset for fields, byteOffset for sections
|
|
1591
|
+
const targetOffset = entry.isSection ? entry.byteOffset : entry.valueByteOffset;
|
|
1592
|
+
editor.setBufferCursor(state.bufferId, targetOffset);
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
// Wrap to first entry
|
|
1598
|
+
if (selectableEntries.length > 0) {
|
|
1599
|
+
const entry = selectableEntries[0];
|
|
1600
|
+
const targetOffset = entry.isSection ? entry.byteOffset : entry.valueByteOffset;
|
|
1601
|
+
editor.setBufferCursor(state.bufferId, targetOffset);
|
|
1602
|
+
}
|
|
1603
|
+
};
|
|
1604
|
+
|
|
1605
|
+
/**
|
|
1606
|
+
* Navigate to previous element (Shift+Tab) - includes both fields and sections
|
|
1607
|
+
*/
|
|
1608
|
+
globalThis.theme_editor_nav_prev_section = function(): void {
|
|
1609
|
+
if (state.bufferId === null) return;
|
|
1610
|
+
|
|
1611
|
+
const selectableEntries = getSelectableEntries();
|
|
1612
|
+
const currentIndex = getCurrentSelectableIndex();
|
|
1613
|
+
|
|
1614
|
+
// Find previous selectable entry before current
|
|
1615
|
+
for (let i = selectableEntries.length - 1; i >= 0; i--) {
|
|
1616
|
+
const entry = selectableEntries[i];
|
|
1617
|
+
if (entry.index < currentIndex) {
|
|
1618
|
+
// Use valueByteOffset for fields, byteOffset for sections
|
|
1619
|
+
const targetOffset = entry.isSection ? entry.byteOffset : entry.valueByteOffset;
|
|
1620
|
+
editor.setBufferCursor(state.bufferId, targetOffset);
|
|
1621
|
+
return;
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
// Wrap to last entry
|
|
1626
|
+
if (selectableEntries.length > 0) {
|
|
1627
|
+
const entry = selectableEntries[selectableEntries.length - 1];
|
|
1628
|
+
const targetOffset = entry.isSection ? entry.byteOffset : entry.valueByteOffset;
|
|
1629
|
+
editor.setBufferCursor(state.bufferId, targetOffset);
|
|
1630
|
+
}
|
|
1631
|
+
};
|
|
1632
|
+
|
|
1633
|
+
// =============================================================================
|
|
1634
|
+
// Public Commands
|
|
1635
|
+
// =============================================================================
|
|
1636
|
+
|
|
1637
|
+
/**
|
|
1638
|
+
* Open the theme editor - prompts user to select theme first
|
|
1639
|
+
*/
|
|
1640
|
+
globalThis.open_theme_editor = async function(): Promise<void> {
|
|
1641
|
+
if (isThemeEditorOpen()) {
|
|
1642
|
+
// Focus the existing theme editor buffer
|
|
1643
|
+
editor.focusBuffer(state.bufferId!);
|
|
1644
|
+
editor.setStatus(editor.t("status.already_open"));
|
|
1645
|
+
return;
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
// Save context
|
|
1649
|
+
state.sourceSplitId = editor.getActiveSplitId();
|
|
1650
|
+
state.sourceBufferId = editor.getActiveBufferId();
|
|
1651
|
+
|
|
1652
|
+
// Load available themes
|
|
1653
|
+
state.builtinThemes = await loadBuiltinThemes();
|
|
1654
|
+
|
|
1655
|
+
// Get current theme name from config
|
|
1656
|
+
const config = editor.getConfig() as Record<string, unknown>;
|
|
1657
|
+
const currentThemeName = (config?.theme as string) || "dark";
|
|
1658
|
+
|
|
1659
|
+
// Prompt user to select which theme to edit
|
|
1660
|
+
editor.startPrompt(editor.t("prompt.select_theme_to_edit"), "theme-select-initial");
|
|
1661
|
+
|
|
1662
|
+
const suggestions: PromptSuggestion[] = [];
|
|
1663
|
+
|
|
1664
|
+
// Add user themes first
|
|
1665
|
+
const userThemes = listUserThemes();
|
|
1666
|
+
for (const name of userThemes) {
|
|
1667
|
+
const isCurrent = name === currentThemeName;
|
|
1668
|
+
suggestions.push({
|
|
1669
|
+
text: name,
|
|
1670
|
+
description: isCurrent ? editor.t("suggestion.user_theme_current") : editor.t("suggestion.user_theme"),
|
|
1671
|
+
value: `user:${name}`,
|
|
1672
|
+
});
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
// Add built-in themes
|
|
1676
|
+
for (const name of state.builtinThemes) {
|
|
1677
|
+
const isCurrent = name === currentThemeName;
|
|
1678
|
+
suggestions.push({
|
|
1679
|
+
text: name,
|
|
1680
|
+
description: isCurrent ? editor.t("suggestion.builtin_theme_current") : editor.t("suggestion.builtin_theme"),
|
|
1681
|
+
value: `builtin:${name}`,
|
|
1682
|
+
});
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
// Sort suggestions to put current theme first
|
|
1686
|
+
suggestions.sort((a, b) => {
|
|
1687
|
+
const aIsCurrent = a.description.includes("current");
|
|
1688
|
+
const bIsCurrent = b.description.includes("current");
|
|
1689
|
+
if (aIsCurrent && !bIsCurrent) return -1;
|
|
1690
|
+
if (!aIsCurrent && bIsCurrent) return 1;
|
|
1691
|
+
return 0;
|
|
1692
|
+
});
|
|
1693
|
+
|
|
1694
|
+
editor.setPromptSuggestions(suggestions);
|
|
1695
|
+
};
|
|
1696
|
+
|
|
1697
|
+
/**
|
|
1698
|
+
* Actually open the theme editor with loaded theme data
|
|
1699
|
+
*/
|
|
1700
|
+
async function doOpenThemeEditor(): Promise<void> {
|
|
1701
|
+
// Build initial entries
|
|
1702
|
+
const entries = buildDisplayEntries();
|
|
1703
|
+
|
|
1704
|
+
// Create virtual buffer in current split (no new split)
|
|
1705
|
+
const bufferId = await editor.createVirtualBuffer({
|
|
1706
|
+
name: "*Theme Editor*",
|
|
1707
|
+
mode: "theme-editor",
|
|
1708
|
+
read_only: true,
|
|
1709
|
+
entries: entries,
|
|
1710
|
+
show_line_numbers: false,
|
|
1711
|
+
show_cursors: true,
|
|
1712
|
+
editing_disabled: true,
|
|
1713
|
+
});
|
|
1714
|
+
|
|
1715
|
+
if (bufferId !== null) {
|
|
1716
|
+
state.bufferId = bufferId;
|
|
1717
|
+
state.splitId = null;
|
|
1718
|
+
|
|
1719
|
+
applyHighlighting();
|
|
1720
|
+
editor.setStatus(editor.t("status.ready"));
|
|
1721
|
+
} else {
|
|
1722
|
+
editor.setStatus(editor.t("status.open_failed"));
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
/**
|
|
1727
|
+
* Close the theme editor
|
|
1728
|
+
*/
|
|
1729
|
+
globalThis.theme_editor_close = function(): void {
|
|
1730
|
+
if (!isThemeEditorOpen()) return;
|
|
1731
|
+
|
|
1732
|
+
if (state.hasChanges) {
|
|
1733
|
+
// Show confirmation prompt before closing with unsaved changes
|
|
1734
|
+
editor.startPrompt(editor.t("prompt.discard_confirm"), "theme-discard-confirm");
|
|
1735
|
+
const suggestions: PromptSuggestion[] = [
|
|
1736
|
+
{ text: editor.t("prompt.discard_yes"), description: "", value: "discard" },
|
|
1737
|
+
{ text: editor.t("prompt.discard_no"), description: "", value: "keep" },
|
|
1738
|
+
];
|
|
1739
|
+
editor.setPromptSuggestions(suggestions);
|
|
1740
|
+
return;
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
doCloseEditor();
|
|
1744
|
+
};
|
|
1745
|
+
|
|
1746
|
+
/**
|
|
1747
|
+
* Actually close the editor (called after confirmation or when no changes)
|
|
1748
|
+
*/
|
|
1749
|
+
function doCloseEditor(): void {
|
|
1750
|
+
// Close the buffer (this will switch to another buffer in the same split)
|
|
1751
|
+
if (state.bufferId !== null) {
|
|
1752
|
+
editor.closeBuffer(state.bufferId);
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
// Reset state
|
|
1756
|
+
state.bufferId = null;
|
|
1757
|
+
state.splitId = null;
|
|
1758
|
+
state.themeData = {};
|
|
1759
|
+
state.originalThemeData = {};
|
|
1760
|
+
state.hasChanges = false;
|
|
1761
|
+
|
|
1762
|
+
editor.setStatus(editor.t("status.closed"));
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
/**
|
|
1766
|
+
* Handle discard confirmation prompt
|
|
1767
|
+
*/
|
|
1768
|
+
globalThis.onThemeDiscardPromptConfirmed = function(args: {
|
|
1769
|
+
prompt_type: string;
|
|
1770
|
+
selected_index: number | null;
|
|
1771
|
+
input: string;
|
|
1772
|
+
}): boolean {
|
|
1773
|
+
if (args.prompt_type !== "theme-discard-confirm") return true;
|
|
1774
|
+
|
|
1775
|
+
const response = args.input.trim().toLowerCase();
|
|
1776
|
+
if (response === "discard" || args.selected_index === 0) {
|
|
1777
|
+
editor.setStatus(editor.t("status.unsaved_discarded"));
|
|
1778
|
+
doCloseEditor();
|
|
1779
|
+
} else {
|
|
1780
|
+
editor.setStatus(editor.t("status.cancelled"));
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
return false;
|
|
1784
|
+
};
|
|
1785
|
+
|
|
1786
|
+
/**
|
|
1787
|
+
* Edit color at cursor
|
|
1788
|
+
*/
|
|
1789
|
+
globalThis.theme_editor_edit_color = function(): void {
|
|
1790
|
+
const field = getFieldAtCursor();
|
|
1791
|
+
if (!field) {
|
|
1792
|
+
editor.setStatus(editor.t("status.no_field"));
|
|
1793
|
+
return;
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
if (field.isSection) {
|
|
1797
|
+
theme_editor_toggle_section();
|
|
1798
|
+
return;
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
editColorField(field);
|
|
1802
|
+
};
|
|
1803
|
+
|
|
1804
|
+
/**
|
|
1805
|
+
* Toggle section expansion
|
|
1806
|
+
*/
|
|
1807
|
+
globalThis.theme_editor_toggle_section = function(): void {
|
|
1808
|
+
const field = getFieldAtCursor();
|
|
1809
|
+
if (!field || !field.isSection) {
|
|
1810
|
+
editor.setStatus(editor.t("status.not_section"));
|
|
1811
|
+
return;
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
if (state.expandedSections.has(field.path)) {
|
|
1815
|
+
state.expandedSections.delete(field.path);
|
|
1816
|
+
} else {
|
|
1817
|
+
state.expandedSections.add(field.path);
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
updateDisplay();
|
|
1821
|
+
};
|
|
1822
|
+
|
|
1823
|
+
/**
|
|
1824
|
+
* Open a theme (builtin or user) for editing
|
|
1825
|
+
*/
|
|
1826
|
+
globalThis.theme_editor_open = function(): void {
|
|
1827
|
+
editor.startPrompt(editor.t("prompt.open_theme"), "theme-open");
|
|
1828
|
+
|
|
1829
|
+
const suggestions: PromptSuggestion[] = [];
|
|
1830
|
+
|
|
1831
|
+
// Add user themes first
|
|
1832
|
+
const userThemes = listUserThemes();
|
|
1833
|
+
for (const name of userThemes) {
|
|
1834
|
+
suggestions.push({
|
|
1835
|
+
text: name,
|
|
1836
|
+
description: editor.t("suggestion.user_theme"),
|
|
1837
|
+
value: `user:${name}`,
|
|
1838
|
+
});
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
// Add built-in themes
|
|
1842
|
+
for (const name of state.builtinThemes) {
|
|
1843
|
+
suggestions.push({
|
|
1844
|
+
text: name,
|
|
1845
|
+
description: editor.t("suggestion.builtin_theme"),
|
|
1846
|
+
value: `builtin:${name}`,
|
|
1847
|
+
});
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
editor.setPromptSuggestions(suggestions);
|
|
1851
|
+
};
|
|
1852
|
+
|
|
1853
|
+
/**
|
|
1854
|
+
* Save theme
|
|
1855
|
+
*/
|
|
1856
|
+
globalThis.theme_editor_save = async function(): Promise<void> {
|
|
1857
|
+
// Save cursor path for restoration after save
|
|
1858
|
+
state.savedCursorPath = getCurrentFieldPath();
|
|
1859
|
+
|
|
1860
|
+
// Built-in themes require Save As
|
|
1861
|
+
if (state.isBuiltin) {
|
|
1862
|
+
editor.setStatus(editor.t("status.builtin_requires_save_as"));
|
|
1863
|
+
theme_editor_save_as();
|
|
1864
|
+
return;
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
// If theme has never been saved (no path), trigger "Save As" instead
|
|
1868
|
+
if (!state.themePath) {
|
|
1869
|
+
theme_editor_save_as();
|
|
1870
|
+
return;
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
if (!state.hasChanges) {
|
|
1874
|
+
editor.setStatus(editor.t("status.no_changes"));
|
|
1875
|
+
return;
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
// Check for name collision if name has changed since last save
|
|
1879
|
+
const userThemesDir = getUserThemesDir();
|
|
1880
|
+
const targetPath = editor.pathJoin(userThemesDir, `${state.themeName}.json`);
|
|
1881
|
+
|
|
1882
|
+
if (state.themePath !== targetPath && editor.fileExists(targetPath)) {
|
|
1883
|
+
// File exists with this name - ask for confirmation
|
|
1884
|
+
editor.startPrompt(editor.t("prompt.overwrite_confirm", { name: state.themeName }), "theme-overwrite-confirm");
|
|
1885
|
+
const suggestions: PromptSuggestion[] = [
|
|
1886
|
+
{ text: editor.t("prompt.overwrite_yes"), description: "", value: "overwrite" },
|
|
1887
|
+
{ text: editor.t("prompt.overwrite_no"), description: "", value: "cancel" },
|
|
1888
|
+
];
|
|
1889
|
+
editor.setPromptSuggestions(suggestions);
|
|
1890
|
+
return;
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
await saveTheme(undefined, state.savedCursorPath);
|
|
1894
|
+
};
|
|
1895
|
+
|
|
1896
|
+
/**
|
|
1897
|
+
* Handle overwrite confirmation prompt
|
|
1898
|
+
*/
|
|
1899
|
+
globalThis.onThemeOverwritePromptConfirmed = async function(args: {
|
|
1900
|
+
prompt_type: string;
|
|
1901
|
+
selected_index: number | null;
|
|
1902
|
+
input: string;
|
|
1903
|
+
}): Promise<boolean> {
|
|
1904
|
+
if (args.prompt_type !== "theme-overwrite-confirm") return true;
|
|
1905
|
+
|
|
1906
|
+
const response = args.input.trim().toLowerCase();
|
|
1907
|
+
if (response === "overwrite" || args.selected_index === 0) {
|
|
1908
|
+
// Use pending name if set (from Save As), otherwise use current name
|
|
1909
|
+
const nameToSave = state.pendingSaveName || state.themeName;
|
|
1910
|
+
state.themeName = nameToSave;
|
|
1911
|
+
state.themeData.name = nameToSave;
|
|
1912
|
+
state.pendingSaveName = null;
|
|
1913
|
+
const restorePath = state.savedCursorPath;
|
|
1914
|
+
state.savedCursorPath = null;
|
|
1915
|
+
await saveTheme(nameToSave, restorePath);
|
|
1916
|
+
} else {
|
|
1917
|
+
state.pendingSaveName = null;
|
|
1918
|
+
state.savedCursorPath = null;
|
|
1919
|
+
editor.setStatus(editor.t("status.cancelled"));
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
return false;
|
|
1923
|
+
};
|
|
1924
|
+
|
|
1925
|
+
/**
|
|
1926
|
+
* Save theme as (new name)
|
|
1927
|
+
*/
|
|
1928
|
+
globalThis.theme_editor_save_as = function(): void {
|
|
1929
|
+
// Save cursor path for restoration after save (if not already saved by theme_editor_save)
|
|
1930
|
+
if (!state.savedCursorPath) {
|
|
1931
|
+
state.savedCursorPath = getCurrentFieldPath();
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
editor.startPrompt(editor.t("prompt.save_as"), "theme-save-as");
|
|
1935
|
+
|
|
1936
|
+
editor.setPromptSuggestions([{
|
|
1937
|
+
text: state.themeName,
|
|
1938
|
+
description: editor.t("suggestion.current"),
|
|
1939
|
+
value: state.themeName,
|
|
1940
|
+
}]);
|
|
1941
|
+
};
|
|
1942
|
+
|
|
1943
|
+
/**
|
|
1944
|
+
* Reload theme
|
|
1945
|
+
*/
|
|
1946
|
+
globalThis.theme_editor_reload = async function(): Promise<void> {
|
|
1947
|
+
if (state.themePath) {
|
|
1948
|
+
const themeName = state.themeName;
|
|
1949
|
+
const themeData = await loadThemeFile(themeName);
|
|
1950
|
+
if (themeData) {
|
|
1951
|
+
state.themeData = deepClone(themeData);
|
|
1952
|
+
state.originalThemeData = deepClone(themeData);
|
|
1953
|
+
state.hasChanges = false;
|
|
1954
|
+
updateDisplay();
|
|
1955
|
+
editor.setStatus(editor.t("status.reloaded"));
|
|
1956
|
+
}
|
|
1957
|
+
} else {
|
|
1958
|
+
state.themeData = createDefaultTheme();
|
|
1959
|
+
state.originalThemeData = deepClone(state.themeData);
|
|
1960
|
+
state.hasChanges = false;
|
|
1961
|
+
updateDisplay();
|
|
1962
|
+
editor.setStatus(editor.t("status.reset"));
|
|
1963
|
+
}
|
|
1964
|
+
};
|
|
1965
|
+
|
|
1966
|
+
/**
|
|
1967
|
+
* Show help
|
|
1968
|
+
*/
|
|
1969
|
+
globalThis.theme_editor_show_help = function(): void {
|
|
1970
|
+
editor.setStatus(editor.t("status.help"));
|
|
1971
|
+
};
|
|
1972
|
+
|
|
1973
|
+
/**
|
|
1974
|
+
* Delete the current user theme
|
|
1975
|
+
*/
|
|
1976
|
+
globalThis.theme_editor_delete = function(): void {
|
|
1977
|
+
// Can only delete saved user themes
|
|
1978
|
+
if (!state.themePath) {
|
|
1979
|
+
editor.setStatus(editor.t("status.cannot_delete_unsaved"));
|
|
1980
|
+
return;
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
// Show confirmation dialog
|
|
1984
|
+
editor.startPrompt(editor.t("prompt.delete_confirm", { name: state.themeName }), "theme-delete-confirm");
|
|
1985
|
+
const suggestions: PromptSuggestion[] = [
|
|
1986
|
+
{ text: editor.t("prompt.delete_yes"), description: "", value: "delete" },
|
|
1987
|
+
{ text: editor.t("prompt.delete_no"), description: "", value: "cancel" },
|
|
1988
|
+
];
|
|
1989
|
+
editor.setPromptSuggestions(suggestions);
|
|
1990
|
+
};
|
|
1991
|
+
|
|
1992
|
+
/**
|
|
1993
|
+
* Handle delete confirmation prompt
|
|
1994
|
+
*/
|
|
1995
|
+
globalThis.onThemeDeletePromptConfirmed = async function(args: {
|
|
1996
|
+
prompt_type: string;
|
|
1997
|
+
selected_index: number | null;
|
|
1998
|
+
input: string;
|
|
1999
|
+
}): Promise<boolean> {
|
|
2000
|
+
if (args.prompt_type !== "theme-delete-confirm") return true;
|
|
2001
|
+
|
|
2002
|
+
const value = args.input.trim();
|
|
2003
|
+
if (value === "delete" || value === editor.t("prompt.delete_yes")) {
|
|
2004
|
+
if (state.themeName) {
|
|
2005
|
+
try {
|
|
2006
|
+
// Delete the theme file by name
|
|
2007
|
+
await editor.deleteTheme(state.themeName);
|
|
2008
|
+
const deletedName = state.themeName;
|
|
2009
|
+
|
|
2010
|
+
// Reset to default theme
|
|
2011
|
+
state.themeData = createDefaultTheme();
|
|
2012
|
+
state.originalThemeData = deepClone(state.themeData);
|
|
2013
|
+
state.themeName = "custom";
|
|
2014
|
+
state.themePath = null;
|
|
2015
|
+
state.hasChanges = false;
|
|
2016
|
+
updateDisplay();
|
|
2017
|
+
|
|
2018
|
+
editor.setStatus(editor.t("status.deleted", { name: deletedName }));
|
|
2019
|
+
} catch (e) {
|
|
2020
|
+
editor.setStatus(editor.t("status.delete_failed", { error: String(e) }));
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
} else {
|
|
2024
|
+
editor.setStatus(editor.t("status.cancelled"));
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
return true;
|
|
2028
|
+
};
|
|
2029
|
+
|
|
2030
|
+
// =============================================================================
|
|
2031
|
+
// Command Registration
|
|
2032
|
+
// =============================================================================
|
|
2033
|
+
|
|
2034
|
+
// Main command to open theme editor (always available)
|
|
2035
|
+
editor.registerCommand(
|
|
2036
|
+
"%cmd.edit_theme",
|
|
2037
|
+
"%cmd.edit_theme_desc",
|
|
2038
|
+
"open_theme_editor",
|
|
2039
|
+
"normal"
|
|
2040
|
+
);
|
|
2041
|
+
|
|
2042
|
+
// Buffer-scoped commands - only visible when a buffer with mode "theme-editor" is focused
|
|
2043
|
+
// The core automatically checks the focused buffer's mode against command contexts
|
|
2044
|
+
editor.registerCommand("%cmd.close_editor", "%cmd.close_editor_desc", "theme_editor_close", "theme-editor");
|
|
2045
|
+
editor.registerCommand("%cmd.edit_color", "%cmd.edit_color_desc", "theme_editor_edit_color", "theme-editor");
|
|
2046
|
+
editor.registerCommand("%cmd.toggle_section", "%cmd.toggle_section_desc", "theme_editor_toggle_section", "theme-editor");
|
|
2047
|
+
editor.registerCommand("%cmd.open_theme", "%cmd.open_theme_desc", "theme_editor_open", "theme-editor");
|
|
2048
|
+
editor.registerCommand("%cmd.save", "%cmd.save_desc", "theme_editor_save", "theme-editor");
|
|
2049
|
+
editor.registerCommand("%cmd.save_as", "%cmd.save_as_desc", "theme_editor_save_as", "theme-editor");
|
|
2050
|
+
editor.registerCommand("%cmd.reload", "%cmd.reload_desc", "theme_editor_reload", "theme-editor");
|
|
2051
|
+
editor.registerCommand("%cmd.show_help", "%cmd.show_help_desc", "theme_editor_show_help", "theme-editor");
|
|
2052
|
+
editor.registerCommand("%cmd.delete_theme", "%cmd.delete_theme_desc", "theme_editor_delete", "theme-editor");
|
|
2053
|
+
editor.registerCommand("%cmd.nav_up", "%cmd.nav_up_desc", "theme_editor_nav_up", "theme-editor");
|
|
2054
|
+
editor.registerCommand("%cmd.nav_down", "%cmd.nav_down_desc", "theme_editor_nav_down", "theme-editor");
|
|
2055
|
+
editor.registerCommand("%cmd.nav_next", "%cmd.nav_next_desc", "theme_editor_nav_next_section", "theme-editor");
|
|
2056
|
+
editor.registerCommand("%cmd.nav_prev", "%cmd.nav_prev_desc", "theme_editor_nav_prev_section", "theme-editor");
|
|
2057
|
+
|
|
2058
|
+
// =============================================================================
|
|
2059
|
+
// Plugin Initialization
|
|
2060
|
+
// =============================================================================
|
|
2061
|
+
|
|
2062
|
+
editor.setStatus(editor.t("status.plugin_loaded"));
|
|
2063
|
+
editor.debug("Theme Editor plugin initialized - Use 'Edit Theme' command to open");
|