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>(
102102 p: progress.Ref = progress,
103103) {
104104 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!");
106108 try {
107109 running = true;
108110 ASSERT(jobs === 0);
109 const start = performance.now();
110111
111112 const value = await compiler(node);
112113 ASSERT(jobs === 0);
......@@ -120,6 +121,8 @@ export async function compile<T>(
120121 newAssets: !Array.from(seenWrites).some((x) => x.startsWith("a:")),
121122 };
122123 } finally {
124 node.text =
125 `sitegen at ${startDate.toLocaleDateString()} ${startDate.toLocaleTimeString()}`;
123126 node.end();
124127 running = false;
125128 newKeys = 0;
lib/assert.ts+2-2
......@@ -1,6 +1,6 @@
11/**
2 * assertions and type narrowing helpers.
3 * intended to be imported by symbol.
2 * assertions and type narrowing helpers. intended to be imported per symbol.
3 * functions are capitalized to make them stand out in a codebase.
44 * @module
55 */
66/* 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", () => {
1818 });
1919 host.write("abc def");
2020 {
21 const unlock = host.getDrawLock();
21 using lock = host.getDrawLock("long");
2222 host.expectFrame(null, {
2323 stdout: "abc def",
2424 });
25 unlock();
25 lock.release("cursor-start-of-line");
2626 }
2727 host.expectFrame(null, { stdout: "" });
2828 {
29 const unlock = host.getDrawLock();
29 const lock = host.getDrawLock("long");
3030 host.write("abc def ");
3131 host.write("hijk");
32 unlock();
32 lock.release("cursor-start-of-line");
3333 host.expectFrame(0, {
3434 stdout: "abc def hijk",
3535 });
......@@ -54,7 +54,6 @@ describe("log widgets", () => {
5454 ansi.cursorUp(1),
5555 ansi.clearFullLine,
5656 "partial line",
57 ansi.cursorSave,
5857 "\n",
5958 "t=0\n",
6059 ]),
......@@ -62,21 +61,23 @@ describe("log widgets", () => {
6261 stderr: testing.MockScreen.sync([
6362 ansi.cursorUp(1),
6463 ansi.clearFullLine,
65 ansi.cursorSave,
6664 "\n",
6765 "t=0\n",
6866 ]),
6967 });
7068 host.write(" and more\n");
69 // partial line
70 // t=0
71 // [C]
7172 host.expectFrame(0, {
7273 merged: testing.MockScreen.sync([
73 ansi.cursorRestore,
74 ansi.cursorUp(2) + ansi.cursorRight("partial line".length),
7475 " and more\n",
7576 "t=0\n",
7677 ]),
7778 stdout: " and more\n",
7879 stderr: testing.MockScreen.sync([
79 ansi.cursorRestore,
80 ansi.cursorUp(2) + ansi.cursorRight("partial line".length),
8081 "t=0\n",
8182 ]),
8283 });
......@@ -104,7 +105,7 @@ describe("log widgets", () => {
104105 ansi.cursorUp(2),
105106 ansi.clearFullLine,
106107 "partial line",
107 ansi.cursorSave + "\n",
108 "\n",
108109 "abc",
109110 ansi.clearToEndOfLine,
110111 "\ndefg\n",
......@@ -120,10 +121,10 @@ describe("log widgets", () => {
120121 merged: testing.MockScreen.sync([
121122 ansi.cursorUp(2),
122123 ansi.clearFullLine,
123 ansi.cursorRestore,
124 ansi.cursorUp(1) + ansi.cursorRight(12),
124125 " and more\n",
125126 "a",
126 ansi.cursorSave + "\n",
127 "\n",
127128 "abc",
128129 ansi.clearToEndOfLine,
129130 "\ndefg\n",
......@@ -197,7 +198,7 @@ describe("log widgets", () => {
197198 {
198199 host.write("warn: line of log\n");
199200 host.expectWithoutConsume(0);
200 const unlock = host.getDrawLock("short");
201 const lock = host.getDrawLock("short");
201202 // info: line of log
202203 // line one 0
203204 // line two
......@@ -213,13 +214,16 @@ describe("log widgets", () => {
213214 ].join(""),
214215 stdout: "warn: line of log\n",
215216 });
216 const unlock2 = host.getDrawLock("long");
217 // info: line of log
218 // warn: line of log
219 // [C]
220 const lock2 = host.getDrawLock("long");
217221 host.expectNone();
218222 host.write("error: line of log\n");
219223 host.expectNone();
220 unlock2();
224 lock2.release("cursor-start-of-line");
221225 host.expectNone();
222 unlock();
226 lock.release("cursor-start-of-line");
223227 // warn: line of log
224228 // [c]
225229 host.expectFrame(0, {
......@@ -232,7 +236,7 @@ describe("log widgets", () => {
232236 host.cancel();
233237 }
234238 {
235 const unlock = host.getDrawLock("short");
239 const lock = host.getDrawLock("short");
236240 // info: line of log
237241 // line one 0
238242 // line two
......@@ -247,11 +251,11 @@ describe("log widgets", () => {
247251 ].join(""),
248252 stdout: "",
249253 });
250 const unlock2 = host.getDrawLock("long");
254 const lock2 = host.getDrawLock("long");
251255 host.expectNone();
252 unlock2();
256 lock2.release("cursor-start-of-line");
253257 host.expectNone();
254 unlock();
258 lock.release("cursor-start-of-line");
255259 // warn: line of log
256260 // [c]
257261 host.expectFrame(0, {
......@@ -264,6 +268,192 @@ describe("log widgets", () => {
264268 }
265269 });
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
267457 test("widget expires", () => {
268458 const host = new testing.MockScreen();
269459
......@@ -316,7 +506,7 @@ describe("log widgets", () => {
316506 stdout: "log line;",
317507 merged: testing.MockScreen.sync([
318508 "log line;",
319 ansi.cursorSave + "\n",
509 "\n",
320510 "widget line one 0\nwidget line two\n",
321511 ]),
322512 });
......@@ -328,7 +518,7 @@ describe("log widgets", () => {
328518 ansi.clearFullLine,
329519 ansi.cursorUp(1),
330520 ansi.clearFullLine,
331 ansi.cursorRestore,
521 ansi.cursorUp(1) + ansi.cursorRight("log line;".length),
332522 " rest of line\n",
333523 ]),
334524 });
lib/log.ts+194-68
......@@ -2,7 +2,7 @@
22 * by using `lib/log.ts`, an application gets easy scoped logging as well as
33 * integration with terminal widgets such as `lib/progress.ts`. even when these
44 * 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.
66 *
77 * the pattern for using this module is to shadow the global `console` with a
88 * per-file logging scope, which makes it impossible to use the wrong logger.
......@@ -28,6 +28,8 @@
2828 * these allow showing temporary or interactive information, such as program
2929 * status or input prompts. a powerful example of this system in action is
3030 * `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`.
3133 * (TODO: widgets cannot recieve "input" data yet)
3234 *
3335 * `lib/log.ts` offers two environment integrations:
......@@ -39,10 +41,11 @@
3941 *
4042 * custom log integrations can be built on top of this module by calling
4143 * `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.
4346 *
4447 * 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.
4649 *
4750 * @module
4851 */
......@@ -142,12 +145,18 @@ export function scoped(name: string): Scope {
142145export function tee(destination: DispatchFunction): ts.Dispose {
143146 return globalLog.tee(destination);
144147}
145/** replace the default writer */
146export function replaceGlobalDestination(destination: DispatchFunction) {
148
149/** replace the default message writer */
150export function replaceGlobalMessageDestination(destination: DispatchFunction) {
147151 globalOutputFunction = destination;
148152}
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 */
151160export function replaceGlobalFormatFunction(
152161 format: (msg: Message, colors: boolean) => string,
153162) {
......@@ -194,15 +203,45 @@ export function writeMessage(m: Message) {
194203}
195204
196205/**
197 * while locked, no widgets will draw. prefer calling `write` to opt into
198 * automatic buffering. this lock is not exclusive. if the lock will be held
199 * for an extremely short amount of time, pass `"short"` which will allow
200 * more optimized use of ansi synchronization codes.
206 * hold a lock that stops `@clo/lib` from drawing to the terminal. this is an
207 * advanced api that is likely unneeded unless messing with external APIs that
208 * freely write to the terminal.
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.
201212 */
202export function getDrawLock(mode: "long" | "short"): ts.Dispose {
213export function getDrawLock(mode: "long" | "short"): DrawLock {
203214 return globalWidgetHost.getDrawLock(mode);
204215}
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
206245export function headlessScope(dispatch: DispatchFunction): RootScope {
207246 return new ScopeImpl(dispatch);
208247}
......@@ -228,8 +267,8 @@ export interface Widget {
228267 onKey?(key: string): void;
229268}
230269
231/** {@linkcode widgetHost}'s input takes terminal i/o as well as timing APIs */
232export interface HeadlessWidgetEnv {
270/** {@linkcode widgetHost}'s input takes terminal I/O as well as timing APIs */
271export interface WidgetHostOptions {
233272 /**
234273 * an exclusive lock on the terminal is held whenever widgets are active. a
235274 * secondary purpose of this is to instrument/deinstrument other code to
......@@ -238,12 +277,11 @@ export interface HeadlessWidgetEnv {
238277 * draw lock.
239278 *
240279 * 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.
242281 *
243282 * currently, this lock must be able to be synchronously aquired at any
244283 * 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 with
246 * `getDrawLock`
284 * can design how it would work.
247285 */
248286 lockTerminal: () => TerminalLock;
249287 /** fast path for writing output without widgets */
......@@ -254,6 +292,10 @@ export interface HeadlessWidgetEnv {
254292 delay: typeof async.delay;
255293}
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 */
257299export interface TerminalLock {
258300 /** recieves ANSI escape sequences for interactive data (should flush immediately) */
259301 writeInteractive(text: string): void;
......@@ -261,6 +303,7 @@ export interface TerminalLock {
261303 writeOutput(text: string): void;
262304 /** called often. TODO: convert this into a subscription */
263305 getSize(): { columns: number; rows: number };
306 // size(cb: (value: { width: number, height: number }) => void): () => void;
264307 /** temporarily free the lock */
265308 temporaryUnlock?(): () => void;
266309 /** completely free the lock */
......@@ -268,11 +311,11 @@ export interface TerminalLock {
268311}
269312
270313/** an implementation of an ANSI-based widget host */
271export interface HeadlessWidgetHost {
314export interface WidgetHost {
272315 /** see the top-level {@linkcode writeLine} function */
273316 write(text: string): void;
274317 /** see the top-level {@linkcode getDrawLock} function */
275 getDrawLock(mode: "long" | "short"): ts.Dispose;
318 getDrawLock(mode: "long" | "short"): DrawLock;
276319 /** see the top-level {@linkcode startWidget} function */
277320 startWidget(widget: Widget): ts.Dispose;
278321 /** stop all widgets and remove all timers. */
......@@ -293,19 +336,8 @@ interface WidgetState {
293336/**
294337 * terminal widget rendering is done by specifying all system APIs up front in
295338 * 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.
307339 */
308export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {
340export function createWidgetHost(env: WidgetHostOptions): WidgetHost {
309341 const { lockTerminal, now, delay, writeOutputTemporaryLock } = env;
310342
311343 let timer: async.Cancelable<void> | null = null;
......@@ -314,7 +346,9 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {
314346 let redrawTime = 0;
315347 let lastFlush = 0;
316348 let buffer = "";
317 let partialLine = false;
349 let partialLineIndex = 0;
350 let needsToSaveCursor = false;
351 let needsToRestoreCursor = false;
318352 const widgets: Widget[] = [];
319353 const internals: WidgetState[] = [];
320354 let lines: string[] = [];
......@@ -329,6 +363,8 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {
329363 // trivial path when not using widgets
330364 if (!lines.length && !widgets.length) {
331365 ASSERT(buffer);
366 needsToRestoreCursor = false;
367 needsToSaveCursor = false;
332368 if (writeOutputTemporaryLock) {
333369 writeOutputTemporaryLock(buffer);
334370 if (hasSyncStart) {
......@@ -345,6 +381,7 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {
345381 terminal.close();
346382 terminal = null;
347383 }
384 partialLineIndex = partialLineLength(buffer);
348385 buffer = "";
349386 return;
350387 }
......@@ -376,13 +413,17 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {
376413 if (next < Infinity) redrawSoon(next);
377414
378415 if (!newWidgetLines[0]) {
416 ASSERT(!needsToSaveCursor);
379417 if (lines.length > 0) {
380418 terminal.writeInteractive(
381419 (hasSyncStart ? "" : ansi.syncStart) +
382420 // clear the widget space
383421 (ansi.cursorUp(1) + ansi.clearFullLine)
384422 .repeat(lines.length) +
385 (partialLine ? ansi.cursorRestore : ""),
423 (partialLineIndex
424 ? ansi.cursorUp(1) + ansi.cursorRight(partialLineIndex)
425 : "") +
426 (needsToRestoreCursor ? ansi.cursorRestore : ""),
386427 );
387428 hasSyncStart = true;
388429 lines = [];
......@@ -403,18 +444,20 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {
403444 // > [cursor is start of this line]
404445 //
405446 // first, clear out the space where new lines are going to intersect
406 const createsPartialLine = !buffer.endsWith("\n");
447 const createsPartialLine = partialLineLength(buffer);
407448 // if more lines are buffered than there are widgets, only some are needed
408449 const clearLinesTop = Math.min(
409450 lines.length,
410451 string.countNewlines(buffer) +
411452 (createsPartialLine ? 1 : 0) +
412 (partialLine ? -1 : 0),
453 (partialLineIndex ? -1 : 0), // "consumes partial line"
413454 );
414455 const oldLines = lines.slice(clearLinesTop);
415456 terminal.writeInteractive(
416 (hasSyncStart ? "" : ansi.syncStart) +
417 ((clearLinesTop > 0 || partialLine)
457 (needsToSaveCursor ? ansi.cursorSave + "\n" : "") +
458 (hasSyncStart ? "" : ansi.syncStart) +
459 (partialLineIndex && !lines[0] ? "\n" : "") +
460 ((clearLinesTop > 0 || (partialLineIndex && lines[0]))
418461 // clear the lines for buffer
419462 ? (clearLinesTop > 0
420463 ? ansi.cursorUp(lines.length - clearLinesTop + 1) +
......@@ -422,17 +465,22 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {
422465 (ansi.cursorUp(1) + ansi.clearFullLine)
423466 .repeat(clearLinesTop - 1)
424467 : "") +
425 (partialLine ? ansi.cursorRestore : "")
468 (partialLineIndex && lines[0]
469 ? ansi.cursorUp(
470 clearLinesTop > 0 ? 1 : lines.length + 1,
471 ) +
472 ansi.cursorRight(partialLineIndex)
473 : "")
426474 : ""),
427475 );
428 partialLine = createsPartialLine;
476 partialLineIndex = createsPartialLine;
429477 // then write output lines on standard out
430478 terminal.writeOutput(buffer);
431479 terminal.writeInteractive(
432480 // if a partial line is created, then the widgets
433481 // have to go on the next line, to avoid breaking stdout,
434482 // the newline gets emitted on the interactive out.
435 (createsPartialLine ? ansi.cursorSave + "\n" : "") +
483 (createsPartialLine ? "\n" : "") +
436484 // the widget text
437485 newWidgetLines.map((newLine, i) =>
438486 (newLine.includes("\x1b") && !newLine.endsWith(ansi.reset)
......@@ -453,7 +501,8 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {
453501 Math.max(0, lines.length - (newWidgetLines?.length ?? 0)),
454502 );
455503 terminal.writeInteractive(
456 (hasSyncStart ? "" : ansi.syncStart) +
504 (needsToSaveCursor ? ansi.cursorSave + "\n" : "") +
505 (hasSyncStart ? "" : ansi.syncStart) +
457506 // clear the bottom lines
458507 (clearLinesBottom
459508 ? (ansi.cursorUp(1) + ansi.clearToEndOfLine)
......@@ -478,6 +527,8 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {
478527 );
479528 }
480529 hasSyncStart = false;
530 needsToRestoreCursor ||= needsToSaveCursor;
531 needsToSaveCursor = false;
481532 lines = newWidgetLines;
482533
483534 buffer = "";
......@@ -505,8 +556,11 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {
505556 // clear the widget space
506557 (ansi.cursorUp(1) + ansi.clearFullLine)
507558 .repeat(lines.length) +
508 (partialLine ? ansi.cursorRestore : "") +
509 (shortTermDrawLock ? "" : ansi.syncEnd),
559 (partialLineIndex
560 ? ansi.cursorUp(1) + ansi.cursorRight(partialLineIndex)
561 : "") +
562 (shortTermDrawLock ? "" : ansi.syncEnd) +
563 (needsToRestoreCursor ? ansi.cursorRestore : ""),
510564 );
511565 lines = [];
512566 hasSyncStart = shortTermDrawLock;
......@@ -543,14 +597,23 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {
543597 }
544598 }
545599 locks += 1;
546 return ts.defer(() => {
547 locks -= 1;
548 if (locks === 0) {
549 tempUnlock?.();
550 tempUnlock = null;
551 if (buffer.length > 0 || widgets.length > 0) redrawSoon(0);
552 }
553 });
600 let disposed = false;
601 return {
602 release(mode) {
603 if (disposed) return;
604 locks -= 1;
605 needsToSaveCursor ||= mode === "cursor-middle-of-line";
606 if (locks === 0) {
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 };
554617 },
555618 startWidget(w) {
556619 ASSERT(!widgets.includes(w), "Cannot start the same widget twice.");
......@@ -583,6 +646,12 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {
583646 };
584647}
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
586655const logColors = node.process?.stderr.isTTY ?? false;
587656
588657let formatLine = /* @__PURE__ */ (() => {
......@@ -702,35 +771,63 @@ const ScopeImpl = class Scope implements RootScope {
702771 }
703772};
704773
705const globalWidgetHost = node.process
774let globalWidgetHost = node.process
706775 ? /* @__PURE__*/ ((process: NonNullable<typeof node.process>) => {
707 const widget = headlessWidgetHost({
776 const widget = createWidgetHost({
708777 lockTerminal() {
709778 const { stdout, stderr } = process;
710779 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
721781 // patch calls to `process.std{out,err}`
722782 // note: `pipe` uses managed calls to `write`, so this is plenty
723783 const stdoutWrite = stdout.write;
724784 const stderrWrite = stderr.write;
725785 const stdoutEnd = stdout.end;
726786 const stderrEnd = stderr.end;
727 const newStdoutWrite = stdout.write = patch(stdoutWrite);
728 const newStderrWrite = stderr.write = patch(stderrWrite);
729 const newStdoutEnd = stdout.end = patch(stdoutEnd);
730 const newStderrEnd = stderr.end = patch(stderrEnd);
787 const newStdoutWrite = stdout.write = widget.write;
788 const newStderrWrite = stderr.write = function (
789 this: typeof stderr,
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
732819 // non-node runtimes will typically implement console in a way that
733 // doesn't use `node:process`, so it must also get patched
820 // 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 }
734831 const console = globalThis
735832 .console as unknown as Record<string, () => void>;
736833 const restoreConsole: [string, old: () => void, patch: () => void][] =
......@@ -738,7 +835,7 @@ const globalWidgetHost = node.process
738835 for (const [key, old] of Object.entries(console)) {
739836 if (typeof old !== "function") continue;
740837 try {
741 const patched = console[key] = patch(old);
838 const patched = console[key] = patchSyncMethod(old);
742839 restoreConsole.push([key, old, patched]);
743840 } catch { /* skip */ }
744841 }
......@@ -774,7 +871,7 @@ const globalWidgetHost = node.process
774871 : /* @__PURE__ */ ((warned = false) => {
775872 return {
776873 write: (line: string) => console.log(line),
777 getDrawLock: () => ts.defer(() => {}),
874 getDrawLock: () => ({ [Symbol.dispose]() {}, release() {} }),
778875 startWidget: (w: Widget) => {
779876 if (!warned) {
780877 console.warn(
......@@ -791,6 +888,35 @@ const globalWidgetHost = node.process
791888 };
792889 })();
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
794920const levelToAnsi: Record<Message["level"], string> = {
795921 info: `${ansi.fgBlue}info`,
796922 warn: `${ansi.fgYellow}warn`,
lib/node.ts+2-1
......@@ -20,7 +20,8 @@ export const process: Process | undefined =
2020
2121export const isServer: boolean = !!process;
2222
23interface Process {
23/** partial types for Node.js `globalThis.process` */
24export interface Process {
2425 getBuiltinModule<K extends keyof Builtins>(name: K): Builtins[K] | null;
2526 binding<K extends keyof Bindings>(name: K): Bindings[K] | null;
2627 addListener(event: string, callback: () => void): this;
lib/progress.test.ts+24-24
......@@ -105,30 +105,30 @@ test("percentValueFormatter", () => {
105105 assert.equal(progress.percentValueFormatter(0.5, null), "50%");
106106});
107107
108// ## `progress.Ema`
109//
110// the exponential moving average algorithm can be used on its own
111//
112test("Ema", () => {
113 // first sample always returns null, if the progress moves linearly then the
114 // estimated time will be the same.
115 let ema = new progress.Ema();
116 assert.equal(ema.sample(50_000, 0), null);
117 assert.equal(ema.sample(50_010, 0.1), 50_100);
118 assert.equal(ema.sample(50_020, 0.2), 50_100);
119 assert.equal(ema.sample(50_080, 0.8), 50_100);
120
121 // this algorithm estimates somewhat well
122 ema = new progress.Ema();
123 assert.equal(ema.sample(50_000, 0), null);
124 assert.equal(ema.sample(50_010, 0.1), 50_100);
125 assert.equal(ema.sample(50_020, 0.3), 50_087.666666666664);
126 assert.equal(ema.sample(50_050, 0.4), 50_109.7);
127 assert.equal(ema.sample(50_100, 0.6), 50_142.486666666664);
128 assert.equal(ema.sample(50_110, 0.7), 50_143.392785714284);
129 assert.equal(ema.sample(50_115, 0.8), 50_137.91067142857);
130 assert.equal(ema.sample(50_130, 0.9), 50_141.754246587305);
131});
108// // ## `progress.Ema`
109// //
110// // the exponential moving average algorithm can be used on its own
111// //
112// test("Ema", () => {
113// // first sample always returns null, if the progress moves linearly then the
114// // estimated time will be the same.
115// let ema = new progress.Ema();
116// assert.equal(ema.sample(50_000, 0), null);
117// assert.equal(ema.sample(50_010, 0.1), 50_100);
118// assert.equal(ema.sample(50_020, 0.2), 50_100);
119// assert.equal(ema.sample(50_080, 0.8), 50_100);
120
121// // this algorithm estimates somewhat well
122// ema = new progress.Ema();
123// assert.equal(ema.sample(50_000, 0), null);
124// assert.equal(ema.sample(50_010, 0.1), 50_100);
125// assert.equal(ema.sample(50_020, 0.3), 50_087.666666666664);
126// assert.equal(ema.sample(50_050, 0.4), 50_109.7);
127// assert.equal(ema.sample(50_100, 0.6), 50_142.486666666664);
128// assert.equal(ema.sample(50_110, 0.7), 50_143.392785714284);
129// assert.equal(ema.sample(50_115, 0.8), 50_137.91067142857);
130// assert.equal(ema.sample(50_130, 0.9), 50_141.754246587305);
131// });
132132
133133// ## internals
134134test("event encoding round trip cases", async () => {
lib/progress.ts+11-7
......@@ -114,7 +114,7 @@ export interface Node extends Ref, Disposable {
114114 */
115115 inc(this: Node, value?: number): void;
116116 /** a scoped logger set to output inline on this item. */
117 readonly log: log.Scope;
117 readonly log: log.RootScope;
118118
119119 /**
120120 * 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[]) {
697697 truncated += 1;
698698 continue;
699699 }
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)) ?? [];
701705 for (const line of logLines) {
702 item += left + box.line + " " + ansi.style(ansi.fgBrightBlack, ">") +
703 " " + log.formatMessage(line, true) + "\n";
706 item += left + (i === length - 1 && !truncated ? " " : box.line) + " " +
707 ansi.style(ansi.fgBrightBlack, ">") +
708 " " + line + "\n";
704709 }
705710 maxHeight -= h + logLines.length;
706711 out += item;
......@@ -754,7 +759,7 @@ export function formatUnicodeBar(progress: number, width: number): string {
754759export function attachToScreen(
755760 root: Root,
756761 { write, startWidget }: Pick<
757 log.HeadlessWidgetHost,
762 log.WidgetHost,
758763 "write" | "startWidget"
759764 >,
760765): ts.Dispose {
......@@ -783,8 +788,7 @@ export function attachToScreen(
783788 while (p = p.parent) title = p.text + " / " + title;
784789 const { logs } = node;
785790 if (logs.length > 0) {
786 const count = `${logs.length} line${logs.length === 1 ? "" : "s"}`;
787 const header = `[${count} from ${title}]`;
791 const header = `[logs from ${title}]`;
788792 write(ansi.style(ansi.fgBrightBlack, header) + "\n");
789793 write(logs.map((msg) => log.formatMessage(msg, true)).join(""));
790794 }
lib/testing.ts+52-20
......@@ -150,23 +150,29 @@ export function removeUndefinedKeys(obj: unknown): unknown {
150150 );
151151}
152152
153interface WriteCall {
154 kind: "interactive" | "output";
155 stack: stack.Frame[];
156 content: string;
157}
158
153159/** implements a log.HeadlessWidgetHost that acts as a fake screen. */
154export class MockScreen implements Disposable, log.HeadlessWidgetHost {
160export class MockScreen implements Disposable, log.WidgetHost {
155161 columns = 80;
156162 rows = 33;
157163
158164 stdout: string = "";
159165 stderr: string = "";
160166 out: string = "";
161 writeCalls = 0;
167 writeCalls: WriteCall[] = [];
162168
163169 timers = new FakeTimers();
164170
165 write: log.HeadlessWidgetHost["write"];
166 getDrawLock: log.HeadlessWidgetHost["getDrawLock"];
167 startWidget: log.HeadlessWidgetHost["startWidget"];
168 delay: log.HeadlessWidgetHost["delay"];
169 now: log.HeadlessWidgetHost["now"];
171 write: log.WidgetHost["write"];
172 getDrawLock: log.WidgetHost["getDrawLock"];
173 startWidget: log.WidgetHost["startWidget"];
174 delay: log.WidgetHost["delay"];
175 now: log.WidgetHost["now"];
170176
171177 hasTerminalLock: null | "locked" | "temporary-unlock" = null;
172178
......@@ -175,20 +181,33 @@ export class MockScreen implements Disposable, log.HeadlessWidgetHost {
175181 }
176182
177183 constructor({ temporaryUnlocking }: { temporaryUnlocking?: boolean } = {}) {
178 const host = log.headlessWidgetHost({
184 const callerFile = UNWRAP(stack.capture()[0]);
185 const host = log.createWidgetHost({
179186 lockTerminal: () => {
180187 ASSERT(!this.hasTerminalLock);
181188 this.hasTerminalLock = "locked";
182189 return {
183 writeInteractive: (text) => {
184 this.stderr += text;
185 this.out += text;
186 this.writeCalls += 1;
190 writeInteractive: (content) => {
191 this.stderr += content;
192 this.out += content;
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 })
187200 },
188 writeOutput: (text) => {
189 this.stdout += text;
190 this.out += text;
191 this.writeCalls += 1;
201 writeOutput: (content) => {
202 this.stdout += content;
203 this.out += content;
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 })
192211 },
193212 getSize: () => {
194213 return this;
......@@ -234,7 +253,7 @@ export class MockScreen implements Disposable, log.HeadlessWidgetHost {
234253 if (ms != null) {
235254 const wait = UNWRAP(
236255 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",
238257 );
239258 ASSERT(
240259 ms === wait.duration,
......@@ -255,25 +274,38 @@ export class MockScreen implements Disposable, log.HeadlessWidgetHost {
255274 () =>
256275 `merged out does not match\n` +
257276 `expected: ${ansi.debugAnsi(out ?? "")}\n` +
258 `actual: ${ansi.debugAnsi(this.out)}\n`,
277 `actual: ${ansi.debugAnsi(this.out)}\n` +
278 this.fmtWriteCalls(),
259279 );
260280 ASSERT(
261281 stdout == null || this.stdout === stdout,
262282 () =>
263283 `standard out does not match\n` +
264284 `expected: ${ansi.debugAnsi(stdout ?? "")}\n` +
265 `actual: ${ansi.debugAnsi(this.stdout)}\n`,
285 `actual: ${ansi.debugAnsi(this.stdout)}\n` +
286 this.fmtWriteCalls(),
266287 );
267288 ASSERT(
268289 stderr == null || this.stderr === stderr,
269290 () =>
270291 `interactive out does not match\n` +
271292 `expected: ${ansi.debugAnsi(stderr ?? "")}\n` +
272 `actual: ${ansi.debugAnsi(this.stderr)}\n`,
293 `actual: ${ansi.debugAnsi(this.stderr)}\n` +
294 this.fmtWriteCalls(),
273295 );
274296 this.stdout = "";
275297 this.stderr = "";
276298 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)
277309 }
278310
279311 [Symbol.dispose]() {