authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-01-13 23:06:24-08:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-03-21 18:03:28-07:00
log6145522a83caa33ce59165003358338da313a290
treeb8eafabf16654ac3497caa620660701b1bc0a56a
parent496bdc100f7d96bdea16152c2edcc12b9b652fb3
signature Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs

feat(lib): support partial lines written during locked output

the way that `process.stdout.write` is patched, you can't actually write to it. now, a much more complicated system of locks is actually used, alongside changes to the headless widget system, which is now just renamed from `HeadlessWidgetHost` to `WidgetHost`. this was good to get completed now since it finalized the shape of `lockTerminal`, which needed to change `getDrawLock` so that one could indicate if the cursor was at the start or middle of the line. the default `node:process` widget host patches the global process write function to trace if the buffer ends in a newline, which ensures. since patching can be undesirable, you *can* opt out of it: const host = log.simpleNodeProcessWidgetHost(process); log.replaceGlobalWidgetHost(host); but i want to make sure that the built in system is perfect so that no user actually hits a bug that forces them to do this. there are some known TODOs that are in scope for handling before the progress post: - support global widgets started from different bundled versions of the clover library. they should coordinate using a symbol global. - handle when other sources invasivly patch the terminal, including when it isn't possible for clover library to un-patch things. right now this just disables the function wrapper, but it should be able to re-use the patched implementation. this patch has no issue right now, but it's a consequence of doing #70.

10 files changed, 607 insertions(+), 145 deletions(-)

