@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.
Files changed (60) hide show
  1. package/README.md +17 -20
  2. package/dist/app/app.d.ts +36 -0
  3. package/dist/app/app.d.ts.map +1 -1
  4. package/dist/app/app.js +150 -1
  5. package/dist/core/focus.d.ts.map +1 -1
  6. package/dist/core/focus.js +12 -1
  7. package/dist/core/store.d.ts.map +1 -1
  8. package/dist/core/store.js +2 -10
  9. package/dist/jsx/intrinsics.d.ts +18 -0
  10. package/dist/jsx/intrinsics.d.ts.map +1 -1
  11. package/dist/render/layout.js +10 -2
  12. package/dist/themes/builtin.d.ts +2 -0
  13. package/dist/themes/builtin.d.ts.map +1 -1
  14. package/dist/themes/builtin.js +64 -1
  15. package/dist/themes/dividers.d.ts +14 -0
  16. package/dist/themes/dividers.d.ts.map +1 -0
  17. package/dist/themes/dividers.js +42 -0
  18. package/dist/themes/glyphs.d.ts.map +1 -1
  19. package/dist/themes/glyphs.js +4 -0
  20. package/dist/themes/index.d.ts +1 -0
  21. package/dist/themes/index.d.ts.map +1 -1
  22. package/dist/themes/index.js +1 -0
  23. package/dist/themes/registry.d.ts.map +1 -1
  24. package/dist/themes/registry.js +31 -1
  25. package/dist/types/command.d.ts +18 -1
  26. package/dist/types/command.d.ts.map +1 -1
  27. package/dist/types/input.d.ts +9 -0
  28. package/dist/types/input.d.ts.map +1 -1
  29. package/dist/types/markdown.d.ts +36 -1
  30. package/dist/types/markdown.d.ts.map +1 -1
  31. package/dist/types/style.d.ts +28 -1
  32. package/dist/types/style.d.ts.map +1 -1
  33. package/dist/types/terminal.d.ts +9 -0
  34. package/dist/types/terminal.d.ts.map +1 -1
  35. package/dist/types/theme.d.ts +26 -1
  36. package/dist/types/theme.d.ts.map +1 -1
  37. package/dist/util/markdown.d.ts.map +1 -1
  38. package/dist/util/markdown.js +172 -2
  39. package/dist/util/paths.d.ts +9 -3
  40. package/dist/util/paths.d.ts.map +1 -1
  41. package/dist/util/paths.js +11 -16
  42. package/package.json +5 -5
  43. package/src/app/app.ts +144 -1
  44. package/src/core/focus.ts +12 -1
  45. package/src/core/store.ts +2 -7
  46. package/src/jsx/intrinsics.ts +18 -0
  47. package/src/render/layout.ts +7 -2
  48. package/src/themes/builtin.ts +65 -1
  49. package/src/themes/dividers.ts +48 -0
  50. package/src/themes/glyphs.ts +4 -0
  51. package/src/themes/index.ts +1 -0
  52. package/src/themes/registry.ts +30 -2
  53. package/src/types/command.ts +18 -1
  54. package/src/types/input.ts +9 -0
  55. package/src/types/markdown.ts +38 -2
  56. package/src/types/style.ts +33 -1
  57. package/src/types/terminal.ts +9 -0
  58. package/src/types/theme.ts +29 -1
  59. package/src/util/markdown.ts +202 -3
  60. package/src/util/paths.ts +12 -15
@@ -1,4 +1,7 @@
1
- import type { MarkdownLayoutOptions, MarkdownRow, MarkdownRun } from '../types/markdown.js';
1
+ import type {
2
+ MarkdownAlign, MarkdownLayoutOptions, MarkdownRow, MarkdownRun,
3
+ } from '../types/markdown.js';
4
+ import type { TableRules } from '../types/style.js';
2
5
  import type { StyleColor } from '../types/style.js';
3
6
  import { graphemes, stringWidth, wrapText } from './text.js';
4
7
 
