| ... | ... | @@ -1,9 +1,11 @@ |
| 1 | 1 | /** |
| 2 | 2 | * by using `lib/log.ts`, an application gets easy scoped logging as well as |
| 3 | | * integration with terminal widgets such as `lib/progress.ts`. |
| 3 | * 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 with |
| 5 | * `console.*` and `process.std{out/err}` are patched to play nice. |
| 4 | 6 | * |
| 5 | 7 | * the pattern for using this module is to shadow the global `console` with a |
| 6 | | * per-file logging scope: |
| 8 | * per-file logging scope, which makes it impossible to use the wrong logger. |
| 7 | 9 | * |
| 8 | 10 | * ```ts |
| 9 | 11 | * import * as log from "@clo/lib/log"; |
| ... | ... | @@ -21,17 +23,19 @@ |
| 21 | 23 | * now, the code reads familiarly (`console.log` is universally understood), |
| 22 | 24 | * but the output is organized into relevant scopes. |
| 23 | 25 | * |
| 24 | | * in addition to static log messages, a system for interactive I/O |
| 25 | | * {@linkcode Widget} is provided by calling {@linkcode startWidget}. these |
| 26 | | * allow showing temporary or interactive information, such as program status |
| 27 | | * or input prompts. a powerful example of this system in action is |
| 28 | | * `lib/progress.ts`, which uses a log widget by default to show status. |
| 26 | * in addition to static log messages, a system for interactive I/O via the |
| 27 | * {@linkcode Widget} interface can be started with {@linkcode startWidget}. |
| 28 | * these allow showing temporary or interactive information, such as program |
| 29 | * 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. |
| 29 | 31 | * (TODO: widgets cannot recieve "input" data yet) |
| 30 | 32 | * |
| 31 | | * this module offers two environment integrations: |
| 33 | * `lib/log.ts` offers two environment integrations: |
| 32 | 34 | * - in node.js, log messages are colored depending on the level and show |
| 33 | | * widgets directly under the long using ANSI cursor controls. |
| 34 | | * - otherwise, logs are surfaced using the global `console` API |
| 35 | * widgets directly under the long using ANSI cursor controls. when the |
| 36 | * terminal is not a TTY, widgets are silent. |
| 37 | * - in browsers and other, logs are surfaced using the global `console` API |
| 38 | * and widgets are disabled. |
| 35 | 39 | * |
| 36 | 40 | * custom log integrations can be built on top of this module by calling |
| 37 | 41 | * `log.tee()` to duplicate all messages elsewhere. for example, a project may |
| ... | ... | @@ -72,6 +76,11 @@ export interface Scope { |
| 72 | 76 | tee(writer: (message: Message) => void): ts.Dispose; |
| 73 | 77 | } |
| 74 | 78 | |
| 79 | export interface RootScope extends Scope { |
| 80 | /** write a partial line to the output. */ |
| 81 | write(text: string): void; |
| 82 | } |
| 83 | |
| 75 | 84 | export const originalLogArgs = Symbol("originalLogArgs"); |
| 76 | 85 | export interface Message { |
| 77 | 86 | level: "error" | "warn" | "info" | "debug"; |
| ... | ... | @@ -85,6 +94,8 @@ export interface Message { |
| 85 | 94 | stack?: stack.Frame[]; |
| 86 | 95 | /** arbitrary data from the logging source. */ |
| 87 | 96 | custom?: Partial<Record<string, ts.Json>>; |
| 97 | /** print a newline at the end of this log line? */ |
| 98 | newline?: boolean; |
| 88 | 99 | /** |
| 89 | 100 | * original logging arguments, if present. this field is indexed by a symbol |
| 90 | 101 | * so that it is lost during JSON serialization, as callers are allowed to log |
| ... | ... | @@ -140,6 +151,7 @@ export function replaceGlobalFormatFunction( |
| 140 | 151 | globalMessageFormatFunction = format; |
| 141 | 152 | } |
| 142 | 153 | |
| 154 | /** includes the trailing newline for standard log messages */ |
| 143 | 155 | export function formatMessage(msg: Message, colors: boolean): string { |
| 144 | 156 | return globalMessageFormatFunction(msg, colors); |
| 145 | 157 | } |
| ... | ... | @@ -169,21 +181,23 @@ export function startWidget(widget: Widget): ts.Dispose { |
| 169 | 181 | * no built-in prefix or formatting. ensures the text does not interweave. data |
| 170 | 182 | * will be flushed in the next frame or when drawing is {@link getDrawLock|unlocked}. |
| 171 | 183 | */ |
| 172 | | export function writeLine(text: string) { |
| 173 | | globalWidgetHost.writeLine(text); |
| 184 | export function write(text: string) { |
| 185 | globalWidgetHost.write(text); |
| 174 | 186 | } |
| 175 | 187 | |
| 176 | | /** write a Message object directly. */ |
| 188 | /** write a {@linkcode Message} object directly. */ |
| 177 | 189 | export function writeMessage(m: Message) { |
| 178 | 190 | globalLog.writeMessage(m); |
| 179 | 191 | } |
| 180 | 192 | |
| 181 | 193 | /** |
| 182 | | * while locked, no widgets will draw. prefer `writeLine`. |
| 183 | | * this lock is not exclusive. |
| 194 | * while locked, no widgets will draw. prefer calling `write` to opt into |
| 195 | * automatic buffering. this lock is not exclusive. if the lock will be held |
| 196 | * for an extremely short amount of time, pass `"short"` which will allow |
| 197 | * more optimized use of ansi synchronization codes. |
| 184 | 198 | */ |
| 185 | | export function getDrawLock(): ts.Dispose { |
| 186 | | return globalWidgetHost.getDrawLock(); |
| 199 | export function getDrawLock(mode: "long" | "short"): ts.Dispose { |
| 200 | return globalWidgetHost.getDrawLock(mode); |
| 187 | 201 | } |
| 188 | 202 | |
| 189 | 203 | export function headlessScope(dispatch: DispatchFunction): Scope { |
| ... | ... | @@ -196,11 +210,13 @@ export interface Widget { |
| 196 | 210 | * return the widget's text. return null to detach the widget. |
| 197 | 211 | * may get called more often than the specified `fps`. |
| 198 | 212 | * supports color codes but not ansi cursor movements. |
| 199 | | */ format( |
| 213 | */ |
| 214 | format( |
| 200 | 215 | now: ReturnType<typeof performance.now>, |
| 201 | 216 | ): |
| 202 | 217 | | string |
| 203 | | | null; /** 'null' to never update (use 'onChange'). defaults to 12 fps */ |
| 218 | | null; |
| 219 | /** 'null' to never update (use 'onChange') */ |
| 204 | 220 | fps?: |
| 205 | 221 | | number |
| 206 | 222 | | null; /** Subscribe to manual widget updates. Call `rerender` when needed. */ |
| ... | ... | @@ -211,26 +227,49 @@ export interface Widget { |
| 211 | 227 | |
| 212 | 228 | /** {@linkcode widgetHost}'s input takes terminal i/o as well as timing APIs */ |
| 213 | 229 | export interface HeadlessWidgetEnv { |
| 214 | | /** recieves ANSI escape sequences for interactive data */ |
| 215 | | writeInteractive(text: string): void; |
| 216 | | /** recieves log content (from `writeLine`) */ |
| 217 | | writeOutput(text: string): void; |
| 230 | /** |
| 231 | * an exclusive lock on the terminal is held whenever widgets are active. a |
| 232 | * secondary purpose of this is to instrument/deinstrument other code to |
| 233 | * integrate with `log.ts`'s widget lock. for example, the node.js adapter |
| 234 | * will patch `process.std{out,err}` to ensure write calls properly get a |
| 235 | * draw lock. |
| 236 | * |
| 237 | * this lock can be cleared by deactiving all widgets, or by calling |
| 238 | * {@linkcode getDrawLock}. |
| 239 | * |
| 240 | * currently, this lock must be able to be synchronously aquired at any |
| 241 | * point. if you desire an async locking function, please contact me so we |
| 242 | * can design how it would work. you can currently work around this with |
| 243 | * `getDrawLock` |
| 244 | */ |
| 245 | lockTerminal: () => TerminalLock; |
| 246 | /** fast path for writing output without widgets */ |
| 247 | writeOutputTemporaryLock?: (buffer: string) => void; |
| 218 | 248 | /** monotonic milliseconds */ |
| 219 | | now(): ReturnType<typeof performance.now>; |
| 249 | now: () => ReturnType<typeof performance.now>; |
| 220 | 250 | /** after resolving, `now()` should have increased by the delay time */ |
| 221 | 251 | delay: typeof async.delay; |
| 222 | | /** called often. */ |
| 252 | } |
| 253 | |
| 254 | export interface TerminalLock { |
| 255 | /** recieves ANSI escape sequences for interactive data (should flush immediately) */ |
| 256 | writeInteractive(text: string): void; |
| 257 | /** recieves log content from `write` (pre-buffered; should flush immediately) */ |
| 258 | writeOutput(text: string): void; |
| 259 | /** called often. TODO: convert this into a subscription */ |
| 223 | 260 | getSize(): { columns: number; rows: number }; |
| 224 | | /** called to enable input events */ |
| 225 | | onInput?(write: (bytes: Uint8Array | string) => void): () => void; |
| 261 | /** temporarily free the lock */ |
| 262 | temporaryUnlock?(): () => void; |
| 263 | /** completely free the lock */ |
| 264 | close(): void; |
| 226 | 265 | } |
| 227 | 266 | |
| 228 | 267 | /** an implementation of an ANSI-based widget host */ |
| 229 | 268 | export interface HeadlessWidgetHost { |
| 230 | 269 | /** see the top-level {@linkcode writeLine} function */ |
| 231 | | writeLine(text: string): void; |
| 270 | write(text: string): void; |
| 232 | 271 | /** see the top-level {@linkcode getDrawLock} function */ |
| 233 | | getDrawLock(): ts.Dispose; |
| 272 | getDrawLock(mode: "long" | "short"): ts.Dispose; |
| 234 | 273 | /** see the top-level {@linkcode startWidget} function */ |
| 235 | 274 | startWidget(widget: Widget): ts.Dispose; |
| 236 | 275 | /** stop all widgets and remove all timers. */ |
| ... | ... | @@ -258,13 +297,13 @@ interface WidgetState { |
| 258 | 297 | * - Maximum of one `wait` call at once. When the expected time suddenly |
| 259 | 298 | * shrinks, the timer is rescheduled. |
| 260 | 299 | * - When redrawing widget lines, three tricks are done to reduce flickering: |
| 261 | | * 1. Tell the terminal not to flicker (ansi.syncStart/syncEnd) |
| 300 | * 1. Tell the terminal not to flicker (ansi.syncStart/syncEnd). |
| 262 | 301 | * 2. A simple prefix-based diffing algorithm for skipping unchanged text |
| 263 | 302 | * 3. Avoid clearing a line before redrawing it. |
| 264 | 303 | * Points 2 and 3 are used for terminals that are slow or do not support sync. |
| 265 | 304 | */ |
| 266 | 305 | export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost { |
| 267 | | const { writeOutput, writeInteractive, now, delay, getSize } = env; |
| 306 | const { lockTerminal, now, delay, writeOutputTemporaryLock } = env; |
| 268 | 307 | |
| 269 | 308 | let timer: async.Cancelable<void> | null = null; |
| 270 | 309 | |
| ... | ... | @@ -272,113 +311,146 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost { |
| 272 | 311 | let redrawTime = 0; |
| 273 | 312 | let lastFlush = 0; |
| 274 | 313 | let buffer = ""; |
| 314 | let partialLine = false; |
| 275 | 315 | const widgets: Widget[] = []; |
| 276 | 316 | const internals: WidgetState[] = []; |
| 277 | 317 | let lines: string[] = []; |
| 318 | let hasSyncStart = false; |
| 319 | let terminal: TerminalLock | null = null; |
| 320 | let tempUnlock: (() => void) | null = null; |
| 278 | 321 | |
| 279 | 322 | function redrawCallback() { |
| 280 | 323 | timer = null; |
| 281 | 324 | redrawTime = (lastFlush = now()) - 0.00001; // windows time precision workaround |
| 282 | 325 | |
| 326 | // trivial path when not using widgets |
| 283 | 327 | if (!lines.length && !widgets.length) { |
| 284 | | buffer && writeOutput(buffer); |
| 328 | ASSERT(buffer); |
| 329 | if (writeOutputTemporaryLock) { |
| 330 | writeOutputTemporaryLock(buffer); |
| 331 | if (hasSyncStart) { |
| 332 | terminal ??= lockTerminal(); |
| 333 | terminal.writeInteractive(ansi.syncEnd); |
| 334 | } |
| 335 | } else { |
| 336 | terminal ??= lockTerminal(); |
| 337 | buffer && terminal.writeOutput(buffer); |
| 338 | if (hasSyncStart) { |
| 339 | terminal ??= lockTerminal(); |
| 340 | terminal.writeInteractive(ansi.syncEnd); |
| 341 | } |
| 342 | terminal.close(); |
| 343 | terminal = null; |
| 344 | } |
| 285 | 345 | buffer = ""; |
| 286 | 346 | return; |
| 287 | 347 | } |
| 288 | 348 | |
| 289 | | const { columns, rows } = getSize(); |
| 349 | terminal ??= lockTerminal(); |
| 350 | |
| 351 | const { columns, rows } = terminal.getSize(); |
| 290 | 352 | let newWidgetLines: string[] = []; |
| 291 | 353 | let next = Infinity; |
| 292 | | if (widgets[0]) { |
| 293 | | for (let w = 0, { length } = widgets; w < length; w += 1) { |
| 294 | | const outText = UNWRAP(widgets[w]).format(lastFlush); |
| 295 | | if (!outText) { |
| 296 | | widgets.splice(w, 1); |
| 297 | | UNWRAP(internals.splice(w, 1)[0]).unsub?.(); |
| 298 | | w -= 1; |
| 299 | | length -= 1; |
| 300 | | continue; |
| 301 | | } |
| 302 | | const rowsLeft = Math.max(1, rows - newWidgetLines.length - 1); |
| 303 | | if (rowsLeft === 1) break; |
| 304 | | const lines = outText.split("\n").slice(0, rowsLeft); |
| 305 | | newWidgetLines.push( |
| 306 | | ...lines.map((line) => ansi.trimToWidth(line, columns - 1)), |
| 307 | | ); |
| 308 | | |
| 309 | | next = Math.min(next, UNWRAP(internals[w]).frameTime); |
| 354 | for (let w = 0, { length } = widgets; w < length; w += 1) { |
| 355 | const outText = UNWRAP(widgets[w]).format(lastFlush); |
| 356 | if (!outText) { |
| 357 | widgets.splice(w, 1); |
| 358 | UNWRAP(internals.splice(w, 1)[0]).unsub?.(); |
| 359 | w -= 1; |
| 360 | length -= 1; |
| 361 | continue; |
| 310 | 362 | } |
| 363 | const rowsLeft = Math.max(1, rows - newWidgetLines.length - 1); |
| 364 | if (rowsLeft === 1) break; |
| 365 | const lines = outText.split("\n").slice(0, rowsLeft); |
| 366 | newWidgetLines.push( |
| 367 | ...lines.map((line) => ansi.trimToWidth(line, columns - 1)), |
| 368 | ); |
| 311 | 369 | |
| 312 | | newWidgetLines = newWidgetLines.slice(0, rows - 1); |
| 370 | next = Math.min(next, UNWRAP(internals[w]).frameTime); |
| 313 | 371 | } |
| 314 | | |
| 372 | newWidgetLines = newWidgetLines.slice(0, rows - 1); |
| 315 | 373 | if (next < Infinity) redrawSoon(next); |
| 316 | 374 | |
| 317 | 375 | if (!newWidgetLines[0]) { |
| 318 | | if (lines.length) { |
| 319 | | let clearLinesTop = Math.min( |
| 320 | | lines.length, |
| 321 | | string.countNewlines(buffer), |
| 322 | | ); |
| 323 | | writeInteractive( |
| 324 | | ansi.startOfLine + |
| 325 | | // skip up the shared widget space |
| 326 | | ansi.cursorUp(lines.length) + |
| 327 | | // clear the lines to contain `buffer` |
| 328 | | (ansi.clearFullLine + ansi.startOfNextLine) |
| 329 | | .repeat(clearLinesTop) + |
| 330 | | ansi.cursorUp(clearLinesTop), |
| 376 | if (lines.length > 0) { |
| 377 | terminal.writeInteractive( |
| 378 | (hasSyncStart ? "" : ansi.syncStart) + |
| 379 | // clear the widget space |
| 380 | (ansi.cursorUp(1) + ansi.clearFullLine) |
| 381 | .repeat(lines.length) + |
| 382 | (partialLine ? ansi.cursorRestore : ""), |
| 331 | 383 | ); |
| 384 | hasSyncStart = true; |
| 332 | 385 | lines = []; |
| 333 | 386 | } |
| 334 | | buffer && writeOutput(buffer); |
| 387 | if (buffer) terminal.writeOutput(buffer); |
| 335 | 388 | buffer = ""; |
| 389 | if (hasSyncStart) terminal.writeInteractive(ansi.syncEnd); |
| 336 | 390 | return; |
| 337 | 391 | } |
| 338 | 392 | |
| 339 | 393 | if (buffer) { |
| 340 | | // do not perform diffing since the entire screen is moving down |
| 341 | | // TODO: should lib/log handle wrapping? |
| 342 | | let clearLinesTop = Math.min( |
| 394 | // when writing a buffer alongside widgets, the screen may look like this |
| 395 | // > [existing log] |
| 396 | // > [optional partial line] |
| 397 | // > [widget line 1] |
| 398 | // > [widget line 2] |
| 399 | // > [widget line 3] |
| 400 | // > [cursor is start of this line] |
| 401 | // |
| 402 | // first, clear out the space where new lines are going to intersect |
| 403 | const createsPartialLine = !buffer.endsWith("\n"); |
| 404 | // if more lines are buffered than there are widgets, only some are needed |
| 405 | const clearLinesTop = Math.min( |
| 343 | 406 | lines.length, |
| 344 | | string.countNewlines(buffer), |
| 407 | string.countNewlines(buffer) + |
| 408 | (createsPartialLine ? 1 : 0) + |
| 409 | (partialLine ? -1 : 0), |
| 345 | 410 | ); |
| 346 | 411 | const oldLines = lines.slice(clearLinesTop); |
| 347 | | writeInteractive( |
| 348 | | ansi.syncStart + (clearLinesTop > 0 |
| 349 | | ? ansi.startOfLine + |
| 350 | | // skip up the shared widget space |
| 351 | | ansi.cursorUp(lines.length - clearLinesTop + 1) + |
| 352 | | // clear the lines to contain `buffer` |
| 353 | | ansi.clearFullLine + |
| 354 | | (ansi.cursorUp(1) + ansi.clearFullLine) |
| 355 | | .repeat(clearLinesTop - 1) |
| 356 | | : ""), |
| 412 | terminal.writeInteractive( |
| 413 | (hasSyncStart ? "" : ansi.syncStart) + |
| 414 | ((clearLinesTop > 0 || partialLine) |
| 415 | // clear the lines for buffer |
| 416 | ? (clearLinesTop > 0 |
| 417 | ? ansi.cursorUp(lines.length - clearLinesTop + 1) + |
| 418 | ansi.clearFullLine + |
| 419 | (ansi.cursorUp(1) + ansi.clearFullLine) |
| 420 | .repeat(clearLinesTop - 1) |
| 421 | : "") + |
| 422 | (partialLine ? ansi.cursorRestore : "") |
| 423 | : ""), |
| 357 | 424 | ); |
| 425 | partialLine = createsPartialLine; |
| 358 | 426 | // then write output lines on standard out |
| 359 | | writeOutput(buffer); |
| 360 | | writeInteractive( |
| 361 | | // the widget text |
| 362 | | newWidgetLines.map((newLine, i) => |
| 363 | | (newLine.includes("\x1b") && !newLine.endsWith(ansi.reset) |
| 364 | | ? newLine + ansi.reset |
| 365 | | : newLine) + |
| 366 | | // clear rest of line if needed |
| 367 | | (oldLines[i] && |
| 368 | | ansi.widthInTerminal(oldLines[i]) > |
| 369 | | ansi.widthInTerminal(newLine) |
| 370 | | ? ansi.clearToEndOfLine |
| 371 | | : "") + |
| 372 | | "\n" |
| 373 | | ).join("") + ansi.syncEnd, |
| 427 | terminal.writeOutput(buffer); |
| 428 | terminal.writeInteractive( |
| 429 | // if a partial line is created, then the widgets |
| 430 | // have to go on the next line, to avoid breaking stdout, |
| 431 | // the newline gets emitted on the interactive out. |
| 432 | (createsPartialLine ? ansi.cursorSave + "\n" : "") + |
| 433 | // the widget text |
| 434 | newWidgetLines.map((newLine, i) => |
| 435 | (newLine.includes("\x1b") && !newLine.endsWith(ansi.reset) |
| 436 | ? newLine + ansi.reset |
| 437 | : newLine) + |
| 438 | // clear rest of line if needed |
| 439 | (oldLines[i] && |
| 440 | ansi.widthInTerminal(oldLines[i]) > |
| 441 | ansi.widthInTerminal(newLine) |
| 442 | ? ansi.clearToEndOfLine |
| 443 | : "") + |
| 444 | "\n" |
| 445 | ).join("") + ansi.syncEnd, |
| 374 | 446 | ); |
| 375 | 447 | } else { |
| 376 | 448 | const clearLinesBottom = Math.min( |
| 377 | 449 | lines.length, |
| 378 | 450 | Math.max(0, lines.length - (newWidgetLines?.length ?? 0)), |
| 379 | 451 | ); |
| 380 | | writeInteractive( |
| 381 | | ansi.syncStart + ansi.startOfLine + |
| 452 | terminal.writeInteractive( |
| 453 | (hasSyncStart ? "" : ansi.syncStart) + |
| 382 | 454 | // clear the bottom lines |
| 383 | 455 | (clearLinesBottom |
| 384 | 456 | ? (ansi.cursorUp(1) + ansi.clearToEndOfLine) |
| ... | ... | @@ -402,6 +474,7 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost { |
| 402 | 474 | ansi.syncEnd, |
| 403 | 475 | ); |
| 404 | 476 | } |
| 477 | hasSyncStart = false; |
| 405 | 478 | lines = newWidgetLines; |
| 406 | 479 | |
| 407 | 480 | buffer = ""; |
| ... | ... | @@ -420,55 +493,87 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost { |
| 420 | 493 | timer.then(redrawCallback); |
| 421 | 494 | } |
| 422 | 495 | |
| 423 | | function flushAndClear() { |
| 496 | function flushAndClear(shortTermDrawLock: boolean) { |
| 424 | 497 | timer?.cancel(); |
| 425 | 498 | timer = null; |
| 426 | 499 | if (lines.length > 0) { |
| 427 | | // clear widgets |
| 500 | UNWRAP(terminal).writeInteractive( |
| 501 | ansi.syncStart + |
| 502 | // clear the widget space |
| 503 | (ansi.cursorUp(1) + ansi.clearFullLine) |
| 504 | .repeat(lines.length) + |
| 505 | (partialLine ? ansi.cursorRestore : "") + |
| 506 | (shortTermDrawLock ? "" : ansi.syncEnd), |
| 507 | ); |
| 508 | lines = []; |
| 509 | hasSyncStart = shortTermDrawLock; |
| 510 | } |
| 511 | if (buffer.length > 0) { |
| 512 | if (widgets.length === 0 && writeOutputTemporaryLock) { |
| 513 | writeOutputTemporaryLock(buffer); |
| 514 | } else { |
| 515 | terminal ??= lockTerminal(); |
| 516 | terminal.writeOutput(buffer); |
| 517 | if (widgets.length === 0) { |
| 518 | terminal.close(); |
| 519 | terminal = null; |
| 520 | } |
| 521 | } |
| 522 | buffer = ""; |
| 428 | 523 | } |
| 429 | | if (buffer.length > 0) writeOutput(buffer), buffer = ""; |
| 430 | 524 | } |
| 431 | 525 | |
| 432 | 526 | return { |
| 433 | | writeLine(chunk) { |
| 434 | | buffer += chunk + "\n"; |
| 435 | | redrawSoon(0); |
| 527 | write(chunk) { |
| 528 | if (chunk) buffer += chunk, redrawSoon(0); |
| 436 | 529 | }, |
| 437 | | getDrawLock() { |
| 438 | | if (locks === 0) flushAndClear(); |
| 530 | getDrawLock(mode) { |
| 531 | if (locks === 0) { |
| 532 | flushAndClear(mode === "short"); |
| 533 | if (widgets.length > 0 && terminal) { |
| 534 | if (terminal.temporaryUnlock) { |
| 535 | tempUnlock = terminal.temporaryUnlock(); |
| 536 | } else { |
| 537 | terminal.close(); |
| 538 | terminal = null; |
| 539 | } |
| 540 | } |
| 541 | } |
| 439 | 542 | locks += 1; |
| 440 | 543 | return ts.defer(() => { |
| 441 | 544 | locks -= 1; |
| 442 | 545 | if (locks === 0) { |
| 443 | | if (buffer.length > 0) writeOutput(buffer), buffer = ""; |
| 444 | | // TODO: reinit widget |
| 546 | tempUnlock?.(); |
| 547 | tempUnlock = null; |
| 548 | if (buffer.length > 0 || widgets.length > 0) redrawSoon(0); |
| 445 | 549 | } |
| 446 | 550 | }); |
| 447 | 551 | }, |
| 448 | 552 | startWidget(w) { |
| 449 | | if (!widgets.includes(w)) { |
| 450 | | const state: WidgetState = { |
| 451 | | next: 0, |
| 452 | | unsub: null, |
| 453 | | frameTime: 1000 / (w.fps ?? 0), |
| 454 | | }; |
| 455 | | widgets.push(w); |
| 456 | | internals.push(state); |
| 457 | | state.unsub = w.onChange?.(() => { |
| 458 | | state.next = 0; |
| 459 | | redrawSoon(0); |
| 460 | | }) ?? null; |
| 553 | ASSERT(!widgets.includes(w), "Cannot start the same widget twice."); |
| 554 | const state: WidgetState = { |
| 555 | next: 0, |
| 556 | unsub: null, |
| 557 | frameTime: 1000 / (w.fps ?? 0), |
| 558 | }; |
| 559 | widgets.push(w); |
| 560 | internals.push(state); |
| 561 | state.unsub = w.onChange?.(() => { |
| 562 | state.next = 0; |
| 461 | 563 | redrawSoon(0); |
| 462 | | } |
| 564 | }) ?? null; |
| 565 | redrawSoon(0); |
| 463 | 566 | return ts.defer(() => { |
| 464 | 567 | const i = widgets.indexOf(w); |
| 568 | if (i === -1) return; |
| 465 | 569 | widgets.splice(i, 1); |
| 466 | 570 | UNWRAP(internals.splice(i, 1)[0]).unsub?.(); |
| 467 | 571 | redrawSoon(0); |
| 468 | 572 | }); |
| 469 | 573 | }, |
| 470 | 574 | cancel() { |
| 471 | | flushAndClear(); |
| 575 | flushAndClear(false); |
| 576 | widgets.splice(0, widgets.length); |
| 472 | 577 | }, |
| 473 | 578 | delay, |
| 474 | 579 | now, |
| ... | ... | @@ -500,7 +605,7 @@ let withinDispatch = false; |
| 500 | 605 | let withinStackCapture = false; |
| 501 | 606 | |
| 502 | 607 | /** this class is an implementation detail */ |
| 503 | | const ScopeImpl = class Scope implements Scope { |
| 608 | const ScopeImpl = class Scope implements RootScope { |
| 504 | 609 | name: string | undefined; |
| 505 | 610 | // TODO: this abstraction implementation has low performance. making every |
| 506 | 611 | // scope define it's own dispatch is needed to correctly implement `tee`. |
| ... | ... | @@ -561,6 +666,23 @@ const ScopeImpl = class Scope implements Scope { |
| 561 | 666 | withinDispatch = false; |
| 562 | 667 | }; |
| 563 | 668 | |
| 669 | write: (text: string) => void = (text) => { |
| 670 | let frames; |
| 671 | if (!withinStackCapture) { |
| 672 | withinStackCapture = true; |
| 673 | frames = stack.capture().slice(2); |
| 674 | withinStackCapture = false; |
| 675 | } |
| 676 | this.writeMessage({ |
| 677 | level: "info", |
| 678 | scope: "", |
| 679 | newline: false, |
| 680 | text, |
| 681 | time: Date.now(), |
| 682 | stack: frames, |
| 683 | }); |
| 684 | }; |
| 685 | |
| 564 | 686 | scoped(name: string): Scope { |
| 565 | 687 | const current = this.name; |
| 566 | 688 | return new Scope( |
| ... | ... | @@ -584,16 +706,17 @@ const globalWidgetHost = /* @__PURE__ */ (() => { |
| 584 | 706 | // return a no-op |
| 585 | 707 | let warned = false; |
| 586 | 708 | return { |
| 587 | | writeLine: (line: string) => console.log(line), |
| 709 | write: (line: string) => console.log(line), |
| 588 | 710 | getDrawLock: () => ts.defer(() => {}), |
| 589 | 711 | startWidget: (w: Widget) => { |
| 590 | 712 | if (!warned) { |
| 591 | 713 | console.warn( |
| 592 | 714 | '"@clo/lib/log.ts"\'s startWidget was called in an environment ' + |
| 593 | | "that does not support the Node.js 'process' API. Widgets" + |
| 715 | "that does not support the Node.js 'process' API. Widgets " + |
| 594 | 716 | "will not be visible.", |
| 595 | 717 | ); |
| 596 | 718 | } |
| 719 | warned = true; |
| 597 | 720 | const close = w.onChange?.(() => {}); |
| 598 | 721 | return ts.defer(close ?? (() => {})); |
| 599 | 722 | }, |
| ... | ... | @@ -601,29 +724,76 @@ const globalWidgetHost = /* @__PURE__ */ (() => { |
| 601 | 724 | }; |
| 602 | 725 | } |
| 603 | 726 | const widget = headlessWidgetHost({ |
| 604 | | writeOutput: (string) => process.stdout.write(string), |
| 605 | | writeInteractive: (string) => process.stderr.write(string), |
| 727 | lockTerminal() { |
| 728 | const { stdout, stderr } = process; |
| 729 | let disposed = false; |
| 730 | |
| 731 | function patch<T, A extends unknown[]>( |
| 732 | fn: (this: T, ...args: A) => void, |
| 733 | ) { |
| 734 | return function (this: T, ...args: A) { |
| 735 | using _ = disposed ? null : widget.getDrawLock(); |
| 736 | fn.apply(this, args); |
| 737 | }; |
| 738 | } |
| 739 | |
| 740 | // patch calls to `process.std{out,err}` |
| 741 | // note: `pipe` uses managed calls to `write`, so this is plenty |
| 742 | const stdoutWrite = stdout.write; |
| 743 | const stderrWrite = stderr.write; |
| 744 | const stdoutEnd = stdout.end; |
| 745 | const stderrEnd = stderr.end; |
| 746 | const newStdoutWrite = stdout.write = patch( |
| 747 | stdoutWrite === node.builtin("stream")?.Writable.prototype.write |
| 748 | ? widget.write |
| 749 | : stdoutWrite, |
| 750 | ); |
| 751 | const newStderrWrite = stderr.write = patch(stderrWrite); |
| 752 | const newStdoutEnd = stdout.end = patch(stdoutEnd); |
| 753 | const newStderrEnd = stderr.end = patch(stderrEnd); |
| 754 | |
| 755 | // non-node runtimes will typically implement console in a way that |
| 756 | // doesn't use `node:process`, so it must also get patched |
| 757 | const console = globalThis |
| 758 | .console as unknown as Record<string, () => void>; |
| 759 | const restoreConsole: [string, old: () => void, patch: () => void][] = []; |
| 760 | for (const [key, old] of Object.entries(console)) { |
| 761 | if (typeof old !== "function") continue; |
| 762 | try { |
| 763 | const patched = console[key] = patch(old); |
| 764 | restoreConsole.push([key, old, patched]); |
| 765 | } catch { /* skip */ } |
| 766 | } |
| 767 | |
| 768 | return { |
| 769 | writeOutput: (string) => stdoutWrite.call(stdout, string), |
| 770 | writeInteractive: (string) => stderrWrite.call(stderr, string), |
| 771 | getSize: () => process.stderr, |
| 772 | temporarilyUnlock() { |
| 773 | // no action needed |
| 774 | }, |
| 775 | close() { |
| 776 | disposed = true; |
| 777 | // leave patches in place if something else tampered with it. |
| 778 | if (stdout.write === newStdoutWrite) stdout.write = stdoutWrite; |
| 779 | if (stderr.write === newStderrWrite) stdout.write = stderrWrite; |
| 780 | if (stdout.end === newStdoutEnd) stdout.end = stdoutEnd; |
| 781 | if (stderr.end === newStderrEnd) stdout.end = stderrEnd; |
| 782 | for (const [key, old, patched] of restoreConsole) { |
| 783 | if (console[key] === patched) console[key] = old; |
| 784 | } |
| 785 | }, |
| 786 | }; |
| 787 | }, |
| 788 | writeOutputTemporaryLock(buffer: string) { |
| 789 | process.stdout.write(buffer); |
| 790 | }, |
| 606 | 791 | now: () => performance.now(), |
| 607 | 792 | delay: async.delay, |
| 608 | | getSize: () => process.stderr, |
| 609 | 793 | }); |
| 610 | 794 | process.addListener("beforeExit", () => widget.cancel()); |
| 611 | 795 | process.addListener("exit", () => widget.cancel()); |
| 612 | 796 | |
| 613 | | // Make sure the default `console` will not interweave with widgets |
| 614 | | try { |
| 615 | | const console = globalThis.console as unknown as Record<string, Function>; |
| 616 | | for (const key of Object.keys(console)) { |
| 617 | | const fn = console[key]; |
| 618 | | if (typeof fn === "function") { |
| 619 | | console[key] = function (...args: unknown[]) { |
| 620 | | using _ = getDrawLock(); |
| 621 | | fn.apply(this, args); |
| 622 | | }; |
| 623 | | } |
| 624 | | } |
| 625 | | } catch {} |
| 626 | | |
| 627 | 797 | return widget; |
| 628 | 798 | })(); |
| 629 | 799 | |
| ... | ... | @@ -635,7 +805,7 @@ const levelToAnsi: Record<Message["level"], string> = { |
| 635 | 805 | }; |
| 636 | 806 | |
| 637 | 807 | let globalMessageFormatFunction: MessageFormatFunction = ( |
| 638 | | { level, scope, text }, |
| 808 | { level, scope, text, newline }, |
| 639 | 809 | colors, |
| 640 | 810 | ) => { |
| 641 | 811 | if (!text) return ""; |
| ... | ... | @@ -648,7 +818,7 @@ let globalMessageFormatFunction: MessageFormatFunction = ( |
| 648 | 818 | : scope |
| 649 | 819 | ? `${level}(${scope}): ` |
| 650 | 820 | : `${level}: `; |
| 651 | | return prefix + text; |
| 821 | return prefix + text + (newline !== false ? "\n" : ""); |
| 652 | 822 | }; |
| 653 | 823 | let globalOutputFunction!: DispatchFunction; |
| 654 | 824 | const globalLog = /* @__PURE__ */ (() => { |
| ... | ... | @@ -656,7 +826,7 @@ const globalLog = /* @__PURE__ */ (() => { |
| 656 | 826 | globalOutputFunction = node.process |
| 657 | 827 | // In Node.js, coordinate with the widget host |
| 658 | 828 | ? (message) => { |
| 659 | | globalWidgetHost.writeLine(globalMessageFormatFunction(message, colors)); |
| 829 | globalWidgetHost.write(globalMessageFormatFunction(message, colors)); |
| 660 | 830 | } |
| 661 | 831 | // Otherwise, forward to `console` |
| 662 | 832 | : (m) => { |
| ... | ... | @@ -673,6 +843,13 @@ const globalLog = /* @__PURE__ */ (() => { |
| 673 | 843 | })(); |
| 674 | 844 | |
| 675 | 845 | export type DispatchFunction = (message: Message) => void; |
| 846 | /** |
| 847 | * agnostic to the backend. should be able to format `newline: false` |
| 848 | * messages without a trailing newline, but not all backends may |
| 849 | * support this. |
| 850 | * |
| 851 | * TODO: change this to a `write`+`flush` pattern? |
| 852 | */ |
| 676 | 853 | export type MessageFormatFunction = ( |
| 677 | 854 | message: Message, |
| 678 | 855 | colors: boolean, |
| ... | ... | @@ -684,4 +861,4 @@ import * as node from "./node.ts"; |
| 684 | 861 | import * as stack from "./log/stack.ts"; |
| 685 | 862 | import * as string from "./string.ts"; |
| 686 | 863 | import * as ts from "./ts.ts"; |
| 687 | | import { UNWRAP } from "./assert.ts"; |
| 864 | import { ASSERT, UNWRAP } from "./assert.ts"; |