@dex-ai/vue-tui 0.1.10
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/package.json +51 -0
- package/src/app.ts +385 -0
- package/src/components/TuiCheckbox.ts +35 -0
- package/src/components/TuiField.ts +123 -0
- package/src/components/TuiSelect.ts +86 -0
- package/src/components/index.ts +7 -0
- package/src/composables/index.ts +19 -0
- package/src/composables/useFocusList.ts +101 -0
- package/src/composables/useForm.ts +335 -0
- package/src/composables/useSelectList.ts +199 -0
- package/src/env.d.ts +6 -0
- package/src/index.ts +131 -0
- package/src/input.ts +603 -0
- package/src/nodes.ts +153 -0
- package/src/panel.ts +51 -0
- package/src/plugin.ts +148 -0
- package/src/register.ts +4 -0
- package/src/render.ts +632 -0
- package/src/renderer.ts +120 -0
- package/src/style.ts +107 -0
- package/src/template.ts +326 -0
- package/src/text-buffer.ts +609 -0
- package/src/theme.ts +44 -0
- package/src/types/form.ts +90 -0
- package/src/widget-renderer.ts +237 -0
- package/src/widget.ts +326 -0
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
import type { KeyEvent } from "./input";
|
|
2
|
+
import { ref, computed, type Ref, type ComputedRef } from "@vue/runtime-core";
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// TextBuffer — multi-line text editing state machine
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
/** Standard action names the text buffer understands. */
|
|
9
|
+
export type BufferAction =
|
|
10
|
+
| "insert"
|
|
11
|
+
| "backspace"
|
|
12
|
+
| "delete"
|
|
13
|
+
| "newline"
|
|
14
|
+
| "clear"
|
|
15
|
+
| "moveLeft"
|
|
16
|
+
| "moveRight"
|
|
17
|
+
| "moveUp"
|
|
18
|
+
| "moveDown"
|
|
19
|
+
| "moveHome"
|
|
20
|
+
| "moveEnd";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Result of a key/action being processed by the text buffer.
|
|
24
|
+
*
|
|
25
|
+
* - `"handled"` — key was consumed; cursor moved or buffer mutated.
|
|
26
|
+
* - `"boundary"` — key would have navigated past the start/end of the
|
|
27
|
+
* buffer (e.g. Up at row 0, Down at last row).
|
|
28
|
+
* Consumers (e.g. history navigation) can use this signal
|
|
29
|
+
* to take over.
|
|
30
|
+
* - `"unhandled"` — key was not recognized by the buffer.
|
|
31
|
+
*/
|
|
32
|
+
export type BufferKeyResult = "handled" | "boundary" | "unhandled";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Convenience helper: treat `"handled"` or `"boundary"` as "key was consumed".
|
|
36
|
+
*/
|
|
37
|
+
export function isKeyHandled(result: BufferKeyResult): boolean {
|
|
38
|
+
return result !== "unhandled";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Paste buffer — unified storage for pasted text and images
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
/** A paste entry: either multi-line text or binary image data. */
|
|
46
|
+
export type PasteEntry =
|
|
47
|
+
| { type: "text"; content: string; lineCount: number }
|
|
48
|
+
| { type: "image"; data: Uint8Array; mediaType: string };
|
|
49
|
+
|
|
50
|
+
/** A slot in the paste buffer — tracks an individual paste with display index. */
|
|
51
|
+
export interface PasteSlot {
|
|
52
|
+
/** Unique ID used in sentinel markers. */
|
|
53
|
+
id: number;
|
|
54
|
+
/** Sequential display index (1-based, per-type). Text pastes: "Pasted N lines", images: "Image #N". */
|
|
55
|
+
index: number;
|
|
56
|
+
/** The paste payload. */
|
|
57
|
+
entry: PasteEntry;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** @deprecated Backwards-compatible alias — use PasteSlot instead. */
|
|
61
|
+
export type PasteToken = PasteSlot;
|
|
62
|
+
|
|
63
|
+
/** @deprecated Backwards-compatible alias — use PasteSlot instead. */
|
|
64
|
+
export type ImageToken = PasteSlot;
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Sentinel system — inline markers in buffer text
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
/** Unified sentinel pattern: \x00SLOT:id\x00 */
|
|
71
|
+
const SENTINEL_RE = /\x00SLOT:(\d+)\x00/g;
|
|
72
|
+
|
|
73
|
+
/** Create a sentinel string for a slot ID. */
|
|
74
|
+
function slotSentinel(id: number): string {
|
|
75
|
+
return `\x00SLOT:${id}\x00`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Info about a sentinel found at a position. */
|
|
79
|
+
interface SentinelInfo {
|
|
80
|
+
start: number;
|
|
81
|
+
end: number;
|
|
82
|
+
id: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Check if a character at a position in a string is inside any sentinel. */
|
|
86
|
+
function findSentinelAt(line: string, col: number): SentinelInfo | null {
|
|
87
|
+
SENTINEL_RE.lastIndex = 0;
|
|
88
|
+
let match: RegExpExecArray | null;
|
|
89
|
+
while ((match = SENTINEL_RE.exec(line)) !== null) {
|
|
90
|
+
const start = match.index;
|
|
91
|
+
const end = start + match[0].length;
|
|
92
|
+
if (col >= start && col < end) {
|
|
93
|
+
return { start, end, id: parseInt(match[1]!, 10) };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Find the sentinel immediately before a cursor position. */
|
|
100
|
+
function findSentinelBefore(line: string, col: number): SentinelInfo | null {
|
|
101
|
+
SENTINEL_RE.lastIndex = 0;
|
|
102
|
+
let match: RegExpExecArray | null;
|
|
103
|
+
while ((match = SENTINEL_RE.exec(line)) !== null) {
|
|
104
|
+
const start = match.index;
|
|
105
|
+
const end = start + match[0].length;
|
|
106
|
+
if (end === col) {
|
|
107
|
+
return { start, end, id: parseInt(match[1]!, 10) };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Find the sentinel immediately after a cursor position. */
|
|
114
|
+
function findSentinelAfter(line: string, col: number): SentinelInfo | null {
|
|
115
|
+
SENTINEL_RE.lastIndex = 0;
|
|
116
|
+
let match: RegExpExecArray | null;
|
|
117
|
+
while ((match = SENTINEL_RE.exec(line)) !== null) {
|
|
118
|
+
const start = match.index;
|
|
119
|
+
const end = start + match[0].length;
|
|
120
|
+
if (start === col) {
|
|
121
|
+
return { start, end, id: parseInt(match[1]!, 10) };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// TextBuffer interface
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
export interface TextBuffer {
|
|
132
|
+
/** The lines of text in the buffer. */
|
|
133
|
+
lines: Ref<string[]>;
|
|
134
|
+
/** Current cursor row (0-indexed). */
|
|
135
|
+
cursorRow: Ref<number>;
|
|
136
|
+
/** Current cursor column (0-indexed). */
|
|
137
|
+
cursorCol: Ref<number>;
|
|
138
|
+
/** The full buffer value as a single string (lines joined with \n). */
|
|
139
|
+
value: ComputedRef<string>;
|
|
140
|
+
/** The resolved text value with paste sentinels expanded (images stripped). */
|
|
141
|
+
resolvedValue: ComputedRef<string>;
|
|
142
|
+
/** The paste buffer — ordered list of all paste/image slots. */
|
|
143
|
+
pasteBuffer: Ref<PasteSlot[]>;
|
|
144
|
+
|
|
145
|
+
/** @deprecated Use pasteBuffer instead. Provides a Map view for backward compat. */
|
|
146
|
+
pasteTokens: Ref<Map<number, PasteSlot>>;
|
|
147
|
+
/** @deprecated Use pasteBuffer instead. Provides a Map view for backward compat. */
|
|
148
|
+
imageTokens: Ref<Map<number, PasteSlot>>;
|
|
149
|
+
|
|
150
|
+
// Mutations
|
|
151
|
+
insert(text: string): void;
|
|
152
|
+
/** Insert a text paste at cursor position. Returns the slot ID. */
|
|
153
|
+
insertPaste(text: string): number;
|
|
154
|
+
/** Insert an image paste at cursor position. Returns the slot ID. */
|
|
155
|
+
insertImage(data: Uint8Array, mediaType: string): number;
|
|
156
|
+
backspace(): void;
|
|
157
|
+
delete(): void;
|
|
158
|
+
newline(): void;
|
|
159
|
+
clear(): void;
|
|
160
|
+
/** Set the buffer to the given text, replacing all content. Cursor moves to end. */
|
|
161
|
+
setValue(text: string): void;
|
|
162
|
+
|
|
163
|
+
// Navigation
|
|
164
|
+
moveLeft(): void;
|
|
165
|
+
moveRight(): void;
|
|
166
|
+
moveUp(): void;
|
|
167
|
+
moveDown(): void;
|
|
168
|
+
moveHome(): void;
|
|
169
|
+
moveEnd(): void;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Handle a KeyEvent — maps keys to mutations/navigation.
|
|
173
|
+
*
|
|
174
|
+
* Returns:
|
|
175
|
+
* - `"handled"` if the key was consumed (cursor moved, text inserted, etc.)
|
|
176
|
+
* - `"boundary"` if the key was a navigation that cannot advance further
|
|
177
|
+
* (Up at row 0, Down at last row). Consumers can use this
|
|
178
|
+
* to trigger external behavior like history recall.
|
|
179
|
+
* - `"unhandled"` if the buffer does not handle this key (caller should).
|
|
180
|
+
*/
|
|
181
|
+
handleKey(key: KeyEvent): BufferKeyResult;
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Handle a named action — directly invoke a buffer operation.
|
|
185
|
+
* Returns `"handled"`, `"boundary"`, or `"unhandled"` (same semantics as handleKey).
|
|
186
|
+
*/
|
|
187
|
+
handleAction(action: string, key?: KeyEvent): BufferKeyResult;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export interface TextBufferOptions {
|
|
191
|
+
/** Allow multi-line input. Default: true */
|
|
192
|
+
multiline?: boolean;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// Implementation
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
export function createTextBuffer(options?: TextBufferOptions): TextBuffer {
|
|
200
|
+
const multiline = options?.multiline ?? true;
|
|
201
|
+
|
|
202
|
+
const lines: Ref<string[]> = ref([""]);
|
|
203
|
+
const cursorRow: Ref<number> = ref(0);
|
|
204
|
+
const cursorCol: Ref<number> = ref(0);
|
|
205
|
+
const pasteBuffer: Ref<PasteSlot[]> = ref([]);
|
|
206
|
+
let nextSlotId = 1;
|
|
207
|
+
let nextIndex = 0;
|
|
208
|
+
|
|
209
|
+
const value: ComputedRef<string> = computed(() => lines.value.join("\n"));
|
|
210
|
+
|
|
211
|
+
/** Resolve paste sentinels → actual text content; image sentinels → empty string. */
|
|
212
|
+
const resolvedValue: ComputedRef<string> = computed(() => {
|
|
213
|
+
const raw = lines.value.join("\n");
|
|
214
|
+
const slots = pasteBuffer.value;
|
|
215
|
+
if (slots.length === 0) return raw;
|
|
216
|
+
const slotMap = new Map(slots.map((s) => [s.id, s]));
|
|
217
|
+
return raw.replace(SENTINEL_RE, (_, idStr: string) => {
|
|
218
|
+
const id = parseInt(idStr, 10);
|
|
219
|
+
const slot = slotMap.get(id);
|
|
220
|
+
if (!slot) return "";
|
|
221
|
+
if (slot.entry.type === "text") return slot.entry.content;
|
|
222
|
+
// Images are stripped from text — they become separate content blocks on submit
|
|
223
|
+
return "";
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// Backwards-compatible Map views
|
|
228
|
+
const pasteTokens: Ref<Map<number, PasteSlot>> = computed(() => {
|
|
229
|
+
const map = new Map<number, PasteSlot>();
|
|
230
|
+
for (const slot of pasteBuffer.value) {
|
|
231
|
+
if (slot.entry.type === "text") map.set(slot.id, slot);
|
|
232
|
+
}
|
|
233
|
+
return map;
|
|
234
|
+
}) as unknown as Ref<Map<number, PasteSlot>>;
|
|
235
|
+
|
|
236
|
+
const imageTokens: Ref<Map<number, PasteSlot>> = computed(() => {
|
|
237
|
+
const map = new Map<number, PasteSlot>();
|
|
238
|
+
for (const slot of pasteBuffer.value) {
|
|
239
|
+
if (slot.entry.type === "image") map.set(slot.id, slot);
|
|
240
|
+
}
|
|
241
|
+
return map;
|
|
242
|
+
}) as unknown as Ref<Map<number, PasteSlot>>;
|
|
243
|
+
|
|
244
|
+
function currentLine(): string {
|
|
245
|
+
return lines.value[cursorRow.value] ?? "";
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function setLine(row: number, content: string): void {
|
|
249
|
+
const updated = [...lines.value];
|
|
250
|
+
updated[row] = content;
|
|
251
|
+
lines.value = updated;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Remove a slot from the paste buffer. */
|
|
255
|
+
function removeSlot(id: number): void {
|
|
256
|
+
pasteBuffer.value = pasteBuffer.value.filter((s) => s.id !== id);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ─── Mutations ──────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
function insert(text: string): void {
|
|
262
|
+
// If cursor is inside a sentinel, remove it first
|
|
263
|
+
const line = currentLine();
|
|
264
|
+
const sentinel = findSentinelAt(line, cursorCol.value);
|
|
265
|
+
if (sentinel) {
|
|
266
|
+
removeSlot(sentinel.id);
|
|
267
|
+
setLine(
|
|
268
|
+
cursorRow.value,
|
|
269
|
+
line.slice(0, sentinel.start) + line.slice(sentinel.end),
|
|
270
|
+
);
|
|
271
|
+
cursorCol.value = sentinel.start;
|
|
272
|
+
}
|
|
273
|
+
const updatedLine = currentLine();
|
|
274
|
+
setLine(
|
|
275
|
+
cursorRow.value,
|
|
276
|
+
updatedLine.slice(0, cursorCol.value) +
|
|
277
|
+
text +
|
|
278
|
+
updatedLine.slice(cursorCol.value),
|
|
279
|
+
);
|
|
280
|
+
cursorCol.value += text.length;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function insertPaste(text: string): number {
|
|
284
|
+
const id = nextSlotId++;
|
|
285
|
+
const lineCount = text.split("\n").length;
|
|
286
|
+
nextIndex++;
|
|
287
|
+
const slot: PasteSlot = {
|
|
288
|
+
id,
|
|
289
|
+
index: nextIndex,
|
|
290
|
+
entry: { type: "text", content: text, lineCount },
|
|
291
|
+
};
|
|
292
|
+
pasteBuffer.value = [...pasteBuffer.value, slot];
|
|
293
|
+
|
|
294
|
+
const sentinel = slotSentinel(id);
|
|
295
|
+
const line = currentLine();
|
|
296
|
+
setLine(
|
|
297
|
+
cursorRow.value,
|
|
298
|
+
line.slice(0, cursorCol.value) + sentinel + line.slice(cursorCol.value),
|
|
299
|
+
);
|
|
300
|
+
cursorCol.value += sentinel.length;
|
|
301
|
+
return id;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function insertImage(data: Uint8Array, mediaType: string): number {
|
|
305
|
+
const id = nextSlotId++;
|
|
306
|
+
nextIndex++;
|
|
307
|
+
const slot: PasteSlot = {
|
|
308
|
+
id,
|
|
309
|
+
index: nextIndex,
|
|
310
|
+
entry: { type: "image", data, mediaType },
|
|
311
|
+
};
|
|
312
|
+
pasteBuffer.value = [...pasteBuffer.value, slot];
|
|
313
|
+
|
|
314
|
+
const sentinel = slotSentinel(id);
|
|
315
|
+
const line = currentLine();
|
|
316
|
+
setLine(
|
|
317
|
+
cursorRow.value,
|
|
318
|
+
line.slice(0, cursorCol.value) + sentinel + line.slice(cursorCol.value),
|
|
319
|
+
);
|
|
320
|
+
cursorCol.value += sentinel.length;
|
|
321
|
+
return id;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function backspace(): void {
|
|
325
|
+
if (cursorCol.value > 0) {
|
|
326
|
+
const line = currentLine();
|
|
327
|
+
// Check if we're backspacing into a sentinel
|
|
328
|
+
const sentinel = findSentinelBefore(line, cursorCol.value);
|
|
329
|
+
if (sentinel) {
|
|
330
|
+
// Remove entire sentinel + slot
|
|
331
|
+
removeSlot(sentinel.id);
|
|
332
|
+
setLine(
|
|
333
|
+
cursorRow.value,
|
|
334
|
+
line.slice(0, sentinel.start) + line.slice(sentinel.end),
|
|
335
|
+
);
|
|
336
|
+
cursorCol.value = sentinel.start;
|
|
337
|
+
} else {
|
|
338
|
+
// Check if cursor is inside a sentinel
|
|
339
|
+
const inside = findSentinelAt(line, cursorCol.value - 1);
|
|
340
|
+
if (inside) {
|
|
341
|
+
removeSlot(inside.id);
|
|
342
|
+
setLine(
|
|
343
|
+
cursorRow.value,
|
|
344
|
+
line.slice(0, inside.start) + line.slice(inside.end),
|
|
345
|
+
);
|
|
346
|
+
cursorCol.value = inside.start;
|
|
347
|
+
} else {
|
|
348
|
+
setLine(
|
|
349
|
+
cursorRow.value,
|
|
350
|
+
line.slice(0, cursorCol.value - 1) + line.slice(cursorCol.value),
|
|
351
|
+
);
|
|
352
|
+
cursorCol.value--;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
} else if (cursorRow.value > 0) {
|
|
356
|
+
// Join with previous line
|
|
357
|
+
const updated = [...lines.value];
|
|
358
|
+
const prevLine = updated[cursorRow.value - 1] ?? "";
|
|
359
|
+
const currLine = updated[cursorRow.value] ?? "";
|
|
360
|
+
const newCol = prevLine.length;
|
|
361
|
+
updated[cursorRow.value - 1] = prevLine + currLine;
|
|
362
|
+
updated.splice(cursorRow.value, 1);
|
|
363
|
+
lines.value = updated;
|
|
364
|
+
cursorRow.value--;
|
|
365
|
+
cursorCol.value = newCol;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function del(): void {
|
|
370
|
+
const line = currentLine();
|
|
371
|
+
if (cursorCol.value < line.length) {
|
|
372
|
+
// Check if we're deleting into a sentinel
|
|
373
|
+
const sentinel = findSentinelAfter(line, cursorCol.value);
|
|
374
|
+
if (sentinel) {
|
|
375
|
+
// Remove entire sentinel + slot
|
|
376
|
+
removeSlot(sentinel.id);
|
|
377
|
+
setLine(
|
|
378
|
+
cursorRow.value,
|
|
379
|
+
line.slice(0, sentinel.start) + line.slice(sentinel.end),
|
|
380
|
+
);
|
|
381
|
+
} else {
|
|
382
|
+
// Check if cursor is inside a sentinel
|
|
383
|
+
const inside = findSentinelAt(line, cursorCol.value);
|
|
384
|
+
if (inside) {
|
|
385
|
+
removeSlot(inside.id);
|
|
386
|
+
setLine(
|
|
387
|
+
cursorRow.value,
|
|
388
|
+
line.slice(0, inside.start) + line.slice(inside.end),
|
|
389
|
+
);
|
|
390
|
+
cursorCol.value = inside.start;
|
|
391
|
+
} else {
|
|
392
|
+
setLine(
|
|
393
|
+
cursorRow.value,
|
|
394
|
+
line.slice(0, cursorCol.value) + line.slice(cursorCol.value + 1),
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
} else if (cursorRow.value < lines.value.length - 1) {
|
|
399
|
+
// Join with next line
|
|
400
|
+
const updated = [...lines.value];
|
|
401
|
+
const nextLine = updated[cursorRow.value + 1] ?? "";
|
|
402
|
+
updated[cursorRow.value] = line + nextLine;
|
|
403
|
+
updated.splice(cursorRow.value + 1, 1);
|
|
404
|
+
lines.value = updated;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function newline(): void {
|
|
409
|
+
if (!multiline) return;
|
|
410
|
+
const line = currentLine();
|
|
411
|
+
const before = line.slice(0, cursorCol.value);
|
|
412
|
+
const after = line.slice(cursorCol.value);
|
|
413
|
+
const updated = [...lines.value];
|
|
414
|
+
updated[cursorRow.value] = before;
|
|
415
|
+
updated.splice(cursorRow.value + 1, 0, after);
|
|
416
|
+
lines.value = updated;
|
|
417
|
+
cursorRow.value++;
|
|
418
|
+
cursorCol.value = 0;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function clear(): void {
|
|
422
|
+
lines.value = [""];
|
|
423
|
+
cursorRow.value = 0;
|
|
424
|
+
cursorCol.value = 0;
|
|
425
|
+
pasteBuffer.value = [];
|
|
426
|
+
nextIndex = 0;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function setValue(text: string): void {
|
|
430
|
+
const newLines = text.split("\n");
|
|
431
|
+
lines.value = newLines;
|
|
432
|
+
cursorRow.value = newLines.length - 1;
|
|
433
|
+
cursorCol.value = (newLines[newLines.length - 1] ?? "").length;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ─── Navigation ─────────────────────────────────────────────
|
|
437
|
+
|
|
438
|
+
function moveLeft(): void {
|
|
439
|
+
if (cursorCol.value > 0) {
|
|
440
|
+
// Check if stepping left enters a sentinel — skip over the whole thing
|
|
441
|
+
const line = currentLine();
|
|
442
|
+
const sentinel = findSentinelAt(line, cursorCol.value - 1);
|
|
443
|
+
if (sentinel) {
|
|
444
|
+
cursorCol.value = sentinel.start;
|
|
445
|
+
} else {
|
|
446
|
+
cursorCol.value--;
|
|
447
|
+
}
|
|
448
|
+
} else if (cursorRow.value > 0) {
|
|
449
|
+
cursorRow.value--;
|
|
450
|
+
cursorCol.value = (lines.value[cursorRow.value] ?? "").length;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function moveRight(): void {
|
|
455
|
+
const lineLen = currentLine().length;
|
|
456
|
+
if (cursorCol.value < lineLen) {
|
|
457
|
+
// Check if stepping right enters a sentinel — skip over the whole thing
|
|
458
|
+
const line = currentLine();
|
|
459
|
+
const sentinel = findSentinelAfter(line, cursorCol.value);
|
|
460
|
+
if (sentinel) {
|
|
461
|
+
cursorCol.value = sentinel.end;
|
|
462
|
+
} else {
|
|
463
|
+
cursorCol.value++;
|
|
464
|
+
}
|
|
465
|
+
} else if (cursorRow.value < lines.value.length - 1) {
|
|
466
|
+
cursorRow.value++;
|
|
467
|
+
cursorCol.value = 0;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function moveUp(): void {
|
|
472
|
+
if (cursorRow.value > 0) {
|
|
473
|
+
cursorRow.value--;
|
|
474
|
+
const lineLen = (lines.value[cursorRow.value] ?? "").length;
|
|
475
|
+
cursorCol.value = Math.min(cursorCol.value, lineLen);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function moveDown(): void {
|
|
480
|
+
if (cursorRow.value < lines.value.length - 1) {
|
|
481
|
+
cursorRow.value++;
|
|
482
|
+
const lineLen = (lines.value[cursorRow.value] ?? "").length;
|
|
483
|
+
cursorCol.value = Math.min(cursorCol.value, lineLen);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function moveHome(): void {
|
|
488
|
+
cursorCol.value = 0;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function moveEnd(): void {
|
|
492
|
+
cursorCol.value = currentLine().length;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// ─── Key handler ────────────────────────────────────────────
|
|
496
|
+
|
|
497
|
+
function handleKey(key: KeyEvent): BufferKeyResult {
|
|
498
|
+
switch (key.name) {
|
|
499
|
+
case "left":
|
|
500
|
+
moveLeft();
|
|
501
|
+
return "handled";
|
|
502
|
+
case "right":
|
|
503
|
+
moveRight();
|
|
504
|
+
return "handled";
|
|
505
|
+
case "up":
|
|
506
|
+
if (cursorRow.value === 0) return "boundary";
|
|
507
|
+
moveUp();
|
|
508
|
+
return "handled";
|
|
509
|
+
case "down":
|
|
510
|
+
if (cursorRow.value === lines.value.length - 1) return "boundary";
|
|
511
|
+
moveDown();
|
|
512
|
+
return "handled";
|
|
513
|
+
case "home":
|
|
514
|
+
moveHome();
|
|
515
|
+
return "handled";
|
|
516
|
+
case "end":
|
|
517
|
+
moveEnd();
|
|
518
|
+
return "handled";
|
|
519
|
+
case "backspace":
|
|
520
|
+
backspace();
|
|
521
|
+
return "handled";
|
|
522
|
+
case "delete":
|
|
523
|
+
del();
|
|
524
|
+
return "handled";
|
|
525
|
+
case "enter":
|
|
526
|
+
return "unhandled"; // let app handle (submit, newline, etc.)
|
|
527
|
+
case "char":
|
|
528
|
+
if (key.char) {
|
|
529
|
+
insert(key.char);
|
|
530
|
+
return "handled";
|
|
531
|
+
}
|
|
532
|
+
return "unhandled";
|
|
533
|
+
default:
|
|
534
|
+
return "unhandled";
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function handleAction(action: string, key?: KeyEvent): BufferKeyResult {
|
|
539
|
+
switch (action) {
|
|
540
|
+
case "insert":
|
|
541
|
+
if (key?.char) {
|
|
542
|
+
insert(key.char);
|
|
543
|
+
return "handled";
|
|
544
|
+
}
|
|
545
|
+
return "unhandled";
|
|
546
|
+
case "backspace":
|
|
547
|
+
backspace();
|
|
548
|
+
return "handled";
|
|
549
|
+
case "delete":
|
|
550
|
+
del();
|
|
551
|
+
return "handled";
|
|
552
|
+
case "newline":
|
|
553
|
+
newline();
|
|
554
|
+
return "handled";
|
|
555
|
+
case "clear":
|
|
556
|
+
clear();
|
|
557
|
+
return "handled";
|
|
558
|
+
case "moveLeft":
|
|
559
|
+
moveLeft();
|
|
560
|
+
return "handled";
|
|
561
|
+
case "moveRight":
|
|
562
|
+
moveRight();
|
|
563
|
+
return "handled";
|
|
564
|
+
case "moveUp":
|
|
565
|
+
if (cursorRow.value === 0) return "boundary";
|
|
566
|
+
moveUp();
|
|
567
|
+
return "handled";
|
|
568
|
+
case "moveDown":
|
|
569
|
+
if (cursorRow.value === lines.value.length - 1) return "boundary";
|
|
570
|
+
moveDown();
|
|
571
|
+
return "handled";
|
|
572
|
+
case "moveHome":
|
|
573
|
+
moveHome();
|
|
574
|
+
return "handled";
|
|
575
|
+
case "moveEnd":
|
|
576
|
+
moveEnd();
|
|
577
|
+
return "handled";
|
|
578
|
+
default:
|
|
579
|
+
return "unhandled";
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
return {
|
|
584
|
+
lines,
|
|
585
|
+
cursorRow,
|
|
586
|
+
cursorCol,
|
|
587
|
+
value,
|
|
588
|
+
resolvedValue,
|
|
589
|
+
pasteBuffer,
|
|
590
|
+
pasteTokens,
|
|
591
|
+
imageTokens,
|
|
592
|
+
insert,
|
|
593
|
+
insertPaste,
|
|
594
|
+
insertImage,
|
|
595
|
+
backspace,
|
|
596
|
+
delete: del,
|
|
597
|
+
newline,
|
|
598
|
+
clear,
|
|
599
|
+
setValue,
|
|
600
|
+
moveLeft,
|
|
601
|
+
moveRight,
|
|
602
|
+
moveUp,
|
|
603
|
+
moveDown,
|
|
604
|
+
moveHome,
|
|
605
|
+
moveEnd,
|
|
606
|
+
handleKey,
|
|
607
|
+
handleAction,
|
|
608
|
+
};
|
|
609
|
+
}
|
package/src/theme.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Theme } from "./style";
|
|
2
|
+
|
|
3
|
+
export const defaultTheme: Theme = {
|
|
4
|
+
colors: {
|
|
5
|
+
accent: "\x1b[38;5;75m", // soft blue
|
|
6
|
+
success: "\x1b[38;5;78m", // soft green
|
|
7
|
+
warn: "\x1b[38;5;214m", // soft orange
|
|
8
|
+
error: "\x1b[31m", // red
|
|
9
|
+
muted: "\x1b[38;5;245m", // medium gray
|
|
10
|
+
subtle: "\x1b[38;5;242m", // dark gray
|
|
11
|
+
text: "\x1b[37m", // white
|
|
12
|
+
|
|
13
|
+
// Background colors
|
|
14
|
+
"bg-accent": "\x1b[48;5;75m",
|
|
15
|
+
"bg-success": "\x1b[48;5;78m",
|
|
16
|
+
"bg-warn": "\x1b[48;5;214m",
|
|
17
|
+
"bg-error": "\x1b[41m",
|
|
18
|
+
"bg-muted": "\x1b[48;5;245m",
|
|
19
|
+
"bg-subtle": "\x1b[48;5;242m",
|
|
20
|
+
"bg-text": "\x1b[47m",
|
|
21
|
+
|
|
22
|
+
// Diff colors
|
|
23
|
+
"diff-add": "\x1b[38;5;114m", // soft green foreground
|
|
24
|
+
"diff-del": "\x1b[38;5;210m", // soft red/pink foreground
|
|
25
|
+
"bg-diff-add": "\x1b[48;5;22m", // dark green background
|
|
26
|
+
"bg-diff-del": "\x1b[48;5;52m", // dark red background
|
|
27
|
+
|
|
28
|
+
// Special
|
|
29
|
+
reverse: "\x1b[7m",
|
|
30
|
+
},
|
|
31
|
+
symbols: {
|
|
32
|
+
divider: "─",
|
|
33
|
+
prompt: "❯",
|
|
34
|
+
bullet: "●",
|
|
35
|
+
spinner: ["◆", "◇"],
|
|
36
|
+
},
|
|
37
|
+
reset: "\x1b[0m",
|
|
38
|
+
bold: "\x1b[1m",
|
|
39
|
+
dim: "\x1b[2m",
|
|
40
|
+
italic: "\x1b[3m",
|
|
41
|
+
underline: "\x1b[4m",
|
|
42
|
+
strikethrough: "\x1b[9m",
|
|
43
|
+
reverse: "\x1b[7m",
|
|
44
|
+
};
|