@@ -141,14 +144,18 @@ export function layoutMarkdown(
141
144
  source: string | string[],
142
145
  options: MarkdownLayoutOptions,
143
146
  ): MarkdownRow[] {
144
- const { width, bullet = '-', quoteBar = '|', ruled = true } = options;
147
+ const { width, bullet = '-', quoteBar = '|', ruled = true, tableRules = 'header' } = options;
145
148
  const lines = Array.isArray(source) ? source : source.replace(/\r\n/g, '\n').split('\n');
146
149
  const rows: MarkdownRow[] = [];
147
150
  let fence = 0;
148
151
  let inFence = false;
149
152
  let language = '';
153
+ let table = 0;
150
154
 
151
- for (const line of lines) {
155
+ // By index rather than by value: a table is only a table because of the line
156
+ // *after* its header, so this loop has to be able to look at it.
157
+ for (let at = 0; at < lines.length; at++) {
158
+ const line = lines[at] as string;
152
159
  const marker = /^\s*```(\S*)\s*$/.exec(line);
153
160
  if (marker) {
154
161
  // An open fence is laid out as a fence from the moment it opens. A turn
@@ -166,6 +173,24 @@ export function layoutMarkdown(
166
173
  continue;
167
174
  }
168
175
 
176
+ // Before headings and lists, because a cell may contain either and the
177
+ // pipes are what decide. After fences, because inside one nothing is.
178
+ if (line.includes('|') && DIVIDER.test(lines[at + 1] ?? '')) {
179
+ const align = cellsOf(lines[at + 1] as string).map(alignOf);
180
+ const body: string[][] = [];
181
+ let end = at + 2;
182
+ while (end < lines.length) {
183
+ const next = lines[end] as string;
184
+ if (!next.includes('|') || next.trim() === '') break;
185
+ body.push(cellsOf(next));
186
+ end++;
187
+ }
188
+ rows.push(...tableRows(cellsOf(line), body, align, width, table, tableRules));
189
+ table++;
190
+ at = end - 1;
191
+ continue;
192
+ }
193
+
169
194
  const heading = /^(#{1,6})\s+(.*)$/.exec(line);
170
195
  if (heading) {
171
196
  const level = (heading[1] as string).length;
@@ -212,6 +237,180 @@ export function layoutMarkdown(
212
237
  return rows;
213
238
  }
214
239
 
240
+ /**
241
+ * The `|---|:--:|---:|` line, which is what makes the line above it a header.
242
+ *
243
+ * A row of pipes on its own is a row of pipes - `a | b` is arithmetic or a
244
+ * shell pipeline far more often than it is a one-column table, and treating
245
+ * every line with a bar in it as a table is how prose ends up in a grid.
246
+ */
247
+ const DIVIDER = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/;
248
+
249
+ /**
250
+ * One line into its cells.
251
+ *
252
+ * The outer pipes are optional in every dialect anybody writes, so they are
253
+ * stripped rather than required. `\|` is an escaped bar inside a cell and not
254
+ * a boundary - which is the only way to put a bar in a table at all.
255
+ */
256
+ function cellsOf(line: string): string[] {
257
+ const trimmed = line.trim().replace(/^\|/, '').replace(/\|$/, '');
258
+ return trimmed
259
+ .split(/(?<!\\)\|/)
260
+ .map((cell) => cell.replace(/\\\|/g, '|').trim());
261
+ }
262
+
263
+ function alignOf(spec: string): MarkdownAlign {
264
+ const left = spec.startsWith(':');
265
+ const right = spec.endsWith(':');
266
+ if (left && right) return 'center';
267
+ return right ? 'right' : 'left';
268
+ }
269
+
270
+ /**
271
+ * Cut and pad one cell to exactly `width`, keeping the styles.
272
+ *
273
+ * Cut, never wrapped: a table row has to stay one row tall or the document's
274
+ * length stops being a count of rows, and every viewer that windows this
275
+ * scrolls to the wrong place.
276
+ */
277
+ function fitRuns(runs: MarkdownRun[], width: number, align: MarkdownAlign): MarkdownRun[] {
278
+ const out: MarkdownRun[] = [];
279
+ let used = 0;
280
+ for (const run of runs) {
281
+ if (used >= width) break;
282
+ const room = width - used;
283
+ if (stringWidth(run.text) <= room) {
284
+ out.push(run);
285
+ used += stringWidth(run.text);
286
+ continue;
287
+ }
288
+ // The ellipsis costs a cell, so it is only worth it when there is a cell
289
+ // to spend: at a width of one, a bare glyph says less than the letter.
290
+ const keep = room > 1 ? room - 1 : room;
291
+ const cut: string[] = [];
292
+ let taken = 0;
293
+ for (const g of graphemes(run.text)) {
294
+ const w = stringWidth(g);
295
+ if (taken + w > keep) break;
296
+ cut.push(g);
297
+ taken += w;
298
+ }
299
+ out.push({ ...run, text: room > 1 ? `${cut.join('')}\u2026` : cut.join('') });
300
+ used = width;
301
+ break;
302
+ }
303
+
304
+ const slack = Math.max(0, width - used);
305
+ if (slack === 0) return out;
306
+ const before = align === 'right' ? slack : align === 'center' ? Math.floor(slack / 2) : 0;
307
+ const after = slack - before;
308
+ return [
309
+ ...(before ? [{ text: ' '.repeat(before) }] : []),
310
+ ...out,
311
+ ...(after ? [{ text: ' '.repeat(after) }] : []),
312
+ ];
313
+ }
314
+
315
+ /**
316
+ * A whole table, measured once.
317
+ *
318
+ * The columns are as wide as their widest cell wants, and when that is more
319
+ * than the space there is they give it back in proportion - the widest loses
320
+ * the most, because it is the one with the most to lose. Three cells is the
321
+ * floor: below that a column is an ellipsis and a space, which is narrower
322
+ * than saying nothing.
323
+ */
324
+ function tableRows(
325
+ head: string[],
326
+ body: string[][],
327
+ align: MarkdownAlign[],
328
+ width: number,
329
+ table: number,
330
+ rules: TableRules,
331
+ ): MarkdownRow[] {
332
+ const columns = Math.max(head.length, align.length, ...body.map((r) => r.length));
333
+ const at = (row: string[], i: number): string => row[i] ?? '';
334
+ const runsFor = (text: string, bold: boolean): MarkdownRun[] =>
335
+ inlineRuns(text).map((run) => (bold ? { ...run, bold: true } : run));
336
+
337
+ const natural = Array.from({ length: columns }, (_, i) => Math.max(
338
+ stringWidth(at(head, i)),
339
+ ...body.map((row) => stringWidth(at(row, i))),
340
+ 1,
341
+ ));
342
+
343
+ // A rule between every pair of columns, and a box around the lot. The box
344
+ // is what makes it read as a table rather than as text that happens to line
345
+ // up - which matters most on a theme that draws no borders anywhere else,
346
+ // because there a table with only its columns ruled has nothing to say it
347
+ // ended.
348
+ const gaps = (columns - 1) * SEPARATOR + OUTER;
349
+ const room = width > 0 ? Math.max(columns * MIN_COLUMN, width - gaps) : Infinity;
350
+ const wanted = natural.reduce((sum, n) => sum + n, 0);
351
+ const widths = wanted <= room
352
+ ? natural
353
+ : share(natural, room);
354
+
355
+ const line = (cells: string[], part: 'head' | 'body'): MarkdownRow => ({
356
+ kind: 'table',
357
+ table,
358
+ part,
359
+ widths,
360
+ align,
361
+ cells: widths.map((w, i) => fitRuns(
362
+ runsFor(at(cells, i), part === 'head'),
363
+ w,
364
+ align[i] ?? 'left',
365
+ )),
366
+ });
367
+
368
+ const edge = (part: 'top' | 'rule' | 'bottom'): MarkdownRow =>
369
+ ({ kind: 'table', table, part, cells: [], widths, align });
370
+
371
+ return [
372
+ edge('top'),
373
+ line(head, 'head'),
374
+ edge('rule'),
375
+ // A rule between every pair, when the theme asked for them. Between, not
376
+ // after: a rule under the last row and then the box's own bottom edge is
377
+ // two lines saying the same thing.
378
+ ...body.flatMap((row, i) => (rules === 'all' && i > 0
379
+ ? [edge('rule'), line(row, 'body')]
380
+ : [line(row, 'body')])),
381
+ edge('bottom'),
382
+ ];
383
+ }
384
+
385
+ /** Cells between two columns: a space, a rule, a space. */
386
+ const SEPARATOR = 3;
387
+ /** The box: a rule and a space at each end. */
388
+ const OUTER = 4;
389
+ const MIN_COLUMN = 3;
390
+
391
+ /**
392
+ * Shrink columns into `room`, proportionally, without losing a cell to
393
+ * rounding.
394
+ *
395
+ * The remainder goes to the widest columns one at a time rather than to the
396
+ * first: handing every leftover cell to column one is what turns a five-column
397
+ * table into one wide column and four ellipses.
398
+ */
399
+ function share(natural: number[], room: number): number[] {
400
+ const total = natural.reduce((sum, n) => sum + n, 0);
401
+ const scaled = natural.map((n) => Math.max(MIN_COLUMN, Math.floor((n / total) * room)));
402
+ let spare = room - scaled.reduce((sum, n) => sum + n, 0);
403
+ const order = natural
404
+ .map((n, i) => [n, i] as const)
405
+ .sort((a, b) => b[0] - a[0])
406
+ .map(([, i]) => i);
407
+ for (let i = 0; spare > 0; i = (i + 1) % order.length) {
408
+ scaled[order[i] as number] = (scaled[order[i] as number] as number) + 1;
409
+ spare--;
410
+ }
411
+ return scaled;
412
+ }
413
+
215
414
  /** Every run's text, for a measurement or a test that does not care how it looks. */
216
415
  export function runsToText(runs: MarkdownRun[]): string {
217
416
  return runs.map((run) => run.text).join('');
package/src/util/paths.ts CHANGED
@@ -51,7 +51,7 @@ export function segments(path: string): string[] {
51
51
  * Canonical map key for a path: segments joined, no sigil.
52
52
  *
53
53
  * The segments are re-escaped on the way out. Everything downstream - the
54
- * store's own walk, `ancestorKeys`, `matchKey` - splits a key on `/`, so a
54
+ * store's own walk, `keysTouch`, `matchKey` - splits a key on `/`, so a
55
55
  * segment that legitimately contains one (a URI used as a key, a filename with
56
56
  * a slash) has to stay escaped or it silently becomes several segments and the
57
57
  * value lands somewhere nobody looks.
@@ -98,25 +98,22 @@ export function parentKey(key: string): string | null {
98
98
  return i === -1 ? (key === '' ? null : '') : key.slice(0, i);
99
99
  }
100
100
 
101
- /** Every ancestor key of `key`, closest first, including `''` for the root. */
102
- export function ancestorKeys(key: string): string[] {
103
- const out: string[] = [];
104
- let cur = key;
105
- for (;;) {
106
- const p = parentKey(cur);
107
- if (p === null) break;
108
- out.push(p);
109
- cur = p;
110
- if (p === '') break;
111
- }
112
- return out;
113
- }
114
-
115
101
  export function isDescendantKey(key: string, ancestor: string): boolean {
116
102
  if (ancestor === '') return key !== '';
117
103
  return key.startsWith(ancestor + '/');
118
104
  }
119
105
 
106
+ /**
107
+ * Whether two concrete keys can affect the same subscribed value.
108
+ *
109
+ * A write to a descendant changes the ancestor object a subscriber reads, and a
110
+ * write to an ancestor may replace the whole subtree below it. This relation is
111
+ * symmetric and avoids building ancestor lists in hot subscription checks.
112
+ */
113
+ export function keysTouch(a: string, b: string): boolean {
114
+ return a === b || isDescendantKey(a, b) || isDescendantKey(b, a);
115
+ }
116
+
120
117
  export function hasWildcard(path: string): boolean {
121
118
  return path.includes('*');
122
119
  }