lognal 0.1.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 (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -0
  3. package/dist/core/filter.d.ts +29 -0
  4. package/dist/core/filter.js +82 -0
  5. package/dist/core/layout/entry-lines.d.ts +16 -0
  6. package/dist/core/layout/entry-lines.js +120 -0
  7. package/dist/core/layout/layout.d.ts +152 -0
  8. package/dist/core/layout/layout.js +784 -0
  9. package/dist/core/layout/row-index.d.ts +33 -0
  10. package/dist/core/layout/row-index.js +99 -0
  11. package/dist/core/layout/types.d.ts +128 -0
  12. package/dist/core/layout/types.js +8 -0
  13. package/dist/core/store.d.ts +96 -0
  14. package/dist/core/store.js +204 -0
  15. package/dist/core/text/ansi.d.ts +20 -0
  16. package/dist/core/text/ansi.js +187 -0
  17. package/dist/core/text/graphemes.d.ts +17 -0
  18. package/dist/core/text/graphemes.js +70 -0
  19. package/dist/core/text/line-splitter.d.ts +18 -0
  20. package/dist/core/text/line-splitter.js +56 -0
  21. package/dist/core/text/measure.d.ts +10 -0
  22. package/dist/core/text/measure.js +38 -0
  23. package/dist/core/text/shape.d.ts +24 -0
  24. package/dist/core/text/shape.js +220 -0
  25. package/dist/core/text/unicode-width-data.d.ts +54 -0
  26. package/dist/core/text/unicode-width-data.js +227 -0
  27. package/dist/core/text/width.d.ts +20 -0
  28. package/dist/core/text/width.js +157 -0
  29. package/dist/core/text/wrap.d.ts +14 -0
  30. package/dist/core/text/wrap.js +94 -0
  31. package/dist/core/time.d.ts +10 -0
  32. package/dist/core/time.js +24 -0
  33. package/dist/core/types.d.ts +131 -0
  34. package/dist/core/types.js +9 -0
  35. package/dist/core/value/preview.d.ts +16 -0
  36. package/dist/core/value/preview.js +207 -0
  37. package/dist/index.d.ts +27 -0
  38. package/dist/index.js +24 -0
  39. package/dist/lognal.css +589 -0
  40. package/dist/react/LogViewer.d.ts +32 -0
  41. package/dist/react/LogViewer.js +183 -0
  42. package/dist/react/index.d.ts +1 -0
  43. package/dist/react/index.js +2 -0
  44. package/dist/renderer/canvas/canvas-renderer.d.ts +45 -0
  45. package/dist/renderer/canvas/canvas-renderer.js +464 -0
  46. package/dist/renderer/canvas/palette.d.ts +6 -0
  47. package/dist/renderer/canvas/palette.js +25 -0
  48. package/dist/renderer/theme.d.ts +3 -0
  49. package/dist/renderer/theme.js +52 -0
  50. package/dist/renderer/types.d.ts +82 -0
  51. package/dist/renderer/types.js +1 -0
  52. package/dist/sources/console/format.d.ts +32 -0
  53. package/dist/sources/console/format.js +189 -0
  54. package/dist/sources/console/hook.d.ts +27 -0
  55. package/dist/sources/console/hook.js +94 -0
  56. package/dist/sources/console/recorder.d.ts +41 -0
  57. package/dist/sources/console/recorder.js +239 -0
  58. package/dist/sources/console/snapshot.d.ts +22 -0
  59. package/dist/sources/console/snapshot.js +547 -0
  60. package/dist/sources/console/table.d.ts +9 -0
  61. package/dist/sources/console/table.js +87 -0
  62. package/dist/sources/text/encoding.d.ts +11 -0
  63. package/dist/sources/text/encoding.js +59 -0
  64. package/dist/sources/text/follow-file.d.ts +49 -0
  65. package/dist/sources/text/follow-file.js +109 -0
  66. package/dist/sources/text/read-file.d.ts +63 -0
  67. package/dist/sources/text/read-file.js +82 -0
  68. package/dist/viewer/icons.d.ts +14 -0
  69. package/dist/viewer/icons.js +30 -0
  70. package/dist/viewer/input-line.d.ts +46 -0
  71. package/dist/viewer/input-line.js +144 -0
  72. package/dist/viewer/labels.d.ts +35 -0
  73. package/dist/viewer/labels.js +61 -0
  74. package/dist/viewer/scrollbar.d.ts +27 -0
  75. package/dist/viewer/scrollbar.js +143 -0
  76. package/dist/viewer/theme.d.ts +15 -0
  77. package/dist/viewer/theme.js +105 -0
  78. package/dist/viewer/viewer.d.ts +217 -0
  79. package/dist/viewer/viewer.js +1201 -0
  80. package/package.json +92 -0
@@ -0,0 +1,183 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { forwardRef, useEffect, useMemo, useRef, useState } from 'react';
4
+ import { DEFAULT_LAYOUT_OPTIONS } from '../core/layout/layout.js';
5
+ import { DEFAULT_STORE_OPTIONS } from '../core/store.js';
6
+ import { LogViewer as Viewer } from '../viewer/viewer.js';
7
+ const PROPS_WITHOUT_OPTIONS = new Set([
8
+ 'className',
9
+ 'style',
10
+ 'hookConsole',
11
+ 'onReady',
12
+ 'onFollowChange',
13
+ 'onFilterChange',
14
+ 'onSelectionChange',
15
+ 'store',
16
+ 'renderer'
17
+ ]);
18
+ /** The value a core option returns to when its prop is removed. */
19
+ const CORE_DEFAULTS = {
20
+ ...DEFAULT_STORE_OPTIONS,
21
+ ...DEFAULT_LAYOUT_OPTIONS,
22
+ filter: null
23
+ };
24
+ const CORE_PREFIX = 'core.';
25
+ /** A string that changes only when the data of a value changes. Functions do not count. */
26
+ const keyOf = (value) => {
27
+ return (JSON.stringify(value, (_key, item) => typeof item === 'function' ? '(function)' : item) ?? '');
28
+ };
29
+ /** The data key of every option, with each core option on its own. */
30
+ const keysOfOptions = (options) => {
31
+ const keys = new Map();
32
+ for (const [name, value] of Object.entries(options)) {
33
+ if (name === 'core' && value) {
34
+ for (const [coreName, coreValue] of Object.entries(value)) {
35
+ keys.set(`${CORE_PREFIX}${coreName}`, keyOf(coreValue));
36
+ }
37
+ }
38
+ else {
39
+ keys.set(name, keyOf(value));
40
+ }
41
+ }
42
+ return keys;
43
+ };
44
+ const assignRef = (ref, value) => {
45
+ if (typeof ref === 'function') {
46
+ ref(value);
47
+ }
48
+ else if (ref) {
49
+ ref.current = value;
50
+ }
51
+ };
52
+ /**
53
+ * The lognal viewer as a React component.
54
+ *
55
+ * The component renders a container and creates the viewer in it after mounting. Log entries
56
+ * never go through React state: write to `store`, to the viewer from `onReady` or the ref, or
57
+ * turn on `hookConsole`.
58
+ *
59
+ * Only the option props whose data changed are applied, so passing a new object with the same
60
+ * contents on every render costs nothing, and a change to one prop does not undo what the
61
+ * user did in the viewer, such as turning off wrapping from the toolbar.
62
+ *
63
+ * Give the component a height, through `style`, `className` or its parent.
64
+ */
65
+ export const LogViewer = forwardRef(function LogViewer(props, ref) {
66
+ const containerRef = useRef(null);
67
+ const latest = useRef(props);
68
+ const latestRef = useRef(ref);
69
+ const appliedKeys = useRef(new Map());
70
+ const [viewer, setViewer] = useState(null);
71
+ const { className, style, store, renderer, hookConsole } = props;
72
+ latest.current = props;
73
+ latestRef.current = ref;
74
+ const options = useMemo(() => {
75
+ const result = {};
76
+ for (const [name, value] of Object.entries(props)) {
77
+ if (!PROPS_WITHOUT_OPTIONS.has(name) && value !== undefined) {
78
+ result[name] = value;
79
+ }
80
+ }
81
+ return result;
82
+ }, [props]);
83
+ const optionsKey = keyOf(options);
84
+ // Functions inside options call the latest props, so a new function on every render does not
85
+ // count as a change.
86
+ const stableOptions = useMemo(() => {
87
+ const current = options;
88
+ // Options that were removed go back to their defaults instead of keeping the old value.
89
+ const result = {
90
+ theme: 'auto',
91
+ font: {},
92
+ timestamps: true,
93
+ toolbar: true,
94
+ statusBar: true,
95
+ input: null,
96
+ labels: {},
97
+ locale: undefined,
98
+ ...current
99
+ };
100
+ if (current.input) {
101
+ result.input = {
102
+ ...current.input,
103
+ onSubmit: (command, instance) => latest.current.input?.onSubmit(command, instance)
104
+ };
105
+ }
106
+ if (typeof current.timestamps === 'function') {
107
+ result.timestamps = (time) => {
108
+ const format = latest.current.timestamps;
109
+ return typeof format === 'function' ? format(time) : String(time);
110
+ };
111
+ }
112
+ if (current.labels?.entries) {
113
+ result.labels = {
114
+ ...current.labels,
115
+ entries: (...args) => latest.current.labels?.entries?.(...args) ?? ''
116
+ };
117
+ }
118
+ return result;
119
+ // Rebuilt only when the data changes, which `optionsKey` tracks.
120
+ }, [optionsKey]);
121
+ const latestOptions = useRef(stableOptions);
122
+ latestOptions.current = stableOptions;
123
+ useEffect(() => {
124
+ const container = containerRef.current;
125
+ if (!container) {
126
+ return;
127
+ }
128
+ const instance = new Viewer(container, { ...latestOptions.current, store, renderer });
129
+ const offFollow = instance.on('follow', (value) => latest.current.onFollowChange?.(value));
130
+ const offFilter = instance.on('filter', (value) => latest.current.onFilterChange?.(value));
131
+ const offSelection = instance.on('selection', (value) => latest.current.onSelectionChange?.(value));
132
+ appliedKeys.current = keysOfOptions(latestOptions.current);
133
+ setViewer(instance);
134
+ assignRef(latestRef.current, instance);
135
+ latest.current.onReady?.(instance);
136
+ return () => {
137
+ offFollow();
138
+ offFilter();
139
+ offSelection();
140
+ instance.dispose();
141
+ setViewer(null);
142
+ assignRef(latestRef.current, null);
143
+ latest.current.onReady?.(null);
144
+ };
145
+ }, [store, renderer]);
146
+ useEffect(() => {
147
+ if (!viewer) {
148
+ return;
149
+ }
150
+ const previous = appliedKeys.current;
151
+ const next = keysOfOptions(stableOptions);
152
+ const changes = {};
153
+ const coreChanges = {};
154
+ for (const name of new Set([...previous.keys(), ...next.keys()])) {
155
+ if (previous.get(name) === next.get(name)) {
156
+ continue;
157
+ }
158
+ if (name.startsWith(CORE_PREFIX)) {
159
+ const coreName = name.slice(CORE_PREFIX.length);
160
+ coreChanges[coreName] = stableOptions.core?.[coreName] ?? CORE_DEFAULTS[coreName];
161
+ }
162
+ else {
163
+ changes[name] = stableOptions[name];
164
+ }
165
+ }
166
+ if (Object.keys(coreChanges).length > 0) {
167
+ changes.core = coreChanges;
168
+ }
169
+ appliedKeys.current = next;
170
+ if (Object.keys(changes).length > 0) {
171
+ viewer.setOptions(changes);
172
+ }
173
+ }, [viewer, stableOptions]);
174
+ const hookKey = keyOf(hookConsole);
175
+ useEffect(() => {
176
+ if (!viewer || !hookConsole) {
177
+ return;
178
+ }
179
+ return viewer.hookConsole(console, hookConsole === true ? undefined : hookConsole);
180
+ // Hooked again only when the data of `hookConsole` changes, which `hookKey` tracks.
181
+ }, [viewer, hookKey]);
182
+ return _jsx("div", { ref: containerRef, className: className, style: { height: '100%', ...style } });
183
+ });
@@ -0,0 +1 @@
1
+ export { LogViewer, type LogViewerProps } from './LogViewer.js';
@@ -0,0 +1,2 @@
1
+ 'use client';
2
+ export { LogViewer } from './LogViewer.js';
@@ -0,0 +1,45 @@
1
+ import type { CellMetrics, FontSettings, RenderFrame, RenderTheme, Renderer } from '../types.js';
2
+ /**
3
+ * Draws the log on a `<canvas>` with the 2D context.
4
+ *
5
+ * Every frame repaints the visible rows. Text that is plain ASCII in one style is drawn with a
6
+ * single `fillText` call; other text is drawn one grapheme cluster at a time at its grid
7
+ * position, so wide characters and characters from a fallback font stay aligned. `fillText`'s
8
+ * `maxWidth` squeezes a glyph that is wider than its cells instead of letting it overlap.
9
+ */
10
+ export declare class CanvasRenderer implements Renderer {
11
+ readonly element: HTMLCanvasElement;
12
+ private readonly context;
13
+ private theme;
14
+ private font;
15
+ private metrics;
16
+ private width;
17
+ private height;
18
+ private pixelRatio;
19
+ private readonly measureCache;
20
+ private readonly requestedGlyphs;
21
+ private pendingGlyphs;
22
+ private glyphRequestScheduled;
23
+ private fontsListener;
24
+ private disposed;
25
+ constructor(ownerDocument?: Document);
26
+ setTheme(theme: RenderTheme): void;
27
+ setFont(font: FontSettings): CellMetrics;
28
+ getMetrics(): CellMetrics;
29
+ resize(width: number, height: number, pixelRatio: number): void;
30
+ onFontsChanged(listener: () => void): void;
31
+ render(frame: RenderFrame): void;
32
+ dispose(): void;
33
+ private drawRowBackground;
34
+ private drawDecoration;
35
+ private colorOf;
36
+ private drawRuns;
37
+ private drawBox;
38
+ private drawExpander;
39
+ private drawGutter;
40
+ private drawRepeatBadge;
41
+ private measure;
42
+ /** Asks the browser to load web font faces that cover characters the frame drew. */
43
+ private requestGlyphs;
44
+ private flushGlyphRequests;
45
+ }
@@ -0,0 +1,464 @@
1
+ import { DEFAULT_RENDER_THEME } from '../theme.js';
2
+ import { resolveAnsiColor } from './palette.js';
3
+ /** How long a measured glyph width stays cached, as a count of entries. */
4
+ const MEASURE_CACHE_SIZE = 4096;
5
+ const PRINTABLE_ASCII = /^[\x20-\x7e]*$/;
6
+ const WHITESPACE = /^\s*$/;
7
+ /**
8
+ * Box-drawing characters drawn as lines, as bits for the arms that leave the cell center: up,
9
+ * right, down and left. Drawing them lets table borders join across rows, which a font glyph
10
+ * does not do once rows are taller than the font.
11
+ */
12
+ const ARM_UP = 1;
13
+ const ARM_RIGHT = 2;
14
+ const ARM_DOWN = 4;
15
+ const ARM_LEFT = 8;
16
+ const BOX_ARMS = new Map([
17
+ ['\u2500', ARM_LEFT | ARM_RIGHT],
18
+ ['\u2502', ARM_UP | ARM_DOWN],
19
+ ['\u250c', ARM_RIGHT | ARM_DOWN],
20
+ ['\u2510', ARM_LEFT | ARM_DOWN],
21
+ ['\u2514', ARM_UP | ARM_RIGHT],
22
+ ['\u2518', ARM_UP | ARM_LEFT],
23
+ ['\u251c', ARM_UP | ARM_DOWN | ARM_RIGHT],
24
+ ['\u2524', ARM_UP | ARM_DOWN | ARM_LEFT],
25
+ ['\u252c', ARM_LEFT | ARM_RIGHT | ARM_DOWN],
26
+ ['\u2534', ARM_LEFT | ARM_RIGHT | ARM_UP],
27
+ ['\u253c', ARM_UP | ARM_RIGHT | ARM_DOWN | ARM_LEFT],
28
+ ['\u256d', ARM_RIGHT | ARM_DOWN],
29
+ ['\u256e', ARM_LEFT | ARM_DOWN],
30
+ ['\u256f', ARM_UP | ARM_LEFT],
31
+ ['\u2570', ARM_UP | ARM_RIGHT]
32
+ ]);
33
+ const fontString = (font, bold, italic) => {
34
+ const weight = bold ? Math.max(700, font.weight) : font.weight;
35
+ return `${italic ? 'italic ' : ''}${weight} ${font.size}px ${font.family}`;
36
+ };
37
+ const documentFonts = () => {
38
+ return globalThis.document?.fonts;
39
+ };
40
+ /**
41
+ * Draws the log on a `<canvas>` with the 2D context.
42
+ *
43
+ * Every frame repaints the visible rows. Text that is plain ASCII in one style is drawn with a
44
+ * single `fillText` call; other text is drawn one grapheme cluster at a time at its grid
45
+ * position, so wide characters and characters from a fallback font stay aligned. `fillText`'s
46
+ * `maxWidth` squeezes a glyph that is wider than its cells instead of letting it overlap.
47
+ */
48
+ export class CanvasRenderer {
49
+ element;
50
+ context;
51
+ theme = DEFAULT_RENDER_THEME;
52
+ font = {
53
+ family: 'monospace',
54
+ size: 13,
55
+ weight: 400,
56
+ lineHeight: 1.5
57
+ };
58
+ metrics = { width: 8, height: 20, baseline: 14 };
59
+ width = 0;
60
+ height = 0;
61
+ pixelRatio = 1;
62
+ measureCache = new Map();
63
+ requestedGlyphs = new Set();
64
+ pendingGlyphs = '';
65
+ glyphRequestScheduled = false;
66
+ fontsListener = null;
67
+ disposed = false;
68
+ constructor(ownerDocument = document) {
69
+ this.element = ownerDocument.createElement('canvas');
70
+ this.element.className = 'lognal-canvas';
71
+ this.element.setAttribute('aria-hidden', 'true');
72
+ // Set here as well as in the stylesheet: a canvas in the normal flow would grow its
73
+ // container, and the container's new size would grow the canvas again.
74
+ this.element.style.position = 'absolute';
75
+ this.element.style.left = '0';
76
+ this.element.style.top = '0';
77
+ const context = this.element.getContext('2d', { alpha: false });
78
+ if (!context) {
79
+ throw new Error('lognal: the 2D canvas context is not available.');
80
+ }
81
+ this.context = context;
82
+ }
83
+ setTheme(theme) {
84
+ this.theme = theme;
85
+ }
86
+ setFont(font) {
87
+ this.font = font;
88
+ this.measureCache.clear();
89
+ this.requestedGlyphs.clear();
90
+ const context = this.context;
91
+ context.font = fontString(font, false, false);
92
+ const sample = context.measureText('M'.repeat(20));
93
+ const width = sample.width / 20;
94
+ const height = Math.max(1, Math.round(font.size * font.lineHeight));
95
+ const ascent = sample.fontBoundingBoxAscent ?? sample.actualBoundingBoxAscent ?? font.size * 0.8;
96
+ const descent = sample.fontBoundingBoxDescent ?? sample.actualBoundingBoxDescent ?? font.size * 0.2;
97
+ this.metrics = {
98
+ width: width > 0 ? width : font.size * 0.6,
99
+ height,
100
+ baseline: Math.round((height - (ascent + descent)) / 2 + ascent)
101
+ };
102
+ return this.metrics;
103
+ }
104
+ getMetrics() {
105
+ return this.metrics;
106
+ }
107
+ resize(width, height, pixelRatio) {
108
+ this.width = Math.max(0, width);
109
+ this.height = Math.max(0, height);
110
+ this.pixelRatio = pixelRatio > 0 ? pixelRatio : 1;
111
+ this.element.width = Math.max(1, Math.round(this.width * this.pixelRatio));
112
+ this.element.height = Math.max(1, Math.round(this.height * this.pixelRatio));
113
+ this.element.style.width = `${this.width}px`;
114
+ this.element.style.height = `${this.height}px`;
115
+ }
116
+ onFontsChanged(listener) {
117
+ this.fontsListener = listener;
118
+ }
119
+ render(frame) {
120
+ const context = this.context;
121
+ const { width: cellWidth, height: rowHeight } = this.metrics;
122
+ const gutterWidth = (frame.timestampCells + frame.markerCells) * cellWidth;
123
+ const contentLeft = frame.paddingLeft + gutterWidth;
124
+ context.setTransform(this.pixelRatio, 0, 0, this.pixelRatio, 0, 0);
125
+ context.textBaseline = 'alphabetic';
126
+ context.fillStyle = this.theme.background;
127
+ context.fillRect(0, 0, this.width, this.height);
128
+ frame.rows.forEach((row, index) => {
129
+ const top = frame.offsetY + index * rowHeight;
130
+ this.drawRowBackground(row, top);
131
+ });
132
+ context.save();
133
+ context.beginPath();
134
+ context.rect(contentLeft, 0, Math.max(0, this.width - contentLeft), this.height);
135
+ context.clip();
136
+ frame.rows.forEach((row, index) => {
137
+ const top = frame.offsetY + index * rowHeight;
138
+ const left = contentLeft - frame.scrollX;
139
+ this.drawDecoration(frame.decorations[index], left, top);
140
+ this.drawRuns(row, left, top);
141
+ });
142
+ context.restore();
143
+ frame.rows.forEach((row, index) => {
144
+ const top = frame.offsetY + index * rowHeight;
145
+ if (row.last) {
146
+ context.fillStyle = this.theme.separator;
147
+ context.fillRect(0, top + rowHeight - 1 / this.pixelRatio, this.width, 1 / this.pixelRatio);
148
+ }
149
+ if (row.first) {
150
+ this.drawGutter(row.entry, frame, top);
151
+ }
152
+ });
153
+ this.flushGlyphRequests();
154
+ }
155
+ dispose() {
156
+ this.disposed = true;
157
+ this.fontsListener = null;
158
+ this.measureCache.clear();
159
+ this.element.remove();
160
+ }
161
+ drawRowBackground(row, top) {
162
+ const { entry } = row;
163
+ let color = null;
164
+ if (entry.level === 'error') {
165
+ color = this.theme.errorBackground;
166
+ }
167
+ else if (entry.level === 'warn') {
168
+ color = this.theme.warnBackground;
169
+ }
170
+ if (color) {
171
+ this.context.fillStyle = color;
172
+ this.context.fillRect(0, top, this.width, this.metrics.height);
173
+ }
174
+ }
175
+ drawDecoration(decoration, left, top) {
176
+ if (!decoration) {
177
+ return;
178
+ }
179
+ const context = this.context;
180
+ const { width: cellWidth, height: rowHeight } = this.metrics;
181
+ if (decoration.matches) {
182
+ context.fillStyle = this.theme.match;
183
+ for (const [from, to] of decoration.matches) {
184
+ context.fillRect(left + from * cellWidth, top, (to - from) * cellWidth, rowHeight);
185
+ }
186
+ }
187
+ if (decoration.selection) {
188
+ const [from, to] = decoration.selection;
189
+ context.fillStyle = this.theme.selection;
190
+ context.fillRect(left + from * cellWidth, top, Math.max(0, to - from) * cellWidth, rowHeight);
191
+ }
192
+ }
193
+ colorOf(run, entry) {
194
+ if (run.style?.color !== undefined) {
195
+ return resolveAnsiColor(run.style.color, this.theme.ansi);
196
+ }
197
+ if (run.token && run.token !== 'default') {
198
+ return this.theme.tokens[run.token];
199
+ }
200
+ if (entry.kind === 'system') {
201
+ return this.theme.muted;
202
+ }
203
+ if (entry.level === 'debug') {
204
+ return this.theme.debug;
205
+ }
206
+ if (entry.level === 'error') {
207
+ return this.theme.error;
208
+ }
209
+ if (entry.level === 'warn') {
210
+ return this.theme.warn;
211
+ }
212
+ return this.theme.foreground;
213
+ }
214
+ drawRuns(row, left, top) {
215
+ const context = this.context;
216
+ const { width: cellWidth, height: rowHeight, baseline } = this.metrics;
217
+ const y = top + baseline;
218
+ let currentFont = '';
219
+ for (const run of row.runs) {
220
+ const x = left + run.column * cellWidth;
221
+ const runWidth = run.cells * cellWidth;
222
+ if (x > this.width || x + runWidth < 0) {
223
+ continue;
224
+ }
225
+ const style = run.style;
226
+ if (style?.background !== undefined) {
227
+ context.fillStyle = resolveAnsiColor(style.background, this.theme.ansi);
228
+ context.fillRect(x, top, runWidth, rowHeight);
229
+ }
230
+ if (run.icon === 'expander') {
231
+ this.drawExpander(x, top, Boolean(run.expanded));
232
+ continue;
233
+ }
234
+ if (WHITESPACE.test(run.text)) {
235
+ continue;
236
+ }
237
+ const font = fontString(this.font, Boolean(style?.bold), Boolean(style?.italic));
238
+ if (font !== currentFont) {
239
+ context.font = font;
240
+ currentFont = font;
241
+ }
242
+ const color = this.colorOf(run, row.entry);
243
+ context.fillStyle = color;
244
+ context.globalAlpha = style?.dim ? 0.6 : 1;
245
+ if (run.simple) {
246
+ context.fillText(run.text, x, y, runWidth);
247
+ }
248
+ else {
249
+ let clusterLeft = x;
250
+ run.clusters.forEach((cluster, index) => {
251
+ const cells = run.widths[index] ?? 1;
252
+ const slot = cells * cellWidth;
253
+ const arms = BOX_ARMS.get(cluster);
254
+ if (arms !== undefined) {
255
+ this.drawBox(arms, clusterLeft, top);
256
+ }
257
+ else if (cluster && !WHITESPACE.test(cluster)) {
258
+ const natural = this.measure(cluster, font);
259
+ const drawn = Math.min(natural, slot);
260
+ const offset = cells > 1 ? (slot - drawn) / 2 : 0;
261
+ context.fillText(cluster, clusterLeft + offset, y, slot);
262
+ this.requestGlyphs(cluster);
263
+ }
264
+ clusterLeft += slot;
265
+ });
266
+ }
267
+ context.globalAlpha = 1;
268
+ if (style?.underline) {
269
+ context.fillRect(x, top + baseline + 2, runWidth, 1);
270
+ }
271
+ if (style?.strikethrough) {
272
+ context.fillRect(x, top + Math.round(rowHeight / 2), runWidth, 1);
273
+ }
274
+ }
275
+ }
276
+ drawBox(arms, left, top) {
277
+ const context = this.context;
278
+ const { width: cellWidth, height: rowHeight } = this.metrics;
279
+ const ratio = this.pixelRatio;
280
+ const thickness = Math.max(1, Math.round((this.font.size / 13) * ratio)) / ratio;
281
+ const snap = (value) => Math.round(value * ratio) / ratio;
282
+ const centerX = snap(left + cellWidth / 2 - thickness / 2);
283
+ const centerY = snap(top + rowHeight / 2 - thickness / 2);
284
+ const right = left + cellWidth;
285
+ const bottom = top + rowHeight;
286
+ if (arms & ARM_LEFT) {
287
+ context.fillRect(left, centerY, centerX + thickness - left, thickness);
288
+ }
289
+ if (arms & ARM_RIGHT) {
290
+ context.fillRect(centerX, centerY, right - centerX, thickness);
291
+ }
292
+ if (arms & ARM_UP) {
293
+ context.fillRect(centerX, top, thickness, centerY + thickness - top);
294
+ }
295
+ if (arms & ARM_DOWN) {
296
+ context.fillRect(centerX, centerY, thickness, bottom - centerY);
297
+ }
298
+ }
299
+ drawExpander(x, top, expanded) {
300
+ const context = this.context;
301
+ const { width: cellWidth, height: rowHeight } = this.metrics;
302
+ const size = Math.max(4, Math.round(this.font.size * 0.36));
303
+ const centerX = x + cellWidth * 0.9;
304
+ const centerY = top + rowHeight / 2;
305
+ context.fillStyle = this.theme.muted;
306
+ context.beginPath();
307
+ if (expanded) {
308
+ context.moveTo(centerX - size / 2, centerY - size / 4);
309
+ context.lineTo(centerX + size / 2, centerY - size / 4);
310
+ context.lineTo(centerX, centerY + size / 3);
311
+ }
312
+ else {
313
+ context.moveTo(centerX - size / 4, centerY - size / 2);
314
+ context.lineTo(centerX + size / 3, centerY);
315
+ context.lineTo(centerX - size / 4, centerY + size / 2);
316
+ }
317
+ context.closePath();
318
+ context.fill();
319
+ }
320
+ drawGutter(entry, frame, top) {
321
+ const context = this.context;
322
+ const { width: cellWidth, height: rowHeight, baseline } = this.metrics;
323
+ let x = frame.paddingLeft;
324
+ if (frame.timestampCells > 0) {
325
+ context.font = fontString(this.font, false, false);
326
+ context.fillStyle = this.theme.muted;
327
+ context.fillText(frame.formatTime(entry.time), x, top + baseline, (frame.timestampCells - 1) * cellWidth);
328
+ x += frame.timestampCells * cellWidth;
329
+ }
330
+ // The marker sits in the first two cells of its column; the last cell is a gap.
331
+ const centerX = x + cellWidth;
332
+ const centerY = top + rowHeight / 2;
333
+ const radius = Math.max(3, this.font.size * 0.32);
334
+ if (entry.repeat > 1) {
335
+ this.drawRepeatBadge(entry, x, top);
336
+ return;
337
+ }
338
+ context.lineWidth = Math.max(1, this.font.size / 12);
339
+ context.lineCap = 'round';
340
+ context.lineJoin = 'round';
341
+ if (entry.kind === 'input') {
342
+ context.strokeStyle = this.theme.accent;
343
+ context.beginPath();
344
+ context.moveTo(centerX - radius * 0.45, centerY - radius * 0.8);
345
+ context.lineTo(centerX + radius * 0.45, centerY);
346
+ context.lineTo(centerX - radius * 0.45, centerY + radius * 0.8);
347
+ context.stroke();
348
+ return;
349
+ }
350
+ if (entry.kind === 'output') {
351
+ context.strokeStyle = this.theme.muted;
352
+ context.beginPath();
353
+ context.moveTo(centerX + radius * 0.45, centerY - radius * 0.8);
354
+ context.lineTo(centerX - radius * 0.45, centerY);
355
+ context.lineTo(centerX + radius * 0.45, centerY + radius * 0.8);
356
+ context.stroke();
357
+ return;
358
+ }
359
+ if (entry.level === 'error') {
360
+ context.fillStyle = this.theme.error;
361
+ context.beginPath();
362
+ context.arc(centerX, centerY, radius, 0, Math.PI * 2);
363
+ context.fill();
364
+ context.strokeStyle = this.theme.background;
365
+ context.beginPath();
366
+ context.moveTo(centerX - radius * 0.4, centerY - radius * 0.4);
367
+ context.lineTo(centerX + radius * 0.4, centerY + radius * 0.4);
368
+ context.moveTo(centerX + radius * 0.4, centerY - radius * 0.4);
369
+ context.lineTo(centerX - radius * 0.4, centerY + radius * 0.4);
370
+ context.stroke();
371
+ }
372
+ else if (entry.level === 'warn') {
373
+ context.fillStyle = this.theme.warn;
374
+ context.beginPath();
375
+ context.moveTo(centerX, centerY - radius * 1.05);
376
+ context.lineTo(centerX + radius * 1.1, centerY + radius * 0.85);
377
+ context.lineTo(centerX - radius * 1.1, centerY + radius * 0.85);
378
+ context.closePath();
379
+ context.fill();
380
+ context.fillStyle = this.theme.background;
381
+ context.fillRect(centerX - context.lineWidth / 2, centerY - radius * 0.45, context.lineWidth, radius * 0.7);
382
+ context.fillRect(centerX - context.lineWidth / 2, centerY + radius * 0.4, context.lineWidth, context.lineWidth);
383
+ }
384
+ else if (entry.level === 'info') {
385
+ context.fillStyle = this.theme.info;
386
+ context.beginPath();
387
+ context.arc(centerX, centerY, radius * 0.55, 0, Math.PI * 2);
388
+ context.fill();
389
+ }
390
+ }
391
+ drawRepeatBadge(entry, x, top) {
392
+ const context = this.context;
393
+ const { width: cellWidth, height: rowHeight } = this.metrics;
394
+ const label = entry.repeat > 99 ? '99+' : String(entry.repeat);
395
+ const fontSize = Math.max(8, Math.round(this.font.size * 0.75));
396
+ const badgeHeight = Math.min(rowHeight - 4, fontSize + 4);
397
+ context.font = `600 ${fontSize}px ${this.font.family}`;
398
+ const textWidth = context.measureText(label).width;
399
+ const badgeWidth = Math.max(badgeHeight, textWidth + 8);
400
+ const left = x + Math.max(0, cellWidth - badgeWidth / 2);
401
+ const badgeTop = top + (rowHeight - badgeHeight) / 2;
402
+ const color = entry.level === 'error'
403
+ ? this.theme.error
404
+ : entry.level === 'warn'
405
+ ? this.theme.warn
406
+ : this.theme.muted;
407
+ context.fillStyle = color;
408
+ context.beginPath();
409
+ if (typeof context.roundRect === 'function') {
410
+ context.roundRect(left, badgeTop, badgeWidth, badgeHeight, badgeHeight / 2);
411
+ }
412
+ else {
413
+ context.rect(left, badgeTop, badgeWidth, badgeHeight);
414
+ }
415
+ context.fill();
416
+ context.fillStyle = this.theme.background;
417
+ context.textAlign = 'center';
418
+ context.fillText(label, left + badgeWidth / 2, badgeTop + badgeHeight / 2 + fontSize * 0.36);
419
+ context.textAlign = 'start';
420
+ }
421
+ measure(cluster, font) {
422
+ const key = `${font}|${cluster}`;
423
+ const cached = this.measureCache.get(key);
424
+ if (cached !== undefined) {
425
+ return cached;
426
+ }
427
+ const width = this.context.measureText(cluster).width;
428
+ if (this.measureCache.size >= MEASURE_CACHE_SIZE) {
429
+ this.measureCache.clear();
430
+ }
431
+ this.measureCache.set(key, width);
432
+ return width;
433
+ }
434
+ /** Asks the browser to load web font faces that cover characters the frame drew. */
435
+ requestGlyphs(cluster) {
436
+ if (PRINTABLE_ASCII.test(cluster) || this.requestedGlyphs.has(cluster)) {
437
+ return;
438
+ }
439
+ this.requestedGlyphs.add(cluster);
440
+ this.pendingGlyphs += cluster;
441
+ }
442
+ flushGlyphRequests() {
443
+ const fonts = documentFonts();
444
+ if (!this.pendingGlyphs || this.glyphRequestScheduled || !fonts) {
445
+ return;
446
+ }
447
+ const text = this.pendingGlyphs;
448
+ const font = fontString(this.font, false, false);
449
+ this.pendingGlyphs = '';
450
+ this.glyphRequestScheduled = true;
451
+ fonts
452
+ .load(font, text)
453
+ .then((faces) => {
454
+ this.glyphRequestScheduled = false;
455
+ if (!this.disposed && faces.length > 0) {
456
+ this.measureCache.clear();
457
+ this.fontsListener?.();
458
+ }
459
+ })
460
+ .catch(() => {
461
+ this.glyphRequestScheduled = false;
462
+ });
463
+ }
464
+ }