| ... | @@ -1,9 +1,11 @@ | ... | @@ -1,9 +1,11 @@ |
| 1 | /** | 1 | /** |
| 2 | * by using `lib/log.ts`, an application gets easy scoped logging as well as | 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 | * the pattern for using this module is to shadow the global `console` with a | 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 | * ```ts | 10 | * ```ts |
| 9 | * import * as log from "@clo/lib/log"; | 11 | * import * as log from "@clo/lib/log"; |
| ... | @@ -21,17 +23,19 @@ | ... | @@ -21,17 +23,19 @@ |
| 21 | * now, the code reads familiarly (`console.log` is universally understood), | 23 | * now, the code reads familiarly (`console.log` is universally understood), |
| 22 | * but the output is organized into relevant scopes. | 24 | * but the output is organized into relevant scopes. |
| 23 | * | 25 | * |
| 24 | * in addition to static log messages, a system for interactive I/O | 26 | * in addition to static log messages, a system for interactive I/O via the |
| 25 | * {@linkcode Widget} is provided by calling {@linkcode startWidget}. these | 27 | * {@linkcode Widget} interface can be started with {@linkcode startWidget}. |
| 26 | * allow showing temporary or interactive information, such as program status | 28 | * these allow showing temporary or interactive information, such as program |
| 27 | * or input prompts. a powerful example of this system in action is | 29 | * status 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. | 30 | * `lib/progress.ts`, which uses a widget as it's default rendering backend. |
| 29 | * (TODO: widgets cannot recieve "input" data yet) | 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 | * - in node.js, log messages are colored depending on the level and show | 34 | * - in node.js, log messages are colored depending on the level and show |
| 33 | * widgets directly under the long using ANSI cursor controls. | 35 | * widgets directly under the long using ANSI cursor controls. when the |
| 34 | * - otherwise, logs are surfaced using the global `console` API | 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 | * custom log integrations can be built on top of this module by calling | 40 | * custom log integrations can be built on top of this module by calling |
| 37 | * `log.tee()` to duplicate all messages elsewhere. for example, a project may | 41 | * `log.tee()` to duplicate all messages elsewhere. for example, a project may |
| ... | @@ -72,6 +76,11 @@ export interface Scope { | ... | @@ -72,6 +76,11 @@ export interface Scope { |
| 72 | tee(writer: (message: Message) => void): ts.Dispose; | 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 | export const originalLogArgs = Symbol("originalLogArgs"); | 84 | export const originalLogArgs = Symbol("originalLogArgs"); |
| 76 | export interface Message { | 85 | export interface Message { |
| 77 | level: "error" | "warn" | "info" | "debug"; | 86 | level: "error" | "warn" | "info" | "debug"; |
| ... | @@ -85,6 +94,8 @@ export interface Message { | ... | @@ -85,6 +94,8 @@ export interface Message { |
| 85 | stack?: stack.Frame[]; | 94 | stack?: stack.Frame[]; |
| 86 | /** arbitrary data from the logging source. */ | 95 | /** arbitrary data from the logging source. */ |
| 87 | custom?: Partial<Record<string, ts.Json>>; | 96 | custom?: Partial<Record<string, ts.Json>>; |
| | 97 | /** print a newline at the end of this log line? */ |
| | 98 | newline?: boolean; |
| 88 | /** | 99 | /** |
| 89 | * original logging arguments, if present. this field is indexed by a symbol | 100 | * original logging arguments, if present. this field is indexed by a symbol |
| 90 | * so that it is lost during JSON serialization, as callers are allowed to log | 101 | * so that it is lost during JSON serialization, as callers are allowed to log |
| ... | @@ -140,6 +151,7 @@ export function replaceGlobalFormatFunction( | ... | @@ -140,6 +151,7 @@ export function replaceGlobalFormatFunction( |
| 140 | globalMessageFormatFunction = format; | 151 | globalMessageFormatFunction = format; |
| 141 | } | 152 | } |
| 142 | | 153 | |
| | 154 | /** includes the trailing newline for standard log messages */ |
| 143 | export function formatMessage(msg: Message, colors: boolean): string { | 155 | export function formatMessage(msg: Message, colors: boolean): string { |
| 144 | return globalMessageFormatFunction(msg, colors); | 156 | return globalMessageFormatFunction(msg, colors); |
| 145 | } | 157 | } |
| ... | @@ -169,21 +181,23 @@ export function startWidget(widget: Widget): ts.Dispose { | ... | @@ -169,21 +181,23 @@ export function startWidget(widget: Widget): ts.Dispose { |
| 169 | * no built-in prefix or formatting. ensures the text does not interweave. data | 181 | * no built-in prefix or formatting. ensures the text does not interweave. data |
| 170 | * will be flushed in the next frame or when drawing is {@link getDrawLock|unlocked}. | 182 | * will be flushed in the next frame or when drawing is {@link getDrawLock|unlocked}. |
| 171 | */ | 183 | */ |
| 172 | export function writeLine(text: string) { | 184 | export function write(text: string) { |
| 173 | globalWidgetHost.writeLine(text); | 185 | globalWidgetHost.write(text); |
| 174 | } | 186 | } |
| 175 | | 187 | |
| 176 | /** write a Message object directly. */ | 188 | /** write a {@linkcode Message} object directly. */ |
| 177 | export function writeMessage(m: Message) { | 189 | export function writeMessage(m: Message) { |
| 178 | globalLog.writeMessage(m); | 190 | globalLog.writeMessage(m); |
| 179 | } | 191 | } |
| 180 | | 192 | |
| 181 | /** | 193 | /** |
| 182 | * while locked, no widgets will draw. prefer `writeLine`. | 194 | * while locked, no widgets will draw. prefer calling `write` to opt into |
| 183 | * this lock is not exclusive. | 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 { | 199 | export function getDrawLock(mode: "long" | "short"): ts.Dispose { |
| 186 | return globalWidgetHost.getDrawLock(); | 200 | return globalWidgetHost.getDrawLock(mode); |
| 187 | } | 201 | } |
| 188 | | 202 | |
| 189 | export function headlessScope(dispatch: DispatchFunction): Scope { | 203 | export function headlessScope(dispatch: DispatchFunction): Scope { |
| ... | @@ -196,11 +210,13 @@ export interface Widget { | ... | @@ -196,11 +210,13 @@ export interface Widget { |
| 196 | * return the widget's text. return null to detach the widget. | 210 | * return the widget's text. return null to detach the widget. |
| 197 | * may get called more often than the specified `fps`. | 211 | * may get called more often than the specified `fps`. |
| 198 | * supports color codes but not ansi cursor movements. | 212 | * supports color codes but not ansi cursor movements. |
| 199 | */ format( | 213 | */ |
| | 214 | format( |
| 200 | now: ReturnType<typeof performance.now>, | 215 | now: ReturnType<typeof performance.now>, |
| 201 | ): | 216 | ): |
| 202 | | string | 217 | | string |
| 203 | | null; /** 'null' to never update (use 'onChange'). defaults to 12 fps */ | 218 | | null; |
| | 219 | /** 'null' to never update (use 'onChange') */ |
| 204 | fps?: | 220 | fps?: |
| 205 | | number | 221 | | number |
| 206 | | null; /** Subscribe to manual widget updates. Call `rerender` when needed. */ | 222 | | null; /** Subscribe to manual widget updates. Call `rerender` when needed. */ |
| ... | @@ -211,26 +227,49 @@ export interface Widget { | ... | @@ -211,26 +227,49 @@ export interface Widget { |
| 211 | | 227 | |
| 212 | /** {@linkcode widgetHost}'s input takes terminal i/o as well as timing APIs */ | 228 | /** {@linkcode widgetHost}'s input takes terminal i/o as well as timing APIs */ |
| 213 | export interface HeadlessWidgetEnv { | 229 | export interface HeadlessWidgetEnv { |
| 214 | /** recieves ANSI escape sequences for interactive data */ | 230 | /** |
| 215 | writeInteractive(text: string): void; | 231 | * an exclusive lock on the terminal is held whenever widgets are active. a |
| 216 | /** recieves log content (from `writeLine`) */ | 232 | * secondary purpose of this is to instrument/deinstrument other code to |
| 217 | writeOutput(text: string): void; | 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 | /** monotonic milliseconds */ | 248 | /** monotonic milliseconds */ |
| 219 | now(): ReturnType<typeof performance.now>; | 249 | now: () => ReturnType<typeof performance.now>; |
| 220 | /** after resolving, `now()` should have increased by the delay time */ | 250 | /** after resolving, `now()` should have increased by the delay time */ |
| 221 | delay: typeof async.delay; | 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 | getSize(): { columns: number; rows: number }; | 260 | getSize(): { columns: number; rows: number }; |
| 224 | /** called to enable input events */ | 261 | /** temporarily free the lock */ |
| 225 | onInput?(write: (bytes: Uint8Array | string) => void): () => void; | 262 | temporaryUnlock?(): () => void; |
| | 263 | /** completely free the lock */ |
| | 264 | close(): void; |
| 226 | } | 265 | } |
| 227 | | 266 | |
| 228 | /** an implementation of an ANSI-based widget host */ | 267 | /** an implementation of an ANSI-based widget host */ |
| 229 | export interface HeadlessWidgetHost { | 268 | export interface HeadlessWidgetHost { |
| 230 | /** see the top-level {@linkcode writeLine} function */ | 269 | /** see the top-level {@linkcode writeLine} function */ |
| 231 | writeLine(text: string): void; | 270 | write(text: string): void; |
| 232 | /** see the top-level {@linkcode getDrawLock} function */ | 271 | /** see the top-level {@linkcode getDrawLock} function */ |
| 233 | getDrawLock(): ts.Dispose; | 272 | getDrawLock(mode: "long" | "short"): ts.Dispose; |
| 234 | /** see the top-level {@linkcode startWidget} function */ | 273 | /** see the top-level {@linkcode startWidget} function */ |
| 235 | startWidget(widget: Widget): ts.Dispose; | 274 | startWidget(widget: Widget): ts.Dispose; |
| 236 | /** stop all widgets and remove all timers. */ | 275 | /** stop all widgets and remove all timers. */ |
| ... | @@ -258,13 +297,13 @@ interface WidgetState { | ... | @@ -258,13 +297,13 @@ interface WidgetState { |
| 258 | * - Maximum of one `wait` call at once. When the expected time suddenly | 297 | * - Maximum of one `wait` call at once. When the expected time suddenly |
| 259 | * shrinks, the timer is rescheduled. | 298 | * shrinks, the timer is rescheduled. |
| 260 | * - When redrawing widget lines, three tricks are done to reduce flickering: | 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 | * 2. A simple prefix-based diffing algorithm for skipping unchanged text | 301 | * 2. A simple prefix-based diffing algorithm for skipping unchanged text |
| 263 | * 3. Avoid clearing a line before redrawing it. | 302 | * 3. Avoid clearing a line before redrawing it. |
| 264 | * Points 2 and 3 are used for terminals that are slow or do not support sync. | 303 | * Points 2 and 3 are used for terminals that are slow or do not support sync. |
| 265 | */ | 304 | */ |
| 266 | export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost { | 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 | let timer: async.Cancelable<void> | null = null; | 308 | let timer: async.Cancelable<void> | null = null; |
| 270 | | 309 | |
| ... | @@ -272,113 +311,146 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost { | ... | @@ -272,113 +311,146 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost { |
| 272 | let redrawTime = 0; | 311 | let redrawTime = 0; |
| 273 | let lastFlush = 0; | 312 | let lastFlush = 0; |
| 274 | let buffer = ""; | 313 | let buffer = ""; |
| | 314 | let partialLine = false; |
| 275 | const widgets: Widget[] = []; | 315 | const widgets: Widget[] = []; |
| 276 | const internals: WidgetState[] = []; | 316 | const internals: WidgetState[] = []; |
| 277 | let lines: string[] = []; | 317 | let lines: string[] = []; |
| | 318 | let hasSyncStart = false; |
| | 319 | let terminal: TerminalLock | null = null; |
| | 320 | let tempUnlock: (() => void) | null = null; |
| 278 | | 321 | |
| 279 | function redrawCallback() { | 322 | function redrawCallback() { |
| 280 | timer = null; | 323 | timer = null; |
| 281 | redrawTime = (lastFlush = now()) - 0.00001; // windows time precision workaround | 324 | redrawTime = (lastFlush = now()) - 0.00001; // windows time precision workaround |
| 282 | | 325 | |
| | 326 | // trivial path when not using widgets |
| 283 | if (!lines.length && !widgets.length) { | 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 | buffer = ""; | 345 | buffer = ""; |
| 286 | return; | 346 | return; |
| 287 | } | 347 | } |
| 288 | | 348 | |
| 289 | const { columns, rows } = getSize(); | 349 | terminal ??= lockTerminal(); |
| | 350 | |
| | 351 | const { columns, rows } = terminal.getSize(); |
| 290 | let newWidgetLines: string[] = []; | 352 | let newWidgetLines: string[] = []; |
| 291 | let next = Infinity; | 353 | let next = Infinity; |
| 292 | if (widgets[0]) { | 354 | for (let w = 0, { length } = widgets; w < length; w += 1) { |
| 293 | for (let w = 0, { length } = widgets; w < length; w += 1) { | 355 | const outText = UNWRAP(widgets[w]).format(lastFlush); |
| 294 | const outText = UNWRAP(widgets[w]).format(lastFlush); | 356 | if (!outText) { |
| 295 | if (!outText) { | 357 | widgets.splice(w, 1); |
| 296 | widgets.splice(w, 1); | 358 | UNWRAP(internals.splice(w, 1)[0]).unsub?.(); |
| 297 | UNWRAP(internals.splice(w, 1)[0]).unsub?.(); | 359 | w -= 1; |
| 298 | w -= 1; | 360 | length -= 1; |
| 299 | length -= 1; | 361 | continue; |
| 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); | | |
| 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 | if (next < Infinity) redrawSoon(next); | 373 | if (next < Infinity) redrawSoon(next); |
| 316 | | 374 | |
| 317 | if (!newWidgetLines[0]) { | 375 | if (!newWidgetLines[0]) { |
| 318 | if (lines.length) { | 376 | if (lines.length > 0) { |
| 319 | let clearLinesTop = Math.min( | 377 | terminal.writeInteractive( |
| 320 | lines.length, | 378 | (hasSyncStart ? "" : ansi.syncStart) + |
| 321 | string.countNewlines(buffer), | 379 | // clear the widget space |
| 322 | ); | 380 | (ansi.cursorUp(1) + ansi.clearFullLine) |
| 323 | writeInteractive( | 381 | .repeat(lines.length) + |
| 324 | ansi.startOfLine + | 382 | (partialLine ? ansi.cursorRestore : ""), |
| 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), | | |
| 331 | ); | 383 | ); |
| | 384 | hasSyncStart = true; |
| 332 | lines = []; | 385 | lines = []; |
| 333 | } | 386 | } |
| 334 | buffer && writeOutput(buffer); | 387 | if (buffer) terminal.writeOutput(buffer); |
| 335 | buffer = ""; | 388 | buffer = ""; |
| | 389 | if (hasSyncStart) terminal.writeInteractive(ansi.syncEnd); |
| 336 | return; | 390 | return; |
| 337 | } | 391 | } |
| 338 | | 392 | |
| 339 | if (buffer) { | 393 | if (buffer) { |
| 340 | // do not perform diffing since the entire screen is moving down | 394 | // when writing a buffer alongside widgets, the screen may look like this |
| 341 | // TODO: should lib/log handle wrapping? | 395 | // > [existing log] |
| 342 | let clearLinesTop = Math.min( | 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 | lines.length, | 406 | lines.length, |
| 344 | string.countNewlines(buffer), | 407 | string.countNewlines(buffer) + |
| | 408 | (createsPartialLine ? 1 : 0) + |
| | 409 | (partialLine ? -1 : 0), |
| 345 | ); | 410 | ); |
| 346 | const oldLines = lines.slice(clearLinesTop); | 411 | const oldLines = lines.slice(clearLinesTop); |
| 347 | writeInteractive( | 412 | terminal.writeInteractive( |
| 348 | ansi.syncStart + (clearLinesTop > 0 | 413 | (hasSyncStart ? "" : ansi.syncStart) + |
| 349 | ? ansi.startOfLine + | 414 | ((clearLinesTop > 0 || partialLine) |
| 350 | // skip up the shared widget space | 415 | // clear the lines for buffer |
| 351 | ansi.cursorUp(lines.length - clearLinesTop + 1) + | 416 | ? (clearLinesTop > 0 |
| 352 | // clear the lines to contain `buffer` | 417 | ? ansi.cursorUp(lines.length - clearLinesTop + 1) + |
| 353 | ansi.clearFullLine + | 418 | ansi.clearFullLine + |
| 354 | (ansi.cursorUp(1) + ansi.clearFullLine) | 419 | (ansi.cursorUp(1) + ansi.clearFullLine) |
| 355 | .repeat(clearLinesTop - 1) | 420 | .repeat(clearLinesTop - 1) |
| 356 | : ""), | 421 | : "") + |
| | 422 | (partialLine ? ansi.cursorRestore : "") |
| | 423 | : ""), |
| 357 | ); | 424 | ); |
| | 425 | partialLine = createsPartialLine; |
| 358 | // then write output lines on standard out | 426 | // then write output lines on standard out |
| 359 | writeOutput(buffer); | 427 | terminal.writeOutput(buffer); |
| 360 | writeInteractive( | 428 | terminal.writeInteractive( |
| 361 | // the widget text | 429 | // if a partial line is created, then the widgets |
| 362 | newWidgetLines.map((newLine, i) => | 430 | // have to go on the next line, to avoid breaking stdout, |
| 363 | (newLine.includes("\x1b") && !newLine.endsWith(ansi.reset) | 431 | // the newline gets emitted on the interactive out. |
| 364 | ? newLine + ansi.reset | 432 | (createsPartialLine ? ansi.cursorSave + "\n" : "") + |
| 365 | : newLine) + | 433 | // the widget text |
| 366 | // clear rest of line if needed | 434 | newWidgetLines.map((newLine, i) => |
| 367 | (oldLines[i] && | 435 | (newLine.includes("\x1b") && !newLine.endsWith(ansi.reset) |
| 368 | ansi.widthInTerminal(oldLines[i]) > | 436 | ? newLine + ansi.reset |
| 369 | ansi.widthInTerminal(newLine) | 437 | : newLine) + |
| 370 | ? ansi.clearToEndOfLine | 438 | // clear rest of line if needed |
| 371 | : "") + | 439 | (oldLines[i] && |
| 372 | "\n" | 440 | ansi.widthInTerminal(oldLines[i]) > |
| 373 | ).join("") + ansi.syncEnd, | 441 | ansi.widthInTerminal(newLine) |
| | 442 | ? ansi.clearToEndOfLine |
| | 443 | : "") + |
| | 444 | "\n" |
| | 445 | ).join("") + ansi.syncEnd, |
| 374 | ); | 446 | ); |
| 375 | } else { | 447 | } else { |
| 376 | const clearLinesBottom = Math.min( | 448 | const clearLinesBottom = Math.min( |
| 377 | lines.length, | 449 | lines.length, |
| 378 | Math.max(0, lines.length - (newWidgetLines?.length ?? 0)), | 450 | Math.max(0, lines.length - (newWidgetLines?.length ?? 0)), |
| 379 | ); | 451 | ); |
| 380 | writeInteractive( | 452 | terminal.writeInteractive( |
| 381 | ansi.syncStart + ansi.startOfLine + | 453 | (hasSyncStart ? "" : ansi.syncStart) + |
| 382 | // clear the bottom lines | 454 | // clear the bottom lines |
| 383 | (clearLinesBottom | 455 | (clearLinesBottom |
| 384 | ? (ansi.cursorUp(1) + ansi.clearToEndOfLine) | 456 | ? (ansi.cursorUp(1) + ansi.clearToEndOfLine) |
| ... | @@ -402,6 +474,7 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost { | ... | @@ -402,6 +474,7 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost { |
| 402 | ansi.syncEnd, | 474 | ansi.syncEnd, |
| 403 | ); | 475 | ); |
| 404 | } | 476 | } |
| | 477 | hasSyncStart = false; |
| 405 | lines = newWidgetLines; | 478 | lines = newWidgetLines; |
| 406 | | 479 | |
| 407 | buffer = ""; | 480 | buffer = ""; |
| ... | @@ -420,55 +493,87 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost { | ... | @@ -420,55 +493,87 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost { |
| 420 | timer.then(redrawCallback); | 493 | timer.then(redrawCallback); |
| 421 | } | 494 | } |
| 422 | | 495 | |
| 423 | function flushAndClear() { | 496 | function flushAndClear(shortTermDrawLock: boolean) { |
| 424 | timer?.cancel(); | 497 | timer?.cancel(); |
| 425 | timer = null; | 498 | timer = null; |
| 426 | if (lines.length > 0) { | 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 | return { | 526 | return { |
| 433 | writeLine(chunk) { | 527 | write(chunk) { |
| 434 | buffer += chunk + "\n"; | 528 | if (chunk) buffer += chunk, redrawSoon(0); |
| 435 | redrawSoon(0); | | |
| 436 | }, | 529 | }, |
| 437 | getDrawLock() { | 530 | getDrawLock(mode) { |
| 438 | if (locks === 0) flushAndClear(); | 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 | locks += 1; | 542 | locks += 1; |
| 440 | return ts.defer(() => { | 543 | return ts.defer(() => { |
| 441 | locks -= 1; | 544 | locks -= 1; |
| 442 | if (locks === 0) { | 545 | if (locks === 0) { |
| 443 | if (buffer.length > 0) writeOutput(buffer), buffer = ""; | 546 | tempUnlock?.(); |
| 444 | // TODO: reinit widget | 547 | tempUnlock = null; |
| | 548 | if (buffer.length > 0 || widgets.length > 0) redrawSoon(0); |
| 445 | } | 549 | } |
| 446 | }); | 550 | }); |
| 447 | }, | 551 | }, |
| 448 | startWidget(w) { | 552 | startWidget(w) { |
| 449 | if (!widgets.includes(w)) { | 553 | ASSERT(!widgets.includes(w), "Cannot start the same widget twice."); |
| 450 | const state: WidgetState = { | 554 | const state: WidgetState = { |
| 451 | next: 0, | 555 | next: 0, |
| 452 | unsub: null, | 556 | unsub: null, |
| 453 | frameTime: 1000 / (w.fps ?? 0), | 557 | frameTime: 1000 / (w.fps ?? 0), |
| 454 | }; | 558 | }; |
| 455 | widgets.push(w); | 559 | widgets.push(w); |
| 456 | internals.push(state); | 560 | internals.push(state); |
| 457 | state.unsub = w.onChange?.(() => { | 561 | state.unsub = w.onChange?.(() => { |
| 458 | state.next = 0; | 562 | state.next = 0; |
| 459 | redrawSoon(0); | | |
| 460 | }) ?? null; | | |
| 461 | redrawSoon(0); | 563 | redrawSoon(0); |
| 462 | } | 564 | }) ?? null; |
| | 565 | redrawSoon(0); |
| 463 | return ts.defer(() => { | 566 | return ts.defer(() => { |
| 464 | const i = widgets.indexOf(w); | 567 | const i = widgets.indexOf(w); |
| | 568 | if (i === -1) return; |
| 465 | widgets.splice(i, 1); | 569 | widgets.splice(i, 1); |
| 466 | UNWRAP(internals.splice(i, 1)[0]).unsub?.(); | 570 | UNWRAP(internals.splice(i, 1)[0]).unsub?.(); |
| 467 | redrawSoon(0); | 571 | redrawSoon(0); |
| 468 | }); | 572 | }); |
| 469 | }, | 573 | }, |
| 470 | cancel() { | 574 | cancel() { |
| 471 | flushAndClear(); | 575 | flushAndClear(false); |
| | 576 | widgets.splice(0, widgets.length); |
| 472 | }, | 577 | }, |
| 473 | delay, | 578 | delay, |
| 474 | now, | 579 | now, |
| ... | @@ -500,7 +605,7 @@ let withinDispatch = false; | ... | @@ -500,7 +605,7 @@ let withinDispatch = false; |
| 500 | let withinStackCapture = false; | 605 | let withinStackCapture = false; |
| 501 | | 606 | |
| 502 | /** this class is an implementation detail */ | 607 | /** this class is an implementation detail */ |
| 503 | const ScopeImpl = class Scope implements Scope { | 608 | const ScopeImpl = class Scope implements RootScope { |
| 504 | name: string | undefined; | 609 | name: string | undefined; |
| 505 | // TODO: this abstraction implementation has low performance. making every | 610 | // TODO: this abstraction implementation has low performance. making every |
| 506 | // scope define it's own dispatch is needed to correctly implement `tee`. | 611 | // scope define it's own dispatch is needed to correctly implement `tee`. |
| ... | @@ -561,6 +666,23 @@ const ScopeImpl = class Scope implements Scope { | ... | @@ -561,6 +666,23 @@ const ScopeImpl = class Scope implements Scope { |
| 561 | withinDispatch = false; | 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 | scoped(name: string): Scope { | 686 | scoped(name: string): Scope { |
| 565 | const current = this.name; | 687 | const current = this.name; |
| 566 | return new Scope( | 688 | return new Scope( |
| ... | @@ -584,16 +706,17 @@ const globalWidgetHost = /* @__PURE__ */ (() => { | ... | @@ -584,16 +706,17 @@ const globalWidgetHost = /* @__PURE__ */ (() => { |
| 584 | // return a no-op | 706 | // return a no-op |
| 585 | let warned = false; | 707 | let warned = false; |
| 586 | return { | 708 | return { |
| 587 | writeLine: (line: string) => console.log(line), | 709 | write: (line: string) => console.log(line), |
| 588 | getDrawLock: () => ts.defer(() => {}), | 710 | getDrawLock: () => ts.defer(() => {}), |
| 589 | startWidget: (w: Widget) => { | 711 | startWidget: (w: Widget) => { |
| 590 | if (!warned) { | 712 | if (!warned) { |
| 591 | console.warn( | 713 | console.warn( |
| 592 | '"@clo/lib/log.ts"\'s startWidget was called in an environment ' + | 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 | "will not be visible.", | 716 | "will not be visible.", |
| 595 | ); | 717 | ); |
| 596 | } | 718 | } |
| | 719 | warned = true; |
| 597 | const close = w.onChange?.(() => {}); | 720 | const close = w.onChange?.(() => {}); |
| 598 | return ts.defer(close ?? (() => {})); | 721 | return ts.defer(close ?? (() => {})); |
| 599 | }, | 722 | }, |
| ... | @@ -601,29 +724,76 @@ const globalWidgetHost = /* @__PURE__ */ (() => { | ... | @@ -601,29 +724,76 @@ const globalWidgetHost = /* @__PURE__ */ (() => { |
| 601 | }; | 724 | }; |
| 602 | } | 725 | } |
| 603 | const widget = headlessWidgetHost({ | 726 | const widget = headlessWidgetHost({ |
| 604 | writeOutput: (string) => process.stdout.write(string), | 727 | lockTerminal() { |
| 605 | writeInteractive: (string) => process.stderr.write(string), | 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 | now: () => performance.now(), | 791 | now: () => performance.now(), |
| 607 | delay: async.delay, | 792 | delay: async.delay, |
| 608 | getSize: () => process.stderr, | | |
| 609 | }); | 793 | }); |
| 610 | process.addListener("beforeExit", () => widget.cancel()); | 794 | process.addListener("beforeExit", () => widget.cancel()); |
| 611 | process.addListener("exit", () => widget.cancel()); | 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 | return widget; | 797 | return widget; |
| 628 | })(); | 798 | })(); |
| 629 | | 799 | |
| ... | @@ -635,7 +805,7 @@ const levelToAnsi: Record<Message["level"], string> = { | ... | @@ -635,7 +805,7 @@ const levelToAnsi: Record<Message["level"], string> = { |
| 635 | }; | 805 | }; |
| 636 | | 806 | |
| 637 | let globalMessageFormatFunction: MessageFormatFunction = ( | 807 | let globalMessageFormatFunction: MessageFormatFunction = ( |
| 638 | { level, scope, text }, | 808 | { level, scope, text, newline }, |
| 639 | colors, | 809 | colors, |
| 640 | ) => { | 810 | ) => { |
| 641 | if (!text) return ""; | 811 | if (!text) return ""; |
| ... | @@ -648,7 +818,7 @@ let globalMessageFormatFunction: MessageFormatFunction = ( | ... | @@ -648,7 +818,7 @@ let globalMessageFormatFunction: MessageFormatFunction = ( |
| 648 | : scope | 818 | : scope |
| 649 | ? `${level}(${scope}): ` | 819 | ? `${level}(${scope}): ` |
| 650 | : `${level}: `; | 820 | : `${level}: `; |
| 651 | return prefix + text; | 821 | return prefix + text + (newline !== false ? "\n" : ""); |
| 652 | }; | 822 | }; |
| 653 | let globalOutputFunction!: DispatchFunction; | 823 | let globalOutputFunction!: DispatchFunction; |
| 654 | const globalLog = /* @__PURE__ */ (() => { | 824 | const globalLog = /* @__PURE__ */ (() => { |
| ... | @@ -656,7 +826,7 @@ const globalLog = /* @__PURE__ */ (() => { | ... | @@ -656,7 +826,7 @@ const globalLog = /* @__PURE__ */ (() => { |
| 656 | globalOutputFunction = node.process | 826 | globalOutputFunction = node.process |
| 657 | // In Node.js, coordinate with the widget host | 827 | // In Node.js, coordinate with the widget host |
| 658 | ? (message) => { | 828 | ? (message) => { |
| 659 | globalWidgetHost.writeLine(globalMessageFormatFunction(message, colors)); | 829 | globalWidgetHost.write(globalMessageFormatFunction(message, colors)); |
| 660 | } | 830 | } |
| 661 | // Otherwise, forward to `console` | 831 | // Otherwise, forward to `console` |
| 662 | : (m) => { | 832 | : (m) => { |
| ... | @@ -673,6 +843,13 @@ const globalLog = /* @__PURE__ */ (() => { | ... | @@ -673,6 +843,13 @@ const globalLog = /* @__PURE__ */ (() => { |
| 673 | })(); | 843 | })(); |
| 674 | | 844 | |
| 675 | export type DispatchFunction = (message: Message) => void; | 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 | export type MessageFormatFunction = ( | 853 | export type MessageFormatFunction = ( |
| 677 | message: Message, | 854 | message: Message, |
| 678 | colors: boolean, | 855 | colors: boolean, |
| ... | @@ -684,4 +861,4 @@ import * as node from "./node.ts"; | ... | @@ -684,4 +861,4 @@ import * as node from "./node.ts"; |
| 684 | import * as stack from "./log/stack.ts"; | 861 | import * as stack from "./log/stack.ts"; |
| 685 | import * as string from "./string.ts"; | 862 | import * as string from "./string.ts"; |
| 686 | import * as ts from "./ts.ts"; | 863 | import * as ts from "./ts.ts"; |
| 687 | import { UNWRAP } from "./assert.ts"; | 864 | import { ASSERT, UNWRAP } from "./assert.ts"; |