@textui/core 0.1.0 → 0.2.0
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/README.md +17 -20
- package/dist/app/app.d.ts +36 -0
- package/dist/app/app.d.ts.map +1 -1
- package/dist/app/app.js +150 -1
- package/dist/core/focus.d.ts.map +1 -1
- package/dist/core/focus.js +12 -1
- package/dist/core/store.d.ts.map +1 -1
- package/dist/core/store.js +2 -10
- package/dist/jsx/intrinsics.d.ts +18 -0
- package/dist/jsx/intrinsics.d.ts.map +1 -1
- package/dist/render/layout.js +10 -2
- package/dist/themes/builtin.d.ts +2 -0
- package/dist/themes/builtin.d.ts.map +1 -1
- package/dist/themes/builtin.js +64 -1
- package/dist/themes/dividers.d.ts +14 -0
- package/dist/themes/dividers.d.ts.map +1 -0
- package/dist/themes/dividers.js +42 -0
- package/dist/themes/glyphs.d.ts.map +1 -1
- package/dist/themes/glyphs.js +4 -0
- package/dist/themes/index.d.ts +1 -0
- package/dist/themes/index.d.ts.map +1 -1
- package/dist/themes/index.js +1 -0
- package/dist/themes/registry.d.ts.map +1 -1
- package/dist/themes/registry.js +31 -1
- package/dist/types/command.d.ts +18 -1
- package/dist/types/command.d.ts.map +1 -1
- package/dist/types/input.d.ts +9 -0
- package/dist/types/input.d.ts.map +1 -1
- package/dist/types/markdown.d.ts +36 -1
- package/dist/types/markdown.d.ts.map +1 -1
- package/dist/types/style.d.ts +28 -1
- package/dist/types/style.d.ts.map +1 -1
- package/dist/types/terminal.d.ts +9 -0
- package/dist/types/terminal.d.ts.map +1 -1
- package/dist/types/theme.d.ts +26 -1
- package/dist/types/theme.d.ts.map +1 -1
- package/dist/util/markdown.d.ts.map +1 -1
- package/dist/util/markdown.js +172 -2
- package/dist/util/paths.d.ts +9 -3
- package/dist/util/paths.d.ts.map +1 -1
- package/dist/util/paths.js +11 -16
- package/package.json +5 -5
- package/src/app/app.ts +144 -1
- package/src/core/focus.ts +12 -1
- package/src/core/store.ts +2 -7
- package/src/jsx/intrinsics.ts +18 -0
- package/src/render/layout.ts +7 -2
- package/src/themes/builtin.ts +65 -1
- package/src/themes/dividers.ts +48 -0
- package/src/themes/glyphs.ts +4 -0
- package/src/themes/index.ts +1 -0
- package/src/themes/registry.ts +30 -2
- package/src/types/command.ts +18 -1
- package/src/types/input.ts +9 -0
- package/src/types/markdown.ts +38 -2
- package/src/types/style.ts +33 -1
- package/src/types/terminal.ts +9 -0
- package/src/types/theme.ts +29 -1
- package/src/util/markdown.ts +202 -3
- package/src/util/paths.ts +12 -15
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"theme.d.ts","sourceRoot":"","sources":["../../src/types/theme.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AACxC,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"theme.d.ts","sourceRoot":"","sources":["../../src/types/theme.ts"],"names":[],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"markdown.d.ts","sourceRoot":"","sources":["../../src/util/markdown.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"markdown.d.ts","sourceRoot":"","sources":["../../src/util/markdown.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACK,qBAAqB,EAAE,WAAW,EAAE,WAAW,EAC/D,MAAM,sBAAsB,CAAC;AAuC9B;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,EAAE,CAiBtD;AAED;;;;;;;GAOG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAAE,WAAW,EAAE,EAAE,KAAK,EAAE,MAAM,GAAG,WAAW,EAAE,EAAE,CA2C5E;AAwBD,wBAAgB,cAAc,CAC5B,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,EACzB,OAAO,EAAE,qBAAqB,GAC7B,WAAW,EAAE,CA4Ff;AAgLD,qFAAqF;AACrF,wBAAgB,UAAU,CAAC,IAAI,EAAE,WAAW,EAAE,GAAG,MAAM,CAEtD;AAED,yEAAyE;AACzE,wBAAgB,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAI5D"}
|
package/dist/util/markdown.js
CHANGED
|
@@ -136,13 +136,17 @@ function withPrefix(runs, width, prefix, options = {}) {
|
|
|
136
136
|
}));
|
|
137
137
|
}
|
|
138
138
|
export function layoutMarkdown(source, options) {
|
|
139
|
-
const { width, bullet = '-', quoteBar = '|', ruled = true } = options;
|
|
139
|
+
const { width, bullet = '-', quoteBar = '|', ruled = true, tableRules = 'header' } = options;
|
|
140
140
|
const lines = Array.isArray(source) ? source : source.replace(/\r\n/g, '\n').split('\n');
|
|
141
141
|
const rows = [];
|
|
142
142
|
let fence = 0;
|
|
143
143
|
let inFence = false;
|
|
144
144
|
let language = '';
|
|
145
|
-
|
|
145
|
+
let table = 0;
|
|
146
|
+
// By index rather than by value: a table is only a table because of the line
|
|
147
|
+
// *after* its header, so this loop has to be able to look at it.
|
|
148
|
+
for (let at = 0; at < lines.length; at++) {
|
|
149
|
+
const line = lines[at];
|
|
146
150
|
const marker = /^\s*```(\S*)\s*$/.exec(line);
|
|
147
151
|
if (marker) {
|
|
148
152
|
// An open fence is laid out as a fence from the moment it opens. A turn
|
|
@@ -162,6 +166,24 @@ export function layoutMarkdown(source, options) {
|
|
|
162
166
|
rows.push({ kind: 'fence', fence, part: 'code', text: line, ...(language ? { language } : {}) });
|
|
163
167
|
continue;
|
|
164
168
|
}
|
|
169
|
+
// Before headings and lists, because a cell may contain either and the
|
|
170
|
+
// pipes are what decide. After fences, because inside one nothing is.
|
|
171
|
+
if (line.includes('|') && DIVIDER.test(lines[at + 1] ?? '')) {
|
|
172
|
+
const align = cellsOf(lines[at + 1]).map(alignOf);
|
|
173
|
+
const body = [];
|
|
174
|
+
let end = at + 2;
|
|
175
|
+
while (end < lines.length) {
|
|
176
|
+
const next = lines[end];
|
|
177
|
+
if (!next.includes('|') || next.trim() === '')
|
|
178
|
+
break;
|
|
179
|
+
body.push(cellsOf(next));
|
|
180
|
+
end++;
|
|
181
|
+
}
|
|
182
|
+
rows.push(...tableRows(cellsOf(line), body, align, width, table, tableRules));
|
|
183
|
+
table++;
|
|
184
|
+
at = end - 1;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
165
187
|
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
166
188
|
if (heading) {
|
|
167
189
|
const level = heading[1].length;
|
|
@@ -207,6 +229,154 @@ export function layoutMarkdown(source, options) {
|
|
|
207
229
|
rows.push({ kind: 'fence', fence, part: 'close' });
|
|
208
230
|
return rows;
|
|
209
231
|
}
|
|
232
|
+
/**
|
|
233
|
+
* The `|---|:--:|---:|` line, which is what makes the line above it a header.
|
|
234
|
+
*
|
|
235
|
+
* A row of pipes on its own is a row of pipes - `a | b` is arithmetic or a
|
|
236
|
+
* shell pipeline far more often than it is a one-column table, and treating
|
|
237
|
+
* every line with a bar in it as a table is how prose ends up in a grid.
|
|
238
|
+
*/
|
|
239
|
+
const DIVIDER = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/;
|
|
240
|
+
/**
|
|
241
|
+
* One line into its cells.
|
|
242
|
+
*
|
|
243
|
+
* The outer pipes are optional in every dialect anybody writes, so they are
|
|
244
|
+
* stripped rather than required. `\|` is an escaped bar inside a cell and not
|
|
245
|
+
* a boundary - which is the only way to put a bar in a table at all.
|
|
246
|
+
*/
|
|
247
|
+
function cellsOf(line) {
|
|
248
|
+
const trimmed = line.trim().replace(/^\|/, '').replace(/\|$/, '');
|
|
249
|
+
return trimmed
|
|
250
|
+
.split(/(?<!\\)\|/)
|
|
251
|
+
.map((cell) => cell.replace(/\\\|/g, '|').trim());
|
|
252
|
+
}
|
|
253
|
+
function alignOf(spec) {
|
|
254
|
+
const left = spec.startsWith(':');
|
|
255
|
+
const right = spec.endsWith(':');
|
|
256
|
+
if (left && right)
|
|
257
|
+
return 'center';
|
|
258
|
+
return right ? 'right' : 'left';
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Cut and pad one cell to exactly `width`, keeping the styles.
|
|
262
|
+
*
|
|
263
|
+
* Cut, never wrapped: a table row has to stay one row tall or the document's
|
|
264
|
+
* length stops being a count of rows, and every viewer that windows this
|
|
265
|
+
* scrolls to the wrong place.
|
|
266
|
+
*/
|
|
267
|
+
function fitRuns(runs, width, align) {
|
|
268
|
+
const out = [];
|
|
269
|
+
let used = 0;
|
|
270
|
+
for (const run of runs) {
|
|
271
|
+
if (used >= width)
|
|
272
|
+
break;
|
|
273
|
+
const room = width - used;
|
|
274
|
+
if (stringWidth(run.text) <= room) {
|
|
275
|
+
out.push(run);
|
|
276
|
+
used += stringWidth(run.text);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
// The ellipsis costs a cell, so it is only worth it when there is a cell
|
|
280
|
+
// to spend: at a width of one, a bare glyph says less than the letter.
|
|
281
|
+
const keep = room > 1 ? room - 1 : room;
|
|
282
|
+
const cut = [];
|
|
283
|
+
let taken = 0;
|
|
284
|
+
for (const g of graphemes(run.text)) {
|
|
285
|
+
const w = stringWidth(g);
|
|
286
|
+
if (taken + w > keep)
|
|
287
|
+
break;
|
|
288
|
+
cut.push(g);
|
|
289
|
+
taken += w;
|
|
290
|
+
}
|
|
291
|
+
out.push({ ...run, text: room > 1 ? `${cut.join('')}\u2026` : cut.join('') });
|
|
292
|
+
used = width;
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
const slack = Math.max(0, width - used);
|
|
296
|
+
if (slack === 0)
|
|
297
|
+
return out;
|
|
298
|
+
const before = align === 'right' ? slack : align === 'center' ? Math.floor(slack / 2) : 0;
|
|
299
|
+
const after = slack - before;
|
|
300
|
+
return [
|
|
301
|
+
...(before ? [{ text: ' '.repeat(before) }] : []),
|
|
302
|
+
...out,
|
|
303
|
+
...(after ? [{ text: ' '.repeat(after) }] : []),
|
|
304
|
+
];
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* A whole table, measured once.
|
|
308
|
+
*
|
|
309
|
+
* The columns are as wide as their widest cell wants, and when that is more
|
|
310
|
+
* than the space there is they give it back in proportion - the widest loses
|
|
311
|
+
* the most, because it is the one with the most to lose. Three cells is the
|
|
312
|
+
* floor: below that a column is an ellipsis and a space, which is narrower
|
|
313
|
+
* than saying nothing.
|
|
314
|
+
*/
|
|
315
|
+
function tableRows(head, body, align, width, table, rules) {
|
|
316
|
+
const columns = Math.max(head.length, align.length, ...body.map((r) => r.length));
|
|
317
|
+
const at = (row, i) => row[i] ?? '';
|
|
318
|
+
const runsFor = (text, bold) => inlineRuns(text).map((run) => (bold ? { ...run, bold: true } : run));
|
|
319
|
+
const natural = Array.from({ length: columns }, (_, i) => Math.max(stringWidth(at(head, i)), ...body.map((row) => stringWidth(at(row, i))), 1));
|
|
320
|
+
// A rule between every pair of columns, and a box around the lot. The box
|
|
321
|
+
// is what makes it read as a table rather than as text that happens to line
|
|
322
|
+
// up - which matters most on a theme that draws no borders anywhere else,
|
|
323
|
+
// because there a table with only its columns ruled has nothing to say it
|
|
324
|
+
// ended.
|
|
325
|
+
const gaps = (columns - 1) * SEPARATOR + OUTER;
|
|
326
|
+
const room = width > 0 ? Math.max(columns * MIN_COLUMN, width - gaps) : Infinity;
|
|
327
|
+
const wanted = natural.reduce((sum, n) => sum + n, 0);
|
|
328
|
+
const widths = wanted <= room
|
|
329
|
+
? natural
|
|
330
|
+
: share(natural, room);
|
|
331
|
+
const line = (cells, part) => ({
|
|
332
|
+
kind: 'table',
|
|
333
|
+
table,
|
|
334
|
+
part,
|
|
335
|
+
widths,
|
|
336
|
+
align,
|
|
337
|
+
cells: widths.map((w, i) => fitRuns(runsFor(at(cells, i), part === 'head'), w, align[i] ?? 'left')),
|
|
338
|
+
});
|
|
339
|
+
const edge = (part) => ({ kind: 'table', table, part, cells: [], widths, align });
|
|
340
|
+
return [
|
|
341
|
+
edge('top'),
|
|
342
|
+
line(head, 'head'),
|
|
343
|
+
edge('rule'),
|
|
344
|
+
// A rule between every pair, when the theme asked for them. Between, not
|
|
345
|
+
// after: a rule under the last row and then the box's own bottom edge is
|
|
346
|
+
// two lines saying the same thing.
|
|
347
|
+
...body.flatMap((row, i) => (rules === 'all' && i > 0
|
|
348
|
+
? [edge('rule'), line(row, 'body')]
|
|
349
|
+
: [line(row, 'body')])),
|
|
350
|
+
edge('bottom'),
|
|
351
|
+
];
|
|
352
|
+
}
|
|
353
|
+
/** Cells between two columns: a space, a rule, a space. */
|
|
354
|
+
const SEPARATOR = 3;
|
|
355
|
+
/** The box: a rule and a space at each end. */
|
|
356
|
+
const OUTER = 4;
|
|
357
|
+
const MIN_COLUMN = 3;
|
|
358
|
+
/**
|
|
359
|
+
* Shrink columns into `room`, proportionally, without losing a cell to
|
|
360
|
+
* rounding.
|
|
361
|
+
*
|
|
362
|
+
* The remainder goes to the widest columns one at a time rather than to the
|
|
363
|
+
* first: handing every leftover cell to column one is what turns a five-column
|
|
364
|
+
* table into one wide column and four ellipses.
|
|
365
|
+
*/
|
|
366
|
+
function share(natural, room) {
|
|
367
|
+
const total = natural.reduce((sum, n) => sum + n, 0);
|
|
368
|
+
const scaled = natural.map((n) => Math.max(MIN_COLUMN, Math.floor((n / total) * room)));
|
|
369
|
+
let spare = room - scaled.reduce((sum, n) => sum + n, 0);
|
|
370
|
+
const order = natural
|
|
371
|
+
.map((n, i) => [n, i])
|
|
372
|
+
.sort((a, b) => b[0] - a[0])
|
|
373
|
+
.map(([, i]) => i);
|
|
374
|
+
for (let i = 0; spare > 0; i = (i + 1) % order.length) {
|
|
375
|
+
scaled[order[i]] = scaled[order[i]] + 1;
|
|
376
|
+
spare--;
|
|
377
|
+
}
|
|
378
|
+
return scaled;
|
|
379
|
+
}
|
|
210
380
|
/** Every run's text, for a measurement or a test that does not care how it looks. */
|
|
211
381
|
export function runsToText(runs) {
|
|
212
382
|
return runs.map((run) => run.text).join('');
|
package/dist/util/paths.d.ts
CHANGED
|
@@ -24,7 +24,7 @@ export declare function segments(path: string): string[];
|
|
|
24
24
|
* Canonical map key for a path: segments joined, no sigil.
|
|
25
25
|
*
|
|
26
26
|
* The segments are re-escaped on the way out. Everything downstream - the
|
|
27
|
-
* store's own walk, `
|
|
27
|
+
* store's own walk, `keysTouch`, `matchKey` - splits a key on `/`, so a
|
|
28
28
|
* segment that legitimately contains one (a URI used as a key, a filename with
|
|
29
29
|
* a slash) has to stay escaped or it silently becomes several segments and the
|
|
30
30
|
* value lands somewhere nobody looks.
|
|
@@ -38,9 +38,15 @@ export declare function joinPath(...parts: (string | number)[]): BindingPath;
|
|
|
38
38
|
export declare function resolvePath(path: string, dataContext?: string): BindingPath;
|
|
39
39
|
export declare function scopeOf(path: string): string;
|
|
40
40
|
export declare function parentKey(key: string): string | null;
|
|
41
|
-
/** Every ancestor key of `key`, closest first, including `''` for the root. */
|
|
42
|
-
export declare function ancestorKeys(key: string): string[];
|
|
43
41
|
export declare function isDescendantKey(key: string, ancestor: string): boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Whether two concrete keys can affect the same subscribed value.
|
|
44
|
+
*
|
|
45
|
+
* A write to a descendant changes the ancestor object a subscriber reads, and a
|
|
46
|
+
* write to an ancestor may replace the whole subtree below it. This relation is
|
|
47
|
+
* symmetric and avoids building ancestor lists in hot subscription checks.
|
|
48
|
+
*/
|
|
49
|
+
export declare function keysTouch(a: string, b: string): boolean;
|
|
44
50
|
export declare function hasWildcard(path: string): boolean;
|
|
45
51
|
/** Match a concrete key against a pattern key that may contain `*`. */
|
|
46
52
|
export declare function matchKey(key: string, pattern: string, subtree?: boolean): boolean;
|
package/dist/util/paths.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"paths.d.ts","sourceRoot":"","sources":["../../src/util/paths.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAErD;;;;;;;;;;;GAWG;AAEH,qBAAa,SAAU,SAAQ,KAAK;IACL,QAAQ,CAAC,IAAI,EAAE,MAAM;gBAAtC,OAAO,EAAE,MAAM,EAAW,IAAI,EAAE,MAAM;CAInD;AAMD,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAEjD;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,IAAI,KAAK,MAAM,EAAE,CAE9D;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEhD;AAED,2EAA2E;AAC3E,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAQ/C;AAED;;;;;;;;GAQG;AACH,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE5C;AAED,wBAAgB,QAAQ,CAAC,GAAG,KAAK,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,EAAE,GAAG,WAAW,CASnE;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,MAAM,GAAG,WAAW,CAW3E;AAED,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAG5C;AAED,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAGpD;AAED
|
|
1
|
+
{"version":3,"file":"paths.d.ts","sourceRoot":"","sources":["../../src/util/paths.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAErD;;;;;;;;;;;GAWG;AAEH,qBAAa,SAAU,SAAQ,KAAK;IACL,QAAQ,CAAC,IAAI,EAAE,MAAM;gBAAtC,OAAO,EAAE,MAAM,EAAW,IAAI,EAAE,MAAM;CAInD;AAMD,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAEjD;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,IAAI,KAAK,MAAM,EAAE,CAE9D;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEhD;AAED,2EAA2E;AAC3E,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAQ/C;AAED;;;;;;;;GAQG;AACH,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE5C;AAED,wBAAgB,QAAQ,CAAC,GAAG,KAAK,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,EAAE,GAAG,WAAW,CASnE;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,MAAM,GAAG,WAAW,CAW3E;AAED,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAG5C;AAED,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAGpD;AAED,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAGtE;AAED;;;;;;GAMG;AACH,wBAAgB,SAAS,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,OAAO,CAEvD;AAED,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEjD;AAED,uEAAuE;AACvE,wBAAgB,QAAQ,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,UAAQ,GAAG,OAAO,CAa/E"}
|
package/dist/util/paths.js
CHANGED
|
@@ -46,7 +46,7 @@ export function segments(path) {
|
|
|
46
46
|
* Canonical map key for a path: segments joined, no sigil.
|
|
47
47
|
*
|
|
48
48
|
* The segments are re-escaped on the way out. Everything downstream - the
|
|
49
|
-
* store's own walk, `
|
|
49
|
+
* store's own walk, `keysTouch`, `matchKey` - splits a key on `/`, so a
|
|
50
50
|
* segment that legitimately contains one (a URI used as a key, a filename with
|
|
51
51
|
* a slash) has to stay escaped or it silently becomes several segments and the
|
|
52
52
|
* value lands somewhere nobody looks.
|
|
@@ -90,26 +90,21 @@ export function parentKey(key) {
|
|
|
90
90
|
const i = key.lastIndexOf('/');
|
|
91
91
|
return i === -1 ? (key === '' ? null : '') : key.slice(0, i);
|
|
92
92
|
}
|
|
93
|
-
/** Every ancestor key of `key`, closest first, including `''` for the root. */
|
|
94
|
-
export function ancestorKeys(key) {
|
|
95
|
-
const out = [];
|
|
96
|
-
let cur = key;
|
|
97
|
-
for (;;) {
|
|
98
|
-
const p = parentKey(cur);
|
|
99
|
-
if (p === null)
|
|
100
|
-
break;
|
|
101
|
-
out.push(p);
|
|
102
|
-
cur = p;
|
|
103
|
-
if (p === '')
|
|
104
|
-
break;
|
|
105
|
-
}
|
|
106
|
-
return out;
|
|
107
|
-
}
|
|
108
93
|
export function isDescendantKey(key, ancestor) {
|
|
109
94
|
if (ancestor === '')
|
|
110
95
|
return key !== '';
|
|
111
96
|
return key.startsWith(ancestor + '/');
|
|
112
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Whether two concrete keys can affect the same subscribed value.
|
|
100
|
+
*
|
|
101
|
+
* A write to a descendant changes the ancestor object a subscriber reads, and a
|
|
102
|
+
* write to an ancestor may replace the whole subtree below it. This relation is
|
|
103
|
+
* symmetric and avoids building ancestor lists in hot subscription checks.
|
|
104
|
+
*/
|
|
105
|
+
export function keysTouch(a, b) {
|
|
106
|
+
return a === b || isDescendantKey(a, b) || isDescendantKey(b, a);
|
|
107
|
+
}
|
|
113
108
|
export function hasWildcard(path) {
|
|
114
109
|
return path.includes('*');
|
|
115
110
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@textui/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Graph-mounted terminal UI runtime - store, registries, renderer, host primitives",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"terminal",
|
|
@@ -58,13 +58,13 @@
|
|
|
58
58
|
"import": "./dist/themes/index.js"
|
|
59
59
|
}
|
|
60
60
|
},
|
|
61
|
-
"publishConfig": {
|
|
62
|
-
"access": "public"
|
|
63
|
-
},
|
|
64
61
|
"scripts": {
|
|
65
62
|
"build": "tsc -p tsconfig.json",
|
|
66
63
|
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json",
|
|
67
64
|
"test": "vitest run",
|
|
68
65
|
"test:watch": "vitest"
|
|
66
|
+
},
|
|
67
|
+
"publishConfig": {
|
|
68
|
+
"access": "public"
|
|
69
69
|
}
|
|
70
|
-
}
|
|
70
|
+
}
|
package/src/app/app.ts
CHANGED
|
@@ -99,6 +99,27 @@ export class App implements TextUIApp {
|
|
|
99
99
|
private resolvedTheme: ResolvedTheme;
|
|
100
100
|
private bag = createBag();
|
|
101
101
|
private hovered: string | null = null;
|
|
102
|
+
/**
|
|
103
|
+
* Whoever took the last button down, until it comes back up.
|
|
104
|
+
*
|
|
105
|
+
* Mouse dispatch is a hit test, so without this a drag stops the moment the
|
|
106
|
+
* pointer leaves the box it started in - which is precisely when a drag
|
|
107
|
+
* becomes interesting. A selection dragged off the bottom of a field, a
|
|
108
|
+
* slider dragged past its own track and a splitter dragged across the pane
|
|
109
|
+
* it is resizing are all the pointer being somewhere the handler is not.
|
|
110
|
+
*/
|
|
111
|
+
private captured: Instance | null = null;
|
|
112
|
+
/**
|
|
113
|
+
* Everything under the pointer, innermost outwards.
|
|
114
|
+
*
|
|
115
|
+
* A chain rather than one node, because hover is inherited the way it is in
|
|
116
|
+
* a browser: a row lights up when the pointer is over the label inside it,
|
|
117
|
+
* and the label is what the hit test finds. Hover used to be a focus id
|
|
118
|
+
* compared against `props.id`, which meant only a focusable node could ever
|
|
119
|
+
* be hovered - so a tool call row, a list row and every other box that is
|
|
120
|
+
* clicked rather than focused had a `hover` style nothing could trigger.
|
|
121
|
+
*/
|
|
122
|
+
private hoveredChain = new Set<Instance>();
|
|
102
123
|
/** Focus registrations created from `focusable` props, by focus id. */
|
|
103
124
|
private declaredFocus = new Map<string, { instanceId: string; dispose(): void }>();
|
|
104
125
|
private lastFrame: Frame | null = null;
|
|
@@ -258,11 +279,23 @@ export class App implements TextUIApp {
|
|
|
258
279
|
}
|
|
259
280
|
this.themeId = id;
|
|
260
281
|
this.resolvedTheme = this.themes.resolve(id, this.terminal.capabilities());
|
|
282
|
+
this.applyCursorShape();
|
|
261
283
|
this.store.set('$/modus/theme', id);
|
|
262
284
|
this.buffer_.invalidate();
|
|
263
285
|
this.requestRender(true);
|
|
264
286
|
}
|
|
265
287
|
|
|
288
|
+
/**
|
|
289
|
+
* Ask the terminal for the theme's caret, when the theme states one.
|
|
290
|
+
*
|
|
291
|
+
* A theme that says nothing leaves the terminal's own setting alone, which
|
|
292
|
+
* is why this is not called with a default.
|
|
293
|
+
*/
|
|
294
|
+
private applyCursorShape(): void {
|
|
295
|
+
const shape = this.resolvedTheme.cursor;
|
|
296
|
+
if (shape) this.terminal.setCursorShape?.(shape);
|
|
297
|
+
}
|
|
298
|
+
|
|
266
299
|
setShell(id: string): void {
|
|
267
300
|
if (!this.shells.get(id)) {
|
|
268
301
|
throw new Error(`[textui] no shell registered as "${id}"`);
|
|
@@ -282,6 +315,7 @@ export class App implements TextUIApp {
|
|
|
282
315
|
setCapabilityOverrides(overrides: CapabilityOverrides): void {
|
|
283
316
|
this.terminal.setCapabilityOverrides(overrides);
|
|
284
317
|
this.resolvedTheme = this.themes.resolve(this.themeId, this.terminal.capabilities());
|
|
318
|
+
this.applyCursorShape();
|
|
285
319
|
this.publishEnvironment();
|
|
286
320
|
this.buffer_.invalidate();
|
|
287
321
|
this.requestRender(true);
|
|
@@ -325,6 +359,10 @@ export class App implements TextUIApp {
|
|
|
325
359
|
...this.options.session,
|
|
326
360
|
});
|
|
327
361
|
|
|
362
|
+
// After `acquire`, never before: with no session there is nothing to put
|
|
363
|
+
// the shape back, so an early call is dropped rather than leaked.
|
|
364
|
+
this.applyCursorShape();
|
|
365
|
+
|
|
328
366
|
this.bag.add(this.terminal.onInput((event) => this.handleInput(event)));
|
|
329
367
|
this.bag.add(this.terminal.onResize((size) => this.handleResize(size)));
|
|
330
368
|
|
|
@@ -625,7 +663,7 @@ export class App implements TextUIApp {
|
|
|
625
663
|
const focusedId = this.focus.focused();
|
|
626
664
|
return {
|
|
627
665
|
focused: focusedId === id || focusedId === `${instance.id}:focus`,
|
|
628
|
-
hovered: this.hovered === id,
|
|
666
|
+
hovered: this.hovered === id || this.hoveredChain.has(instance),
|
|
629
667
|
active: false,
|
|
630
668
|
selected: instance.props.selected === true,
|
|
631
669
|
disabled: instance.props.disabled === true,
|
|
@@ -907,6 +945,28 @@ export class App implements TextUIApp {
|
|
|
907
945
|
return;
|
|
908
946
|
}
|
|
909
947
|
|
|
948
|
+
/*
|
|
949
|
+
* A modal takes escape before any keybinding does.
|
|
950
|
+
*
|
|
951
|
+
* `dismissOnEscape` was unreachable in any application with a global
|
|
952
|
+
* escape binding, which most have: the binding matched first, the layer
|
|
953
|
+
* stayed open, and the key went to whatever "go back" means behind it.
|
|
954
|
+
* What that looks like is a confirm dialog you cannot leave, over a screen
|
|
955
|
+
* that has navigated somewhere else while you were reading it.
|
|
956
|
+
*
|
|
957
|
+
* The test is `trapFocus`, not `dismissOnEscape` alone. A layer that traps
|
|
958
|
+
* focus has claimed the keyboard, so nothing behind it should be acting on
|
|
959
|
+
* keys; a toast or a tooltip has not, and those keep the old order so that
|
|
960
|
+
* escape still reaches the application while one happens to be up.
|
|
961
|
+
*/
|
|
962
|
+
if (event.name === 'escape') {
|
|
963
|
+
const top = this.layers.topmostDismissible();
|
|
964
|
+
if (top && top.trapFocus) {
|
|
965
|
+
this.layers.close(top.id, 'escape');
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
910
970
|
if (this.keybindings.handle(event) !== 'unhandled') {
|
|
911
971
|
this.requestRender();
|
|
912
972
|
return;
|
|
@@ -930,16 +990,44 @@ export class App implements TextUIApp {
|
|
|
930
990
|
}
|
|
931
991
|
|
|
932
992
|
private handleMouse(event: MouseEvent): void {
|
|
993
|
+
// The rest of the gesture belongs to whoever took the button down,
|
|
994
|
+
// wherever the pointer has got to since - and it is delivered whether or
|
|
995
|
+
// not the handler claims it, because during a drag there is nothing else
|
|
996
|
+
// it could be for.
|
|
997
|
+
if (this.captured && (event.action === 'drag' || event.action === 'up')) {
|
|
998
|
+
const target = this.captured;
|
|
999
|
+
if (event.action === 'up') this.captured = null;
|
|
1000
|
+
if (target.mounted) {
|
|
1001
|
+
const onMouse = target.props.onMouse;
|
|
1002
|
+
if (typeof onMouse === 'function') {
|
|
1003
|
+
(onMouse as (e: MouseEvent) => boolean | void)(event);
|
|
1004
|
+
this.requestRender();
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
// Unmounted mid-drag, or it stopped taking the pointer: the gesture has
|
|
1009
|
+
// nowhere to go, so let the hit test have it back.
|
|
1010
|
+
this.captured = null;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
933
1013
|
const hit = this.focus.at(event.x, event.y);
|
|
934
1014
|
|
|
935
1015
|
if (event.action === 'move') {
|
|
1016
|
+
const moved = this.updateHover(event);
|
|
936
1017
|
if (hit !== this.hovered) {
|
|
937
1018
|
this.hovered = hit;
|
|
938
1019
|
this.requestRender();
|
|
1020
|
+
} else if (moved) {
|
|
1021
|
+
this.requestRender();
|
|
939
1022
|
}
|
|
940
1023
|
return;
|
|
941
1024
|
}
|
|
942
1025
|
|
|
1026
|
+
// A new press ends the last one, claimed or not: an `up` that never
|
|
1027
|
+
// arrived - a terminal that lost focus mid-drag - must not leave a capture
|
|
1028
|
+
// that eats the next gesture.
|
|
1029
|
+
if (event.action === 'down') this.captured = null;
|
|
1030
|
+
|
|
943
1031
|
if (event.action === 'down' && hit) this.focus.focus(hit);
|
|
944
1032
|
|
|
945
1033
|
if (this.root) {
|
|
@@ -948,6 +1036,41 @@ export class App implements TextUIApp {
|
|
|
948
1036
|
this.requestRender();
|
|
949
1037
|
}
|
|
950
1038
|
|
|
1039
|
+
/**
|
|
1040
|
+
* Who the pointer is over now, and who it has just left.
|
|
1041
|
+
*
|
|
1042
|
+
* `onHover` is called on the way in and the way out, once each - a handler
|
|
1043
|
+
* that fires on every pixel of movement is a handler nobody can use for
|
|
1044
|
+
* anything but a repaint.
|
|
1045
|
+
*/
|
|
1046
|
+
private updateHover(event: MouseEvent): boolean {
|
|
1047
|
+
const next = new Set<Instance>();
|
|
1048
|
+
if (this.root) collectHover(this.root, event.x, event.y, next);
|
|
1049
|
+
|
|
1050
|
+
let changed = next.size !== this.hoveredChain.size;
|
|
1051
|
+
if (!changed) {
|
|
1052
|
+
for (const instance of next) {
|
|
1053
|
+
if (!this.hoveredChain.has(instance)) { changed = true; break; }
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
if (!changed) return false;
|
|
1057
|
+
|
|
1058
|
+
const previous = this.hoveredChain;
|
|
1059
|
+
this.hoveredChain = next;
|
|
1060
|
+
|
|
1061
|
+
for (const instance of previous) {
|
|
1062
|
+
if (next.has(instance) || !instance.mounted) continue;
|
|
1063
|
+
const onHover = instance.props.onHover;
|
|
1064
|
+
if (typeof onHover === 'function') (onHover as (over: boolean) => void)(false);
|
|
1065
|
+
}
|
|
1066
|
+
for (const instance of next) {
|
|
1067
|
+
if (previous.has(instance)) continue;
|
|
1068
|
+
const onHover = instance.props.onHover;
|
|
1069
|
+
if (typeof onHover === 'function') (onHover as (over: boolean) => void)(true);
|
|
1070
|
+
}
|
|
1071
|
+
return true;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
951
1074
|
/** Innermost box under the pointer first, then outward. */
|
|
952
1075
|
private dispatchMouse(instance: Instance, event: MouseEvent): boolean {
|
|
953
1076
|
for (let i = instance.children.length - 1; i >= 0; i--) {
|
|
@@ -962,6 +1085,8 @@ export class App implements TextUIApp {
|
|
|
962
1085
|
|
|
963
1086
|
const onMouse = instance.props.onMouse;
|
|
964
1087
|
if (typeof onMouse === 'function' && (onMouse as (e: MouseEvent) => boolean | void)(event) === true) {
|
|
1088
|
+
// Taking the button down is what claims the drag that follows it.
|
|
1089
|
+
if (event.action === 'down') this.captured = instance;
|
|
965
1090
|
return true;
|
|
966
1091
|
}
|
|
967
1092
|
|
|
@@ -1094,3 +1219,21 @@ export const WRITER_KEY = serviceKey<FrameWriter>('textui.writer');
|
|
|
1094
1219
|
export function createApp(options: CreateAppOptions = {}): App {
|
|
1095
1220
|
return new App(options);
|
|
1096
1221
|
}
|
|
1222
|
+
|
|
1223
|
+
/**
|
|
1224
|
+
* Every box containing the point, from the root down.
|
|
1225
|
+
*
|
|
1226
|
+
* The whole chain rather than the innermost one: a row is hovered when the
|
|
1227
|
+
* pointer is over the text inside it, and the text is what a hit test finds.
|
|
1228
|
+
* Only boxes with a laid-out rect count - a component that produced no box
|
|
1229
|
+
* occupies no space, so there is nothing to be over.
|
|
1230
|
+
*/
|
|
1231
|
+
function collectHover(instance: Instance, x: number, y: number, into: Set<Instance>): void {
|
|
1232
|
+
const box = instance.box;
|
|
1233
|
+
if (box) {
|
|
1234
|
+
const { x: bx, y: by, width, height } = box.rect;
|
|
1235
|
+
if (x < bx || x >= bx + width || y < by || y >= by + height) return;
|
|
1236
|
+
into.add(instance);
|
|
1237
|
+
}
|
|
1238
|
+
for (const child of instance.children) collectHover(child, x, y, into);
|
|
1239
|
+
}
|
package/src/core/focus.ts
CHANGED
|
@@ -129,7 +129,18 @@ export class Focus implements FocusManager {
|
|
|
129
129
|
// the screen this is for - pushed over whatever had focus, which the push
|
|
130
130
|
// unmounted, leaving none - and a dialog, which opens while its opener
|
|
131
131
|
// still holds focus and whose own controls say which of them wants it.
|
|
132
|
-
|
|
132
|
+
//
|
|
133
|
+
// And only onto something a person could have tabbed to. A `global`
|
|
134
|
+
// handler registers as a focusable - that is how a layer reads escape
|
|
135
|
+
// without holding focus - and it is `skipTab`, so it is not a place focus
|
|
136
|
+
// can land. Handed it anyway, focus sat on a node that consumes nothing:
|
|
137
|
+
// the keys fell through to whatever was behind, and every real control's
|
|
138
|
+
// own `autoFocus` stood down, because it claims focus only when the scope
|
|
139
|
+
// does not already hold it. A question with a text field in it was
|
|
140
|
+
// therefore unanswerable - the field was drawn, `required` beside it, and
|
|
141
|
+
// what was typed at it went into the composer underneath.
|
|
142
|
+
const focusable = options.disabled !== true && options.skipTab !== true && options.global !== true;
|
|
143
|
+
if (!existing && focusable && this.current === null) {
|
|
133
144
|
const scopeId = options.scopeId ?? GLOBAL_SCOPE;
|
|
134
145
|
if (this.scopes.get(scopeId)?.autoFocus === true && this.stack.includes(scopeId)) {
|
|
135
146
|
this.focus(options.id);
|
package/src/core/store.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type { BindingPath } from '../types/graph.js';
|
|
|
6
6
|
import type { Disposable } from '../types/disposable.js';
|
|
7
7
|
import { toDisposable } from '../util/disposable.js';
|
|
8
8
|
import {
|
|
9
|
-
|
|
9
|
+
hasWildcard, isDescendantKey, keysTouch, matchKey, pathKey, PathError,
|
|
10
10
|
} from '../util/paths.js';
|
|
11
11
|
|
|
12
12
|
interface Subscription {
|
|
@@ -222,12 +222,7 @@ export class Store implements ReactiveStore {
|
|
|
222
222
|
|
|
223
223
|
private subMatches(sub: Subscription, changedKey: string): boolean {
|
|
224
224
|
if (sub.wildcard) return matchKey(changedKey, sub.key, sub.subtree);
|
|
225
|
-
|
|
226
|
-
// A write below an exact subscription still changes that object's contents.
|
|
227
|
-
if (isDescendantKey(changedKey, sub.key)) return true;
|
|
228
|
-
// A write above it may have replaced the subtree the subscriber reads.
|
|
229
|
-
if (isDescendantKey(sub.key, changedKey)) return true;
|
|
230
|
-
return sub.subtree && ancestorKeys(changedKey).includes(sub.key);
|
|
225
|
+
return keysTouch(changedKey, sub.key);
|
|
231
226
|
}
|
|
232
227
|
|
|
233
228
|
// ---------------------------------------------------------- subscriptions
|
package/src/jsx/intrinsics.ts
CHANGED
|
@@ -42,8 +42,26 @@ export interface BaseProps extends Style {
|
|
|
42
42
|
onKey?(event: KeyEvent): boolean | void;
|
|
43
43
|
onFocus?(): void;
|
|
44
44
|
onBlur?(): void;
|
|
45
|
+
/**
|
|
46
|
+
* Every mouse action on this node, innermost first.
|
|
47
|
+
*
|
|
48
|
+
* Returning `true` stops it going any further - and on a `down`, **claims
|
|
49
|
+
* the rest of the gesture**: the `drag`s and the `up` that follow come here
|
|
50
|
+
* whatever they are over, until the button comes back up. Dispatch is
|
|
51
|
+
* otherwise a hit test, so without that a drag would stop at the edge of the
|
|
52
|
+
* node it started in, which is where a drag starts being worth having.
|
|
53
|
+
*/
|
|
45
54
|
onMouse?(event: MouseEvent): boolean | void;
|
|
55
|
+
/** The left button going down - a third of a gesture. `onMouse` for the rest. */
|
|
46
56
|
onClick?: Action | ((event: MouseEvent) => void);
|
|
57
|
+
/**
|
|
58
|
+
* The pointer entered or left this node. Called once each way, not per cell.
|
|
59
|
+
*
|
|
60
|
+
* Hover is inherited the way it is in a browser: a row is hovered while the
|
|
61
|
+
* pointer is over the label inside it, because the label is what a hit test
|
|
62
|
+
* finds. A `style` with a `hover` overlay needs nothing else - this is for
|
|
63
|
+
* the cases where something other than a colour has to happen.
|
|
64
|
+
*/
|
|
47
65
|
onHover?(hovering: boolean): void;
|
|
48
66
|
|
|
49
67
|
/** OSC 8 link target, where the terminal supports hyperlinks. */
|
package/src/render/layout.ts
CHANGED
|
@@ -465,8 +465,13 @@ function layoutBox(box: LayoutBox, rect: Rect): void {
|
|
|
465
465
|
return;
|
|
466
466
|
}
|
|
467
467
|
|
|
468
|
-
const flow =
|
|
469
|
-
const absolute =
|
|
468
|
+
const flow: LayoutBox[] = [];
|
|
469
|
+
const absolute: LayoutBox[] = [];
|
|
470
|
+
for (const child of box.children) {
|
|
471
|
+
if (isHidden(child)) continue;
|
|
472
|
+
if (isAbsolute(child)) absolute.push(child);
|
|
473
|
+
else flow.push(child);
|
|
474
|
+
}
|
|
470
475
|
|
|
471
476
|
if (flow.length > 0) layoutFlow(box, flow);
|
|
472
477
|
for (const child of absolute) layoutAbsolute(box, child);
|