framework/incremental.ts+5-2
...@@ -102,11 +102,12 @@ export async function compile<T>(...@@ -102,11 +102,12 @@ export async function compile<T>(
102 p: progress.Ref = progress,102 p: progress.Ref = progress,
103) {103) {
104 ASSERT(!running, `Cannot run twice at the same time.`);104 ASSERT(!running, `Cannot run twice at the same time.`);
105 using node = p.start("sitegen!");105 const startDate = new Date();
106 const start = performance.now();
107 const node = p.start("sitegen!");
106 try {108 try {
107 running = true;109 running = true;
108 ASSERT(jobs === 0);110 ASSERT(jobs === 0);
109 const start = performance.now();
110111
111 const value = await compiler(node);112 const value = await compiler(node);
112 ASSERT(jobs === 0);113 ASSERT(jobs === 0);
...@@ -120,6 +121,8 @@ export async function compile<T>(...@@ -120,6 +121,8 @@ export async function compile<T>(
120 newAssets: !Array.from(seenWrites).some((x) => x.startsWith("a:")),121 newAssets: !Array.from(seenWrites).some((x) => x.startsWith("a:")),
121 };122 };
122 } finally {123 } finally {
124 node.text =
125 `sitegen at ${startDate.toLocaleDateString()} ${startDate.toLocaleTimeString()}`;
123 node.end();126 node.end();
124 running = false;127 running = false;
125 newKeys = 0;128 newKeys = 0;
lib/assert.ts+2-2
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1/**1/**
2 * assertions and type narrowing helpers.2 * assertions and type narrowing helpers. intended to be imported per symbol.
3 * intended to be imported by symbol.3 * functions are capitalized to make them stand out in a codebase.
4 * @module4 * @module
5 */5 */
6/* node:coverage disable */6/* node:coverage disable */
lib/examples/progress-log-stream.ts created+67
...@@ -0,0 +1,67 @@
1const lorem1 =
2 `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut scelerisque elit ut ligula feugiat, at sagittis metus eleifend. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam aliquam, lorem volutpat sagittis efficitur, dui lectus rhoncus nunc, id porttitor sapien velit vel nunc. Praesent laoreet neque eu fringilla fermentum. Fusce eget nisl non libero porta sagittis. Duis diam neque, iaculis quis dignissim sit amet, iaculis eu ex. Aliquam suscipit nibh et arcu accumsan semper. Sed et nibh at sem pretium interdum.
3
4Phasellus nec dapibus diam. Duis feugiat fermentum erat, eu vulputate metus dapibus non. In aliquam venenatis accumsan. Pellentesque non libero dapibus, sagittis sem sed, lacinia augue. Cras vitae neque ac velit lobortis pulvinar. Suspendisse et risus eget urna rutrum ornare at at tortor. Praesent vulputate commodo dolor, ut mattis mauris tincidunt interdum.
5
6Praesent sapien leo, efficitur vitae porttitor at, iaculis et neque. Aliquam vel magna vitae odio imperdiet convallis sit amet a ante. Ut mollis lacus vel erat accumsan tempus. Ut ullamcorper neque vel quam egestas, nec rhoncus dolor vehicula. Donec ornare tempor leo sit amet consequat. Quisque dapibus leo sapien, ut fringilla mi scelerisque quis. Praesent id leo a arcu pulvinar ornare id vitae enim. Phasellus ut imperdiet nunc. Fusce sagittis sapien elit, sit amet tristique dui cursus eget. Integer nec dui felis. Aenean sodales ligula congue, convallis ex at, porta est. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Fusce lacinia dolor vitae mi porta, et imperdiet erat suscipit. Phasellus dictum lorem nec eros volutpat interdum. Cras fermentum fringilla felis, in bibendum metus. Suspendisse vitae justo at odio tincidunt luctus.
7
8Curabitur vitae dignissim sem, nec viverra libero. Nunc rhoncus semper lacus, ac interdum nisl sagittis in. Sed eget volutpat nisi, id volutpat orci. Nulla ut urna elementum, tempus lectus sed, scelerisque elit. Phasellus viverra justo sed gravida rutrum. Sed fermentum sapien nibh, id tincidunt nisl porta in. Cras malesuada mauris eget quam aliquam, nec laoreet lacus aliquet. Fusce id felis quis lectus laoreet interdum non ac lacus. Aliquam lobortis vestibulum orci, id pharetra velit vulputate non. Sed hendrerit sem risus, nec finibus mi efficitur non. Vivamus eu dui at nisl lacinia molestie ac et odio. Cras quis tristique dui. Pellentesque maximus auctor erat. Suspendisse tincidunt, purus eu dictum ultrices, metus libero tristique ante, eu aliquam odio augue id nisi.
9
10Sed egestas nisl vitae metus consectetur aliquam. Aliquam erat volutpat. Nullam nunc turpis, pretium ut odio in, cursus scelerisque velit. Integer non odio sed nunc sodales suscipit non et lectus. Suspendisse aliquet nisl in justo feugiat, et blandit justo tincidunt. Proin id condimentum tortor. Curabitur scelerisque erat quis feugiat fringilla. Maecenas faucibus elit a neque tempor, vitae interdum odio feugiat. Donec ut malesuada nunc. Quisque at ultrices neque. Nunc odio ante, sagittis id nulla et, finibus cursus lectus. Interdum et malesuada fames ac ante ipsum primis in faucibus. Sed tortor turpis, blandit sit amet erat a, fringilla suscipit ligula. Proin at ante eu nibh tristique tincidunt. Nam sagittis lectus ac erat dictum lacinia.`
11 .split(" ");
12const lorem2 =
13 `Phasellus vitae erat ac diam lacinia feugiat vitae vitae dolor. Etiam neque metus, cursus ut feugiat eu, aliquam eu nisl. Curabitur rutrum, purus ac vulputate consectetur, lectus urna maximus tortor, vel blandit sapien mauris nec justo. Aenean eget blandit urna, et blandit risus. Sed convallis in nisl bibendum luctus. Fusce placerat ante eget nisl accumsan, nec tincidunt turpis hendrerit. Sed efficitur vulputate nisi quis auctor. Ut quis mauris magna. Cras nulla magna, vehicula a elementum ac, facilisis eu mi. Etiam malesuada justo eget eleifend sagittis. Nunc vulputate lorem ante, in luctus justo pellentesque cursus. Aliquam auctor urna ut vulputate blandit. Morbi eget pharetra risus. Phasellus accumsan, quam facilisis facilisis congue, augue enim vestibulum mauris, ut ultrices erat lacus id est.
14
15Sed at felis nec velit laoreet tincidunt. Integer gravida congue tellus ac consectetur. Etiam vitae nisi nec ante dapibus dictum a at nunc. Phasellus nec ligula non elit dignissim sagittis non ac justo. Praesent feugiat metus ligula. Nunc imperdiet, dolor iaculis vulputate auctor, orci odio tincidunt odio, a iaculis mi nisi vitae nibh. Nulla sit amet efficitur mi, a suscipit purus. Vestibulum suscipit diam at arcu tincidunt tempus. Donec finibus lectus laoreet condimentum pharetra. Nullam et libero ac orci aliquam luctus sed sed quam.
16
17Aliquam lacinia lacus ipsum. Aenean ornare semper lacus vel ullamcorper. Sed fringilla convallis dolor, at semper velit facilisis non. Morbi et ligula efficitur, condimentum turpis id, dignissim dolor. Donec suscipit pretium euismod. Duis tempor interdum ligula sit amet vehicula. Aenean iaculis, magna sed cursus faucibus, ipsum libero fringilla erat, eu tincidunt nibh nisl non libero. Nulla facilisi. In a quam ut neque euismod scelerisque eu a diam. Aenean lobortis consequat felis. Praesent et consequat nunc.
18
19Fusce at enim tempus, facilisis justo eget, laoreet elit. Proin sit amet ultrices sem. Suspendisse porta vitae lectus sed vehicula. Quisque tempor eros non sem malesuada hendrerit. Nullam eget neque in lacus consectetur feugiat non sed erat. Cras cursus aliquam felis, non maximus velit auctor in. Donec velit nisi, imperdiet a odio eget, consectetur vehicula elit.
20
21Maecenas fringilla maximus felis. Donec consequat sapien vitae consequat tempor. Suspendisse suscipit, magna nec feugiat gravida, neque arcu pretium lorem, ac interdum magna ligula et massa. Vivamus lobortis turpis ipsum, sed imperdiet lectus finibus vel. Maecenas non quam erat. Nunc vulputate libero ut justo fringilla, ut fringilla nisl facilisis. Aliquam id ligula sit amet nunc maximus condimentum. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Phasellus rhoncus molestie efficitur. Suspendisse est felis, dapibus at lacinia a, vestibulum sit amet turpis.`
22 .split(" ");
23
24log.startWidget({ format: () => "" })
25
26async.delay(500).then(async () => {
27 const root = progress.start("generation");
28 const a = root.start("lorem ipsum generator 1");
29 const b = root.start("the second generator");
30
31 (async () => {
32 let first = true;
33 let wrap = 0;
34 for (const word of lorem1) {
35 await async.delay(30 + Math.random() * 50);
36 if ((wrap + word.length) >= 80) {
37 a.log.write("\n");
38 first = true;
39 wrap = 0;
40 }
41 if (!first) a.log.write(" ");
42 first = false;
43 a.log.write(word);
44 wrap += word.length + " ".length;
45 }
46 })();
47 (async () => {
48 let first = true;
49 let wrap = 0;
50 for (const word of lorem2) {
51 await async.delay(30 + Math.random() * 50);
52 if ((wrap + word.length) >= 80) {
53 b.log.write("\n");
54 first = true;
55 wrap = 0;
56 }
57 if (!first) b.log.write(" ");
58 first = false;
59 b.log.write(word);
60 wrap += word.length + " ".length;
61 }
62 })();
63});
64
65import * as async from "../async.ts";
66import * as progress from "../progress.ts";
67import * as log from "../log.ts";
lib/examples/progress-stdout-stream.ts created+39
...@@ -0,0 +1,39 @@
1const lorem1 =
2 `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut scelerisque elit ut ligula feugiat, at sagittis metus eleifend. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam aliquam, lorem volutpat sagittis efficitur, dui lectus rhoncus nunc, id porttitor sapien velit vel nunc. Praesent laoreet neque eu fringilla fermentum. Fusce eget nisl non libero porta sagittis. Duis diam neque, iaculis quis dignissim sit amet, iaculis eu ex. Aliquam suscipit nibh et arcu accumsan semper. Sed et nibh at sem pretium interdum.
3
4Phasellus nec dapibus diam. Duis feugiat fermentum erat, eu vulputate metus dapibus non. In aliquam venenatis accumsan. Pellentesque non libero dapibus, sagittis sem sed, lacinia augue. Cras vitae neque ac velit lobortis pulvinar. Suspendisse et risus eget urna rutrum ornare at at tortor. Praesent vulputate commodo dolor, ut mattis mauris tincidunt interdum.
5
6Praesent sapien leo, efficitur vitae porttitor at, iaculis et neque. Aliquam vel magna vitae odio imperdiet convallis sit amet a ante. Ut mollis lacus vel erat accumsan tempus. Ut ullamcorper neque vel quam egestas, nec rhoncus dolor vehicula. Donec ornare tempor leo sit amet consequat. Quisque dapibus leo sapien, ut fringilla mi scelerisque quis. Praesent id leo a arcu pulvinar ornare id vitae enim. Phasellus ut imperdiet nunc. Fusce sagittis sapien elit, sit amet tristique dui cursus eget. Integer nec dui felis. Aenean sodales ligula congue, convallis ex at, porta est. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Fusce lacinia dolor vitae mi porta, et imperdiet erat suscipit. Phasellus dictum lorem nec eros volutpat interdum. Cras fermentum fringilla felis, in bibendum metus. Suspendisse vitae justo at odio tincidunt luctus.
7
8Curabitur vitae dignissim sem, nec viverra libero. Nunc rhoncus semper lacus, ac interdum nisl sagittis in. Sed eget volutpat nisi, id volutpat orci. Nulla ut urna elementum, tempus lectus sed, scelerisque elit. Phasellus viverra justo sed gravida rutrum. Sed fermentum sapien nibh, id tincidunt nisl porta in. Cras malesuada mauris eget quam aliquam, nec laoreet lacus aliquet. Fusce id felis quis lectus laoreet interdum non ac lacus. Aliquam lobortis vestibulum orci, id pharetra velit vulputate non. Sed hendrerit sem risus, nec finibus mi efficitur non. Vivamus eu dui at nisl lacinia molestie ac et odio. Cras quis tristique dui. Pellentesque maximus auctor erat. Suspendisse tincidunt, purus eu dictum ultrices, metus libero tristique ante, eu aliquam odio augue id nisi.
9
10Sed egestas nisl vitae metus consectetur aliquam. Aliquam erat volutpat. Nullam nunc turpis, pretium ut odio in, cursus scelerisque velit. Integer non odio sed nunc sodales suscipit non et lectus. Suspendisse aliquet nisl in justo feugiat, et blandit justo tincidunt. Proin id condimentum tortor. Curabitur scelerisque erat quis feugiat fringilla. Maecenas faucibus elit a neque tempor, vitae interdum odio feugiat. Donec ut malesuada nunc. Quisque at ultrices neque. Nunc odio ante, sagittis id nulla et, finibus cursus lectus. Interdum et malesuada fames ac ante ipsum primis in faucibus. Sed tortor turpis, blandit sit amet erat a, fringilla suscipit ligula. Proin at ante eu nibh tristique tincidunt. Nam sagittis lectus ac erat dictum lacinia.`
11 .split(" ");
12
13log.startWidget({ format: () => "" });
14
15(async () => {
16 let first = true;
17 let wrap = 0;
18 const stream = process.stderr;
19 for (const word of lorem1) {
20 await async.delay(200);
21 if ((wrap + word.length) >= 80) {
22 stream.write("\n");
23 first = true;
24 wrap = 0;
25 }
26 if (!first) stream.write(" ");
27 first = false;
28 stream.write(word);
29 wrap += word.length + " ".length;
30 }
31})();
32
33async.delay(1000).then(async () => {
34 const root = progress.start("generation");
35});
36
37import * as async from "../async.ts";
38import * as progress from "../progress.ts";
39import * as log from "../log.ts";
lib/log.test.ts+211-21
...@@ -18,18 +18,18 @@ describe("log widgets", () => {...@@ -18,18 +18,18 @@ describe("log widgets", () => {
18 });18 });
19 host.write("abc def");19 host.write("abc def");
20 {20 {
21 const unlock = host.getDrawLock();21 using lock = host.getDrawLock("long");
22 host.expectFrame(null, {22 host.expectFrame(null, {
23 stdout: "abc def",23 stdout: "abc def",
24 });24 });
25 unlock();25 lock.release("cursor-start-of-line");
26 }26 }
27 host.expectFrame(null, { stdout: "" });27 host.expectFrame(null, { stdout: "" });
28 {28 {
29 const unlock = host.getDrawLock();29 const lock = host.getDrawLock("long");
30 host.write("abc def ");30 host.write("abc def ");
31 host.write("hijk");31 host.write("hijk");
32 unlock();32 lock.release("cursor-start-of-line");
33 host.expectFrame(0, {33 host.expectFrame(0, {
34 stdout: "abc def hijk",34 stdout: "abc def hijk",
35 });35 });
...@@ -54,7 +54,6 @@ describe("log widgets", () => {...@@ -54,7 +54,6 @@ describe("log widgets", () => {
54 ansi.cursorUp(1),54 ansi.cursorUp(1),
55 ansi.clearFullLine,55 ansi.clearFullLine,
56 "partial line",56 "partial line",
57 ansi.cursorSave,
58 "\n",57 "\n",
59 "t=0\n",58 "t=0\n",
60 ]),59 ]),
...@@ -62,21 +61,23 @@ describe("log widgets", () => {...@@ -62,21 +61,23 @@ describe("log widgets", () => {
62 stderr: testing.MockScreen.sync([61 stderr: testing.MockScreen.sync([
63 ansi.cursorUp(1),62 ansi.cursorUp(1),
64 ansi.clearFullLine,63 ansi.clearFullLine,
65 ansi.cursorSave,
66 "\n",64 "\n",
67 "t=0\n",65 "t=0\n",
68 ]),66 ]),
69 });67 });
70 host.write(" and more\n");68 host.write(" and more\n");
69 // partial line
70 // t=0
71 // [C]
71 host.expectFrame(0, {72 host.expectFrame(0, {
72 merged: testing.MockScreen.sync([73 merged: testing.MockScreen.sync([
73 ansi.cursorRestore,74 ansi.cursorUp(2) + ansi.cursorRight("partial line".length),
74 " and more\n",75 " and more\n",
75 "t=0\n",76 "t=0\n",
76 ]),77 ]),
77 stdout: " and more\n",78 stdout: " and more\n",
78 stderr: testing.MockScreen.sync([79 stderr: testing.MockScreen.sync([
79 ansi.cursorRestore,80 ansi.cursorUp(2) + ansi.cursorRight("partial line".length),
80 "t=0\n",81 "t=0\n",
81 ]),82 ]),
82 });83 });
...@@ -104,7 +105,7 @@ describe("log widgets", () => {...@@ -104,7 +105,7 @@ describe("log widgets", () => {
104 ansi.cursorUp(2),105 ansi.cursorUp(2),
105 ansi.clearFullLine,106 ansi.clearFullLine,
106 "partial line",107 "partial line",
107 ansi.cursorSave + "\n",108 "\n",
108 "abc",109 "abc",
109 ansi.clearToEndOfLine,110 ansi.clearToEndOfLine,
110 "\ndefg\n",111 "\ndefg\n",
...@@ -120,10 +121,10 @@ describe("log widgets", () => {...@@ -120,10 +121,10 @@ describe("log widgets", () => {
120 merged: testing.MockScreen.sync([121 merged: testing.MockScreen.sync([
121 ansi.cursorUp(2),122 ansi.cursorUp(2),
122 ansi.clearFullLine,123 ansi.clearFullLine,
123 ansi.cursorRestore,124 ansi.cursorUp(1) + ansi.cursorRight(12),
124 " and more\n",125 " and more\n",
125 "a",126 "a",
126 ansi.cursorSave + "\n",127 "\n",
127 "abc",128 "abc",
128 ansi.clearToEndOfLine,129 ansi.clearToEndOfLine,
129 "\ndefg\n",130 "\ndefg\n",
...@@ -197,7 +198,7 @@ describe("log widgets", () => {...@@ -197,7 +198,7 @@ describe("log widgets", () => {
197 {198 {
198 host.write("warn: line of log\n");199 host.write("warn: line of log\n");
199 host.expectWithoutConsume(0);200 host.expectWithoutConsume(0);
200 const unlock = host.getDrawLock("short");201 const lock = host.getDrawLock("short");
201 // info: line of log202 // info: line of log
202 // line one 0203 // line one 0
203 // line two204 // line two
...@@ -213,13 +214,16 @@ describe("log widgets", () => {...@@ -213,13 +214,16 @@ describe("log widgets", () => {
213 ].join(""),214 ].join(""),
214 stdout: "warn: line of log\n",215 stdout: "warn: line of log\n",
215 });216 });
216 const unlock2 = host.getDrawLock("long");217 // info: line of log
218 // warn: line of log
219 // [C]
220 const lock2 = host.getDrawLock("long");
217 host.expectNone();221 host.expectNone();
218 host.write("error: line of log\n");222 host.write("error: line of log\n");
219 host.expectNone();223 host.expectNone();
220 unlock2();224 lock2.release("cursor-start-of-line");
221 host.expectNone();225 host.expectNone();
222 unlock();226 lock.release("cursor-start-of-line");
223 // warn: line of log227 // warn: line of log
224 // [c]228 // [c]
225 host.expectFrame(0, {229 host.expectFrame(0, {
...@@ -232,7 +236,7 @@ describe("log widgets", () => {...@@ -232,7 +236,7 @@ describe("log widgets", () => {
232 host.cancel();236 host.cancel();
233 }237 }
234 {238 {
235 const unlock = host.getDrawLock("short");239 const lock = host.getDrawLock("short");
236 // info: line of log240 // info: line of log
237 // line one 0241 // line one 0
238 // line two242 // line two
...@@ -247,11 +251,11 @@ describe("log widgets", () => {...@@ -247,11 +251,11 @@ describe("log widgets", () => {
247 ].join(""),251 ].join(""),
248 stdout: "",252 stdout: "",
249 });253 });
250 const unlock2 = host.getDrawLock("long");254 const lock2 = host.getDrawLock("long");
251 host.expectNone();255 host.expectNone();
252 unlock2();256 lock2.release("cursor-start-of-line");
253 host.expectNone();257 host.expectNone();
254 unlock();258 lock.release("cursor-start-of-line");
255 // warn: line of log259 // warn: line of log
256 // [c]260 // [c]
257 host.expectFrame(0, {261 host.expectFrame(0, {
...@@ -264,6 +268,192 @@ describe("log widgets", () => {...@@ -264,6 +268,192 @@ describe("log widgets", () => {
264 }268 }
265 });269 });
266270
271 test("draw lock middle of cursor restoration short", () => {
272 const host = new testing.MockScreen();
273
274 using _ = host.startWidget({
275 format: (now) => `line one ${now}\nline two`,
276 });
277
278 host.expectFrame(0, {
279 stderr: testing.MockScreen.sync([
280 "line one 0\nline two\n",
281 ]),
282 });
283
284 host.write("info: line of log\n");
285 host.expectFrame(0, {
286 merged: testing.MockScreen.sync([
287 ansi.cursorUp(2),
288 ansi.clearFullLine,
289 "info: line of log\n",
290 "line one 0\nline two\n",
291 ]),
292 });
293
294 host.write("warn: line of log\n");
295 host.expectWithoutConsume(0);
296 const lock = host.getDrawLock("short");
297 // info: line of log
298 // line one 0
299 // line two
300 // [C]
301 host.expectFrame(null, {
302 merged: [
303 ansi.syncStart,
304 ansi.cursorUp(1),
305 ansi.clearFullLine,
306 ansi.cursorUp(1),
307 ansi.clearFullLine,
308 "warn: line of log\n",
309 ].join(""),
310 stdout: "warn: line of log\n",
311 });
312 // info: line of log
313 // warn: line of log
314 // [C]
315 const lock2 = host.getDrawLock("long");
316 host.expectNone();
317 // now we will pretend the environment writes "hello " without
318 // a newline. without extra write calls, it has to save and
319 // restore the cursor perfectly. when multiple locks are held,
320 // it's a bit ambiguous, so the implementation takes the slow path
321 // if any releases are uncertain of the terminal state.
322 lock2.release("cursor-middle-of-line");
323 host.expectNone();
324 lock.release("cursor-start-of-line");
325 // warn: line of log
326 // [c]
327 host.expectFrame(0, {
328 merged: [
329 ansi.cursorSave,
330 "\n",
331 "line one 0\nline two\n",
332 ansi.syncEnd,
333 ].join(""),
334 });
335 const lock3 = host.getDrawLock("short");
336 // hello [S]
337 // line one 0
338 // line two
339 // [C]
340 host.expectFrame(null, {
341 merged: [
342 ansi.syncStart,
343 ansi.cursorUp(1),
344 ansi.clearFullLine,
345 ansi.cursorUp(1),
346 ansi.clearFullLine,
347 ansi.cursorRestore,
348 ].join(""),
349 });
350 lock3.release("cursor-middle-of-line");
351 host.expectFrame(0, {
352 merged: [
353 ansi.cursorSave,
354 "\n",
355 "line one 0\nline two\n",
356 ansi.syncEnd,
357 ].join(""),
358 });
359 host.cancel();
360 });
361
362 test("draw lock middle of cursor restoration long", () => {
363 const host = new testing.MockScreen();
364
365 using _ = host.startWidget({
366 format: (now) => `line one ${now}\nline two`,
367 });
368
369 host.expectFrame(0, {
370 stderr: testing.MockScreen.sync([
371 "line one 0\nline two\n",
372 ]),
373 });
374
375 host.write("info: line of log\n");
376 host.expectFrame(0, {
377 merged: testing.MockScreen.sync([
378 ansi.cursorUp(2),
379 ansi.clearFullLine,
380 "info: line of log\n",
381 "line one 0\nline two\n",
382 ]),
383 });
384
385 host.write("warn: line of log\n");
386 host.expectWithoutConsume(0);
387 const lock = host.getDrawLock("long");
388 // info: line of log
389 // line one 0
390 // line two
391 // [C]
392 host.expectFrame(null, {
393 merged: [
394 ansi.syncStart,
395 ansi.cursorUp(1),
396 ansi.clearFullLine,
397 ansi.cursorUp(1),
398 ansi.clearFullLine,
399 ansi.syncEnd,
400 "warn: line of log\n",
401 ].join(""),
402 stdout: "warn: line of log\n",
403 });
404 // info: line of log
405 // warn: line of log
406 // [C]
407 const lock2 = host.getDrawLock("long");
408 host.expectNone();
409 // now we will pretend the environment writes "hello " without
410 // a newline. without extra write calls, it has to save and
411 // restore the cursor perfectly. when multiple locks are held,
412 // it's a bit ambiguous, so the implementation takes the slow path
413 // if any releases are uncertain of the terminal state.
414 lock2.release("cursor-middle-of-line");
415 host.expectNone();
416 lock.release("cursor-start-of-line");
417 // warn: line of log
418 // [c]
419 host.expectFrame(0, {
420 merged: [
421 ansi.cursorSave,
422 "\n",
423 ansi.syncStart,
424 "line one 0\nline two\n",
425 ansi.syncEnd,
426 ].join(""),
427 });
428 const lock3 = host.getDrawLock("long");
429 // hello [S]
430 // line one 0
431 // line two
432 // [C]
433 host.expectFrame(null, {
434 merged: [
435 ansi.syncStart,
436 ansi.cursorUp(1),
437 ansi.clearFullLine,
438 ansi.cursorUp(1),
439 ansi.clearFullLine,
440 ansi.syncEnd,
441 ansi.cursorRestore,
442 ].join(""),
443 });
444 lock3.release("cursor-middle-of-line");
445 host.expectFrame(0, {
446 merged: [
447 ansi.cursorSave,
448 "\n",
449 ansi.syncStart,
450 "line one 0\nline two\n",
451 ansi.syncEnd,
452 ].join(""),
453 });
454 host.cancel();
455 });
456
267 test("widget expires", () => {457 test("widget expires", () => {
268 const host = new testing.MockScreen();458 const host = new testing.MockScreen();
269459
...@@ -316,7 +506,7 @@ describe("log widgets", () => {...@@ -316,7 +506,7 @@ describe("log widgets", () => {
316 stdout: "log line;",506 stdout: "log line;",
317 merged: testing.MockScreen.sync([507 merged: testing.MockScreen.sync([
318 "log line;",508 "log line;",
319 ansi.cursorSave + "\n",509 "\n",
320 "widget line one 0\nwidget line two\n",510 "widget line one 0\nwidget line two\n",
321 ]),511 ]),
322 });512 });
...@@ -328,7 +518,7 @@ describe("log widgets", () => {...@@ -328,7 +518,7 @@ describe("log widgets", () => {
328 ansi.clearFullLine,518 ansi.clearFullLine,
329 ansi.cursorUp(1),519 ansi.cursorUp(1),
330 ansi.clearFullLine,520 ansi.clearFullLine,
331 ansi.cursorRestore,521 ansi.cursorUp(1) + ansi.cursorRight("log line;".length),
332 " rest of line\n",522 " rest of line\n",
333 ]),523 ]),
334 });524 });
lib/log.ts+194-68
...@@ -2,7 +2,7 @@...@@ -2,7 +2,7 @@
2 * by using `lib/log.ts`, an application gets easy scoped logging as well as2 * by using `lib/log.ts`, an application gets easy scoped logging as well as
3 * integration with terminal widgets such as `lib/progress.ts`. even when these3 * integration with terminal widgets such as `lib/progress.ts`. even when these
4 * widgets are active, using the logging interface is optional; global I/O with4 * widgets are active, using the logging interface is optional; global I/O with
5 * `console.*` and `process.std{out/err}` are patched to play nice.5 * `console.*` and `process.std{out/err}` are automatically patched to play nice.
6 *6 *
7 * the pattern for using this module is to shadow the global `console` with a7 * the pattern for using this module is to shadow the global `console` with a
8 * per-file logging scope, which makes it impossible to use the wrong logger.8 * per-file logging scope, which makes it impossible to use the wrong logger.
...@@ -28,6 +28,8 @@...@@ -28,6 +28,8 @@
28 * these allow showing temporary or interactive information, such as program28 * these allow showing temporary or interactive information, such as program
29 * status or input prompts. a powerful example of this system in action is29 * status or input prompts. a powerful example of this system in action is
30 * `lib/progress.ts`, which uses a widget as it's default rendering backend.30 * `lib/progress.ts`, which uses a widget as it's default rendering backend.
31 * alongside this, a very complicated locking system is provided to ensure that
32 * widgets to not interfere with globals like `process.stdout.write`.
31 * (TODO: widgets cannot recieve "input" data yet)33 * (TODO: widgets cannot recieve "input" data yet)
32 *34 *
33 * `lib/log.ts` offers two environment integrations:35 * `lib/log.ts` offers two environment integrations:
...@@ -39,10 +41,11 @@...@@ -39,10 +41,11 @@
39 *41 *
40 * custom log integrations can be built on top of this module by calling42 * custom log integrations can be built on top of this module by calling
41 * `log.tee()` to duplicate all messages elsewhere. for example, a project may43 * `log.tee()` to duplicate all messages elsewhere. for example, a project may
42 * configure logs to upload to a telemetry service.44 * configure logs to upload to a telemetry service. these logs can carry custom
45 * metadata as well as source code traces -- amazing tools for debugging.
43 *46 *
44 * if bundling for the browser, see the details in `./node.ts` on how to47 * if bundling for the browser, see the details in `./node.ts` on how to
45 * trigger tree-shaking to eliminate the node.js bindings in your bundle.48 * trigger tree-shaking to eliminate the node.js bindings in your web bundle.
46 *49 *
47 * @module50 * @module
48 */51 */
...@@ -142,12 +145,18 @@ export function scoped(name: string): Scope {...@@ -142,12 +145,18 @@ export function scoped(name: string): Scope {
142export function tee(destination: DispatchFunction): ts.Dispose {145export function tee(destination: DispatchFunction): ts.Dispose {
143 return globalLog.tee(destination);146 return globalLog.tee(destination);
144}147}
145/** replace the default writer */148
146export function replaceGlobalDestination(destination: DispatchFunction) {149/** replace the default message writer */
150export function replaceGlobalMessageDestination(destination: DispatchFunction) {
147 globalOutputFunction = destination;151 globalOutputFunction = destination;
148}152}
149153
150/** replace the default formatter */154/** replace the default interactive widget host */
155export function replaceGlobalWidgetHost(widgetHost: WidgetHost) {
156 globalWidgetHost = widgetHost;
157}
158
159/** replace the default message formatter */
151export function replaceGlobalFormatFunction(160export function replaceGlobalFormatFunction(
152 format: (msg: Message, colors: boolean) => string,161 format: (msg: Message, colors: boolean) => string,
153) {162) {
...@@ -194,15 +203,45 @@ export function writeMessage(m: Message) {...@@ -194,15 +203,45 @@ export function writeMessage(m: Message) {
194}203}
195204
196/**205/**
197 * while locked, no widgets will draw. prefer calling `write` to opt into206 * hold a lock that stops `@clo/lib` from drawing to the terminal. this is an
198 * automatic buffering. this lock is not exclusive. if the lock will be held207 * advanced api that is likely unneeded unless messing with external APIs that
199 * for an extremely short amount of time, pass `"short"` which will allow208 * freely write to the terminal.
200 * more optimized use of ansi synchronization codes.209 *
210 * NOTE: if the lock will be held for an extremely short amount of time, pass
211 * `"short"` which will allow more optimized use of ansi synchronization codes.
201 */212 */
202export function getDrawLock(mode: "long" | "short"): ts.Dispose {213export function getDrawLock(mode: "long" | "short"): DrawLock {
203 return globalWidgetHost.getDrawLock(mode);214 return globalWidgetHost.getDrawLock(mode);
204}215}
205216
217/**
218 * call this if you are doing silly things with `process.stderr` and you want to
219 * make sure that starting a log widget (such as `@clo/lib/progress.ts`) doesn't
220 * have interweaving bugs. in particular, you need this if your program directly
221 * writes incomplete lines to the terminal (missing a trailing `\n`).
222 *
223 * the globals are patched to track if widget text must move to a fresh line
224 * before drawing, as well as knowing to return to the original position
225 * afterwards.
226 *
227 * you most likely do not need to call this API.
228 */
229export function ensureGlobalsArePatched(): ts.Dispose {
230 return globalWidgetHost.startWidget({ format: () => "" });
231}
232
233export interface DrawLock {
234 /**
235 * decides if widget drawing requires an extra newline, which is needed if
236 * there is extra text on the line that the lock is being released on.
237 */
238 release(
239 cursorPosition: "cursor-start-of-line" | "cursor-middle-of-line",
240 ): void;
241 /** Assumes worst case `cursor-middle-of-line` */
242 [Symbol.dispose](): void;
243}
244
206export function headlessScope(dispatch: DispatchFunction): RootScope {245export function headlessScope(dispatch: DispatchFunction): RootScope {
207 return new ScopeImpl(dispatch);246 return new ScopeImpl(dispatch);
208}247}
...@@ -228,8 +267,8 @@ export interface Widget {...@@ -228,8 +267,8 @@ export interface Widget {
228 onKey?(key: string): void;267 onKey?(key: string): void;
229}268}
230269
231/** {@linkcode widgetHost}'s input takes terminal i/o as well as timing APIs */270/** {@linkcode widgetHost}'s input takes terminal I/O as well as timing APIs */
232export interface HeadlessWidgetEnv {271export interface WidgetHostOptions {
233 /**272 /**
234 * an exclusive lock on the terminal is held whenever widgets are active. a273 * an exclusive lock on the terminal is held whenever widgets are active. a
235 * secondary purpose of this is to instrument/deinstrument other code to274 * secondary purpose of this is to instrument/deinstrument other code to
...@@ -238,12 +277,11 @@ export interface HeadlessWidgetEnv {...@@ -238,12 +277,11 @@ export interface HeadlessWidgetEnv {
238 * draw lock.277 * draw lock.
239 *278 *
240 * this lock can be cleared by deactiving all widgets, or by calling279 * this lock can be cleared by deactiving all widgets, or by calling
241 * {@linkcode getDrawLock}.280 * {@linkcode getDrawLock} to force `@clo/lib` to stop drawing.
242 *281 *
243 * currently, this lock must be able to be synchronously aquired at any282 * currently, this lock must be able to be synchronously aquired at any
244 * point. if you desire an async locking function, please contact me so we283 * point. if you desire an async locking function, please contact me so we
245 * can design how it would work. you can currently work around this with284 * can design how it would work.
246 * `getDrawLock`
247 */285 */
248 lockTerminal: () => TerminalLock;286 lockTerminal: () => TerminalLock;
249 /** fast path for writing output without widgets */287 /** fast path for writing output without widgets */
...@@ -254,6 +292,10 @@ export interface HeadlessWidgetEnv {...@@ -254,6 +292,10 @@ export interface HeadlessWidgetEnv {
254 delay: typeof async.delay;292 delay: typeof async.delay;
255}293}
256294
295/**
296 * When `@clo/lib` requests a lock on the terminal, the adapter provides this
297 * interface to communicate everything about the terminal state correctly.
298 */
257export interface TerminalLock {299export interface TerminalLock {
258 /** recieves ANSI escape sequences for interactive data (should flush immediately) */300 /** recieves ANSI escape sequences for interactive data (should flush immediately) */
259 writeInteractive(text: string): void;301 writeInteractive(text: string): void;
...@@ -261,6 +303,7 @@ export interface TerminalLock {...@@ -261,6 +303,7 @@ export interface TerminalLock {
261 writeOutput(text: string): void;303 writeOutput(text: string): void;
262 /** called often. TODO: convert this into a subscription */304 /** called often. TODO: convert this into a subscription */
263 getSize(): { columns: number; rows: number };305 getSize(): { columns: number; rows: number };
306 // size(cb: (value: { width: number, height: number }) => void): () => void;
264 /** temporarily free the lock */307 /** temporarily free the lock */
265 temporaryUnlock?(): () => void;308 temporaryUnlock?(): () => void;
266 /** completely free the lock */309 /** completely free the lock */
...@@ -268,11 +311,11 @@ export interface TerminalLock {...@@ -268,11 +311,11 @@ export interface TerminalLock {
268}311}
269312
270/** an implementation of an ANSI-based widget host */313/** an implementation of an ANSI-based widget host */
271export interface HeadlessWidgetHost {314export interface WidgetHost {
272 /** see the top-level {@linkcode writeLine} function */315 /** see the top-level {@linkcode writeLine} function */
273 write(text: string): void;316 write(text: string): void;
274 /** see the top-level {@linkcode getDrawLock} function */317 /** see the top-level {@linkcode getDrawLock} function */
275 getDrawLock(mode: "long" | "short"): ts.Dispose;318 getDrawLock(mode: "long" | "short"): DrawLock;
276 /** see the top-level {@linkcode startWidget} function */319 /** see the top-level {@linkcode startWidget} function */
277 startWidget(widget: Widget): ts.Dispose;320 startWidget(widget: Widget): ts.Dispose;
278 /** stop all widgets and remove all timers. */321 /** stop all widgets and remove all timers. */
...@@ -293,19 +336,8 @@ interface WidgetState {...@@ -293,19 +336,8 @@ interface WidgetState {
293/**336/**
294 * terminal widget rendering is done by specifying all system APIs up front in337 * terminal widget rendering is done by specifying all system APIs up front in
295 * an interface, creating an instance of the "widget host".338 * an interface, creating an instance of the "widget host".
296 *
297 * some notes on widget rendering:
298 * - Never flush immediately (exception for
299 * {@linkcode WidgetHost["getDrawLock"]|getDrawLock}), always do it next tick.
300 * - Maximum of one `wait` call at once. When the expected time suddenly
301 * shrinks, the timer is rescheduled.
302 * - When redrawing widget lines, three tricks are done to reduce flickering:
303 * 1. Tell the terminal not to flicker (ansi.syncStart/syncEnd).
304 * 2. A simple prefix-based diffing algorithm for skipping unchanged text
305 * 3. Avoid clearing a line before redrawing it.
306 * Points 2 and 3 are used for terminals that are slow or do not support sync.
307 */339 */
308export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {340export function createWidgetHost(env: WidgetHostOptions): WidgetHost {
309 const { lockTerminal, now, delay, writeOutputTemporaryLock } = env;341 const { lockTerminal, now, delay, writeOutputTemporaryLock } = env;
310342
311 let timer: async.Cancelable<void> | null = null;343 let timer: async.Cancelable<void> | null = null;
...@@ -314,7 +346,9 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {...@@ -314,7 +346,9 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {
314 let redrawTime = 0;346 let redrawTime = 0;
315 let lastFlush = 0;347 let lastFlush = 0;
316 let buffer = "";348 let buffer = "";
317 let partialLine = false;349 let partialLineIndex = 0;
350 let needsToSaveCursor = false;
351 let needsToRestoreCursor = false;
318 const widgets: Widget[] = [];352 const widgets: Widget[] = [];
319 const internals: WidgetState[] = [];353 const internals: WidgetState[] = [];
320 let lines: string[] = [];354 let lines: string[] = [];
...@@ -329,6 +363,8 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {...@@ -329,6 +363,8 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {
329 // trivial path when not using widgets363 // trivial path when not using widgets
330 if (!lines.length && !widgets.length) {364 if (!lines.length && !widgets.length) {
331 ASSERT(buffer);365 ASSERT(buffer);
366 needsToRestoreCursor = false;
367 needsToSaveCursor = false;
332 if (writeOutputTemporaryLock) {368 if (writeOutputTemporaryLock) {
333 writeOutputTemporaryLock(buffer);369 writeOutputTemporaryLock(buffer);
334 if (hasSyncStart) {370 if (hasSyncStart) {
...@@ -345,6 +381,7 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {...@@ -345,6 +381,7 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {
345 terminal.close();381 terminal.close();
346 terminal = null;382 terminal = null;
347 }383 }
384 partialLineIndex = partialLineLength(buffer);
348 buffer = "";385 buffer = "";
349 return;386 return;
350 }387 }
...@@ -376,13 +413,17 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {...@@ -376,13 +413,17 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {
376 if (next < Infinity) redrawSoon(next);413 if (next < Infinity) redrawSoon(next);
377414
378 if (!newWidgetLines[0]) {415 if (!newWidgetLines[0]) {
416 ASSERT(!needsToSaveCursor);
379 if (lines.length > 0) {417 if (lines.length > 0) {
380 terminal.writeInteractive(418 terminal.writeInteractive(
381 (hasSyncStart ? "" : ansi.syncStart) +419 (hasSyncStart ? "" : ansi.syncStart) +
382 // clear the widget space420 // clear the widget space
383 (ansi.cursorUp(1) + ansi.clearFullLine)421 (ansi.cursorUp(1) + ansi.clearFullLine)
384 .repeat(lines.length) +422 .repeat(lines.length) +
385 (partialLine ? ansi.cursorRestore : ""),423 (partialLineIndex
424 ? ansi.cursorUp(1) + ansi.cursorRight(partialLineIndex)
425 : "") +
426 (needsToRestoreCursor ? ansi.cursorRestore : ""),
386 );427 );
387 hasSyncStart = true;428 hasSyncStart = true;
388 lines = [];429 lines = [];
...@@ -403,18 +444,20 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {...@@ -403,18 +444,20 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {
403 // > [cursor is start of this line]444 // > [cursor is start of this line]
404 //445 //
405 // first, clear out the space where new lines are going to intersect446 // first, clear out the space where new lines are going to intersect
406 const createsPartialLine = !buffer.endsWith("\n");447 const createsPartialLine = partialLineLength(buffer);
407 // if more lines are buffered than there are widgets, only some are needed448 // if more lines are buffered than there are widgets, only some are needed
408 const clearLinesTop = Math.min(449 const clearLinesTop = Math.min(
409 lines.length,450 lines.length,
410 string.countNewlines(buffer) +451 string.countNewlines(buffer) +
411 (createsPartialLine ? 1 : 0) +452 (createsPartialLine ? 1 : 0) +
412 (partialLine ? -1 : 0),453 (partialLineIndex ? -1 : 0), // "consumes partial line"
413 );454 );
414 const oldLines = lines.slice(clearLinesTop);455 const oldLines = lines.slice(clearLinesTop);
415 terminal.writeInteractive(456 terminal.writeInteractive(
416 (hasSyncStart ? "" : ansi.syncStart) +457 (needsToSaveCursor ? ansi.cursorSave + "\n" : "") +
417 ((clearLinesTop > 0 || partialLine)458 (hasSyncStart ? "" : ansi.syncStart) +
459 (partialLineIndex && !lines[0] ? "\n" : "") +
460 ((clearLinesTop > 0 || (partialLineIndex && lines[0]))
418 // clear the lines for buffer461 // clear the lines for buffer
419 ? (clearLinesTop > 0462 ? (clearLinesTop > 0
420 ? ansi.cursorUp(lines.length - clearLinesTop + 1) +463 ? ansi.cursorUp(lines.length - clearLinesTop + 1) +
...@@ -422,17 +465,22 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {...@@ -422,17 +465,22 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {
422 (ansi.cursorUp(1) + ansi.clearFullLine)465 (ansi.cursorUp(1) + ansi.clearFullLine)
423 .repeat(clearLinesTop - 1)466 .repeat(clearLinesTop - 1)
424 : "") +467 : "") +
425 (partialLine ? ansi.cursorRestore : "")468 (partialLineIndex && lines[0]
469 ? ansi.cursorUp(
470 clearLinesTop > 0 ? 1 : lines.length + 1,
471 ) +
472 ansi.cursorRight(partialLineIndex)
473 : "")
426 : ""),474 : ""),
427 );475 );
428 partialLine = createsPartialLine;476 partialLineIndex = createsPartialLine;
429 // then write output lines on standard out477 // then write output lines on standard out
430 terminal.writeOutput(buffer);478 terminal.writeOutput(buffer);
431 terminal.writeInteractive(479 terminal.writeInteractive(
432 // if a partial line is created, then the widgets480 // if a partial line is created, then the widgets
433 // have to go on the next line, to avoid breaking stdout,481 // have to go on the next line, to avoid breaking stdout,
434 // the newline gets emitted on the interactive out.482 // the newline gets emitted on the interactive out.
435 (createsPartialLine ? ansi.cursorSave + "\n" : "") +483 (createsPartialLine ? "\n" : "") +
436 // the widget text484 // the widget text
437 newWidgetLines.map((newLine, i) =>485 newWidgetLines.map((newLine, i) =>
438 (newLine.includes("\x1b") && !newLine.endsWith(ansi.reset)486 (newLine.includes("\x1b") && !newLine.endsWith(ansi.reset)
...@@ -453,7 +501,8 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {...@@ -453,7 +501,8 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {
453 Math.max(0, lines.length - (newWidgetLines?.length ?? 0)),501 Math.max(0, lines.length - (newWidgetLines?.length ?? 0)),
454 );502 );
455 terminal.writeInteractive(503 terminal.writeInteractive(
456 (hasSyncStart ? "" : ansi.syncStart) +504 (needsToSaveCursor ? ansi.cursorSave + "\n" : "") +
505 (hasSyncStart ? "" : ansi.syncStart) +
457 // clear the bottom lines506 // clear the bottom lines
458 (clearLinesBottom507 (clearLinesBottom
459 ? (ansi.cursorUp(1) + ansi.clearToEndOfLine)508 ? (ansi.cursorUp(1) + ansi.clearToEndOfLine)
...@@ -478,6 +527,8 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {...@@ -478,6 +527,8 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {
478 );527 );
479 }528 }
480 hasSyncStart = false;529 hasSyncStart = false;
530 needsToRestoreCursor ||= needsToSaveCursor;
531 needsToSaveCursor = false;
481 lines = newWidgetLines;532 lines = newWidgetLines;
482533
483 buffer = "";534 buffer = "";
...@@ -505,8 +556,11 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {...@@ -505,8 +556,11 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {
505 // clear the widget space556 // clear the widget space
506 (ansi.cursorUp(1) + ansi.clearFullLine)557 (ansi.cursorUp(1) + ansi.clearFullLine)
507 .repeat(lines.length) +558 .repeat(lines.length) +
508 (partialLine ? ansi.cursorRestore : "") +559 (partialLineIndex
509 (shortTermDrawLock ? "" : ansi.syncEnd),560 ? ansi.cursorUp(1) + ansi.cursorRight(partialLineIndex)
561 : "") +
562 (shortTermDrawLock ? "" : ansi.syncEnd) +
563 (needsToRestoreCursor ? ansi.cursorRestore : ""),
510 );564 );
511 lines = [];565 lines = [];
512 hasSyncStart = shortTermDrawLock;566 hasSyncStart = shortTermDrawLock;
...@@ -543,14 +597,23 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {...@@ -543,14 +597,23 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {
543 }597 }
544 }598 }
545 locks += 1;599 locks += 1;
546 return ts.defer(() => {600 let disposed = false;
547 locks -= 1;601 return {
548 if (locks === 0) {602 release(mode) {
549 tempUnlock?.();603 if (disposed) return;
550 tempUnlock = null;604 locks -= 1;
551 if (buffer.length > 0 || widgets.length > 0) redrawSoon(0);605 needsToSaveCursor ||= mode === "cursor-middle-of-line";
552 }606 if (locks === 0) {
553 });607 tempUnlock?.();
608 tempUnlock = null;
609 if (buffer.length > 0 || widgets.length > 0) redrawSoon(0);
610 }
611 disposed = true;
612 },
613 [Symbol.dispose]() {
614 this.release("cursor-middle-of-line");
615 },
616 };
554 },617 },
555 startWidget(w) {618 startWidget(w) {
556 ASSERT(!widgets.includes(w), "Cannot start the same widget twice.");619 ASSERT(!widgets.includes(w), "Cannot start the same widget twice.");
...@@ -583,6 +646,12 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {...@@ -583,6 +646,12 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {
583 };646 };
584}647}
585648
649function partialLineLength(text: string) {
650 const idx = text.lastIndexOf("\n");
651 if (idx === -1) return text.length;
652 return text.length - idx - 1;
653}
654
586const logColors = node.process?.stderr.isTTY ?? false;655const logColors = node.process?.stderr.isTTY ?? false;
587656
588let formatLine = /* @__PURE__ */ (() => {657let formatLine = /* @__PURE__ */ (() => {
...@@ -702,35 +771,63 @@ const ScopeImpl = class Scope implements RootScope {...@@ -702,35 +771,63 @@ const ScopeImpl = class Scope implements RootScope {
702 }771 }
703};772};
704773
705const globalWidgetHost = node.process774let globalWidgetHost = node.process
706 ? /* @__PURE__*/ ((process: NonNullable<typeof node.process>) => {775 ? /* @__PURE__*/ ((process: NonNullable<typeof node.process>) => {
707 const widget = headlessWidgetHost({776 const widget = createWidgetHost({
708 lockTerminal() {777 lockTerminal() {
709 const { stdout, stderr } = process;778 const { stdout, stderr } = process;
710 let disposed = false;779 let disposed = false;
711780
712 function patch<T, A extends unknown[]>(
713 fn: (this: T, ...args: A) => void,
714 ) {
715 return function (this: T, ...args: A) {
716 using _ = disposed ? null : widget.getDrawLock("short");
717 fn.apply(this, args);
718 };
719 }
720
721 // patch calls to `process.std{out,err}`781 // patch calls to `process.std{out,err}`
722 // note: `pipe` uses managed calls to `write`, so this is plenty782 // note: `pipe` uses managed calls to `write`, so this is plenty
723 const stdoutWrite = stdout.write;783 const stdoutWrite = stdout.write;
724 const stderrWrite = stderr.write;784 const stderrWrite = stderr.write;
725 const stdoutEnd = stdout.end;785 const stdoutEnd = stdout.end;
726 const stderrEnd = stderr.end;786 const stderrEnd = stderr.end;
727 const newStdoutWrite = stdout.write = patch(stdoutWrite);787 const newStdoutWrite = stdout.write = widget.write;
728 const newStderrWrite = stderr.write = patch(stderrWrite);788 const newStderrWrite = stderr.write = function (
729 const newStdoutEnd = stdout.end = patch(stdoutEnd);789 this: typeof stderr,
730 const newStderrEnd = stderr.end = patch(stderrEnd);790 ...args
791 ) {
792 using lock = disposed ? null : widget.getDrawLock("short");
793 stderrWrite.apply(stderr, args);
794 if (lock) {
795 lock.release(
796 (typeof args[0] === "string"
797 ? args[0].endsWith("\n")
798 : bufferEndsInNewline(args[0]))
799 ? "cursor-start-of-line"
800 : "cursor-middle-of-line",
801 );
802 }
803 };
804 function patchEndMethod<T, A extends unknown[]>(
805 fn: (this: T, ...args: A) => void,
806 ) {
807 return function (this: T, ...args: A) {
808 using lock = disposed ? null : widget.getDrawLock("short");
809 fn.apply(this, args);
810 // TODO: this is not handled correctly, but nobody closes
811 // their fucking standard error! this likely should just
812 // disable the library if you call end.
813 if (lock) lock.release("cursor-start-of-line");
814 };
815 }
816 const newStdoutEnd = stdout.end = patchEndMethod(stdoutEnd);
817 const newStderrEnd = stderr.end = patchEndMethod(stderrEnd);
731818
732 // non-node runtimes will typically implement console in a way that819 // non-node runtimes will typically implement console in a way that
733 // doesn't use `node:process`, so it must also get patched820 // doesn't use `node:process`, so it must also get patched. this is
821 // okay because the lock is re-enterant.
822 function patchSyncMethod<T, A extends unknown[]>(
823 fn: (this: T, ...args: A) => void,
824 ) {
825 return function (this: T, ...args: A) {
826 using lock = disposed ? null : widget.getDrawLock("short");
827 fn.apply(this, args);
828 if (lock) lock.release("cursor-start-of-line");
829 };
830 }
734 const console = globalThis831 const console = globalThis
735 .console as unknown as Record<string, () => void>;832 .console as unknown as Record<string, () => void>;
736 const restoreConsole: [string, old: () => void, patch: () => void][] =833 const restoreConsole: [string, old: () => void, patch: () => void][] =
...@@ -738,7 +835,7 @@ const globalWidgetHost = node.process...@@ -738,7 +835,7 @@ const globalWidgetHost = node.process
738 for (const [key, old] of Object.entries(console)) {835 for (const [key, old] of Object.entries(console)) {
739 if (typeof old !== "function") continue;836 if (typeof old !== "function") continue;
740 try {837 try {
741 const patched = console[key] = patch(old);838 const patched = console[key] = patchSyncMethod(old);
742 restoreConsole.push([key, old, patched]);839 restoreConsole.push([key, old, patched]);
743 } catch { /* skip */ }840 } catch { /* skip */ }
744 }841 }
...@@ -774,7 +871,7 @@ const globalWidgetHost = node.process...@@ -774,7 +871,7 @@ const globalWidgetHost = node.process
774 : /* @__PURE__ */ ((warned = false) => {871 : /* @__PURE__ */ ((warned = false) => {
775 return {872 return {
776 write: (line: string) => console.log(line),873 write: (line: string) => console.log(line),
777 getDrawLock: () => ts.defer(() => {}),874 getDrawLock: () => ({ [Symbol.dispose]() {}, release() {} }),
778 startWidget: (w: Widget) => {875 startWidget: (w: Widget) => {
779 if (!warned) {876 if (!warned) {
780 console.warn(877 console.warn(
...@@ -791,6 +888,35 @@ const globalWidgetHost = node.process...@@ -791,6 +888,35 @@ const globalWidgetHost = node.process
791 };888 };
792 })();889 })();
793890
891export function simpleNodeProcessWidgetHost(process: node.Process) {
892 return createWidgetHost({
893 lockTerminal() {
894 return {
895 writeOutput: (string) => process.stdout.write(string),
896 writeInteractive: (string) => process.stderr.write(string),
897 getSize: () => process.stderr,
898 temporarilyUnlock() {
899 // no action needed
900 },
901 close() {
902 // no action needed
903 },
904 };
905 },
906 now: () => performance.now(),
907 delay: async.delay,
908 });
909}
910
911function bufferEndsInNewline(buffer: ArrayBufferView | undefined) {
912 return buffer
913 ? buffer instanceof Uint8Array
914 ? buffer.at(-1) === 0xa
915 : new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength)
916 .at(-1) === 0xa
917 : false;
918}
919
794const levelToAnsi: Record<Message["level"], string> = {920const levelToAnsi: Record<Message["level"], string> = {
795 info: `${ansi.fgBlue}info`,921 info: `${ansi.fgBlue}info`,
796 warn: `${ansi.fgYellow}warn`,922 warn: `${ansi.fgYellow}warn`,
lib/node.ts+2-1
...@@ -20,7 +20,8 @@ export const process: Process | undefined =...@@ -20,7 +20,8 @@ export const process: Process | undefined =
2020
21export const isServer: boolean = !!process;21export const isServer: boolean = !!process;
2222
23interface Process {23/** partial types for Node.js `globalThis.process` */
24export interface Process {
24 getBuiltinModule<K extends keyof Builtins>(name: K): Builtins[K] | null;25 getBuiltinModule<K extends keyof Builtins>(name: K): Builtins[K] | null;
25 binding<K extends keyof Bindings>(name: K): Bindings[K] | null;26 binding<K extends keyof Bindings>(name: K): Bindings[K] | null;
26 addListener(event: string, callback: () => void): this;27 addListener(event: string, callback: () => void): this;
lib/progress.test.ts+24-24
...@@ -105,30 +105,30 @@ test("percentValueFormatter", () => {...@@ -105,30 +105,30 @@ test("percentValueFormatter", () => {
105 assert.equal(progress.percentValueFormatter(0.5, null), "50%");105 assert.equal(progress.percentValueFormatter(0.5, null), "50%");
106});106});
107107
108// ## `progress.Ema`108// // ## `progress.Ema`
109//109// //
110// the exponential moving average algorithm can be used on its own110// // the exponential moving average algorithm can be used on its own
111//111// //
112test("Ema", () => {112// test("Ema", () => {
113 // first sample always returns null, if the progress moves linearly then the113// // first sample always returns null, if the progress moves linearly then the
114 // estimated time will be the same.114// // estimated time will be the same.
115 let ema = new progress.Ema();115// let ema = new progress.Ema();
116 assert.equal(ema.sample(50_000, 0), null);116// assert.equal(ema.sample(50_000, 0), null);
117 assert.equal(ema.sample(50_010, 0.1), 50_100);117// assert.equal(ema.sample(50_010, 0.1), 50_100);
118 assert.equal(ema.sample(50_020, 0.2), 50_100);118// assert.equal(ema.sample(50_020, 0.2), 50_100);
119 assert.equal(ema.sample(50_080, 0.8), 50_100);119// assert.equal(ema.sample(50_080, 0.8), 50_100);
120120
121 // this algorithm estimates somewhat well121// // this algorithm estimates somewhat well
122 ema = new progress.Ema();122// ema = new progress.Ema();
123 assert.equal(ema.sample(50_000, 0), null);123// assert.equal(ema.sample(50_000, 0), null);
124 assert.equal(ema.sample(50_010, 0.1), 50_100);124// assert.equal(ema.sample(50_010, 0.1), 50_100);
125 assert.equal(ema.sample(50_020, 0.3), 50_087.666666666664);125// assert.equal(ema.sample(50_020, 0.3), 50_087.666666666664);
126 assert.equal(ema.sample(50_050, 0.4), 50_109.7);126// assert.equal(ema.sample(50_050, 0.4), 50_109.7);
127 assert.equal(ema.sample(50_100, 0.6), 50_142.486666666664);127// assert.equal(ema.sample(50_100, 0.6), 50_142.486666666664);
128 assert.equal(ema.sample(50_110, 0.7), 50_143.392785714284);128// assert.equal(ema.sample(50_110, 0.7), 50_143.392785714284);
129 assert.equal(ema.sample(50_115, 0.8), 50_137.91067142857);129// assert.equal(ema.sample(50_115, 0.8), 50_137.91067142857);
130 assert.equal(ema.sample(50_130, 0.9), 50_141.754246587305);130// assert.equal(ema.sample(50_130, 0.9), 50_141.754246587305);
131});131// });
132132
133// ## internals133// ## internals
134test("event encoding round trip cases", async () => {134test("event encoding round trip cases", async () => {
lib/progress.ts+11-7
...@@ -114,7 +114,7 @@ export interface Node extends Ref, Disposable {...@@ -114,7 +114,7 @@ export interface Node extends Ref, Disposable {
114 */114 */
115 inc(this: Node, value?: number): void;115 inc(this: Node, value?: number): void;
116 /** a scoped logger set to output inline on this item. */116 /** a scoped logger set to output inline on this item. */
117 readonly log: log.Scope;117 readonly log: log.RootScope;
118118
119 /**119 /**
120 * a time for when this node is estimated to be completed, in milliseconds120 * a time for when this node is estimated to be completed, in milliseconds
...@@ -697,10 +697,15 @@ function renderChildren(state: ReadOnlyNode, now: number, depth: boolean[]) {...@@ -697,10 +697,15 @@ function renderChildren(state: ReadOnlyNode, now: number, depth: boolean[]) {
697 truncated += 1;697 truncated += 1;
698 continue;698 continue;
699 }699 }
700 const logLines = child?.logs.slice(-Math.min(3, maxHeight)) ?? [];700 const logLines = child?.logs
701 .map((msg) => log.formatMessage(msg, true))
702 .join("")
703 .split("\n")
704 .slice(-Math.min(3, maxHeight)) ?? [];
701 for (const line of logLines) {705 for (const line of logLines) {
702 item += left + box.line + " " + ansi.style(ansi.fgBrightBlack, ">") +706 item += left + (i === length - 1 && !truncated ? " " : box.line) + " " +
703 " " + log.formatMessage(line, true) + "\n";707 ansi.style(ansi.fgBrightBlack, ">") +
708 " " + line + "\n";
704 }709 }
705 maxHeight -= h + logLines.length;710 maxHeight -= h + logLines.length;
706 out += item;711 out += item;
...@@ -754,7 +759,7 @@ export function formatUnicodeBar(progress: number, width: number): string {...@@ -754,7 +759,7 @@ export function formatUnicodeBar(progress: number, width: number): string {
754export function attachToScreen(759export function attachToScreen(
755 root: Root,760 root: Root,
756 { write, startWidget }: Pick<761 { write, startWidget }: Pick<
757 log.HeadlessWidgetHost,762 log.WidgetHost,
758 "write" | "startWidget"763 "write" | "startWidget"
759 >,764 >,
760): ts.Dispose {765): ts.Dispose {
...@@ -783,8 +788,7 @@ export function attachToScreen(...@@ -783,8 +788,7 @@ export function attachToScreen(
783 while (p = p.parent) title = p.text + " / " + title;788 while (p = p.parent) title = p.text + " / " + title;
784 const { logs } = node;789 const { logs } = node;
785 if (logs.length > 0) {790 if (logs.length > 0) {
786 const count = `${logs.length} line${logs.length === 1 ? "" : "s"}`;791 const header = `[logs from ${title}]`;
787 const header = `[${count} from ${title}]`;
788 write(ansi.style(ansi.fgBrightBlack, header) + "\n");792 write(ansi.style(ansi.fgBrightBlack, header) + "\n");
789 write(logs.map((msg) => log.formatMessage(msg, true)).join(""));793 write(logs.map((msg) => log.formatMessage(msg, true)).join(""));
790 }794 }
lib/testing.ts+52-20
...@@ -150,23 +150,29 @@ export function removeUndefinedKeys(obj: unknown): unknown {...@@ -150,23 +150,29 @@ export function removeUndefinedKeys(obj: unknown): unknown {
150 );150 );
151}151}
152152
153interface WriteCall {
154 kind: "interactive" | "output";
155 stack: stack.Frame[];
156 content: string;
157}
158
153/** implements a log.HeadlessWidgetHost that acts as a fake screen. */159/** implements a log.HeadlessWidgetHost that acts as a fake screen. */
154export class MockScreen implements Disposable, log.HeadlessWidgetHost {160export class MockScreen implements Disposable, log.WidgetHost {
155 columns = 80;161 columns = 80;
156 rows = 33;162 rows = 33;
157163
158 stdout: string = "";164 stdout: string = "";
159 stderr: string = "";165 stderr: string = "";
160 out: string = "";166 out: string = "";
161 writeCalls = 0;167 writeCalls: WriteCall[] = [];
162168
163 timers = new FakeTimers();169 timers = new FakeTimers();
164170
165 write: log.HeadlessWidgetHost["write"];171 write: log.WidgetHost["write"];
166 getDrawLock: log.HeadlessWidgetHost["getDrawLock"];172 getDrawLock: log.WidgetHost["getDrawLock"];
167 startWidget: log.HeadlessWidgetHost["startWidget"];173 startWidget: log.WidgetHost["startWidget"];
168 delay: log.HeadlessWidgetHost["delay"];174 delay: log.WidgetHost["delay"];
169 now: log.HeadlessWidgetHost["now"];175 now: log.WidgetHost["now"];
170176
171 hasTerminalLock: null | "locked" | "temporary-unlock" = null;177 hasTerminalLock: null | "locked" | "temporary-unlock" = null;
172178
...@@ -175,20 +181,33 @@ export class MockScreen implements Disposable, log.HeadlessWidgetHost {...@@ -175,20 +181,33 @@ export class MockScreen implements Disposable, log.HeadlessWidgetHost {
175 }181 }
176182
177 constructor({ temporaryUnlocking }: { temporaryUnlocking?: boolean } = {}) {183 constructor({ temporaryUnlocking }: { temporaryUnlocking?: boolean } = {}) {
178 const host = log.headlessWidgetHost({184 const callerFile = UNWRAP(stack.capture()[0]);
185 const host = log.createWidgetHost({
179 lockTerminal: () => {186 lockTerminal: () => {
180 ASSERT(!this.hasTerminalLock);187 ASSERT(!this.hasTerminalLock);
181 this.hasTerminalLock = "locked";188 this.hasTerminalLock = "locked";
182 return {189 return {
183 writeInteractive: (text) => {190 writeInteractive: (content) => {
184 this.stderr += text;191 this.stderr += content;
185 this.out += text;192 this.out += content;
186 this.writeCalls += 1;193 const frames = stack.capture().filter(x => x.file !== import.meta.filename);
194 const cutoff = frames.findIndex(f => f.file === callerFile.file && f.fn === callerFile.fn)
195 this.writeCalls.push({
196 kind: "interactive",
197 stack: cutoff === -1 ? frames : frames.slice(0, cutoff),
198 content,
199 })
187 },200 },
188 writeOutput: (text) => {201 writeOutput: (content) => {
189 this.stdout += text;202 this.stdout += content;
190 this.out += text;203 this.out += content;
191 this.writeCalls += 1;204 const frames = stack.capture().filter(x => x.file !== import.meta.filename);
205 const cutoff = frames.findIndex(f => f.file === callerFile.file && f.fn === callerFile.fn)
206 this.writeCalls.push({
207 kind: "output",
208 stack: cutoff === -1 ? frames : frames.slice(0, cutoff),
209 content,
210 })
192 },211 },
193 getSize: () => {212 getSize: () => {
194 return this;213 return this;
...@@ -234,7 +253,7 @@ export class MockScreen implements Disposable, log.HeadlessWidgetHost {...@@ -234,7 +253,7 @@ export class MockScreen implements Disposable, log.HeadlessWidgetHost {
234 if (ms != null) {253 if (ms != null) {
235 const wait = UNWRAP(254 const wait = UNWRAP(
236 this.timers.entries.shift(),255 this.timers.entries.shift(),
237 "no call to MockScreen.delay",256 () => this.out.length > 0 ? "terminal i/o did not wait" : "no terminal i/o",
238 );257 );
239 ASSERT(258 ASSERT(
240 ms === wait.duration,259 ms === wait.duration,
...@@ -255,25 +274,38 @@ export class MockScreen implements Disposable, log.HeadlessWidgetHost {...@@ -255,25 +274,38 @@ export class MockScreen implements Disposable, log.HeadlessWidgetHost {
255 () =>274 () =>
256 `merged out does not match\n` +275 `merged out does not match\n` +
257 `expected: ${ansi.debugAnsi(out ?? "")}\n` +276 `expected: ${ansi.debugAnsi(out ?? "")}\n` +
258 `actual: ${ansi.debugAnsi(this.out)}\n`,277 `actual: ${ansi.debugAnsi(this.out)}\n` +
278 this.fmtWriteCalls(),
259 );279 );
260 ASSERT(280 ASSERT(
261 stdout == null || this.stdout === stdout,281 stdout == null || this.stdout === stdout,
262 () =>282 () =>
263 `standard out does not match\n` +283 `standard out does not match\n` +
264 `expected: ${ansi.debugAnsi(stdout ?? "")}\n` +284 `expected: ${ansi.debugAnsi(stdout ?? "")}\n` +
265 `actual: ${ansi.debugAnsi(this.stdout)}\n`,285 `actual: ${ansi.debugAnsi(this.stdout)}\n` +
286 this.fmtWriteCalls(),
266 );287 );
267 ASSERT(288 ASSERT(
268 stderr == null || this.stderr === stderr,289 stderr == null || this.stderr === stderr,
269 () =>290 () =>
270 `interactive out does not match\n` +291 `interactive out does not match\n` +
271 `expected: ${ansi.debugAnsi(stderr ?? "")}\n` +292 `expected: ${ansi.debugAnsi(stderr ?? "")}\n` +
272 `actual: ${ansi.debugAnsi(this.stderr)}\n`,293 `actual: ${ansi.debugAnsi(this.stderr)}\n` +
294 this.fmtWriteCalls(),
273 );295 );
274 this.stdout = "";296 this.stdout = "";
275 this.stderr = "";297 this.stderr = "";
276 this.out = "";298 this.out = "";
299 this.writeCalls = [];
300 }
301
302 fmtWriteCalls() {
303 return `\n${ansi.reset}breakdown of calls that rendered this frame:\n` +
304 this.writeCalls.map((call, i) =>
305 `${i + 1}. [${call.kind}] ${ansi.debugAnsi(call.content)}\n`
306 + call.stack.map(frame => ansi.reset + stack.formatFrame(frame, true)).join('\n') +
307 `\n`
308 ).join('\n').replaceAll(ansi.fgReset, ansi.reset)
277 }309 }
278310
279 [Symbol.dispose]() {311 [Symbol.dispose]() {