@xynogen/pix-pretty 1.16.0 → 1.16.2
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/package.json +1 -1
- package/src/utils.test.ts +112 -0
- package/src/utils.ts +59 -7
package/package.json
CHANGED
package/src/utils.test.ts
CHANGED
|
@@ -31,6 +31,23 @@ class MockTextComponent {
|
|
|
31
31
|
invalidate(): void {}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// Counting variant: records how often the inner component is re-fitted, so a
|
|
35
|
+
// regression that recomputes every frame (the pre-memo CPU bug) fails loudly.
|
|
36
|
+
class CountingTextComponent {
|
|
37
|
+
static setCalls = 0;
|
|
38
|
+
static renderCalls = 0;
|
|
39
|
+
private text = "";
|
|
40
|
+
setText(value: string): void {
|
|
41
|
+
CountingTextComponent.setCalls++;
|
|
42
|
+
this.text = value;
|
|
43
|
+
}
|
|
44
|
+
render(): string[] {
|
|
45
|
+
CountingTextComponent.renderCalls++;
|
|
46
|
+
return this.text.split("\n");
|
|
47
|
+
}
|
|
48
|
+
invalidate(): void {}
|
|
49
|
+
}
|
|
50
|
+
|
|
34
51
|
describe("viewportText", () => {
|
|
35
52
|
it("trims a pre-filled row to Pi's narrower fullscreen viewport", () => {
|
|
36
53
|
const text = viewportText(MockTextComponent);
|
|
@@ -39,6 +56,101 @@ describe("viewportText", () => {
|
|
|
39
56
|
expect(plain(text.render(9)[0]!)).toBe("tool".padEnd(9));
|
|
40
57
|
expect(plain(text.render(10)[0]!)).toBe("tool".padEnd(10));
|
|
41
58
|
});
|
|
59
|
+
|
|
60
|
+
it("memoizes: repeat render at same width does not re-fit the inner component", () => {
|
|
61
|
+
CountingTextComponent.setCalls = 0;
|
|
62
|
+
const text = viewportText(CountingTextComponent);
|
|
63
|
+
text.setText("line one\nline two");
|
|
64
|
+
|
|
65
|
+
text.render(20);
|
|
66
|
+
text.render(20);
|
|
67
|
+
text.render(20);
|
|
68
|
+
// One fit for the width; repeats hit the cache. Pre-memo this was 3.
|
|
69
|
+
expect(CountingTextComponent.setCalls).toBe(1);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("re-fits when width changes, and again when it returns", () => {
|
|
73
|
+
CountingTextComponent.setCalls = 0;
|
|
74
|
+
const text = viewportText(CountingTextComponent);
|
|
75
|
+
text.setText("abcdefghij");
|
|
76
|
+
|
|
77
|
+
text.render(20);
|
|
78
|
+
text.render(5); // width changed → re-fit
|
|
79
|
+
text.render(5); // same → cached
|
|
80
|
+
text.render(20); // changed back → re-fit
|
|
81
|
+
expect(CountingTextComponent.setCalls).toBe(3);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("re-fits after setText changes the content", () => {
|
|
85
|
+
CountingTextComponent.setCalls = 0;
|
|
86
|
+
const text = viewportText(CountingTextComponent);
|
|
87
|
+
text.setText("first");
|
|
88
|
+
text.render(20);
|
|
89
|
+
text.render(20); // cached
|
|
90
|
+
text.setText("second"); // invalidates memo
|
|
91
|
+
text.render(20); // re-fit
|
|
92
|
+
expect(CountingTextComponent.setCalls).toBe(2);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("setText with identical value is a no-op (no memo invalidation)", () => {
|
|
96
|
+
CountingTextComponent.setCalls = 0;
|
|
97
|
+
const text = viewportText(CountingTextComponent);
|
|
98
|
+
text.setText("same");
|
|
99
|
+
text.render(20);
|
|
100
|
+
text.setText("same"); // identical → must not invalidate
|
|
101
|
+
text.render(20); // still cached
|
|
102
|
+
expect(CountingTextComponent.setCalls).toBe(1);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("idle frames (same text+width) do not re-call the inner render", () => {
|
|
106
|
+
CountingTextComponent.renderCalls = 0;
|
|
107
|
+
const text = viewportText(CountingTextComponent);
|
|
108
|
+
text.setText("line one\nline two");
|
|
109
|
+
text.render(20);
|
|
110
|
+
text.render(20);
|
|
111
|
+
text.render(20);
|
|
112
|
+
// One inner render for the width; spinner ticks reuse the memoized output.
|
|
113
|
+
expect(CountingTextComponent.renderCalls).toBe(1);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("re-calls inner render after content changes", () => {
|
|
117
|
+
CountingTextComponent.renderCalls = 0;
|
|
118
|
+
const text = viewportText(CountingTextComponent);
|
|
119
|
+
text.setText("first");
|
|
120
|
+
text.render(20);
|
|
121
|
+
text.setText("second"); // content changed → render output stale
|
|
122
|
+
text.render(20);
|
|
123
|
+
expect(CountingTextComponent.renderCalls).toBe(2);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("invalidate() drops the render memo so a same-width frame recomputes (theme change)", () => {
|
|
127
|
+
CountingTextComponent.renderCalls = 0;
|
|
128
|
+
const text = viewportText(CountingTextComponent);
|
|
129
|
+
text.setText("body");
|
|
130
|
+
text.render(20);
|
|
131
|
+
text.render(20); // memo hit
|
|
132
|
+
expect(CountingTextComponent.renderCalls).toBe(1);
|
|
133
|
+
text.invalidate(); // theme/style changed → output stale even at same width
|
|
134
|
+
text.render(20); // must recompute, not serve pre-invalidate output
|
|
135
|
+
expect(CountingTextComponent.renderCalls).toBe(2);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("streaming append reuses already-fitted lines and fits only the new tail", () => {
|
|
139
|
+
const text = viewportText(MockTextComponent);
|
|
140
|
+
text.setText("aaaaaaaaaa\nbbbbbbbbbb");
|
|
141
|
+
expect(text.render(5).map(plain)).toEqual(["aaaaa", "bbbbb"]);
|
|
142
|
+
// Append (streaming). Old lines fit identically; new line appears.
|
|
143
|
+
text.setText("aaaaaaaaaa\nbbbbbbbbbb\ncccccccccc");
|
|
144
|
+
expect(text.render(5).map(plain)).toEqual(["aaaaa", "bbbbb", "ccccc"]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("width change re-fits all lines (line cache is width-scoped, no stale serve)", () => {
|
|
148
|
+
const text = viewportText(MockTextComponent);
|
|
149
|
+
text.setText("abcdefghij");
|
|
150
|
+
expect(text.render(5).map(plain)).toEqual(["abcde"]);
|
|
151
|
+
// Must NOT serve the stale 5-wide fit for the same raw line at a new width.
|
|
152
|
+
expect(text.render(8).map(plain)).toEqual(["abcdefgh"]);
|
|
153
|
+
});
|
|
42
154
|
});
|
|
43
155
|
|
|
44
156
|
// Strip ANSI escapes so assertions test content, not color codes.
|
package/src/utils.ts
CHANGED
|
@@ -71,12 +71,33 @@ type ViewportComponent = {
|
|
|
71
71
|
|
|
72
72
|
class ViewportText implements TextComponentLike, ViewportComponent {
|
|
73
73
|
private text = "";
|
|
74
|
+
// Pi re-renders every frame (spinner/streaming). Two-level cache:
|
|
75
|
+
// 1. per-line: `truncateToWidth` result keyed by raw line + width. Streaming
|
|
76
|
+
// only appends to the tail, so already-fitted lines hit the cache and
|
|
77
|
+
// only the new/changed lines pay the pi-tui width cost (was O(total
|
|
78
|
+
// lines) per chunk, now O(new lines)).
|
|
79
|
+
// 2. per-blob: skip re-joining + re-setText'ing the inner component when
|
|
80
|
+
// neither text nor width changed (idle frames, spinner ticks).
|
|
81
|
+
private fittedWidth = -1;
|
|
82
|
+
private fittedText = "";
|
|
83
|
+
private lineCache = new Map<string, string>();
|
|
84
|
+
private lineCacheWidth = -1;
|
|
85
|
+
// Render-output memo. The inner component's render is a pure function of
|
|
86
|
+
// (fittedText, width), so on an unchanged frame (spinner tick, idle) we skip
|
|
87
|
+
// calling into pi-tui AND skip the fallback split — otherwise both fired
|
|
88
|
+
// every single frame even when nothing changed. The returned array is shared
|
|
89
|
+
// by reference; pi-tui's render contract is read-only (the host joins/prints
|
|
90
|
+
// the lines, never mutates), so we don't defensively copy per frame.
|
|
91
|
+
private rendered: string[] = [];
|
|
92
|
+
private renderedWidth = -1;
|
|
74
93
|
|
|
75
94
|
constructor(private readonly component: TextComponentLike) {}
|
|
76
95
|
|
|
77
96
|
setText(value: string): void {
|
|
97
|
+
if (value === this.text) return;
|
|
78
98
|
this.text = value;
|
|
79
|
-
this.
|
|
99
|
+
this.fittedWidth = -1; // invalidate blob memo (line cache stays valid)
|
|
100
|
+
this.renderedWidth = -1; // invalidate render-output memo
|
|
80
101
|
}
|
|
81
102
|
|
|
82
103
|
getText(): string {
|
|
@@ -84,15 +105,46 @@ class ViewportText implements TextComponentLike, ViewportComponent {
|
|
|
84
105
|
}
|
|
85
106
|
|
|
86
107
|
render(width: number): string[] {
|
|
87
|
-
|
|
88
|
-
.
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
108
|
+
if (width !== this.fittedWidth) {
|
|
109
|
+
if (width !== this.lineCacheWidth) {
|
|
110
|
+
this.lineCache.clear(); // width changed → every line must re-fit
|
|
111
|
+
this.lineCacheWidth = width;
|
|
112
|
+
}
|
|
113
|
+
const cache = this.lineCache;
|
|
114
|
+
const lines = this.text.split("\n");
|
|
115
|
+
// Bound the cache so a long streaming log can't grow it without limit.
|
|
116
|
+
// The working set is the visible line count; a generous multiple keeps
|
|
117
|
+
// steady-state hits while capping worst-case memory. Reset when exceeded
|
|
118
|
+
// rather than LRU-evicting — simpler, and a width-stable frame refills it.
|
|
119
|
+
if (cache.size > 4 * lines.length + 256) cache.clear();
|
|
120
|
+
this.fittedText = lines
|
|
121
|
+
.map((line) => {
|
|
122
|
+
let fitted = cache.get(line);
|
|
123
|
+
if (fitted === undefined) {
|
|
124
|
+
fitted = truncateToWidth(line, width, "");
|
|
125
|
+
cache.set(line, fitted);
|
|
126
|
+
}
|
|
127
|
+
return fitted;
|
|
128
|
+
})
|
|
129
|
+
.join("\n");
|
|
130
|
+
this.fittedWidth = width;
|
|
131
|
+
this.renderedWidth = -1; // fitted text rebuilt → render output is stale
|
|
132
|
+
this.component.setText(this.fittedText);
|
|
133
|
+
}
|
|
134
|
+
if (width !== this.renderedWidth) {
|
|
135
|
+
this.rendered = this.component.render?.(width) ?? this.fittedText.split("\n");
|
|
136
|
+
this.renderedWidth = width;
|
|
137
|
+
}
|
|
138
|
+
return this.rendered;
|
|
93
139
|
}
|
|
94
140
|
|
|
95
141
|
invalidate(): void {
|
|
142
|
+
// invalidate = "output stale for reasons other than (text,width)" — theme /
|
|
143
|
+
// style change. Fitting is pure on (text,width) so fittedText/lineCache stay
|
|
144
|
+
// valid, but the RENDER output (colors) may differ, so drop the render memo;
|
|
145
|
+
// otherwise a same-width frame after invalidate would serve pre-invalidate
|
|
146
|
+
// output forever.
|
|
147
|
+
this.renderedWidth = -1;
|
|
96
148
|
this.component.invalidate?.();
|
|
97
149
|
}
|
|
98
150
|
}
|