@textui/core 0.1.0 → 0.3.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 +64 -0
- package/dist/app/app.d.ts.map +1 -1
- package/dist/app/app.js +202 -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/runtime/bindings.d.ts.map +1 -1
- package/dist/runtime/bindings.js +31 -4
- package/dist/runtime/hooks.d.ts +14 -2
- package/dist/runtime/hooks.d.ts.map +1 -1
- package/dist/runtime/hooks.js +15 -4
- 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/app.d.ts +15 -0
- package/dist/types/app.d.ts.map +1 -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 +198 -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/runtime/bindings.ts +29 -3
- package/src/runtime/hooks.ts +15 -4
- 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/app.ts +13 -0
- 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
package/src/themes/registry.ts
CHANGED
|
@@ -2,13 +2,14 @@ import type {
|
|
|
2
2
|
ResolvedTheme, ThemeDefinition, ThemeGlyphs, ThemeRegistry, ThemeSpacing,
|
|
3
3
|
} from '../types/theme.js';
|
|
4
4
|
import type { Color } from '../types/cells.js';
|
|
5
|
-
import type { BorderChars, BorderStyle, ColorToken, Density, StyleColor } from '../types/style.js';
|
|
5
|
+
import type { BorderChars, BorderStyle, ColorToken, CursorStyle, Density, DividerChars, DividerStyle, StyleColor, TableRules } from '../types/style.js';
|
|
6
6
|
import type { Style } from '../types/style.js';
|
|
7
7
|
import type { SyntaxScope } from '../types/syntax.js';
|
|
8
8
|
import type { TerminalCapabilities } from '../types/capabilities.js';
|
|
9
9
|
import type { Disposable } from '../types/disposable.js';
|
|
10
10
|
import { toDisposable } from '../util/disposable.js';
|
|
11
11
|
import { borderCharsFor } from './borders.js';
|
|
12
|
+
import { dividerCharsFor } from './dividers.js';
|
|
12
13
|
import { glyphsFor } from './glyphs.js';
|
|
13
14
|
import { BUILTIN_THEMES } from './builtin.js';
|
|
14
15
|
|
|
@@ -16,7 +17,7 @@ const DEFAULT_SPACING: ThemeSpacing = { none: 0, xs: 0, sm: 1, md: 1, lg: 2, xl:
|
|
|
16
17
|
|
|
17
18
|
const FALLBACK_COLORS: Record<ColorToken, Color> = {
|
|
18
19
|
canvas: 'default', surface: 'default', surfaceAlt: 'default', overlay: 'default',
|
|
19
|
-
border: 'default', borderStrong: 'default', borderSubtle: 'default',
|
|
20
|
+
border: 'default', borderStrong: 'default', borderSubtle: 'default', divider: 'default',
|
|
20
21
|
text: 'default', muted: 'default', subtle: 'default', inverted: 'default',
|
|
21
22
|
accent: 'default', primary: 'default', secondary: 'default',
|
|
22
23
|
success: 'default', warning: 'default', danger: 'default', info: 'default',
|
|
@@ -115,6 +116,14 @@ export class Themes implements ThemeRegistry {
|
|
|
115
116
|
let spacing = { ...DEFAULT_SPACING };
|
|
116
117
|
let glyphOverrides: Partial<ThemeGlyphs> = {};
|
|
117
118
|
let border: BorderStyle = 'single';
|
|
119
|
+
let divider: DividerStyle = 'single';
|
|
120
|
+
// Undefined means "leave the terminal's own setting alone", which is the
|
|
121
|
+
// right default: a theme that says nothing should not restyle the caret.
|
|
122
|
+
let cursorStyle: CursorStyle | undefined;
|
|
123
|
+
// The quiet one. A rule between every pair of rows is right for a table of
|
|
124
|
+
// few, long rows and noise on a table of twenty short ones, so a theme
|
|
125
|
+
// opts in rather than out.
|
|
126
|
+
let tableRules: TableRules = 'header';
|
|
118
127
|
let density: Density = 'normal';
|
|
119
128
|
const components: Record<string, Record<string, Style>> = {};
|
|
120
129
|
let syntaxOverrides: Partial<Record<SyntaxScope, StyleColor>> = {};
|
|
@@ -142,6 +151,9 @@ export class Themes implements ThemeRegistry {
|
|
|
142
151
|
if (def.spacing) spacing = { ...spacing, ...def.spacing };
|
|
143
152
|
if (def.glyphs) glyphOverrides = { ...glyphOverrides, ...def.glyphs };
|
|
144
153
|
if (def.border) border = def.border;
|
|
154
|
+
if (def.divider) divider = def.divider;
|
|
155
|
+
if (def.cursor) cursorStyle = def.cursor;
|
|
156
|
+
if (def.tableRules) tableRules = def.tableRules;
|
|
145
157
|
if (def.density) density = def.density;
|
|
146
158
|
for (const [name, variants] of Object.entries(def.components ?? {})) {
|
|
147
159
|
components[name] = { ...components[name], ...variants };
|
|
@@ -172,6 +184,7 @@ export class Themes implements ThemeRegistry {
|
|
|
172
184
|
|
|
173
185
|
const glyphs: ThemeGlyphs = { ...glyphsFor(caps.unicode), ...glyphOverrides };
|
|
174
186
|
const charCache = new Map<BorderStyle, BorderChars>();
|
|
187
|
+
const ruleCache = new Map<DividerStyle, DividerChars>();
|
|
175
188
|
|
|
176
189
|
const resolved: ResolvedTheme = {
|
|
177
190
|
id: leaf.id,
|
|
@@ -181,6 +194,9 @@ export class Themes implements ThemeRegistry {
|
|
|
181
194
|
spacing,
|
|
182
195
|
glyphs,
|
|
183
196
|
border,
|
|
197
|
+
divider,
|
|
198
|
+
cursor: cursorStyle,
|
|
199
|
+
tableRules,
|
|
184
200
|
density,
|
|
185
201
|
components,
|
|
186
202
|
syntax,
|
|
@@ -203,6 +219,18 @@ export class Themes implements ThemeRegistry {
|
|
|
203
219
|
return chars;
|
|
204
220
|
},
|
|
205
221
|
|
|
222
|
+
dividerChars(style?: DividerStyle): DividerChars {
|
|
223
|
+
const s = style ?? divider;
|
|
224
|
+
let chars = ruleCache.get(s);
|
|
225
|
+
if (!chars) {
|
|
226
|
+
const base = dividerCharsFor(s, caps.unicode);
|
|
227
|
+
const custom = leaf.dividerChars?.[s];
|
|
228
|
+
chars = custom ? { ...base, ...custom } : base;
|
|
229
|
+
ruleCache.set(s, chars);
|
|
230
|
+
}
|
|
231
|
+
return chars;
|
|
232
|
+
},
|
|
233
|
+
|
|
206
234
|
styleFor(component: string, variants: string[] = []): Style {
|
|
207
235
|
const entry = components[component];
|
|
208
236
|
if (!entry) return {};
|
package/src/types/app.ts
CHANGED
|
@@ -69,6 +69,19 @@ export interface TextUIApp extends Disposable {
|
|
|
69
69
|
stop(): Promise<void>;
|
|
70
70
|
/** Force a frame now, outside the scheduler. Tests and screenshots use it. */
|
|
71
71
|
flush(): void;
|
|
72
|
+
/**
|
|
73
|
+
* Render until nothing is left to render, and answer whether it went quiet.
|
|
74
|
+
*
|
|
75
|
+
* The question `flush` cannot answer. A frame settles in more than one pass -
|
|
76
|
+
* an effect marks something dirty, a measurement runs the layout again - so a
|
|
77
|
+
* program that wants one true frame has to wait rather than force one.
|
|
78
|
+
*
|
|
79
|
+
* It returns as soon as a pass finds nothing pending, so an animating
|
|
80
|
+
* application settles between its frames. `false` means the passes kept
|
|
81
|
+
* producing work until the limit ran out - a render loop that does not
|
|
82
|
+
* converge, rather than one that is merely busy.
|
|
83
|
+
*/
|
|
84
|
+
settled(options?: { limit?: number }): Promise<boolean>;
|
|
72
85
|
/** The last painted frame. */
|
|
73
86
|
buffer(): CellBuffer;
|
|
74
87
|
|
package/src/types/command.ts
CHANGED
|
@@ -43,6 +43,16 @@ export interface ArgSpec {
|
|
|
43
43
|
/** Fixed choices, or a resolver for a picker. */
|
|
44
44
|
choices?: ArgChoices | (() => Promise<ArgChoices> | ArgChoices);
|
|
45
45
|
default?: unknown;
|
|
46
|
+
/**
|
|
47
|
+
* How the picker should lay out each choice's `description`.
|
|
48
|
+
*
|
|
49
|
+
* The argument is what knows: a list of branch names has nothing to say
|
|
50
|
+
* under each one, and a list of approval modes is *only* told apart by what
|
|
51
|
+
* is under each one. `below` gives every choice a second line, which is the
|
|
52
|
+
* only place a sentence fits - inline it shares the width with the label and
|
|
53
|
+
* every answer shows the same truncated half.
|
|
54
|
+
*/
|
|
55
|
+
descriptions?: 'inline' | 'below';
|
|
46
56
|
|
|
47
57
|
/**
|
|
48
58
|
* Show what a choice would do, before it is chosen.
|
|
@@ -75,6 +85,13 @@ export interface CommandDefinition {
|
|
|
75
85
|
id: string;
|
|
76
86
|
title: string;
|
|
77
87
|
description?: string;
|
|
88
|
+
/**
|
|
89
|
+
* The group this belongs to. The palette names it once, above the group.
|
|
90
|
+
*
|
|
91
|
+
* It is not a per-row label: repeating it beside every row spends the width
|
|
92
|
+
* the rows need for saying what they do, and still does not say where one
|
|
93
|
+
* group ends and the next begins.
|
|
94
|
+
*/
|
|
78
95
|
category?: string;
|
|
79
96
|
icon?: string;
|
|
80
97
|
/**
|
|
@@ -86,7 +103,7 @@ export interface CommandDefinition {
|
|
|
86
103
|
*/
|
|
87
104
|
keepOpen?: boolean;
|
|
88
105
|
/**
|
|
89
|
-
* A short state word shown beside the row, in place of
|
|
106
|
+
* A short state word shown beside the row, in place of its description.
|
|
90
107
|
*
|
|
91
108
|
* The icon is the row's identity and should not move under the reader as
|
|
92
109
|
* state changes; this is where the state goes instead.
|
package/src/types/input.ts
CHANGED
|
@@ -52,6 +52,15 @@ export interface MouseEvent {
|
|
|
52
52
|
ctrl: boolean;
|
|
53
53
|
alt: boolean;
|
|
54
54
|
shift: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* When it happened, in milliseconds.
|
|
57
|
+
*
|
|
58
|
+
* A terminal reports presses and releases and never says "double click" -
|
|
59
|
+
* there is no such thing on the wire - so telling one gesture from two is
|
|
60
|
+
* arithmetic on when they arrived and where. Stamped by whatever produced
|
|
61
|
+
* the event, which is the only thing that knows.
|
|
62
|
+
*/
|
|
63
|
+
at?: number;
|
|
55
64
|
handled: boolean;
|
|
56
65
|
}
|
|
57
66
|
|
package/src/types/markdown.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { StyleColor } from './style.js';
|
|
1
|
+
import type { StyleColor, TableRules } from './style.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Markdown, as rows a terminal can scroll.
|
|
@@ -14,6 +14,9 @@ import type { StyleColor } from './style.js';
|
|
|
14
14
|
* put the visible ones back into one box.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
/** Which edge a table column's text is pushed against. */
|
|
18
|
+
export type MarkdownAlign = 'left' | 'center' | 'right';
|
|
19
|
+
|
|
17
20
|
/** A run of text with one style. Inline emphasis is why rows are not strings. */
|
|
18
21
|
export interface MarkdownRun {
|
|
19
22
|
text: string;
|
|
@@ -28,7 +31,30 @@ export type MarkdownRow =
|
|
|
28
31
|
| { kind: 'rule' }
|
|
29
32
|
| { kind: 'heading'; runs: MarkdownRun[]; level: number }
|
|
30
33
|
| { kind: 'text'; runs: MarkdownRun[]; prefix?: string; prefixFg?: StyleColor; fg?: StyleColor }
|
|
31
|
-
| { kind: 'fence'; fence: number; part: 'open' | 'code' | 'close'; text?: string; language?: string }
|
|
34
|
+
| { kind: 'fence'; fence: number; part: 'open' | 'code' | 'close'; text?: string; language?: string }
|
|
35
|
+
/**
|
|
36
|
+
* One line of a table, with its cells already fitted to their columns.
|
|
37
|
+
*
|
|
38
|
+
* The widths are decided once for the whole table and repeated on every row
|
|
39
|
+
* of it, because a column that is measured per row is not a column. Cells
|
|
40
|
+
* arrive padded and truncated to exactly `widths[i]`, so a painter only has
|
|
41
|
+
* to put the separators in - which is also what keeps the one-row-per-row
|
|
42
|
+
* invariant: a cell too long for its column is cut, never wrapped, or the
|
|
43
|
+
* table would be a different height than the document said it was.
|
|
44
|
+
*
|
|
45
|
+
* `top`, `rule` and `bottom` are edges and carry no cells; the painter draws
|
|
46
|
+
* them from the widths, in whatever the theme's border characters are. They
|
|
47
|
+
* are rows here for the same reason a fence's rules are: they take a line on
|
|
48
|
+
* the screen, so anything counting rows has to be able to count them.
|
|
49
|
+
*/
|
|
50
|
+
| {
|
|
51
|
+
kind: 'table';
|
|
52
|
+
table: number;
|
|
53
|
+
part: 'top' | 'head' | 'rule' | 'body' | 'bottom';
|
|
54
|
+
cells: MarkdownRun[][];
|
|
55
|
+
widths: number[];
|
|
56
|
+
align: MarkdownAlign[];
|
|
57
|
+
};
|
|
32
58
|
|
|
33
59
|
export interface MarkdownLayoutOptions {
|
|
34
60
|
/** Cells to wrap to. Zero means "not measured yet" - nothing is wrapped. */
|
|
@@ -44,4 +70,14 @@ export interface MarkdownLayoutOptions {
|
|
|
44
70
|
* rules nobody draws stops two rows short of the end of the document.
|
|
45
71
|
*/
|
|
46
72
|
ruled?: boolean;
|
|
73
|
+
/**
|
|
74
|
+
* How much of a table gets ruled. The theme's `tableRules`, passed through.
|
|
75
|
+
*
|
|
76
|
+
* It has to be decided here rather than by whatever paints the rows,
|
|
77
|
+
* because a rule between two rows *is* a row - it takes a line on the
|
|
78
|
+
* screen. A painter that added them would be drawing more lines than the
|
|
79
|
+
* layout counted, and every viewer that scrolls by row index would land in
|
|
80
|
+
* the wrong place by one per table row.
|
|
81
|
+
*/
|
|
82
|
+
tableRules?: TableRules;
|
|
47
83
|
}
|
package/src/types/style.ts
CHANGED
|
@@ -16,7 +16,7 @@ export type ColorToken =
|
|
|
16
16
|
| 'onAccent' | 'onPrimary' | 'onSecondary'
|
|
17
17
|
| 'onSuccess' | 'onWarning' | 'onDanger' | 'onInfo'
|
|
18
18
|
| 'hover' | 'active' | 'selected' | 'focus' | 'disabled'
|
|
19
|
-
| 'scrim' | 'cursor' | 'shadow';
|
|
19
|
+
| 'scrim' | 'cursor' | 'shadow' | 'divider';
|
|
20
20
|
|
|
21
21
|
/** Anywhere a colour is accepted, a semantic token is accepted too. */
|
|
22
22
|
export type StyleColor = ColorToken | Color;
|
|
@@ -45,6 +45,38 @@ export interface BorderChars {
|
|
|
45
45
|
teeRight: string;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* A rule that separates, rather than a frame that encloses.
|
|
50
|
+
*
|
|
51
|
+
* Kept apart from `BorderStyle` on purpose: a theme that draws no frames may
|
|
52
|
+
* still want a rule, and tying the two means choosing a divider glyph decides
|
|
53
|
+
* whether every bordered component reserves a ring.
|
|
54
|
+
*/
|
|
55
|
+
export type DividerStyle =
|
|
56
|
+
| 'none' | 'single' | 'double' | 'dashed' | 'thick' | 'ascii';
|
|
57
|
+
|
|
58
|
+
/** A divider runs either way, so it names both. */
|
|
59
|
+
export interface DividerChars {
|
|
60
|
+
horizontal: string;
|
|
61
|
+
vertical: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The shape of the caret.
|
|
66
|
+
*
|
|
67
|
+
* Named for DECSCUSR, which is what a terminal understands, so a theme value
|
|
68
|
+
* maps straight onto the escape sequence with nothing to translate.
|
|
69
|
+
*/
|
|
70
|
+
export type CursorStyle = 'block' | 'underline' | 'bar';
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* How much of a table gets ruled: the header only, or between every row.
|
|
74
|
+
*
|
|
75
|
+
* Not a border style - it is a question about how many lines, not which
|
|
76
|
+
* glyphs. The glyphs are the theme's border set either way.
|
|
77
|
+
*/
|
|
78
|
+
export type TableRules = 'header' | 'all';
|
|
79
|
+
|
|
48
80
|
export type BorderSides = {
|
|
49
81
|
top?: boolean;
|
|
50
82
|
right?: boolean;
|
package/src/types/terminal.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CursorStyle } from './style.js';
|
|
1
2
|
import type { Disposable } from './disposable.js';
|
|
2
3
|
import type { Size } from './geometry.js';
|
|
3
4
|
import type { TerminalCapabilities, CapabilityOverrides } from './capabilities.js';
|
|
@@ -30,6 +31,8 @@ export interface AcquiredState {
|
|
|
30
31
|
focusEvents: boolean;
|
|
31
32
|
paste: boolean;
|
|
32
33
|
cursorHidden: boolean;
|
|
34
|
+
/** Whether this session changed the caret shape, and so owes a reset. */
|
|
35
|
+
cursorShaped?: boolean;
|
|
33
36
|
enhancedKeys: boolean;
|
|
34
37
|
rawMode: boolean;
|
|
35
38
|
titleSet: boolean;
|
|
@@ -55,4 +58,10 @@ export interface TerminalAdapter extends Disposable {
|
|
|
55
58
|
/** OSC 52, when the terminal allows it. */
|
|
56
59
|
writeClipboard?(text: string): void;
|
|
57
60
|
setTitle?(title: string): void;
|
|
61
|
+
/**
|
|
62
|
+
* DECSCUSR. Session state rather than frame state - it survives until
|
|
63
|
+
* something changes it, so it is set when it changes and put back on
|
|
64
|
+
* teardown, the way the alt screen and raw mode are.
|
|
65
|
+
*/
|
|
66
|
+
setCursorShape?(shape: CursorStyle): void;
|
|
58
67
|
}
|
package/src/types/theme.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { Color } from './cells.js';
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
BorderChars, BorderStyle, ColorToken, CursorStyle, Density, DividerChars, DividerStyle,
|
|
4
|
+
Style, StyleColor, TableRules,
|
|
5
|
+
} from './style.js';
|
|
3
6
|
import type { SyntaxScope } from './syntax.js';
|
|
4
7
|
import type { Disposable } from './disposable.js';
|
|
5
8
|
import type { TerminalCapabilities } from './capabilities.js';
|
|
@@ -33,6 +36,8 @@ export interface ThemeGlyphs {
|
|
|
33
36
|
chevronUp: string;
|
|
34
37
|
arrowUp: string;
|
|
35
38
|
arrowDown: string;
|
|
39
|
+
arrowLeft: string;
|
|
40
|
+
arrowRight: string;
|
|
36
41
|
ellipsis: string;
|
|
37
42
|
search: string;
|
|
38
43
|
radioOn: string;
|
|
@@ -80,6 +85,25 @@ export interface ThemeDefinition {
|
|
|
80
85
|
/** Default border style for chrome. `'none'` gives the borderless look. */
|
|
81
86
|
border?: BorderStyle;
|
|
82
87
|
borderChars?: Partial<Record<BorderStyle, BorderChars>>;
|
|
88
|
+
/**
|
|
89
|
+
* Default rule style. Independent of `border`, so a borderless theme can
|
|
90
|
+
* still separate with a line.
|
|
91
|
+
*/
|
|
92
|
+
divider?: DividerStyle;
|
|
93
|
+
dividerChars?: Partial<Record<DividerStyle, DividerChars>>;
|
|
94
|
+
/** The caret's shape. The terminal's own setting is the default. */
|
|
95
|
+
cursor?: CursorStyle;
|
|
96
|
+
/**
|
|
97
|
+
* How much of a table gets ruled.
|
|
98
|
+
*
|
|
99
|
+
* `header` is the default and the quiet one: a box, and a rule under the
|
|
100
|
+
* header. `all` puts a rule between every pair of rows as well, which is
|
|
101
|
+
* what a table of few, long rows wants - a wrapped-looking cell beside a
|
|
102
|
+
* short one is ambiguous about which row it belongs to until something
|
|
103
|
+
* separates them. On a table of twenty short rows the same lines are noise,
|
|
104
|
+
* which is why it is the theme's call rather than the default.
|
|
105
|
+
*/
|
|
106
|
+
tableRules?: TableRules;
|
|
83
107
|
density?: Density;
|
|
84
108
|
/** Per-component style overrides, keyed by component name then variant. */
|
|
85
109
|
components?: Record<string, Record<string, Style>>;
|
|
@@ -100,6 +124,9 @@ export interface ResolvedTheme {
|
|
|
100
124
|
spacing: ThemeSpacing;
|
|
101
125
|
glyphs: ThemeGlyphs;
|
|
102
126
|
border: BorderStyle;
|
|
127
|
+
divider: DividerStyle;
|
|
128
|
+
cursor: CursorStyle | undefined;
|
|
129
|
+
tableRules: TableRules;
|
|
103
130
|
density: Density;
|
|
104
131
|
components: Record<string, Record<string, Style>>;
|
|
105
132
|
/** Every syntax scope, resolved to a colour. */
|
|
@@ -107,6 +134,7 @@ export interface ResolvedTheme {
|
|
|
107
134
|
/** Resolve a token (or pass a literal colour through). */
|
|
108
135
|
color(token: string): Color;
|
|
109
136
|
borderChars(style?: BorderStyle): BorderChars;
|
|
137
|
+
dividerChars(style?: DividerStyle): DividerChars;
|
|
110
138
|
/** Component style for a name + variant list, merged in order. */
|
|
111
139
|
styleFor(component: string, variants?: string[]): Style;
|
|
112
140
|
}
|
package/src/util/markdown.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import type {
|
|
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
|
-
|
|
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, `
|
|
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
|
}
|