@markii/ansi 0.16.0 → 0.17.0-next.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/LICENSE +21 -0
- package/dist/components/callout.d.ts +15 -1
- package/dist/components/callout.js +32 -19
- package/dist/components/card.d.ts +10 -0
- package/dist/components/card.js +21 -13
- package/dist/components/chart.d.ts +1 -1
- package/dist/components/chart.js +8 -9
- package/dist/components/details-string.js +1 -1
- package/dist/components/details.d.ts +3 -1
- package/dist/components/details.js +7 -6
- package/dist/components/divider.js +3 -5
- package/dist/components/progress.js +2 -3
- package/dist/components/rating.js +2 -2
- package/dist/components/row.d.ts +2 -0
- package/dist/components/row.js +3 -1
- package/dist/components/stat.js +1 -2
- package/dist/components/table-grid.d.ts +2 -1
- package/dist/components/table-grid.js +13 -10
- package/dist/components/table.js +1 -1
- package/dist/components/tabs.d.ts +3 -1
- package/dist/components/tabs.js +25 -7
- package/dist/failure-presentation.d.ts +15 -0
- package/dist/failure-presentation.js +22 -0
- package/dist/glyphs.d.ts +73 -0
- package/dist/glyphs.js +88 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/ink-string.d.ts +11 -0
- package/dist/ink-string.js +24 -0
- package/dist/interactive.d.ts +31 -6
- package/dist/interactive.js +155 -12
- package/dist/pack-loader.d.ts +64 -0
- package/dist/pack-loader.js +172 -0
- package/dist/pack-script.d.ts +44 -0
- package/dist/pack-script.js +135 -0
- package/dist/registry.d.ts +15 -0
- package/dist/render.d.ts +24 -1
- package/dist/render.js +401 -83
- package/dist/text-grid.d.ts +43 -7
- package/dist/text-grid.js +69 -18
- package/package.json +6 -5
|
@@ -37,6 +37,8 @@ export declare function emptyInlineTitle(name: string): string;
|
|
|
37
37
|
export declare function invalidAttributeValueTitle(directive: string, attribute: string, value: string): string;
|
|
38
38
|
/** The marker text for a component-built image whose `src` was refused as unsafe. Wording identical to `@markii/html`'s `unsafeImageSrcTitle`. */
|
|
39
39
|
export declare function unsafeImageSrcTitle(directive: string): string;
|
|
40
|
+
/** The diagnostic text for a registered block component written as an inline directive. Wording identical to `@markii/html`'s `formMismatchTitle`. */
|
|
41
|
+
export declare function formMismatchTitle(directive: string): string;
|
|
40
42
|
/**
|
|
41
43
|
* The INLINE marker text for a known attribute's value outside its closed
|
|
42
44
|
* enum, this engine's own departure from `@markii/html`'s identical wording:
|
|
@@ -56,3 +58,16 @@ export declare function invalidAttributeValueLabel(directive: string, attribute:
|
|
|
56
58
|
* only.
|
|
57
59
|
*/
|
|
58
60
|
export declare function unsafeImageSrcLabel(directive: string): string;
|
|
61
|
+
/**
|
|
62
|
+
* The words of a fallback label for a directive that names a component of
|
|
63
|
+
* an installed pack with no view in this engine. Followed by the directive
|
|
64
|
+
* name in backticks, like the other fallback labels.
|
|
65
|
+
*/
|
|
66
|
+
export declare const NO_ENGINE_VIEW_LABEL = "no terminal view for";
|
|
67
|
+
/**
|
|
68
|
+
* The diagnostic text for the same case: the pack, and the engines the
|
|
69
|
+
* component does ship a view for. A terminal has no tooltip, so this
|
|
70
|
+
* sentence reaches the host's diagnostics surface through `onDiagnostic`
|
|
71
|
+
* and is never printed in the note.
|
|
72
|
+
*/
|
|
73
|
+
export declare function noEngineViewTitle(directive: string, pack: string, engines: readonly string[]): string;
|
|
@@ -99,6 +99,10 @@ export function invalidAttributeValueTitle(directive, attribute, value) {
|
|
|
99
99
|
export function unsafeImageSrcTitle(directive) {
|
|
100
100
|
return `${directive}: image source was refused as unsafe and was not shown`;
|
|
101
101
|
}
|
|
102
|
+
/** The diagnostic text for a registered block component written as an inline directive. Wording identical to `@markii/html`'s `formMismatchTitle`. */
|
|
103
|
+
export function formMismatchTitle(directive) {
|
|
104
|
+
return `${directive}: block component was written inline and was not rendered`;
|
|
105
|
+
}
|
|
102
106
|
/**
|
|
103
107
|
* The INLINE marker text for a known attribute's value outside its closed
|
|
104
108
|
* enum, this engine's own departure from `@markii/html`'s identical wording:
|
|
@@ -122,3 +126,21 @@ export function invalidAttributeValueLabel(directive, attribute) {
|
|
|
122
126
|
export function unsafeImageSrcLabel(directive) {
|
|
123
127
|
return `${directive}: image not shown`;
|
|
124
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* The words of a fallback label for a directive that names a component of
|
|
131
|
+
* an installed pack with no view in this engine. Followed by the directive
|
|
132
|
+
* name in backticks, like the other fallback labels.
|
|
133
|
+
*/
|
|
134
|
+
export const NO_ENGINE_VIEW_LABEL = 'no terminal view for';
|
|
135
|
+
/**
|
|
136
|
+
* The diagnostic text for the same case: the pack, and the engines the
|
|
137
|
+
* component does ship a view for. A terminal has no tooltip, so this
|
|
138
|
+
* sentence reaches the host's diagnostics surface through `onDiagnostic`
|
|
139
|
+
* and is never printed in the note.
|
|
140
|
+
*/
|
|
141
|
+
export function noEngineViewTitle(directive, pack, engines) {
|
|
142
|
+
const has = engines.length > 0
|
|
143
|
+
? `it has views for: ${engines.join(', ')}`
|
|
144
|
+
: 'it has no views for any engine';
|
|
145
|
+
return `The ${pack} pack has no terminal view for this component; ${has}`;
|
|
146
|
+
}
|
package/dist/glyphs.d.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every glyph this engine draws on its own, as one table per box style.
|
|
3
|
+
* `unicode` is the default; `ascii` is a 7-bit form for a terminal that
|
|
4
|
+
* cannot show box drawing or symbols. Every component, frame, rule, and
|
|
5
|
+
* status-line string reads its glyphs from the set the render was given,
|
|
6
|
+
* so the two styles cannot drift apart: `glyphs.test.ts` renders the whole
|
|
7
|
+
* render corpus in `ascii` and fails on any byte above 0x7f that did not
|
|
8
|
+
* come from the note itself.
|
|
9
|
+
*
|
|
10
|
+
* Each entry occupies the same number of terminal columns in both sets
|
|
11
|
+
* where layout depends on it (frames, rules, bars, focus and fold markers),
|
|
12
|
+
* so switching style never changes how a note wraps. The engine never
|
|
13
|
+
* picks a style from the environment; the host decides.
|
|
14
|
+
*/
|
|
15
|
+
export declare const BOX_STYLES: readonly ["unicode", "ascii"];
|
|
16
|
+
export type BoxStyle = (typeof BOX_STYLES)[number];
|
|
17
|
+
export declare function isBoxStyle(value: string): value is BoxStyle;
|
|
18
|
+
/** One frame edge set: the solid frame of a card, or the dashed one of a fallback box. */
|
|
19
|
+
export interface FrameEdges {
|
|
20
|
+
readonly horizontal: string;
|
|
21
|
+
readonly vertical: string;
|
|
22
|
+
}
|
|
23
|
+
export interface GlyphSet {
|
|
24
|
+
/** Corners shared by every frame and by a table's outer edge. */
|
|
25
|
+
readonly topLeft: string;
|
|
26
|
+
readonly topRight: string;
|
|
27
|
+
readonly bottomLeft: string;
|
|
28
|
+
readonly bottomRight: string;
|
|
29
|
+
readonly solid: FrameEdges;
|
|
30
|
+
readonly dashed: FrameEdges;
|
|
31
|
+
/** A table's inner junctions. */
|
|
32
|
+
readonly teeDown: string;
|
|
33
|
+
readonly teeUp: string;
|
|
34
|
+
readonly teeRight: string;
|
|
35
|
+
readonly teeLeft: string;
|
|
36
|
+
readonly cross: string;
|
|
37
|
+
/** A thematic break and a `divider` drawn as a line. */
|
|
38
|
+
readonly rule: string;
|
|
39
|
+
/** A `divider` drawn as dots. */
|
|
40
|
+
readonly ruleDots: string;
|
|
41
|
+
/** A `divider{style=ornament}` centerpiece. */
|
|
42
|
+
readonly ornament: string;
|
|
43
|
+
/** The bar a blockquote's lines start with, including its trailing space. */
|
|
44
|
+
readonly quoteBar: string;
|
|
45
|
+
/** The bar a callout's lines start with, including its trailing space. */
|
|
46
|
+
readonly calloutBar: string;
|
|
47
|
+
readonly calloutIcons: {
|
|
48
|
+
readonly info: string;
|
|
49
|
+
readonly warning: string;
|
|
50
|
+
readonly danger: string;
|
|
51
|
+
};
|
|
52
|
+
/** The marker before a focused block's heading, including its trailing space. */
|
|
53
|
+
readonly focus: string;
|
|
54
|
+
/** A details block's fold marker. */
|
|
55
|
+
readonly foldOpen: string;
|
|
56
|
+
readonly foldClosed: string;
|
|
57
|
+
/** The collapsed script marker: its leading symbol and the separator before its detail. */
|
|
58
|
+
readonly script: string;
|
|
59
|
+
readonly scriptSeparator: string;
|
|
60
|
+
/** Eight steps, lowest first, that a line chart's sparkline is built from. */
|
|
61
|
+
readonly sparkline: string;
|
|
62
|
+
/** A filled bar cell (chart bars, a progress bar's done part) and an empty one. */
|
|
63
|
+
readonly barFilled: string;
|
|
64
|
+
readonly barEmpty: string;
|
|
65
|
+
readonly starFilled: string;
|
|
66
|
+
readonly starEmpty: string;
|
|
67
|
+
/** What a `stat` shows when its value is empty. */
|
|
68
|
+
readonly emptyValue: string;
|
|
69
|
+
/** The key legend on the live viewer's status line. */
|
|
70
|
+
readonly keyLegend: string;
|
|
71
|
+
}
|
|
72
|
+
/** The glyph set for `style`; anything other than `'ascii'` gets the unicode set. */
|
|
73
|
+
export declare function glyphsFor(style: BoxStyle | undefined): GlyphSet;
|
package/dist/glyphs.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every glyph this engine draws on its own, as one table per box style.
|
|
3
|
+
* `unicode` is the default; `ascii` is a 7-bit form for a terminal that
|
|
4
|
+
* cannot show box drawing or symbols. Every component, frame, rule, and
|
|
5
|
+
* status-line string reads its glyphs from the set the render was given,
|
|
6
|
+
* so the two styles cannot drift apart: `glyphs.test.ts` renders the whole
|
|
7
|
+
* render corpus in `ascii` and fails on any byte above 0x7f that did not
|
|
8
|
+
* come from the note itself.
|
|
9
|
+
*
|
|
10
|
+
* Each entry occupies the same number of terminal columns in both sets
|
|
11
|
+
* where layout depends on it (frames, rules, bars, focus and fold markers),
|
|
12
|
+
* so switching style never changes how a note wraps. The engine never
|
|
13
|
+
* picks a style from the environment; the host decides.
|
|
14
|
+
*/
|
|
15
|
+
export const BOX_STYLES = ['unicode', 'ascii'];
|
|
16
|
+
export function isBoxStyle(value) {
|
|
17
|
+
return BOX_STYLES.includes(value);
|
|
18
|
+
}
|
|
19
|
+
const UNICODE = {
|
|
20
|
+
topLeft: '┌',
|
|
21
|
+
topRight: '┐',
|
|
22
|
+
bottomLeft: '└',
|
|
23
|
+
bottomRight: '┘',
|
|
24
|
+
solid: { horizontal: '─', vertical: '│' },
|
|
25
|
+
dashed: { horizontal: '╌', vertical: '┆' },
|
|
26
|
+
teeDown: '┬',
|
|
27
|
+
teeUp: '┴',
|
|
28
|
+
teeRight: '├',
|
|
29
|
+
teeLeft: '┤',
|
|
30
|
+
cross: '┼',
|
|
31
|
+
rule: '─',
|
|
32
|
+
ruleDots: '·',
|
|
33
|
+
ornament: '❖',
|
|
34
|
+
quoteBar: '│ ',
|
|
35
|
+
calloutBar: '▌ ',
|
|
36
|
+
calloutIcons: { info: 'ℹ', warning: '▲', danger: '✕' },
|
|
37
|
+
focus: '› ',
|
|
38
|
+
foldOpen: '▾',
|
|
39
|
+
foldClosed: '▸',
|
|
40
|
+
script: '⚙',
|
|
41
|
+
scriptSeparator: '·',
|
|
42
|
+
sparkline: '▁▂▃▄▅▆▇█',
|
|
43
|
+
barFilled: '█',
|
|
44
|
+
barEmpty: '░',
|
|
45
|
+
starFilled: '★',
|
|
46
|
+
starEmpty: '☆',
|
|
47
|
+
emptyValue: '—',
|
|
48
|
+
keyLegend: 'q quit ↑↓/jk focus ←→/tab switch enter open/close space/pgdn scroll g/G top/end',
|
|
49
|
+
};
|
|
50
|
+
const ASCII = {
|
|
51
|
+
topLeft: '+',
|
|
52
|
+
topRight: '+',
|
|
53
|
+
bottomLeft: '+',
|
|
54
|
+
bottomRight: '+',
|
|
55
|
+
solid: { horizontal: '-', vertical: '|' },
|
|
56
|
+
dashed: { horizontal: '.', vertical: ':' },
|
|
57
|
+
teeDown: '+',
|
|
58
|
+
teeUp: '+',
|
|
59
|
+
teeRight: '+',
|
|
60
|
+
teeLeft: '+',
|
|
61
|
+
cross: '+',
|
|
62
|
+
rule: '-',
|
|
63
|
+
ruleDots: '.',
|
|
64
|
+
ornament: '*',
|
|
65
|
+
quoteBar: '| ',
|
|
66
|
+
calloutBar: '| ',
|
|
67
|
+
calloutIcons: { info: 'i', warning: '!', danger: 'x' },
|
|
68
|
+
focus: '> ',
|
|
69
|
+
foldOpen: '-',
|
|
70
|
+
foldClosed: '+',
|
|
71
|
+
script: '#',
|
|
72
|
+
scriptSeparator: '-',
|
|
73
|
+
sparkline: '_.-:=+*#',
|
|
74
|
+
barFilled: '#',
|
|
75
|
+
barEmpty: '.',
|
|
76
|
+
starFilled: '*',
|
|
77
|
+
starEmpty: '.',
|
|
78
|
+
emptyValue: '-',
|
|
79
|
+
keyLegend: 'q quit up/down/jk focus left/right/tab switch enter open/close space/pgdn scroll g/G top/end',
|
|
80
|
+
};
|
|
81
|
+
const GLYPHS = {
|
|
82
|
+
unicode: UNICODE,
|
|
83
|
+
ascii: ASCII,
|
|
84
|
+
};
|
|
85
|
+
/** The glyph set for `style`; anything other than `'ascii'` gets the unicode set. */
|
|
86
|
+
export function glyphsFor(style) {
|
|
87
|
+
return style === 'ascii' ? GLYPHS.ascii : GLYPHS.unicode;
|
|
88
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { renderMarkToAnsi, renderMarkNodeToAnsi, renderMarkInlineToAnsi, buildMarkElement, type RenderMarkOptions, } from './render.js';
|
|
2
2
|
export { fg, colorize, bold, dim, italic, underline, inverse, hyperlink, detectColorLevel, resolveColorOption, type ColorLevel, type ColorOption, type AnsiColor, } from './ansi.js';
|
|
3
3
|
export { defaultAnsiTheme, type AnsiTheme, type Tier1Token } from './theme.js';
|
|
4
|
+
export { BOX_STYLES, isBoxStyle, glyphsFor, type BoxStyle, type FrameEdges, type GlyphSet, } from './glyphs.js';
|
|
4
5
|
export { style } from './style.js';
|
|
5
6
|
export { createAnsiRegistry, mergeAnsiRegistries, registryAliases, readRegistryComponent, resolveDirectiveAlias, childrenText, REGISTRY_ALIASES, type DirectiveAttributes, type AnsiRegistry, type AnsiRegistryEntry, type AnsiComponent, type AnsiComponentProps, type AnsiRenderContext, type RegistryAlias, type RegistryAliases, type ResolvedDirective, type ValueResolution, } from './registry.js';
|
|
6
7
|
export { resolveStorePath, resolveScopedPath, VAULT_NAME_PREFIX, type StorePathResolution, type ValueScope, } from './resolve.js';
|
|
@@ -11,3 +12,6 @@ export { resolveLayoutAttributes, applyLayout, LAYOUT_ATTRIBUTE_KEYS, type Resol
|
|
|
11
12
|
export { type ResolveImageSrc } from './image-resolve.js';
|
|
12
13
|
export { type ResolveHref } from './href-resolve.js';
|
|
13
14
|
export type { AnsiValueStore, AnsiVaultStore, FailureKind, StoredValue, ValueStatus, } from './value-types.js';
|
|
15
|
+
export { defaultAnsiRegistry } from './components/index.js';
|
|
16
|
+
export { ANSI_ENGINE_ID, loadAnsiPack, installAnsiPacks, sanitizeValueForPack, type AnsiPackComponentModules, type AnsiPackToInstall, type LoadAnsiPackResult, type InstallAnsiPacksResult, } from './pack-loader.js';
|
|
17
|
+
export { loadTerminalPackScript, installTerminalPackGlobals, TERMINAL_REGISTER_GLOBAL, TERMINAL_REACT_GLOBAL, TERMINAL_INK_GLOBAL, type LoadTerminalPackScriptResult, } from './pack-script.js';
|
package/dist/index.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
export { renderMarkToAnsi, renderMarkNodeToAnsi, renderMarkInlineToAnsi, buildMarkElement, } from './render.js';
|
|
8
8
|
export { fg, colorize, bold, dim, italic, underline, inverse, hyperlink, detectColorLevel, resolveColorOption, } from './ansi.js';
|
|
9
9
|
export { defaultAnsiTheme } from './theme.js';
|
|
10
|
+
export { BOX_STYLES, isBoxStyle, glyphsFor, } from './glyphs.js';
|
|
10
11
|
export { style } from './style.js';
|
|
11
12
|
export { createAnsiRegistry, mergeAnsiRegistries, registryAliases, readRegistryComponent, resolveDirectiveAlias, childrenText, REGISTRY_ALIASES, } from './registry.js';
|
|
12
13
|
export { resolveStorePath, resolveScopedPath, VAULT_NAME_PREFIX, } from './resolve.js';
|
|
@@ -14,3 +15,6 @@ export { failurePhrase, failureTitle, failureToken, dataStateSuffix, emptyInline
|
|
|
14
15
|
export { stringifyStoredValue } from './value-format.js';
|
|
15
16
|
export { stripControlCharacters, sanitizeBlockText, sanitizeUrlText, BLOCK_TAB_WIDTH, } from './sanitize.js';
|
|
16
17
|
export { resolveLayoutAttributes, applyLayout, LAYOUT_ATTRIBUTE_KEYS, } from './layout.js';
|
|
18
|
+
export { defaultAnsiRegistry } from './components/index.js';
|
|
19
|
+
export { ANSI_ENGINE_ID, loadAnsiPack, installAnsiPacks, sanitizeValueForPack, } from './pack-loader.js';
|
|
20
|
+
export { loadTerminalPackScript, installTerminalPackGlobals, TERMINAL_REGISTER_GLOBAL, TERMINAL_REACT_GLOBAL, TERMINAL_INK_GLOBAL, } from './pack-script.js';
|
package/dist/ink-string.d.ts
CHANGED
|
@@ -10,3 +10,14 @@ import type { ReactElement } from 'react';
|
|
|
10
10
|
* the ordinary ESM module-cache guarantee.
|
|
11
11
|
*/
|
|
12
12
|
export declare function renderInkToString(element: ReactElement, columns: number): Promise<string>;
|
|
13
|
+
/**
|
|
14
|
+
* The same capture as `renderInkToString`, synchronously. Valid only once
|
|
15
|
+
* Ink's module has finished evaluating, which is always true inside a walk
|
|
16
|
+
* (`render.tsx` imports Ink statically). Used for a registry component that
|
|
17
|
+
* returns an Ink element instead of a string (a terminal pack's view): the
|
|
18
|
+
* element is laid out at the width the directive was given and captured as
|
|
19
|
+
* text, so it composes with the string-built blocks around it. The result
|
|
20
|
+
* is a snapshot, so a component rendered this way has no live state.
|
|
21
|
+
* Trailing newlines Ink adds are removed.
|
|
22
|
+
*/
|
|
23
|
+
export declare function renderInkToStringSync(element: ReactElement, columns: number): string;
|
package/dist/ink-string.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Writable } from 'node:stream';
|
|
2
|
+
import { render as inkRender } from 'ink';
|
|
2
3
|
function makeCollectingStream(columns) {
|
|
3
4
|
const chunks = [];
|
|
4
5
|
const stream = new Writable({
|
|
@@ -39,3 +40,26 @@ export async function renderInkToString(element, columns) {
|
|
|
39
40
|
instance.unmount();
|
|
40
41
|
return getOutput();
|
|
41
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* The same capture as `renderInkToString`, synchronously. Valid only once
|
|
45
|
+
* Ink's module has finished evaluating, which is always true inside a walk
|
|
46
|
+
* (`render.tsx` imports Ink statically). Used for a registry component that
|
|
47
|
+
* returns an Ink element instead of a string (a terminal pack's view): the
|
|
48
|
+
* element is laid out at the width the directive was given and captured as
|
|
49
|
+
* text, so it composes with the string-built blocks around it. The result
|
|
50
|
+
* is a snapshot, so a component rendered this way has no live state.
|
|
51
|
+
* Trailing newlines Ink adds are removed.
|
|
52
|
+
*/
|
|
53
|
+
export function renderInkToStringSync(element, columns) {
|
|
54
|
+
const { stream, getOutput } = makeCollectingStream(columns);
|
|
55
|
+
const instance = inkRender(element, {
|
|
56
|
+
stdout: stream,
|
|
57
|
+
stdin: process.stdin,
|
|
58
|
+
exitOnCtrlC: false,
|
|
59
|
+
patchConsole: false,
|
|
60
|
+
debug: false,
|
|
61
|
+
interactive: false,
|
|
62
|
+
});
|
|
63
|
+
instance.unmount();
|
|
64
|
+
return getOutput().replace(/\n+$/, '');
|
|
65
|
+
}
|
package/dist/interactive.d.ts
CHANGED
|
@@ -1,18 +1,32 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
|
+
import type { DOMElement } from 'ink';
|
|
2
3
|
import type { AnsiTheme } from './theme.js';
|
|
3
4
|
import type { ColorLevel } from './ansi.js';
|
|
4
5
|
/** Whether the component identified by `focusId` is the currently focused one. `undefined` (a non-interactive render, or a component that never got a `focusId`) is never focused. */
|
|
5
6
|
export declare function useIsFocused(focusId: number | undefined): boolean;
|
|
6
7
|
/** Wraps `text` in the accent-colored focus marker when interactive AND `focusId` is the focused one; otherwise returns `text` unchanged. */
|
|
7
8
|
export declare function useFocusMarker(text: string, focusId: number | undefined): string;
|
|
9
|
+
/**
|
|
10
|
+
* Lets a `tabs` instance report its currently active panel's label to
|
|
11
|
+
* `InteractiveRoot`, so the status line names the panel showing rather than
|
|
12
|
+
* the block's first one. Call it from an effect, never during render (see
|
|
13
|
+
* `FocusApi.reportActiveLabel`).
|
|
14
|
+
*/
|
|
15
|
+
export declare function useReportActiveLabel(): (focusId: number, label: string) => void;
|
|
16
|
+
/**
|
|
17
|
+
* The ref callback a focusable component puts on its outermost `Box`, so
|
|
18
|
+
* the live viewer can scroll the focused block into view. A no-op without a
|
|
19
|
+
* `focusId` or outside an interactive render.
|
|
20
|
+
*/
|
|
21
|
+
export declare function useFocusAnchor(focusId: number | undefined): (node: DOMElement | null) => void;
|
|
8
22
|
/**
|
|
9
23
|
* One focusable component, in the same document order `render.tsx`'s
|
|
10
24
|
* counting walk assigns `focusId`s in — `focusables[focusId]` is always
|
|
11
|
-
* that component's descriptor. `label` is
|
|
12
|
-
* the FIRST panel's label
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
25
|
+
* that component's descriptor. `label` is the descriptor's STABLE identity
|
|
26
|
+
* (for `tabs`, the FIRST panel's label): it is used on the status line only
|
|
27
|
+
* until a `tabs` instance reports its actual active panel via
|
|
28
|
+
* `reportActiveLabel`, and as the fallback before any report has happened
|
|
29
|
+
* yet (mount, or a reader who has not switched panels).
|
|
16
30
|
*/
|
|
17
31
|
export interface FocusableDescriptor {
|
|
18
32
|
kind: 'tabs' | 'details';
|
|
@@ -25,6 +39,17 @@ export interface InteractiveRootProps {
|
|
|
25
39
|
onExit?: () => void;
|
|
26
40
|
color: ColorLevel;
|
|
27
41
|
theme: AnsiTheme;
|
|
42
|
+
/** The key legend on the status line, identical regardless of what is focused: the render's `GlyphSet.keyLegend`, so it follows the box style. */
|
|
43
|
+
keyLegend: string;
|
|
44
|
+
/**
|
|
45
|
+
* The terminal's row count. When given, the document gets a viewport of
|
|
46
|
+
* this many rows minus the one-row status line, scrolled with the keys in
|
|
47
|
+
* the legend, and the status line stays pinned at the bottom. Absent, the
|
|
48
|
+
* document is laid out at its full height with the status line after it.
|
|
49
|
+
*/
|
|
50
|
+
viewportRows?: number;
|
|
51
|
+
/** The columns the status line is cut to, so it stays one row. */
|
|
52
|
+
width: number;
|
|
28
53
|
children: ReactNode;
|
|
29
54
|
}
|
|
30
55
|
/**
|
|
@@ -38,4 +63,4 @@ export interface InteractiveRootProps {
|
|
|
38
63
|
* default (uninteractive) value there, so a string render never sees a
|
|
39
64
|
* focus marker, a glyph, or a status line.
|
|
40
65
|
*/
|
|
41
|
-
export declare function InteractiveRoot({ focusables, onExit, color, theme, children, }: InteractiveRootProps): ReactNode;
|
|
66
|
+
export declare function InteractiveRoot({ focusables, onExit, color, theme, keyLegend, viewportRows, width, children, }: InteractiveRootProps): ReactNode;
|
package/dist/interactive.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { createContext, useContext, useMemo, useState } from 'react';
|
|
3
|
-
import { Box, Text, useInput } from 'ink';
|
|
2
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react';
|
|
3
|
+
import { Box, Text, measureElement, useInput } from 'ink';
|
|
4
4
|
import { dim } from './ansi.js';
|
|
5
5
|
import { style } from './style.js';
|
|
6
|
+
import { cutToWidth } from './text-grid.js';
|
|
6
7
|
const FocusContext = createContext({
|
|
7
8
|
interactive: false,
|
|
8
9
|
focusedId: undefined,
|
|
9
10
|
focusMarker: (text) => text,
|
|
11
|
+
reportActiveLabel: () => { },
|
|
12
|
+
registerAnchor: () => { },
|
|
10
13
|
});
|
|
11
14
|
/** Whether the component identified by `focusId` is the currently focused one. `undefined` (a non-interactive render, or a component that never got a `focusId`) is never focused. */
|
|
12
15
|
export function useIsFocused(focusId) {
|
|
@@ -19,8 +22,29 @@ export function useFocusMarker(text, focusId) {
|
|
|
19
22
|
const focused = focusId !== undefined && api.interactive && api.focusedId === focusId;
|
|
20
23
|
return focused ? api.focusMarker(text) : text;
|
|
21
24
|
}
|
|
22
|
-
/**
|
|
23
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Lets a `tabs` instance report its currently active panel's label to
|
|
27
|
+
* `InteractiveRoot`, so the status line names the panel showing rather than
|
|
28
|
+
* the block's first one. Call it from an effect, never during render (see
|
|
29
|
+
* `FocusApi.reportActiveLabel`).
|
|
30
|
+
*/
|
|
31
|
+
export function useReportActiveLabel() {
|
|
32
|
+
const api = useContext(FocusContext);
|
|
33
|
+
return api.reportActiveLabel;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The ref callback a focusable component puts on its outermost `Box`, so
|
|
37
|
+
* the live viewer can scroll the focused block into view. A no-op without a
|
|
38
|
+
* `focusId` or outside an interactive render.
|
|
39
|
+
*/
|
|
40
|
+
export function useFocusAnchor(focusId) {
|
|
41
|
+
const { registerAnchor } = useContext(FocusContext);
|
|
42
|
+
return useCallback((node) => {
|
|
43
|
+
if (focusId === undefined)
|
|
44
|
+
return;
|
|
45
|
+
registerAnchor(focusId, node ?? undefined);
|
|
46
|
+
}, [focusId, registerAnchor]);
|
|
47
|
+
}
|
|
24
48
|
/**
|
|
25
49
|
* The root of an interactive render: owns which `focusId` is focused, moves
|
|
26
50
|
* it on up/down/j/k, calls `onExit` on `q`, and renders the one-line status
|
|
@@ -32,33 +56,152 @@ const KEY_LEGEND = '↑↓/jk focus ←→/tab switch enter open/close q quit
|
|
|
32
56
|
* default (uninteractive) value there, so a string render never sees a
|
|
33
57
|
* focus marker, a glyph, or a status line.
|
|
34
58
|
*/
|
|
35
|
-
export function InteractiveRoot({ focusables, onExit, color, theme, children, }) {
|
|
59
|
+
export function InteractiveRoot({ focusables, onExit, color, theme, keyLegend, viewportRows, width, children, }) {
|
|
36
60
|
const focusableCount = focusables.length;
|
|
37
61
|
const [focusIndex, setFocusIndex] = useState(0);
|
|
62
|
+
// `focusId -> the label its last active-panel report carried`. Only a
|
|
63
|
+
// `tabs` descriptor is ever reported against; `details` never calls
|
|
64
|
+
// `reportActiveLabel`, so its entry here simply never appears. Absent
|
|
65
|
+
// keys are expected (mount, or a `tabs` the reader has not switched yet)
|
|
66
|
+
// and fall back to the descriptor's own stable `label` below.
|
|
67
|
+
const [activeLabels, setActiveLabels] = useState({});
|
|
68
|
+
const viewport = viewportRows !== undefined ? Math.max(1, viewportRows - 1) : undefined;
|
|
69
|
+
const [offset, setOffset] = useState(0);
|
|
70
|
+
const contentRef = useRef(null);
|
|
71
|
+
const anchors = useRef(new Map());
|
|
72
|
+
// What the last reveal pass saw, so a reveal runs when focus MOVES or the
|
|
73
|
+
// focused block changes height (a details opening), and not on every
|
|
74
|
+
// render: a reader who scrolled away on purpose is left where they are.
|
|
75
|
+
const lastRevealed = useRef({
|
|
76
|
+
id: undefined,
|
|
77
|
+
height: -1,
|
|
78
|
+
});
|
|
79
|
+
const contentHeight = () => contentRef.current ? measureElement(contentRef.current).height : 0;
|
|
80
|
+
const maxOffset = () => viewport === undefined ? 0 : Math.max(0, contentHeight() - viewport);
|
|
81
|
+
const scrollBy = (delta) => {
|
|
82
|
+
if (viewport === undefined)
|
|
83
|
+
return;
|
|
84
|
+
const limit = maxOffset();
|
|
85
|
+
setOffset((current) => Math.min(limit, Math.max(0, current + delta)));
|
|
86
|
+
};
|
|
87
|
+
const scrollTo = (target) => {
|
|
88
|
+
if (viewport === undefined)
|
|
89
|
+
return;
|
|
90
|
+
setOffset(target === 'top' ? 0 : maxOffset());
|
|
91
|
+
};
|
|
38
92
|
useInput((input, key) => {
|
|
39
93
|
if (input === 'q') {
|
|
40
94
|
onExit?.();
|
|
41
95
|
return;
|
|
42
96
|
}
|
|
43
|
-
if (
|
|
97
|
+
if (viewport !== undefined) {
|
|
98
|
+
const page = Math.max(1, viewport - 1);
|
|
99
|
+
if (key.pageDown || input === ' ' || (key.ctrl && input === 'f')) {
|
|
100
|
+
scrollBy(page);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (key.pageUp || input === 'b' || (key.ctrl && input === 'b')) {
|
|
104
|
+
scrollBy(-page);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (key.home || input === 'g') {
|
|
108
|
+
scrollTo('top');
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (key.end || input === 'G') {
|
|
112
|
+
scrollTo('end');
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (key.ctrl && input === 'e') {
|
|
116
|
+
scrollBy(1);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (key.ctrl && input === 'y') {
|
|
120
|
+
scrollBy(-1);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const down = key.downArrow || input === 'j';
|
|
125
|
+
const up = key.upArrow || input === 'k';
|
|
126
|
+
if (focusableCount === 0) {
|
|
127
|
+
// Nothing to focus: the same keys scroll a line at a time.
|
|
128
|
+
if (down)
|
|
129
|
+
scrollBy(1);
|
|
130
|
+
else if (up)
|
|
131
|
+
scrollBy(-1);
|
|
44
132
|
return;
|
|
45
|
-
|
|
133
|
+
}
|
|
134
|
+
if (down) {
|
|
46
135
|
setFocusIndex((current) => (current + 1) % focusableCount);
|
|
47
136
|
}
|
|
48
|
-
else if (
|
|
137
|
+
else if (up) {
|
|
49
138
|
setFocusIndex((current) => (current - 1 + focusableCount) % focusableCount);
|
|
50
139
|
}
|
|
51
140
|
}, { isActive: true });
|
|
52
141
|
const focusedId = focusableCount > 0 ? focusIndex % focusableCount : undefined;
|
|
142
|
+
// Invoked from `tabs.tsx`'s KEY HANDLER, never during render — see
|
|
143
|
+
// `FocusApi.reportActiveLabel`'s doc comment. The equality guard avoids an
|
|
144
|
+
// unnecessary re-render when a switch reports the label it already had
|
|
145
|
+
// (e.g. wrapping from the last panel back past the first is still a real
|
|
146
|
+
// change, but re-reporting the same active panel is not).
|
|
147
|
+
const reportActiveLabel = useCallback((focusId, label) => {
|
|
148
|
+
setActiveLabels((current) => current[focusId] === label ? current : { ...current, [focusId]: label });
|
|
149
|
+
}, []);
|
|
150
|
+
const registerAnchor = useCallback((focusId, node) => {
|
|
151
|
+
if (node)
|
|
152
|
+
anchors.current.set(focusId, node);
|
|
153
|
+
else
|
|
154
|
+
anchors.current.delete(focusId);
|
|
155
|
+
}, []);
|
|
53
156
|
const api = useMemo(() => ({
|
|
54
157
|
interactive: true,
|
|
55
158
|
focusedId,
|
|
56
159
|
focusMarker: (text) => style(text, '--mk-accent', theme, color),
|
|
57
|
-
|
|
160
|
+
reportActiveLabel,
|
|
161
|
+
registerAnchor,
|
|
162
|
+
}), [focusedId, color, theme, reportActiveLabel, registerAnchor]);
|
|
163
|
+
// After every commit Ink has laid the tree out, so this is where the
|
|
164
|
+
// viewport is kept honest: the offset never exceeds the document (a
|
|
165
|
+
// resize or a closing details can shrink it), and a focus move, or a
|
|
166
|
+
// focused block growing, scrolls that block into view.
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
if (viewport === undefined || !contentRef.current)
|
|
169
|
+
return;
|
|
170
|
+
const content = measureElement(contentRef.current);
|
|
171
|
+
const limit = Math.max(0, content.height - viewport);
|
|
172
|
+
let next = Math.min(offset, limit);
|
|
173
|
+
const anchor = focusedId !== undefined ? anchors.current.get(focusedId) : undefined;
|
|
174
|
+
if (anchor) {
|
|
175
|
+
const box = measureElement(anchor);
|
|
176
|
+
const seen = lastRevealed.current;
|
|
177
|
+
if (seen.id !== focusedId || seen.height !== box.height) {
|
|
178
|
+
lastRevealed.current = { id: focusedId, height: box.height };
|
|
179
|
+
const top = box.y - content.y;
|
|
180
|
+
const bottom = top + box.height;
|
|
181
|
+
if (top < next)
|
|
182
|
+
next = top;
|
|
183
|
+
else if (bottom > next + viewport)
|
|
184
|
+
next = Math.min(top, bottom - viewport);
|
|
185
|
+
next = Math.min(limit, Math.max(0, next));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (next !== offset)
|
|
189
|
+
setOffset(next);
|
|
190
|
+
});
|
|
58
191
|
const focused = focusedId !== undefined ? focusables[focusedId] : undefined;
|
|
192
|
+
const focusedLabel = focused && focusedId !== undefined
|
|
193
|
+
? (activeLabels[focusedId] ?? focused.label)
|
|
194
|
+
: undefined;
|
|
59
195
|
const statusLabel = focused
|
|
60
|
-
? `${focused.kind} "${
|
|
196
|
+
? `${focused.kind} "${focusedLabel}"`
|
|
61
197
|
: 'nothing focusable';
|
|
62
|
-
|
|
63
|
-
|
|
198
|
+
// Cut to the width on plain text: the status line must stay one row (the
|
|
199
|
+
// viewport math counts it as one), and a label or legend that wrapped
|
|
200
|
+
// would push the document up.
|
|
201
|
+
const statusLine = dim(cutToWidth(`${statusLabel} ${keyLegend}`, width), color);
|
|
202
|
+
const status = _jsx(Text, { children: statusLine });
|
|
203
|
+
if (viewport === undefined) {
|
|
204
|
+
return (_jsx(FocusContext.Provider, { value: api, children: _jsxs(Box, { flexDirection: "column", children: [children, status] }) }));
|
|
205
|
+
}
|
|
206
|
+
return (_jsx(FocusContext.Provider, { value: api, children: _jsxs(Box, { flexDirection: "column", height: viewport + 1, children: [_jsx(Box, { flexDirection: "column", height: viewport, flexShrink: 0, overflow: "hidden", children: _jsx(Box, { ref: contentRef, flexDirection: "column", flexShrink: 0, marginTop: -offset, children: children }) }), status] }) }));
|
|
64
207
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { PackManifest } from '@markii/pack';
|
|
2
|
+
import type { AnsiRegistry, AnsiRegistryEntry } from './registry.js';
|
|
3
|
+
/**
|
|
4
|
+
* This engine's id in a pack manifest's per-component `sources` map
|
|
5
|
+
* (docs/packs.md). A component with an `ansi` source has a terminal view.
|
|
6
|
+
*/
|
|
7
|
+
export declare const ANSI_ENGINE_ID = "ansi";
|
|
8
|
+
/**
|
|
9
|
+
* The component modules a host hands to `loadAnsiPack`, keyed by the
|
|
10
|
+
* manifest's LOCAL component name. Each is the `{ component, inline? }`
|
|
11
|
+
* pair a pack's `terminal.js` registered (`./pack-script.ts`).
|
|
12
|
+
*/
|
|
13
|
+
export type AnsiPackComponentModules = Record<string, AnsiRegistryEntry>;
|
|
14
|
+
/**
|
|
15
|
+
* Returns `value` with every string in it run through
|
|
16
|
+
* `stripControlCharacters`, so a pack that prints a stored value cannot
|
|
17
|
+
* print a control character a script smuggled in. Arrays and plain objects
|
|
18
|
+
* are copied (null-prototype for objects, hasOwn-guarded), never mutated.
|
|
19
|
+
* Anything past `MAX_SANITIZE_DEPTH`, and every non-JSON value, becomes
|
|
20
|
+
* `undefined`. Never throws: a throwing getter reads as `undefined`.
|
|
21
|
+
*/
|
|
22
|
+
export declare function sanitizeValueForPack(value: unknown, depth?: number): unknown;
|
|
23
|
+
/** What `loadAnsiPack` reports about a pack it could not run. */
|
|
24
|
+
export interface LoadAnsiPackResult {
|
|
25
|
+
registry: AnsiRegistry;
|
|
26
|
+
/** Present when the pack has components but none has an `ansi` source: the pack contributes nothing. `engine` names the engines it does declare. */
|
|
27
|
+
dropped?: {
|
|
28
|
+
name: string;
|
|
29
|
+
engine: string;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Loads one pack's terminal components into a namespaced `AnsiRegistry`,
|
|
34
|
+
* mirroring `@markii/react`'s `loadPack`: gating is per component (only
|
|
35
|
+
* components with an `ansi` source register; the rest fall back to the
|
|
36
|
+
* unknown-component box), a manifest component with no module is skipped,
|
|
37
|
+
* a stray module is ignored, and a declared `kind` decides the `inline`
|
|
38
|
+
* flag. Reads are `Object.hasOwn`-guarded. Never throws.
|
|
39
|
+
*/
|
|
40
|
+
export declare function loadAnsiPack(manifest: PackManifest, componentModules: AnsiPackComponentModules): LoadAnsiPackResult;
|
|
41
|
+
/** One pack ready to install: its manifest plus its registered component modules. */
|
|
42
|
+
export interface AnsiPackToInstall {
|
|
43
|
+
manifest: PackManifest;
|
|
44
|
+
componentModules: AnsiPackComponentModules;
|
|
45
|
+
}
|
|
46
|
+
export type InstallAnsiPacksResult = {
|
|
47
|
+
ok: true;
|
|
48
|
+
registry: AnsiRegistry;
|
|
49
|
+
dropped: {
|
|
50
|
+
name: string;
|
|
51
|
+
engine: string;
|
|
52
|
+
}[];
|
|
53
|
+
} | {
|
|
54
|
+
ok: false;
|
|
55
|
+
collisions: readonly string[];
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Installs several packs into one registry on top of `base`, rejecting the
|
|
59
|
+
* whole install when two share a namespace (the install-time rule in
|
|
60
|
+
* docs/packs.md), exactly like `@markii/react`'s `installPacks`. A host
|
|
61
|
+
* that wants keep-first behavior (a bundled pack wins over a user pack of
|
|
62
|
+
* the same namespace) filters the list before calling.
|
|
63
|
+
*/
|
|
64
|
+
export declare function installAnsiPacks(packs: readonly AnsiPackToInstall[], base?: AnsiRegistry): InstallAnsiPacksResult;
|