| author | |
| committer | |
| log | 6f72c75c17126db8f199d81e8e58650a45ed131e |
| tree | 2653b4417a73b91e6a53854e9b5e734609935d77 |
| parent | cccf69cbca01bafa974afa80c256a86437141d47 |
| signature | Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ |
closes #94
Assisted-by: Claude:claude-fable-58 files changed, 943 insertions(+), 151 deletions(-)
lib/log.test.ts+390| ... | @@ -524,6 +524,394 @@ describe("log widgets", () => { | ... | @@ -524,6 +524,394 @@ describe("log widgets", () => { |
| 524 | host.cancel(); | 524 | host.cancel(); |
| 525 | }); | 525 | }); |
| 526 | 526 | ||
| 527 | test("empty string keeps the widget alive", () => { | ||
| 528 | const host = new testing.MockScreen(); | ||
| 529 | let text = ""; | ||
| 530 | const w = UNWRAP(host.startWidget({ format: () => text })); | ||
| 531 | // an empty frame draws nothing, but the widget is not detached | ||
| 532 | host.expectFrame(0, { merged: "" }); | ||
| 533 | text = "now!"; | ||
| 534 | w.redraw(); | ||
| 535 | host.expectFrame(0, { | ||
| 536 | stderr: testing.MockScreen.sync(["now!\n"]), | ||
| 537 | }); | ||
| 538 | text = ""; | ||
| 539 | w.redraw(); | ||
| 540 | host.expectFrame(0, { | ||
| 541 | stderr: testing.MockScreen.sync([ | ||
| 542 | ansi.cursorUp(1), | ||
| 543 | ansi.clearFullLine, | ||
| 544 | ]), | ||
| 545 | }); | ||
| 546 | w.stop(); | ||
| 547 | host.expectFrame(0, { merged: "" }); | ||
| 548 | host.cancel(); | ||
| 549 | }); | ||
| 550 | |||
| 551 | test("leading blank line in widget output", () => { | ||
| 552 | const host = new testing.MockScreen(); | ||
| 553 | using _ = host.startWidget({ format: () => "\nfoo" }); | ||
| 554 | host.expectFrame(0, { | ||
| 555 | stderr: testing.MockScreen.sync([ | ||
| 556 | "\n", | ||
| 557 | "foo\n", | ||
| 558 | ]), | ||
| 559 | }); | ||
| 560 | host.cancel(); | ||
| 561 | }); | ||
| 562 | |||
| 563 | test("empty widget frame with a mid-line draw lock release", () => { | ||
| 564 | // regression: this combination used to crash an assertion, because an | ||
| 565 | // empty first widget line was treated as "no widgets on screen" | ||
| 566 | const host = new testing.MockScreen(); | ||
| 567 | let text = ""; | ||
| 568 | const w = UNWRAP(host.startWidget({ format: () => ({ text }) })); | ||
| 569 | host.expectFrame(0, { merged: "" }); | ||
| 570 | const lock = host.getDrawLock("short"); | ||
| 571 | lock.release("cursor-middle-of-line"); | ||
| 572 | host.expectFrame(0, { merged: "" }); | ||
| 573 | // the foreign partial line is respected once the widget renders | ||
| 574 | text = "hi"; | ||
| 575 | w.redraw(); | ||
| 576 | host.expectFrame(0, { | ||
| 577 | stderr: ansi.cursorSave + "\n" + testing.MockScreen.sync(["hi\n"]), | ||
| 578 | }); | ||
| 579 | host.cancel(); | ||
| 580 | }); | ||
| 581 | |||
| 582 | test("temporary unlock is used when available", () => { | ||
| 583 | const host = new testing.MockScreen({ temporaryUnlocking: true }); | ||
| 584 | using _ = host.startWidget({ format: () => "w" }); | ||
| 585 | host.expectFrame(0, { | ||
| 586 | stderr: testing.MockScreen.sync(["w\n"]), | ||
| 587 | }); | ||
| 588 | ASSERT(host.hasTerminalLock === "locked"); | ||
| 589 | const lock = host.getDrawLock("short"); | ||
| 590 | ASSERT(host.hasTerminalLock === "temporary-unlock"); | ||
| 591 | host.expectFrame(null, { | ||
| 592 | stderr: ansi.syncStart + ansi.cursorUp(1) + ansi.clearFullLine, | ||
| 593 | }); | ||
| 594 | lock.release("cursor-start-of-line"); | ||
| 595 | ASSERT(host.hasTerminalLock === "locked"); | ||
| 596 | host.expectFrame(0, { | ||
| 597 | stderr: "w\n" + ansi.syncEnd, | ||
| 598 | }); | ||
| 599 | host.cancel(); | ||
| 600 | }); | ||
| 601 | |||
| 602 | test("cursor restore is not replayed for stale saves", () => { | ||
| 603 | const host = new testing.MockScreen(); | ||
| 604 | using _ = host.startWidget({ format: () => "w" }); | ||
| 605 | host.expectFrame(0, { | ||
| 606 | stderr: testing.MockScreen.sync(["w\n"]), | ||
| 607 | }); | ||
| 608 | |||
| 609 | // a foreign partial line forces a cursor save | ||
| 610 | { | ||
| 611 | const lock = host.getDrawLock("short"); | ||
| 612 | host.expectFrame(null, { | ||
| 613 | merged: ansi.syncStart + ansi.cursorUp(1) + ansi.clearFullLine, | ||
| 614 | }); | ||
| 615 | lock.release("cursor-middle-of-line"); | ||
| 616 | host.expectFrame(0, { | ||
| 617 | merged: ansi.cursorSave + "\n" + "w\n" + ansi.syncEnd, | ||
| 618 | }); | ||
| 619 | } | ||
| 620 | |||
| 621 | // clearing the widget restores the saved cursor, exactly once | ||
| 622 | { | ||
| 623 | const lock = host.getDrawLock("short"); | ||
| 624 | host.expectFrame(null, { | ||
| 625 | merged: ansi.syncStart + ansi.cursorUp(1) + ansi.clearFullLine | ||
| 626 | + ansi.cursorRestore, | ||
| 627 | }); | ||
| 628 | lock.release("cursor-start-of-line"); | ||
| 629 | host.expectFrame(0, { | ||
| 630 | merged: "w\n" + ansi.syncEnd, | ||
| 631 | }); | ||
| 632 | } | ||
| 633 | |||
| 634 | // regression: the stale save register must not be restored again | ||
| 635 | { | ||
| 636 | const lock = host.getDrawLock("short"); | ||
| 637 | host.expectFrame(null, { | ||
| 638 | merged: ansi.syncStart + ansi.cursorUp(1) + ansi.clearFullLine, | ||
| 639 | }); | ||
| 640 | lock.release("cursor-start-of-line"); | ||
| 641 | host.expectFrame(0, { | ||
| 642 | merged: "w\n" + ansi.syncEnd, | ||
| 643 | }); | ||
| 644 | } | ||
| 645 | host.cancel(); | ||
| 646 | }); | ||
| 647 | |||
| 648 | test("log lines wider than the terminal", () => { | ||
| 649 | const host = new testing.MockScreen(); | ||
| 650 | host.columns = 10; | ||
| 651 | using _ = host.startWidget({ format: () => "w1\nw2" }); | ||
| 652 | host.expectFrame(0, { | ||
| 653 | stderr: testing.MockScreen.sync(["w1\nw2\n"]), | ||
| 654 | }); | ||
| 655 | // a 15 cell line occupies two rows, so both widget rows get cleared | ||
| 656 | host.writeOutput("123456789012345\n"); | ||
| 657 | host.expectFrame(0, { | ||
| 658 | merged: testing.MockScreen.sync([ | ||
| 659 | ansi.cursorUp(1), | ||
| 660 | ansi.clearFullLine, | ||
| 661 | ansi.cursorUp(1), | ||
| 662 | ansi.clearFullLine, | ||
| 663 | "123456789012345\n", | ||
| 664 | "w1\n", | ||
| 665 | "w2\n", | ||
| 666 | ]), | ||
| 667 | }); | ||
| 668 | host.cancel(); | ||
| 669 | }); | ||
| 670 | |||
| 671 | test("partial line wider than the terminal", () => { | ||
| 672 | const host = new testing.MockScreen(); | ||
| 673 | host.columns = 10; | ||
| 674 | using _ = host.startWidget({ format: () => "w1\nw2" }); | ||
| 675 | host.expectFrame(0, { | ||
| 676 | stderr: testing.MockScreen.sync(["w1\nw2\n"]), | ||
| 677 | }); | ||
| 678 | // 14 cells: one wrapped row plus a partial row at column 4 | ||
| 679 | host.writeOutput("12345678901234"); | ||
| 680 | host.expectFrame(0, { | ||
| 681 | merged: testing.MockScreen.sync([ | ||
| 682 | ansi.cursorUp(1), | ||
| 683 | ansi.clearFullLine, | ||
| 684 | ansi.cursorUp(1), | ||
| 685 | ansi.clearFullLine, | ||
| 686 | "12345678901234", | ||
| 687 | "\n", | ||
| 688 | "w1\n", | ||
| 689 | "w2\n", | ||
| 690 | ]), | ||
| 691 | }); | ||
| 692 | // the continuation point is column 4 of the partial row, not column 14 | ||
| 693 | host.writeOutput(" more\n"); | ||
| 694 | host.expectFrame(0, { | ||
| 695 | merged: testing.MockScreen.sync([ | ||
| 696 | ansi.cursorUp(3) + ansi.cursorRight(4), | ||
| 697 | " more\n", | ||
| 698 | "w1\n", | ||
| 699 | "w2\n", | ||
| 700 | ]), | ||
| 701 | }); | ||
| 702 | host.cancel(); | ||
| 703 | }); | ||
| 704 | |||
| 705 | test("partial line with ansi codes measures visible width", () => { | ||
| 706 | const host = new testing.MockScreen(); | ||
| 707 | using _ = host.startWidget({ format: () => "w" }); | ||
| 708 | host.expectFrame(0, { | ||
| 709 | stderr: testing.MockScreen.sync(["w\n"]), | ||
| 710 | }); | ||
| 711 | host.writeOutput(ansi.fgBlue + "blue" + ansi.fgReset); | ||
| 712 | host.expectFrame(0, { | ||
| 713 | merged: testing.MockScreen.sync([ | ||
| 714 | ansi.cursorUp(1), | ||
| 715 | ansi.clearFullLine, | ||
| 716 | ansi.fgBlue + "blue" + ansi.fgReset, | ||
| 717 | "\n", | ||
| 718 | "w\n", | ||
| 719 | ]), | ||
| 720 | }); | ||
| 721 | // "blue" is 4 cells wide; the escape sequences take none | ||
| 722 | host.writeOutput(" sky\n"); | ||
| 723 | host.expectFrame(0, { | ||
| 724 | merged: testing.MockScreen.sync([ | ||
| 725 | ansi.cursorUp(2) + ansi.cursorRight(4), | ||
| 726 | " sky\n", | ||
| 727 | "w\n", | ||
| 728 | ]), | ||
| 729 | }); | ||
| 730 | host.cancel(); | ||
| 731 | }); | ||
| 732 | |||
| 733 | test("off-screen output does not move widgets", () => { | ||
| 734 | // models stdout redirected to a file while stderr stays the terminal: | ||
| 735 | // log output flushes to stdout, but the widget block is untouched by it | ||
| 736 | const host = new testing.MockScreen({ outputSharesScreen: false }); | ||
| 737 | using _ = host.startWidget({ format: () => "w1\nw2" }); | ||
| 738 | host.expectFrame(0, { | ||
| 739 | stderr: testing.MockScreen.sync(["w1\nw2\n"]), | ||
| 740 | }); | ||
| 741 | host.writeOutput("a long log line that would have wrapped on screen\n"); | ||
| 742 | host.expectFrame(0, { | ||
| 743 | stdout: "a long log line that would have wrapped on screen\n", | ||
| 744 | stderr: testing.MockScreen.sync([ | ||
| 745 | ansi.cursorUp(2), | ||
| 746 | "w1\nw2\n", | ||
| 747 | ]), | ||
| 748 | }); | ||
| 749 | // partial lines in the redirected stream do not shift the widgets either | ||
| 750 | host.writeOutput("partial"); | ||
| 751 | host.expectFrame(0, { | ||
| 752 | stdout: "partial", | ||
| 753 | stderr: testing.MockScreen.sync([ | ||
| 754 | ansi.cursorUp(2), | ||
| 755 | "w1\nw2\n", | ||
| 756 | ]), | ||
| 757 | }); | ||
| 758 | host.cancel(); | ||
| 759 | }); | ||
| 760 | |||
| 761 | test("widgets start below a partial line", () => { | ||
| 762 | const host = new testing.MockScreen(); | ||
| 763 | let text = ""; | ||
| 764 | const w = UNWRAP(host.startWidget({ format: () => text })); | ||
| 765 | host.expectFrame(0, { merged: "" }); | ||
| 766 | host.writeOutput("part"); | ||
| 767 | host.expectFrame(0, { stdout: "part", merged: "part" }); | ||
| 768 | text = "W"; | ||
| 769 | w.redraw(); | ||
| 770 | host.expectFrame(0, { | ||
| 771 | stderr: testing.MockScreen.sync(["\n", "W\n"]), | ||
| 772 | }); | ||
| 773 | // the partial line can still be continued afterwards | ||
| 774 | host.writeOutput(" two\n"); | ||
| 775 | host.expectFrame(0, { | ||
| 776 | merged: testing.MockScreen.sync([ | ||
| 777 | ansi.cursorUp(2) + ansi.cursorRight(4), | ||
| 778 | " two\n", | ||
| 779 | "W\n", | ||
| 780 | ]), | ||
| 781 | }); | ||
| 782 | host.cancel(); | ||
| 783 | }); | ||
| 784 | |||
| 785 | test("resize invalidates the widget area", () => { | ||
| 786 | const host = new testing.MockScreen(); | ||
| 787 | using _ = host.startWidget({ format: () => "w1\nw2" }); | ||
| 788 | host.expectFrame(0, { | ||
| 789 | stderr: testing.MockScreen.sync(["w1\nw2\n"]), | ||
| 790 | }); | ||
| 791 | host.resize(40); | ||
| 792 | host.expectFrame(0, { | ||
| 793 | merged: ansi.cursorUp(2) + "\r" + ansi.clearToEndOfScreen | ||
| 794 | + testing.MockScreen.sync(["w1\nw2\n"]), | ||
| 795 | }); | ||
| 796 | host.cancel(); | ||
| 797 | }); | ||
| 798 | |||
| 799 | test("widget redraw during another widget's render", () => { | ||
| 800 | const host = new testing.MockScreen(); | ||
| 801 | let kicked = false; | ||
| 802 | const b = UNWRAP(host.startWidget({ format: () => "b" })); | ||
| 803 | using _ = host.startWidget({ | ||
| 804 | format: () => { | ||
| 805 | if (!kicked) { | ||
| 806 | kicked = true; | ||
| 807 | b.redraw(); | ||
| 808 | } | ||
| 809 | return "a"; | ||
| 810 | }, | ||
| 811 | }); | ||
| 812 | host.expectFrame(0, { | ||
| 813 | stderr: testing.MockScreen.sync(["b\na\n"]), | ||
| 814 | }); | ||
| 815 | // the request from inside the render pass produces a follow-up frame | ||
| 816 | host.expectFrame(0, { | ||
| 817 | stderr: testing.MockScreen.sync([ | ||
| 818 | ansi.cursorUp(2), | ||
| 819 | "b\na\n", | ||
| 820 | ]), | ||
| 821 | }); | ||
| 822 | host.expectNone(); | ||
| 823 | host.cancel(); | ||
| 824 | }); | ||
| 825 | |||
| 826 | test("widget stopped during another widget's render", () => { | ||
| 827 | const host = new testing.MockScreen(); | ||
| 828 | let b: log.WidgetInstance | null = null; | ||
| 829 | using _ = host.startWidget({ | ||
| 830 | format: () => { | ||
| 831 | b?.stop(); | ||
| 832 | b = null; | ||
| 833 | return "a"; | ||
| 834 | }, | ||
| 835 | }); | ||
| 836 | b = host.startWidget({ format: () => "b" }); | ||
| 837 | host.expectFrame(0, { | ||
| 838 | stderr: testing.MockScreen.sync(["a\n"]), | ||
| 839 | }); | ||
| 840 | // the stop request also schedules a follow-up frame; it draws no change | ||
| 841 | host.expectFrame(0, { | ||
| 842 | stderr: testing.MockScreen.sync([ | ||
| 843 | ansi.cursorUp(1), | ||
| 844 | "a\n", | ||
| 845 | ]), | ||
| 846 | }); | ||
| 847 | host.expectNone(); | ||
| 848 | host.cancel(); | ||
| 849 | }); | ||
| 850 | |||
| 851 | test("fps setter schedules frames", () => { | ||
| 852 | const host = new testing.MockScreen(); | ||
| 853 | const w = UNWRAP(host.startWidget({ format: ({ now }) => `t=${now}` })); | ||
| 854 | host.expectFrame(0, { | ||
| 855 | stderr: testing.MockScreen.sync(["t=0\n"]), | ||
| 856 | }); | ||
| 857 | host.expectNone(); | ||
| 858 | w.fps = 1; | ||
| 859 | host.expectFrame(0, { | ||
| 860 | stderr: testing.MockScreen.sync([ | ||
| 861 | ansi.cursorUp(1), | ||
| 862 | "t=0\n", | ||
| 863 | ]), | ||
| 864 | }); | ||
| 865 | host.expectWithoutConsume(1000); | ||
| 866 | w.fps = null; | ||
| 867 | host.expectFrame(0, { | ||
| 868 | stderr: testing.MockScreen.sync([ | ||
| 869 | ansi.cursorUp(1), | ||
| 870 | "t=0\n", | ||
| 871 | ]), | ||
| 872 | }); | ||
| 873 | host.expectNone(); | ||
| 874 | w.stop(); | ||
| 875 | host.expectFrame(0, { | ||
| 876 | stderr: testing.MockScreen.sync([ | ||
| 877 | ansi.cursorUp(1), | ||
| 878 | ansi.clearFullLine, | ||
| 879 | ]), | ||
| 880 | }); | ||
| 881 | host.cancel(); | ||
| 882 | }); | ||
| 883 | |||
| 884 | test("node host patches and restores std streams", async () => { | ||
| 885 | const proc = UNWRAP(node.process); | ||
| 886 | const origOut = proc.stdout.write; | ||
| 887 | const origErr = proc.stderr.write; | ||
| 888 | let stdoutData = ""; | ||
| 889 | let stderrData = ""; | ||
| 890 | proc.stdout.write = (chunk) => (stdoutData += chunk, true); | ||
| 891 | proc.stderr.write = (chunk) => (stderrData += chunk, true); | ||
| 892 | const myOut = proc.stdout.write; | ||
| 893 | const myErr = proc.stderr.write; | ||
| 894 | try { | ||
| 895 | const host = log.defaultNodeProcessWidgetHost(proc, true); | ||
| 896 | for (let i = 0; i < 3; i += 1) { | ||
| 897 | host.writeOutput(`line ${i}\n`); | ||
| 898 | await new Promise((resolve) => setTimeout(resolve, 2)); | ||
| 899 | } | ||
| 900 | host.cancel(); | ||
| 901 | // regression: the unpatch used to clobber stdout with stderr's methods | ||
| 902 | // and leave a permanently growing wrapper chain on stderr | ||
| 903 | ASSERT(proc.stdout.write === myOut, "stdout.write was not restored"); | ||
| 904 | ASSERT(proc.stderr.write === myErr, "stderr.write was not restored"); | ||
| 905 | // log output lands on real stdout, so it stays redirectable | ||
| 906 | ASSERT(stdoutData.includes("line 0\n")); | ||
| 907 | ASSERT(stdoutData.includes("line 2\n")); | ||
| 908 | ASSERT(stderrData === ""); | ||
| 909 | } finally { | ||
| 910 | proc.stdout.write = origOut; | ||
| 911 | proc.stderr.write = origErr; | ||
| 912 | } | ||
| 913 | }); | ||
| 914 | |||
| 527 | test("writing during a render function is OK", () => { | 915 | test("writing during a render function is OK", () => { |
| 528 | const host = new testing.MockScreen(); | 916 | const host = new testing.MockScreen(); |
| 529 | 917 | ||
| ... | @@ -564,6 +952,8 @@ describe("log widgets", () => { | ... | @@ -564,6 +952,8 @@ describe("log widgets", () => { |
| 564 | }); | 952 | }); |
| 565 | 953 | ||
| 566 | import { describe, test } from "vitest"; | 954 | import { describe, test } from "vitest"; |
| 955 | import { ASSERT, UNWRAP } from "./assert.ts"; | ||
| 567 | import * as log from "./log.ts"; | 956 | import * as log from "./log.ts"; |
| 957 | import * as node from "./node.ts"; | ||
| 568 | import * as ansi from "./string/ansi.ts"; | 958 | import * as ansi from "./string/ansi.ts"; |
| 569 | import * as testing from "./testing.ts"; | 959 | import * as testing from "./testing.ts"; |
lib/log.ts+273-96| ... | @@ -374,6 +374,15 @@ export interface TerminalWidgetHostOptions { | ... | @@ -374,6 +374,15 @@ export interface TerminalWidgetHostOptions { |
| 374 | delay: typeof async.delay; | 374 | delay: typeof async.delay; |
| 375 | /** is there color support? */ | 375 | /** is there color support? */ |
| 376 | color: boolean; | 376 | color: boolean; |
| 377 | /** | ||
| 378 | * whether text passed to `writeOutput` lands on the same screen as the | ||
| 379 | * interactive output. pass `false` when output is redirected to a file or | ||
| 380 | * pipe while the interactive stream remains a terminal; the widget cursor | ||
| 381 | * math then ignores log output entirely, since written rows do not | ||
| 382 | * displace the widget block. | ||
| 383 | * @default true | ||
| 384 | */ | ||
| 385 | outputSharesScreen?: boolean; | ||
| 377 | } | 386 | } |
| 378 | 387 | ||
| 379 | /** | 388 | /** |
| ... | @@ -385,9 +394,17 @@ export interface TerminalLock { | ... | @@ -385,9 +394,17 @@ export interface TerminalLock { |
| 385 | writeInteractive(text: string): void; | 394 | writeInteractive(text: string): void; |
| 386 | /** recieves log content from `write` (pre-buffered; should flush immediately) */ | 395 | /** recieves log content from `write` (pre-buffered; should flush immediately) */ |
| 387 | writeOutput(text: string): void; | 396 | writeOutput(text: string): void; |
| 388 | /** called often. TODO: convert this into a subscription */ | 397 | /** |
| 389 | getSize(): { columns: number; rows: number }; | 398 | * subscribe to the terminal size. the callback must fire synchronously |
| 390 | // size(cb: (value: { width: number, height: number }) => void): () => void; | 399 | * with the current size before this function returns, and again whenever |
| 400 | * the size changes (SIGWINCH). returns an unsubscribe function. on a size | ||
| 401 | * change, the widget host erases its stale drawing and repaints, since a | ||
| 402 | * resize rewraps previously drawn rows and invalidates all relative | ||
| 403 | * cursor math. | ||
| 404 | */ | ||
| 405 | observeSize( | ||
| 406 | callback: (size: { columns: number; rows: number }) => void, | ||
| 407 | ): () => void; | ||
| 391 | /** temporarily free the lock */ | 408 | /** temporarily free the lock */ |
| 392 | temporaryUnlock?(): () => void; | 409 | temporaryUnlock?(): () => void; |
| 393 | /** completely free the lock */ | 410 | /** completely free the lock */ |
| ... | @@ -424,7 +441,6 @@ export interface WidgetHost { | ... | @@ -424,7 +441,6 @@ export interface WidgetHost { |
| 424 | /** @internal state */ | 441 | /** @internal state */ |
| 425 | interface WidgetState { | 442 | interface WidgetState { |
| 426 | frameTime: number; | 443 | frameTime: number; |
| 427 | next: number; | ||
| 428 | } | 444 | } |
| 429 | 445 | ||
| 430 | /** | 446 | /** |
| ... | @@ -435,15 +451,20 @@ export function createTerminalWidgetHost( | ... | @@ -435,15 +451,20 @@ export function createTerminalWidgetHost( |
| 435 | env: TerminalWidgetHostOptions, | 451 | env: TerminalWidgetHostOptions, |
| 436 | ): WidgetHost { | 452 | ): WidgetHost { |
| 437 | const { lockTerminal, now, delay, writeOutputTemporaryLock, color } = env; | 453 | const { lockTerminal, now, delay, writeOutputTemporaryLock, color } = env; |
| 454 | const outputOnScreen = env.outputSharesScreen ?? true; | ||
| 438 | 455 | ||
| 439 | let timer: async.Cancelable<void> | null = null; | 456 | let timer: async.Cancelable<void> | null = null; |
| 440 | 457 | ||
| 441 | let rendering = false; | 458 | let rendering = false; |
| 459 | let pendingRedraw = false; | ||
| 442 | let locks = 0; | 460 | let locks = 0; |
| 443 | let redrawTime = 0; | 461 | let redrawTime = 0; |
| 444 | let lastFlush = 0; | 462 | let lastFlush = 0; |
| 445 | let buffer = ""; | 463 | let buffer = ""; |
| 446 | let partialLineIndex = 0; | 464 | // visible width of the trailing partial log line (text since the last "\n" |
| 465 | // written to output). the cursor column is derived as `partialWidth % | ||
| 466 | // columns` at draw time, so the value survives resizes and wrapped lines. | ||
| 467 | let partialWidth = 0; | ||
| 447 | let needsToSaveCursor = false; | 468 | let needsToSaveCursor = false; |
| 448 | let needsToRestoreCursor = false; | 469 | let needsToRestoreCursor = false; |
| 449 | const widgets: WidgetOptions[] = []; | 470 | const widgets: WidgetOptions[] = []; |
| ... | @@ -451,8 +472,53 @@ export function createTerminalWidgetHost( | ... | @@ -451,8 +472,53 @@ export function createTerminalWidgetHost( |
| 451 | let lines: string[] = []; | 472 | let lines: string[] = []; |
| 452 | let hasSyncStart = false; | 473 | let hasSyncStart = false; |
| 453 | let terminal: TerminalLock | null = null; | 474 | let terminal: TerminalLock | null = null; |
| 475 | let size: { columns: number; rows: number } | null = null; | ||
| 476 | let unwatchSize: (() => void) | null = null; | ||
| 454 | let tempUnlock: (() => void) | null = null; | 477 | let tempUnlock: (() => void) | null = null; |
| 455 | 478 | ||
| 479 | // the size subscription lives exactly as long as the terminal lock, so | ||
| 480 | // acquisition and closing are funneled through these two functions. | ||
| 481 | function acquireTerminal(): TerminalLock { | ||
| 482 | if (!terminal) { | ||
| 483 | let initial = true; | ||
| 484 | size = null; | ||
| 485 | terminal = lockTerminal(); | ||
| 486 | unwatchSize = terminal.observeSize((next) => { | ||
| 487 | size = next; | ||
| 488 | if (!initial) handleResize(); | ||
| 489 | }); | ||
| 490 | ASSERT( | ||
| 491 | size, | ||
| 492 | "TerminalLock.observeSize must call back synchronously with the current size", | ||
| 493 | ); | ||
| 494 | initial = false; | ||
| 495 | } | ||
| 496 | return terminal; | ||
| 497 | } | ||
| 498 | function closeTerminal() { | ||
| 499 | unwatchSize?.(); | ||
| 500 | unwatchSize = null; | ||
| 501 | terminal?.close(); | ||
| 502 | terminal = null; | ||
| 503 | } | ||
| 504 | |||
| 505 | function handleResize() { | ||
| 506 | // previously drawn rows have rewrapped to the new width, so the stored | ||
| 507 | // line count no longer matches the screen. erasing from the widget top | ||
| 508 | // downward is best-effort: on shrink, rows that wrapped above the cursor | ||
| 509 | // cannot be recovered. | ||
| 510 | if (lines.length > 0 && terminal) { | ||
| 511 | terminal.writeInteractive( | ||
| 512 | ansi.cursorUp(lines.length) + "\r" + ansi.clearToEndOfScreen, | ||
| 513 | ); | ||
| 514 | lines = []; | ||
| 515 | } | ||
| 516 | // the save register holds pre-resize coordinates; restoring it would | ||
| 517 | // jump somewhere unrelated. | ||
| 518 | needsToRestoreCursor = false; | ||
| 519 | requestRedraw(); | ||
| 520 | } | ||
| 521 | |||
| 456 | function redrawCallback() { | 522 | function redrawCallback() { |
| 457 | timer = null; | 523 | timer = null; |
| 458 | ASSERT(!rendering); | 524 | ASSERT(!rendering); |
| ... | @@ -464,6 +530,16 @@ export function createTerminalWidgetHost( | ... | @@ -464,6 +530,16 @@ export function createTerminalWidgetHost( |
| 464 | redrawCallbackInner(); | 530 | redrawCallbackInner(); |
| 465 | } finally { | 531 | } finally { |
| 466 | rendering = false; | 532 | rendering = false; |
| 533 | if (pendingRedraw) { | ||
| 534 | pendingRedraw = false; | ||
| 535 | redrawSoon(0); | ||
| 536 | } | ||
| 537 | // cancel() during this render pass (an exit handler unwinding through | ||
| 538 | // a crashed format callback) skips its teardown; finish it here. | ||
| 539 | if (widgets.length === 0 && terminal && !timer && !buffer) { | ||
| 540 | lines = []; | ||
| 541 | closeTerminal(); | ||
| 542 | } | ||
| 467 | } | 543 | } |
| 468 | } | 544 | } |
| 469 | 545 | ||
| ... | @@ -477,33 +553,27 @@ export function createTerminalWidgetHost( | ... | @@ -477,33 +553,27 @@ export function createTerminalWidgetHost( |
| 477 | if (!buffer) return; | 553 | if (!buffer) return; |
| 478 | needsToRestoreCursor = false; | 554 | needsToRestoreCursor = false; |
| 479 | needsToSaveCursor = false; | 555 | needsToSaveCursor = false; |
| 480 | if (writeOutputTemporaryLock) { | 556 | if (!terminal && writeOutputTemporaryLock) { |
| 481 | writeOutputTemporaryLock(buffer); | 557 | writeOutputTemporaryLock(buffer); |
| 482 | if (hasSyncStart) { | ||
| 483 | terminal ??= lockTerminal(); | ||
| 484 | terminal.writeInteractive(ansi.syncEnd); | ||
| 485 | } | ||
| 486 | } else { | 558 | } else { |
| 487 | terminal ??= lockTerminal(); | 559 | acquireTerminal().writeOutput(buffer); |
| 488 | buffer && terminal.writeOutput(buffer); | ||
| 489 | if (hasSyncStart) { | ||
| 490 | terminal ??= lockTerminal(); | ||
| 491 | terminal.writeInteractive(ansi.syncEnd); | ||
| 492 | } | ||
| 493 | terminal.close(); | ||
| 494 | terminal = null; | ||
| 495 | } | 560 | } |
| 496 | partialLineIndex = partialLineLength(buffer); | 561 | if (hasSyncStart) { |
| 562 | acquireTerminal().writeInteractive(ansi.syncEnd); | ||
| 563 | hasSyncStart = false; | ||
| 564 | } | ||
| 565 | closeTerminal(); | ||
| 566 | trackPartialWidth(buffer); | ||
| 497 | buffer = ""; | 567 | buffer = ""; |
| 498 | return; | 568 | return; |
| 499 | } | 569 | } |
| 500 | 570 | ||
| 501 | terminal ??= lockTerminal(); | 571 | const term = acquireTerminal(); |
| 502 | 572 | const { columns, rows } = UNWRAP(size); | |
| 503 | const { columns, rows } = terminal.getSize(); | ||
| 504 | let newWidgetLines: string[] = []; | 573 | let newWidgetLines: string[] = []; |
| 505 | let next = Infinity; | 574 | let next = Infinity; |
| 506 | for (let w = 0, { length } = widgets; w < length; w += 1) { | 575 | // `widgets.length` is read live: a format callback may stop another widget |
| 576 | for (let w = 0; w < widgets.length; w += 1) { | ||
| 507 | const widget = UNWRAP(widgets[w]); | 577 | const widget = UNWRAP(widgets[w]); |
| 508 | let out: string | { text: string } | null; | 578 | let out: string | { text: string } | null; |
| 509 | try { | 579 | try { |
| ... | @@ -515,50 +585,68 @@ export function createTerminalWidgetHost( | ... | @@ -515,50 +585,68 @@ export function createTerminalWidgetHost( |
| 515 | } catch (e) { | 585 | } catch (e) { |
| 516 | out = e instanceof Error ? stack.format(e, color) : errors.message(e); | 586 | out = e instanceof Error ? stack.format(e, color) : errors.message(e); |
| 517 | } | 587 | } |
| 518 | if (!out) { | 588 | if (out == null) { |
| 519 | widgets.splice(w, 1); | 589 | widgets.splice(w, 1); |
| 520 | UNWRAP(internals.splice(w, 1)[0]); | 590 | UNWRAP(internals.splice(w, 1)[0]); |
| 521 | w -= 1; | 591 | w -= 1; |
| 522 | length -= 1; | ||
| 523 | continue; | 592 | continue; |
| 524 | } | 593 | } |
| 594 | next = Math.min(next, UNWRAP(internals[w]).frameTime); | ||
| 525 | const outText = typeof out === "string" ? out : out.text; | 595 | const outText = typeof out === "string" ? out : out.text; |
| 596 | // an empty string is a live widget that currently displays nothing; | ||
| 597 | // only `null` detaches. | ||
| 598 | if (outText === "") continue; | ||
| 526 | const rowsLeft = Math.max(1, rows - newWidgetLines.length - 1); | 599 | const rowsLeft = Math.max(1, rows - newWidgetLines.length - 1); |
| 527 | if (rowsLeft === 1) break; | 600 | if (rowsLeft === 1) continue; |
| 528 | const lines = outText.split("\n").slice(0, rowsLeft); | ||
| 529 | newWidgetLines.push( | 601 | newWidgetLines.push( |
| 530 | ...lines.map((line) => ansi.trimForTerminal(line, columns - 1)), | 602 | ...outText.split("\n").slice(0, rowsLeft) |
| 603 | .map((line) => ansi.trimForTerminal(line, columns - 1)), | ||
| 531 | ); | 604 | ); |
| 532 | |||
| 533 | next = Math.min(next, UNWRAP(internals[w]).frameTime); | ||
| 534 | } | 605 | } |
| 535 | newWidgetLines = newWidgetLines.slice(0, rows - 1); | 606 | newWidgetLines = newWidgetLines.slice(0, rows - 1); |
| 536 | if (next < Infinity) redrawSoon(next); | 607 | if (next < Infinity) redrawSoon(next); |
| 537 | 608 | ||
| 538 | terminal ??= lockTerminal(); | 609 | const pCol = partialWidth % columns; |
| 539 | 610 | ||
| 540 | if (!newWidgetLines[0]) { | 611 | if (newWidgetLines.length === 0) { |
| 541 | ASSERT(!needsToSaveCursor); | ||
| 542 | if (lines.length > 0) { | 612 | if (lines.length > 0) { |
| 543 | terminal.writeInteractive( | 613 | term.writeInteractive( |
| 544 | (hasSyncStart ? "" : ansi.syncStart) | 614 | (hasSyncStart ? "" : ansi.syncStart) |
| 545 | // clear the widget space | 615 | // clear the widget space |
| 546 | + (ansi.cursorUp(1) + ansi.clearFullLine) | 616 | + (ansi.cursorUp(1) + ansi.clearFullLine) |
| 547 | .repeat(lines.length) | 617 | .repeat(lines.length) |
| 548 | + (partialLineIndex | 618 | + (pCol |
| 549 | ? ansi.cursorUp(1) + ansi.cursorRight(partialLineIndex) | 619 | ? ansi.cursorUp(1) + ansi.cursorRight(pCol) |
| 550 | : "") | 620 | : "") |
| 551 | + (needsToRestoreCursor ? ansi.cursorRestore : ""), | 621 | + (needsToRestoreCursor ? ansi.cursorRestore : ""), |
| 552 | ); | 622 | ); |
| 553 | hasSyncStart = true; | 623 | hasSyncStart = true; |
| 624 | needsToRestoreCursor = false; | ||
| 554 | lines = []; | 625 | lines = []; |
| 555 | } | 626 | } |
| 556 | if (buffer) terminal.writeOutput(buffer); | 627 | if (buffer) { |
| 557 | buffer = ""; | 628 | term.writeOutput(buffer); |
| 558 | if (hasSyncStart) terminal.writeInteractive(ansi.syncEnd); | 629 | trackPartialWidth(buffer); |
| 630 | buffer = ""; | ||
| 631 | } | ||
| 632 | if (hasSyncStart) { | ||
| 633 | term.writeInteractive(ansi.syncEnd); | ||
| 634 | hasSyncStart = false; | ||
| 635 | } | ||
| 636 | // the last widget may have detached this frame; release the terminal | ||
| 637 | // (and its patches) instead of holding the lock until the next flush | ||
| 638 | if (widgets.length === 0) closeTerminal(); | ||
| 559 | return; | 639 | return; |
| 560 | } | 640 | } |
| 561 | 641 | ||
| 642 | if (buffer && !outputOnScreen) { | ||
| 643 | // off-screen output (e.g. stdout redirected to a file) does not | ||
| 644 | // interact with the widget block; flush it plainly and fall through | ||
| 645 | // to a pure widget redraw | ||
| 646 | term.writeOutput(buffer); | ||
| 647 | buffer = ""; | ||
| 648 | } | ||
| 649 | |||
| 562 | if (buffer) { | 650 | if (buffer) { |
| 563 | // when writing a buffer alongside widgets, the screen may look like this | 651 | // when writing a buffer alongside widgets, the screen may look like this |
| 564 | // > [existing log] | 652 | // > [existing log] |
| ... | @@ -568,21 +656,24 @@ export function createTerminalWidgetHost( | ... | @@ -568,21 +656,24 @@ export function createTerminalWidgetHost( |
| 568 | // > [widget line 3] | 656 | // > [widget line 3] |
| 569 | // > [cursor is start of this line] | 657 | // > [cursor is start of this line] |
| 570 | // | 658 | // |
| 571 | // first, clear out the space where new lines are going to intersect | 659 | // first, clear out the space where new lines are going to intersect. |
| 572 | const createsPartialLine = partialLineLength(buffer); | 660 | // `span.rows` measures the cursor descent in physical rows, so log |
| 573 | // if more lines are buffered than there are widgets, only some are needed | 661 | // lines wider than the terminal are accounted for correctly. |
| 662 | const span = measureTerminalSpan(buffer, pCol, columns); | ||
| 663 | // if more rows are buffered than there are widgets, only some are | ||
| 664 | // needed. when a partial line exists, the buffer starts on its row | ||
| 665 | // (one above the widget block), hence the -1. | ||
| 574 | const clearLinesTop = Math.min( | 666 | const clearLinesTop = Math.min( |
| 575 | lines.length, | 667 | lines.length, |
| 576 | string.countNewlines(buffer) | 668 | span.rows |
| 577 | + (createsPartialLine ? 1 : 0) | 669 | + (span.endWidth % columns > 0 ? 1 : 0) |
| 578 | + (partialLineIndex ? -1 : 0), // "consumes partial line" | 670 | + (pCol ? -1 : 0), |
| 579 | ); | 671 | ); |
| 580 | const oldLines = lines.slice(clearLinesTop); | 672 | const oldLines = lines.slice(clearLinesTop); |
| 581 | terminal.writeInteractive( | 673 | term.writeInteractive( |
| 582 | (needsToSaveCursor ? ansi.cursorSave + "\n" : "") | 674 | (needsToSaveCursor ? ansi.cursorSave + "\n" : "") |
| 583 | + (hasSyncStart ? "" : ansi.syncStart) | 675 | + (hasSyncStart ? "" : ansi.syncStart) |
| 584 | + (partialLineIndex && !lines[0] ? "\n" : "") | 676 | + ((clearLinesTop > 0 || (pCol && lines.length > 0)) |
| 585 | + ((clearLinesTop > 0 || (partialLineIndex && lines[0])) | ||
| 586 | // clear the lines for buffer | 677 | // clear the lines for buffer |
| 587 | ? (clearLinesTop > 0 | 678 | ? (clearLinesTop > 0 |
| 588 | ? ansi.cursorUp(lines.length - clearLinesTop + 1) | 679 | ? ansi.cursorUp(lines.length - clearLinesTop + 1) |
| ... | @@ -590,22 +681,23 @@ export function createTerminalWidgetHost( | ... | @@ -590,22 +681,23 @@ export function createTerminalWidgetHost( |
| 590 | + (ansi.cursorUp(1) + ansi.clearFullLine) | 681 | + (ansi.cursorUp(1) + ansi.clearFullLine) |
| 591 | .repeat(clearLinesTop - 1) | 682 | .repeat(clearLinesTop - 1) |
| 592 | : "") | 683 | : "") |
| 593 | + (partialLineIndex && lines[0] | 684 | + (pCol && lines.length > 0 |
| 594 | ? ansi.cursorUp( | 685 | ? ansi.cursorUp( |
| 595 | clearLinesTop > 0 ? 1 : lines.length + 1, | 686 | clearLinesTop > 0 ? 1 : lines.length + 1, |
| 596 | ) | 687 | ) |
| 597 | + ansi.cursorRight(partialLineIndex) | 688 | + ansi.cursorRight(pCol) |
| 598 | : "") | 689 | : "") |
| 599 | : ""), | 690 | : ""), |
| 600 | ); | 691 | ); |
| 601 | partialLineIndex = createsPartialLine; | ||
| 602 | // then write output lines on standard out | 692 | // then write output lines on standard out |
| 603 | terminal.writeOutput(buffer); | 693 | term.writeOutput(buffer); |
| 604 | terminal.writeInteractive( | 694 | term.writeInteractive( |
| 605 | // if a partial line is created, then the widgets | 695 | // if the buffer leaves a partial line, the widgets have to go on the |
| 606 | // have to go on the next line, to avoid breaking stdout, | 696 | // next line. to avoid breaking stdout, the newline gets emitted on |
| 607 | // the newline gets emitted on the interactive out. | 697 | // the interactive out. (a line ending exactly on the terminal edge |
| 608 | (createsPartialLine ? "\n" : "") | 698 | // leaves the cursor wrap-deferred; this newline lands on the next |
| 699 | // row, which is also where `span.rows` placed the continuation.) | ||
| 700 | (span.endWidth > 0 ? "\n" : "") | ||
| 609 | // the widget text | 701 | // the widget text |
| 610 | + newWidgetLines.map((newLine, i) => | 702 | + newWidgetLines.map((newLine, i) => |
| 611 | (newLine.includes("\x1b") && !newLine.endsWith(ansi.reset) | 703 | (newLine.includes("\x1b") && !newLine.endsWith(ansi.reset) |
| ... | @@ -620,14 +712,20 @@ export function createTerminalWidgetHost( | ... | @@ -620,14 +712,20 @@ export function createTerminalWidgetHost( |
| 620 | + "\n" | 712 | + "\n" |
| 621 | ).join("") + ansi.syncEnd, | 713 | ).join("") + ansi.syncEnd, |
| 622 | ); | 714 | ); |
| 715 | partialWidth = span.endWidth; | ||
| 623 | } else { | 716 | } else { |
| 624 | const clearLinesBottom = Math.min( | 717 | const clearLinesBottom = Math.min( |
| 625 | lines.length, | 718 | lines.length, |
| 626 | Math.max(0, lines.length - (newWidgetLines?.length ?? 0)), | 719 | Math.max(0, lines.length - newWidgetLines.length), |
| 627 | ); | 720 | ); |
| 628 | terminal.writeInteractive( | 721 | term.writeInteractive( |
| 629 | (needsToSaveCursor ? ansi.cursorSave + "\n" : "") | 722 | (needsToSaveCursor ? ansi.cursorSave + "\n" : "") |
| 630 | + (hasSyncStart ? "" : ansi.syncStart) | 723 | + (hasSyncStart ? "" : ansi.syncStart) |
| 724 | // the first draw can land just after a partial log line; widgets | ||
| 725 | // must move below it. (`partialWidth` is zero whenever | ||
| 726 | // `needsToSaveCursor` is set, since releasing a draw lock resets | ||
| 727 | // it, so this never combines with the cursorSave newline.) | ||
| 728 | + (lines.length === 0 && partialWidth > 0 ? "\n" : "") | ||
| 631 | // clear the bottom lines | 729 | // clear the bottom lines |
| 632 | + (clearLinesBottom | 730 | + (clearLinesBottom |
| 633 | ? (ansi.cursorUp(1) + ansi.clearToEndOfLine) | 731 | ? (ansi.cursorUp(1) + ansi.clearToEndOfLine) |
| ... | @@ -658,6 +756,17 @@ export function createTerminalWidgetHost( | ... | @@ -658,6 +756,17 @@ export function createTerminalWidgetHost( |
| 658 | buffer = ""; | 756 | buffer = ""; |
| 659 | } | 757 | } |
| 660 | 758 | ||
| 759 | /** | ||
| 760 | * update `partialWidth` after writing `text` to the output. rows written | ||
| 761 | * off-screen never displace the widget block, so they are not tracked. | ||
| 762 | */ | ||
| 763 | function trackPartialWidth(text: string) { | ||
| 764 | if (!outputOnScreen) return; | ||
| 765 | const i = text.lastIndexOf("\n"); | ||
| 766 | partialWidth = ansi.widthInTerminal(text.slice(i + 1)) | ||
| 767 | + (i === -1 ? partialWidth : 0); | ||
| 768 | } | ||
| 769 | |||
| 661 | function redrawSoon(ms: number) { | 770 | function redrawSoon(ms: number) { |
| 662 | if (locks > 0 || (ms === 0 && rendering)) return; | 771 | if (locks > 0 || (ms === 0 && rendering)) return; |
| 663 | const newRedrawTime = now() + ms; | 772 | const newRedrawTime = now() + ms; |
| ... | @@ -671,35 +780,40 @@ export function createTerminalWidgetHost( | ... | @@ -671,35 +780,40 @@ export function createTerminalWidgetHost( |
| 671 | timer.then(redrawCallback); | 780 | timer.then(redrawCallback); |
| 672 | } | 781 | } |
| 673 | 782 | ||
| 783 | function requestRedraw() { | ||
| 784 | if (rendering) pendingRedraw = true; | ||
| 785 | else redrawSoon(0); | ||
| 786 | } | ||
| 787 | |||
| 674 | function flushAndClear(shortTermDrawLock: boolean) { | 788 | function flushAndClear(shortTermDrawLock: boolean) { |
| 675 | timer?.cancel(); | 789 | timer?.cancel(); |
| 676 | timer = null; | 790 | timer = null; |
| 677 | if (lines.length > 0) { | 791 | if (lines.length > 0) { |
| 678 | UNWRAP(terminal).writeInteractive( | 792 | const term = UNWRAP(terminal); |
| 793 | const pCol = partialWidth % UNWRAP(size).columns; | ||
| 794 | term.writeInteractive( | ||
| 679 | ansi.syncStart | 795 | ansi.syncStart |
| 680 | // clear the widget space | 796 | // clear the widget space |
| 681 | + (ansi.cursorUp(1) + ansi.clearFullLine) | 797 | + (ansi.cursorUp(1) + ansi.clearFullLine) |
| 682 | .repeat(lines.length) | 798 | .repeat(lines.length) |
| 683 | + (partialLineIndex | 799 | + (pCol |
| 684 | ? ansi.cursorUp(1) + ansi.cursorRight(partialLineIndex) | 800 | ? ansi.cursorUp(1) + ansi.cursorRight(pCol) |
| 685 | : "") | 801 | : "") |
| 686 | + (shortTermDrawLock ? "" : ansi.syncEnd) | 802 | + (shortTermDrawLock ? "" : ansi.syncEnd) |
| 687 | + (needsToRestoreCursor ? ansi.cursorRestore : ""), | 803 | + (needsToRestoreCursor ? ansi.cursorRestore : ""), |
| 688 | ); | 804 | ); |
| 805 | needsToRestoreCursor = false; | ||
| 689 | lines = []; | 806 | lines = []; |
| 690 | hasSyncStart = shortTermDrawLock; | 807 | hasSyncStart = shortTermDrawLock; |
| 691 | } | 808 | } |
| 692 | if (buffer.length > 0) { | 809 | if (buffer.length > 0) { |
| 693 | if (widgets.length === 0 && writeOutputTemporaryLock) { | 810 | if (widgets.length === 0 && !terminal && writeOutputTemporaryLock) { |
| 694 | writeOutputTemporaryLock(buffer); | 811 | writeOutputTemporaryLock(buffer); |
| 695 | } else { | 812 | } else { |
| 696 | terminal ??= lockTerminal(); | 813 | acquireTerminal().writeOutput(buffer); |
| 697 | terminal.writeOutput(buffer); | 814 | if (widgets.length === 0) closeTerminal(); |
| 698 | if (widgets.length === 0) { | ||
| 699 | terminal.close(); | ||
| 700 | terminal = null; | ||
| 701 | } | ||
| 702 | } | 815 | } |
| 816 | trackPartialWidth(buffer); | ||
| 703 | buffer = ""; | 817 | buffer = ""; |
| 704 | } | 818 | } |
| 705 | } | 819 | } |
| ... | @@ -720,8 +834,7 @@ export function createTerminalWidgetHost( | ... | @@ -720,8 +834,7 @@ export function createTerminalWidgetHost( |
| 720 | if (terminal.temporaryUnlock) { | 834 | if (terminal.temporaryUnlock) { |
| 721 | tempUnlock = terminal.temporaryUnlock(); | 835 | tempUnlock = terminal.temporaryUnlock(); |
| 722 | } else { | 836 | } else { |
| 723 | terminal.close(); | 837 | closeTerminal(); |
| 724 | terminal = null; | ||
| 725 | } | 838 | } |
| 726 | } | 839 | } |
| 727 | } | 840 | } |
| ... | @@ -730,14 +843,19 @@ export function createTerminalWidgetHost( | ... | @@ -730,14 +843,19 @@ export function createTerminalWidgetHost( |
| 730 | return { | 843 | return { |
| 731 | release(mode) { | 844 | release(mode) { |
| 732 | if (disposed) return; | 845 | if (disposed) return; |
| 846 | disposed = true; | ||
| 733 | locks -= 1; | 847 | locks -= 1; |
| 734 | needsToSaveCursor ||= mode === "cursor-middle-of-line"; | 848 | needsToSaveCursor ||= mode === "cursor-middle-of-line"; |
| 849 | // whatever the lock holder wrote has detached the cursor from any | ||
| 850 | // previously tracked partial log line. "cursor-start-of-line" | ||
| 851 | // states the cursor is on a fresh line; "cursor-middle-of-line" | ||
| 852 | // engages the save/restore dance instead. | ||
| 853 | partialWidth = 0; | ||
| 735 | if (locks === 0) { | 854 | if (locks === 0) { |
| 736 | tempUnlock?.(); | 855 | tempUnlock?.(); |
| 737 | tempUnlock = null; | 856 | tempUnlock = null; |
| 738 | if (buffer.length > 0 || widgets.length > 0) redrawSoon(0); | 857 | if (buffer.length > 0 || widgets.length > 0) redrawSoon(0); |
| 739 | } | 858 | } |
| 740 | disposed = true; | ||
| 741 | }, | 859 | }, |
| 742 | [Symbol.dispose]() { | 860 | [Symbol.dispose]() { |
| 743 | this.release("cursor-middle-of-line"); | 861 | this.release("cursor-middle-of-line"); |
| ... | @@ -748,12 +866,11 @@ export function createTerminalWidgetHost( | ... | @@ -748,12 +866,11 @@ export function createTerminalWidgetHost( |
| 748 | ASSERT(!widgets.includes(options), "Cannot start the same widget twice."); | 866 | ASSERT(!widgets.includes(options), "Cannot start the same widget twice."); |
| 749 | let fps = options.fps ?? null; | 867 | let fps = options.fps ?? null; |
| 750 | const state: WidgetState = { | 868 | const state: WidgetState = { |
| 751 | next: 0, | ||
| 752 | frameTime: 1000 / (fps ?? 0), | 869 | frameTime: 1000 / (fps ?? 0), |
| 753 | }; | 870 | }; |
| 754 | widgets.push(options); | 871 | widgets.push(options); |
| 755 | internals.push(state); | 872 | internals.push(state); |
| 756 | redrawSoon(0); | 873 | requestRedraw(); |
| 757 | return { | 874 | return { |
| 758 | options, | 875 | options, |
| 759 | get fps() { | 876 | get fps() { |
| ... | @@ -762,17 +879,16 @@ export function createTerminalWidgetHost( | ... | @@ -762,17 +879,16 @@ export function createTerminalWidgetHost( |
| 762 | set fps(value) { | 879 | set fps(value) { |
| 763 | fps = value; | 880 | fps = value; |
| 764 | state.frameTime = 1000 / (fps ?? 0); | 881 | state.frameTime = 1000 / (fps ?? 0); |
| 882 | // a pending frame may sit beyond the new cadence; draw to reanchor | ||
| 883 | requestRedraw(); | ||
| 765 | }, | 884 | }, |
| 766 | redraw() { | 885 | redraw: requestRedraw, |
| 767 | state.next = 0; | ||
| 768 | redrawSoon(0); | ||
| 769 | }, | ||
| 770 | stop() { | 886 | stop() { |
| 771 | const i = widgets.indexOf(options); | 887 | const i = widgets.indexOf(options); |
| 772 | if (i === -1) return; | 888 | if (i === -1) return; |
| 773 | widgets.splice(i, 1); | 889 | widgets.splice(i, 1); |
| 774 | UNWRAP(internals.splice(i, 1)[0]); | 890 | UNWRAP(internals.splice(i, 1)[0]); |
| 775 | redrawSoon(0); | 891 | requestRedraw(); |
| 776 | }, | 892 | }, |
| 777 | [Symbol.dispose]() { | 893 | [Symbol.dispose]() { |
| 778 | this.stop(); | 894 | this.stop(); |
| ... | @@ -787,6 +903,9 @@ export function createTerminalWidgetHost( | ... | @@ -787,6 +903,9 @@ export function createTerminalWidgetHost( |
| 787 | if (!rendering) flushAndClear(false); | 903 | if (!rendering) flushAndClear(false); |
| 788 | widgets.splice(0, widgets.length); | 904 | widgets.splice(0, widgets.length); |
| 789 | internals.splice(0, internals.length); | 905 | internals.splice(0, internals.length); |
| 906 | timer?.cancel(); | ||
| 907 | timer = null; | ||
| 908 | if (!rendering) closeTerminal(); | ||
| 790 | }, | 909 | }, |
| 791 | delay, | 910 | delay, |
| 792 | now, | 911 | now, |
| ... | @@ -794,13 +913,35 @@ export function createTerminalWidgetHost( | ... | @@ -794,13 +913,35 @@ export function createTerminalWidgetHost( |
| 794 | }; | 913 | }; |
| 795 | } | 914 | } |
| 796 | 915 | ||
| 797 | function partialLineLength(text: string) { | 916 | /** |
| 798 | const idx = text.lastIndexOf("\n"); | 917 | * compute the cursor descent (`rows`) and trailing line width (`endWidth`) |
| 799 | if (idx === -1) return text.length; | 918 | * from writing `text` to a terminal `columns` wide, with the cursor starting |
| 800 | return text.length - idx - 1; | 919 | * `startWidth` cells into a line. wrapping follows the DECAWM deferred-wrap |
| 920 | * convention shared by modern terminals: a line of exactly `columns` cells | ||
| 921 | * leaves the cursor pending on the same row, so a newline after it descends | ||
| 922 | * only one row. a trailing line ending exactly on the boundary reports its | ||
| 923 | * continuation point at column 0 of the next row (`endWidth % columns === 0` | ||
| 924 | * with the descended row included in `rows`). | ||
| 925 | */ | ||
| 926 | function measureTerminalSpan( | ||
| 927 | text: string, | ||
| 928 | startWidth: number, | ||
| 929 | columns: number, | ||
| 930 | ): { rows: number; endWidth: number } { | ||
| 931 | let rows = 0; | ||
| 932 | let width = startWidth; | ||
| 933 | const parts = text.split("\n"); | ||
| 934 | for (let i = 0; i < parts.length - 1; i += 1) { | ||
| 935 | width += ansi.widthInTerminal(UNWRAP(parts[i])); | ||
| 936 | rows += Math.max(1, Math.ceil(width / columns)); | ||
| 937 | width = 0; | ||
| 938 | } | ||
| 939 | width += ansi.widthInTerminal(UNWRAP(parts[parts.length - 1])); | ||
| 940 | rows += Math.floor(width / columns); | ||
| 941 | return { rows, endWidth: width }; | ||
| 801 | } | 942 | } |
| 802 | 943 | ||
| 803 | const logColors = node.process?.stderr.isTTY ?? false; | 944 | const logColors = node.process?.stdout.isTTY ?? false; |
| 804 | 945 | ||
| 805 | let formatLine = /* @__PURE__ */ (() => { | 946 | let formatLine = /* @__PURE__ */ (() => { |
| 806 | const fwo = node.builtin("util")?.formatWithOptions; | 947 | const fwo = node.builtin("util")?.formatWithOptions; |
| ... | @@ -944,7 +1085,7 @@ interface GlobalCommunication { | ... | @@ -944,7 +1085,7 @@ interface GlobalCommunication { |
| 944 | } | 1085 | } |
| 945 | 1086 | ||
| 946 | const globalSymbol = /* @__PURE__ */ Symbol.for("@clo/lib/log"); | 1087 | const globalSymbol = /* @__PURE__ */ Symbol.for("@clo/lib/log"); |
| 947 | const version = 4; | 1088 | const version = 5; |
| 948 | let global: GlobalCommunication = /* @__PURE__ */ (() => { | 1089 | let global: GlobalCommunication = /* @__PURE__ */ (() => { |
| 949 | const global = (globalThis as { [globalSymbol]?: GlobalCommunication })[globalSymbol] ??= { | 1090 | const global = (globalThis as { [globalSymbol]?: GlobalCommunication })[globalSymbol] ??= { |
| 950 | readme: | 1091 | readme: |
| ... | @@ -1032,13 +1173,16 @@ export function defaultNodeProcessWidgetHost( | ... | @@ -1032,13 +1173,16 @@ export function defaultNodeProcessWidgetHost( |
| 1032 | const stderrEnd = stderr.end; | 1173 | const stderrEnd = stderr.end; |
| 1033 | function patchWriteMethod<T, R, A extends [string | Uint8Array]>( | 1174 | function patchWriteMethod<T, R, A extends [string | Uint8Array]>( |
| 1034 | fn: (this: T, ...args: A) => R, | 1175 | fn: (this: T, ...args: A) => R, |
| 1176 | touchesScreen: boolean, | ||
| 1035 | ) { | 1177 | ) { |
| 1036 | return function(this: T, ...args: A) { | 1178 | return function(this: T, ...args: A) { |
| 1037 | using lock = disposed ? null : host.getDrawLock("short"); | 1179 | using lock = disposed ? null : host.getDrawLock("short"); |
| 1038 | const ret = fn.apply(this, args); | 1180 | const ret = fn.apply(this, args); |
| 1039 | if (lock) { | 1181 | if (lock) { |
| 1182 | // writes to a redirected stream never move the screen cursor | ||
| 1040 | lock.release( | 1183 | lock.release( |
| 1041 | (typeof args[0] === "string" | 1184 | !touchesScreen |
| 1185 | || (typeof args[0] === "string" | ||
| 1042 | ? args[0].endsWith("\n") | 1186 | ? args[0].endsWith("\n") |
| 1043 | : bufferEndsInNewline(args[0])) | 1187 | : bufferEndsInNewline(args[0])) |
| 1044 | ? "cursor-start-of-line" | 1188 | ? "cursor-start-of-line" |
| ... | @@ -1048,8 +1192,14 @@ export function defaultNodeProcessWidgetHost( | ... | @@ -1048,8 +1192,14 @@ export function defaultNodeProcessWidgetHost( |
| 1048 | return ret; | 1192 | return ret; |
| 1049 | }; | 1193 | }; |
| 1050 | } | 1194 | } |
| 1051 | const newStdoutWrite = stdout.write = patchWriteMethod(stdoutWrite); | 1195 | const newStdoutWrite = stdout.write = patchWriteMethod( |
| 1052 | const newStderrWrite = stderr.write = patchWriteMethod(stderrWrite); | 1196 | stdoutWrite, |
| 1197 | stdout.isTTY, | ||
| 1198 | ); | ||
| 1199 | const newStderrWrite = stderr.write = patchWriteMethod( | ||
| 1200 | stderrWrite, | ||
| 1201 | true, | ||
| 1202 | ); | ||
| 1053 | function patchEndMethod<T, R, A extends unknown[]>( | 1203 | function patchEndMethod<T, R, A extends unknown[]>( |
| 1054 | fn: (this: T, ...args: A) => R, | 1204 | fn: (this: T, ...args: A) => R, |
| 1055 | ) { | 1205 | ) { |
| ... | @@ -1090,19 +1240,34 @@ export function defaultNodeProcessWidgetHost( | ... | @@ -1090,19 +1240,34 @@ export function defaultNodeProcessWidgetHost( |
| 1090 | } | 1240 | } |
| 1091 | 1241 | ||
| 1092 | return { | 1242 | return { |
| 1093 | writeOutput: (string) => stdoutWrite.call(stderr, string), | 1243 | // log output goes to real stdout so it stays redirectable; the |
| 1244 | // `outputSharesScreen` option tells the host whether those rows | ||
| 1245 | // land on the widget screen and must be counted by the cursor math | ||
| 1246 | writeOutput: (string) => stdoutWrite.call(stdout, string), | ||
| 1094 | writeInteractive: (string) => stderrWrite.call(stderr, string), | 1247 | writeInteractive: (string) => stderrWrite.call(stderr, string), |
| 1095 | getSize: () => process.stderr, | 1248 | observeSize(callback) { |
| 1096 | temporarilyUnlock() { | 1249 | const emit = () => |
| 1097 | // no action needed | 1250 | callback({ |
| 1251 | columns: stderr.columns ?? 80, | ||
| 1252 | rows: stderr.rows ?? 24, | ||
| 1253 | }); | ||
| 1254 | emit(); | ||
| 1255 | stderr.addListener("resize", emit); | ||
| 1256 | return () => void stderr.removeListener("resize", emit); | ||
| 1257 | }, | ||
| 1258 | temporaryUnlock() { | ||
| 1259 | // no action needed: the patched write methods re-enter the draw | ||
| 1260 | // lock, which is re-entrant, so external writes flow correctly | ||
| 1261 | // while the lock is held. | ||
| 1262 | return () => {}; | ||
| 1098 | }, | 1263 | }, |
| 1099 | close() { | 1264 | close() { |
| 1100 | disposed = true; | 1265 | disposed = true; |
| 1101 | // leave patches in place if something else tampered with it. | 1266 | // leave patches in place if something else tampered with it. |
| 1102 | if (stdout.write === newStdoutWrite) stdout.write = stdoutWrite; | 1267 | if (stdout.write === newStdoutWrite) stdout.write = stdoutWrite; |
| 1103 | if (stderr.write === newStderrWrite) stdout.write = stderrWrite; | 1268 | if (stderr.write === newStderrWrite) stderr.write = stderrWrite; |
| 1104 | if (stdout.end === newStdoutEnd) stdout.end = stdoutEnd; | 1269 | if (stdout.end === newStdoutEnd) stdout.end = stdoutEnd; |
| 1105 | if (stderr.end === newStderrEnd) stdout.end = stderrEnd; | 1270 | if (stderr.end === newStderrEnd) stderr.end = stderrEnd; |
| 1106 | for (const [key, old, patched] of restoreConsole) { | 1271 | for (const [key, old, patched] of restoreConsole) { |
| 1107 | if (console[key] === patched) console[key] = old; | 1272 | if (console[key] === patched) console[key] = old; |
| 1108 | } | 1273 | } |
| ... | @@ -1112,6 +1277,7 @@ export function defaultNodeProcessWidgetHost( | ... | @@ -1112,6 +1277,7 @@ export function defaultNodeProcessWidgetHost( |
| 1112 | now: () => performance.now(), | 1277 | now: () => performance.now(), |
| 1113 | delay: async.delay, | 1278 | delay: async.delay, |
| 1114 | color: process.stderr.isTTY, | 1279 | color: process.stderr.isTTY, |
| 1280 | outputSharesScreen: process.stdout.isTTY, | ||
| 1115 | }); | 1281 | }); |
| 1116 | process.addListener("beforeExit", () => host.cancel()); | 1282 | process.addListener("beforeExit", () => host.cancel()); |
| 1117 | process.addListener("exit", () => host.cancel()); | 1283 | process.addListener("exit", () => host.cancel()); |
| ... | @@ -1156,7 +1322,16 @@ export function simpleNodeProcessWidgetHost( | ... | @@ -1156,7 +1322,16 @@ export function simpleNodeProcessWidgetHost( |
| 1156 | lockTerminal: () => ({ | 1322 | lockTerminal: () => ({ |
| 1157 | writeOutput: (string) => process.stdout.write(string), | 1323 | writeOutput: (string) => process.stdout.write(string), |
| 1158 | writeInteractive: (string) => process.stderr.write(string), | 1324 | writeInteractive: (string) => process.stderr.write(string), |
| 1159 | getSize: () => process.stderr, | 1325 | observeSize(callback) { |
| 1326 | const emit = () => | ||
| 1327 | callback({ | ||
| 1328 | columns: process.stderr.columns ?? 80, | ||
| 1329 | rows: process.stderr.rows ?? 24, | ||
| 1330 | }); | ||
| 1331 | emit(); | ||
| 1332 | process.stderr.addListener("resize", emit); | ||
| 1333 | return () => void process.stderr.removeListener("resize", emit); | ||
| 1334 | }, | ||
| 1160 | close() { | 1335 | close() { |
| 1161 | // no action needed | 1336 | // no action needed |
| 1162 | }, | 1337 | }, |
| ... | @@ -1164,6 +1339,7 @@ export function simpleNodeProcessWidgetHost( | ... | @@ -1164,6 +1339,7 @@ export function simpleNodeProcessWidgetHost( |
| 1164 | now: () => performance.now(), | 1339 | now: () => performance.now(), |
| 1165 | delay: async.delay, | 1340 | delay: async.delay, |
| 1166 | color: process.stderr.isTTY, | 1341 | color: process.stderr.isTTY, |
| 1342 | outputSharesScreen: process.stdout.isTTY, | ||
| 1167 | }) | 1343 | }) |
| 1168 | : { | 1344 | : { |
| 1169 | writeOutput: (string) => process.stdout.write(string), | 1345 | writeOutput: (string) => process.stdout.write(string), |
| ... | @@ -1201,6 +1377,8 @@ const globalLog = /* @__PURE__ */ (() => | ... | @@ -1201,6 +1377,8 @@ const globalLog = /* @__PURE__ */ (() => |
| 1201 | } else if (node.process) { | 1377 | } else if (node.process) { |
| 1202 | globalWidgetHost()[ | 1378 | globalWidgetHost()[ |
| 1203 | (m.level ?? "info") === "info" ? "writeOutput" : "writeError" | 1379 | (m.level ?? "info") === "info" ? "writeOutput" : "writeError" |
| 1380 | // colors keyed off stdout, the destination of host log output; | ||
| 1381 | // `logColors` (used for inspect formatting) matches. | ||
| 1204 | ](formatAnsiMessage(m, node.process.stdout.isTTY)); | 1382 | ](formatAnsiMessage(m, node.process.stdout.isTTY)); |
| 1205 | } else { | 1383 | } else { |
| 1206 | let { level = "info", [originalLogArgs]: args = [m.text], scope } = m; | 1384 | let { level = "info", [originalLogArgs]: args = [m.text], scope } = m; |
| ... | @@ -1233,6 +1411,5 @@ import * as async from "./async.ts"; | ... | @@ -1233,6 +1411,5 @@ import * as async from "./async.ts"; |
| 1233 | import * as errors from "./error.ts"; | 1411 | import * as errors from "./error.ts"; |
| 1234 | import * as stack from "./log/stack.ts"; | 1412 | import * as stack from "./log/stack.ts"; |
| 1235 | import * as node from "./node.ts"; | 1413 | import * as node from "./node.ts"; |
| 1236 | import * as string from "./string.ts"; | ||
| 1237 | import * as ansi from "./string/ansi.ts"; | 1414 | import * as ansi from "./string/ansi.ts"; |
| 1238 | import * as ts from "./ts.ts"; | 1415 | import * as ts from "./ts.ts"; |
lib/node.ts+5-2| ... | @@ -30,9 +30,12 @@ interface Process { | ... | @@ -30,9 +30,12 @@ interface Process { |
| 30 | 30 | ||
| 31 | interface Tty { | 31 | interface Tty { |
| 32 | isTTY: boolean; | 32 | isTTY: boolean; |
| 33 | columns: number; | 33 | /** only present when the stream is a tty */ |
| 34 | rows: number; | 34 | columns: number | undefined; |
| 35 | /** only present when the stream is a tty */ | ||
| 36 | rows: number | undefined; | ||
| 35 | addListener(event: string, callback: () => void): this; | 37 | addListener(event: string, callback: () => void): this; |
| 38 | removeListener(event: string, callback: () => void): this; | ||
| 36 | end(text?: string | Uint8Array): this; | 39 | end(text?: string | Uint8Array): this; |
| 37 | write(text: string | Uint8Array): boolean; | 40 | write(text: string | Uint8Array): boolean; |
| 38 | } | 41 | } |
lib/progress.test.ts+155-6| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | // a trivial example of how to use progress | 1 | // a trivial example of how to use progress |
| 2 | test.skip("trivial end-to-end example", () => { | 2 | test("trivial end-to-end example", () => { |
| 3 | const { fgBlue: FB, fgReset: FR, reset: R } = ansi; | 3 | const { fgBlue: FB, fgReset: FR, reset: R } = ansi; |
| 4 | const screen = new testing.MockScreen(); // create a mock terminal | 4 | const screen = new testing.MockScreen(); // create a mock terminal |
| 5 | const root = new progress.Root(screen); // sync with mock timers | 5 | const root = new progress.Root(screen); // sync with mock timers |
| ... | @@ -13,18 +13,20 @@ test.skip("trivial end-to-end example", () => { | ... | @@ -13,18 +13,20 @@ test.skip("trivial end-to-end example", () => { |
| 13 | assert.equal(screen.timers.entries.length, 1); | 13 | assert.equal(screen.timers.entries.length, 1); |
| 14 | 14 | ||
| 15 | screen.expectFrame(1, { merged: "" }); // debounce | 15 | screen.expectFrame(1, { merged: "" }); // debounce |
| 16 | // TODO: why the full resets? | 16 | // widget lines containing escapes get a trailing ansi.reset from the |
| 17 | // renderer, so a malformed format function cannot bleed styles | ||
| 17 | screen.expectFrame(0, { | 18 | screen.expectFrame(0, { |
| 18 | merged: testing.MockScreen.sync([ | 19 | merged: testing.MockScreen.sync([ |
| 19 | `\r${FB}⠋${FR} hello${R}\n`, | 20 | `${FB}⠋${FR} hello${R}\n`, |
| 20 | `${FB}⠋${FR} cats${R}\n`, | 21 | `${FB}⠋${FR} cats${R}\n`, |
| 21 | "└─ meow\n", | 22 | "└─ meow\n", |
| 22 | ]), | 23 | ]), |
| 23 | }); | 24 | }); |
| 24 | 25 | ||
| 26 | // spinners animate from the clock, so frames tick at the spinner rate | ||
| 25 | screen.expectFrame(80, { | 27 | screen.expectFrame(80, { |
| 26 | merged: testing.MockScreen.sync([ | 28 | merged: testing.MockScreen.sync([ |
| 27 | `\r` + ansi.cursorUp(3), | 29 | ansi.cursorUp(3), |
| 28 | `${FB}⠙${FR} hello${R}\n`, | 30 | `${FB}⠙${FR} hello${R}\n`, |
| 29 | `${FB}⠙${FR} cats${R}\n`, | 31 | `${FB}⠙${FR} cats${R}\n`, |
| 30 | "└─ meow\n", | 32 | "└─ meow\n", |
| ... | @@ -34,13 +36,66 @@ test.skip("trivial end-to-end example", () => { | ... | @@ -34,13 +36,66 @@ test.skip("trivial end-to-end example", () => { |
| 34 | a.end(); | 36 | a.end(); |
| 35 | b.end(); | 37 | b.end(); |
| 36 | 38 | ||
| 37 | screen.expectFrame(80, { | 39 | // ending fires the change debounce, which stops the widget and clears it |
| 40 | screen.expectFrame(1, { merged: "" }); | ||
| 41 | screen.expectFrame(0, { | ||
| 38 | merged: testing.MockScreen.sync([ | 42 | merged: testing.MockScreen.sync([ |
| 39 | `\r` + ansi.cursorUp(1) + ansi.clearFullLine, | ||
| 40 | ansi.cursorUp(1) + ansi.clearFullLine, | 43 | ansi.cursorUp(1) + ansi.clearFullLine, |
| 41 | ansi.cursorUp(1) + ansi.clearFullLine, | 44 | ansi.cursorUp(1) + ansi.clearFullLine, |
| 45 | ansi.cursorUp(1) + ansi.clearFullLine, | ||
| 46 | ]), | ||
| 47 | }); | ||
| 48 | screen.expectNone(); | ||
| 49 | }); | ||
| 50 | |||
| 51 | test("bars without estimates do not animate", () => { | ||
| 52 | const screen = new testing.MockScreen(); | ||
| 53 | const root = new progress.Root(screen); | ||
| 54 | progress.attachToScreen(root, screen); | ||
| 55 | |||
| 56 | const a = root.start("download", { total: 4, value: 1 }); | ||
| 57 | screen.expectFrame(1, { merged: "" }); // debounce | ||
| 58 | screen.expectFrame(0, {}); // initial draw | ||
| 59 | // a bar is static between value changes, so no frame timer is pending | ||
| 60 | screen.expectNone(); | ||
| 61 | |||
| 62 | a.inc(); | ||
| 63 | screen.expectFrame(1, { merged: "" }); | ||
| 64 | screen.expectFrame(0, {}); | ||
| 65 | screen.expectNone(); | ||
| 66 | |||
| 67 | a.end(); | ||
| 68 | screen.expectFrame(1, { merged: "" }); | ||
| 69 | screen.expectFrame(0, {}); | ||
| 70 | screen.expectNone(); | ||
| 71 | }); | ||
| 72 | |||
| 73 | test("attachToScreen dispose detaches from the root", () => { | ||
| 74 | const screen = new testing.MockScreen(); | ||
| 75 | const root = new progress.Root(screen); | ||
| 76 | const detach = progress.attachToScreen(root, screen); | ||
| 77 | |||
| 78 | const a = root.start("hello"); | ||
| 79 | screen.expectFrame(1, { merged: "" }); | ||
| 80 | screen.expectFrame(0, { | ||
| 81 | stderr: testing.MockScreen.sync([ | ||
| 82 | `${ansi.style(ansi.fgBlue, "⠋")} hello${ansi.reset}\n`, | ||
| 83 | ]), | ||
| 84 | }); | ||
| 85 | |||
| 86 | detach[Symbol.dispose](); | ||
| 87 | // the widget is stopped and removed from the screen on detach | ||
| 88 | screen.expectFrame(0, { | ||
| 89 | stderr: testing.MockScreen.sync([ | ||
| 90 | ansi.cursorUp(1), | ||
| 91 | ansi.clearFullLine, | ||
| 42 | ]), | 92 | ]), |
| 43 | }); | 93 | }); |
| 94 | |||
| 95 | // further progress activity no longer renders | ||
| 96 | a.inc(); | ||
| 97 | screen.expectFrame(1, { merged: "" }); // root's own debounce still runs | ||
| 98 | screen.expectNone(); | ||
| 44 | }); | 99 | }); |
| 45 | 100 | ||
| 46 | describe("encodeEventStream", (t) => { | 101 | describe("encodeEventStream", (t) => { |
| ... | @@ -73,6 +128,99 @@ describe("encodeEventStream", (t) => { | ... | @@ -73,6 +128,99 @@ describe("encodeEventStream", (t) => { |
| 73 | [1], | 128 | [1], |
| 74 | ]); | 129 | ]); |
| 75 | }); | 130 | }); |
| 131 | |||
| 132 | test("end closes the stream and decode resolves", async () => { | ||
| 133 | const root = new progress.Root<number>(); | ||
| 134 | const a = root.start("task"); | ||
| 135 | const handle = progress.decodeEventStream<number>( | ||
| 136 | progress.encodeEventStream(root), | ||
| 137 | progress.nullNode, | ||
| 138 | ); | ||
| 139 | a.end(); | ||
| 140 | root.end(42); | ||
| 141 | assert.equal(await handle, 42); | ||
| 142 | }); | ||
| 143 | |||
| 144 | test("error rejects the decode promise", async () => { | ||
| 145 | const root = new progress.Root(); | ||
| 146 | root.start("task"); | ||
| 147 | const handle = progress.decodeEventStream( | ||
| 148 | progress.encodeEventStream(root), | ||
| 149 | progress.nullNode, | ||
| 150 | ); | ||
| 151 | root.error(new Error("compute exploded")); | ||
| 152 | await assert.rejects(handle, /compute exploded/); | ||
| 153 | }); | ||
| 154 | |||
| 155 | test("custom events pass through", async () => { | ||
| 156 | const root = new progress.Root<number, { meow: [string] }>(); | ||
| 157 | root.start("task"); | ||
| 158 | const handle = progress.decodeEventStream<number, { meow: [string] }>( | ||
| 159 | progress.encodeEventStream(root), | ||
| 160 | progress.nullNode, | ||
| 161 | ); | ||
| 162 | const got: string[] = []; | ||
| 163 | handle.on("meow", (s) => void got.push(s)); | ||
| 164 | root.emit("meow", "purr"); | ||
| 165 | root.end(1); | ||
| 166 | assert.equal(await handle, 1); | ||
| 167 | assert.deepEqual(got, ["purr"]); | ||
| 168 | }); | ||
| 169 | |||
| 170 | test("a stream that closes without end rejects", async () => { | ||
| 171 | const encoded = new ReadableStream<progress.StreamEvent>({ | ||
| 172 | start(c) { | ||
| 173 | c.enqueue([-1]); | ||
| 174 | c.close(); | ||
| 175 | }, | ||
| 176 | }); | ||
| 177 | const handle = progress.decodeEventStream(encoded, progress.nullNode); | ||
| 178 | await assert.rejects(handle, /Stream terminated early/); | ||
| 179 | }); | ||
| 180 | |||
| 181 | test("events after a long idle gap are not throttled", async () => { | ||
| 182 | let t = 0; | ||
| 183 | const delays: number[] = []; | ||
| 184 | const root = new progress.Root({ | ||
| 185 | now: () => t, | ||
| 186 | delay: (ms) => (delays.push(ms), async.delay(0)), | ||
| 187 | }); | ||
| 188 | progress.encodeEventStream(root); | ||
| 189 | t = 1000; | ||
| 190 | // regression: the throttle used to wait `idle + throttleMs` instead of | ||
| 191 | // `throttleMs - idle`, so this would schedule a ~1033ms delay | ||
| 192 | root.start("x"); | ||
| 193 | assert.ok( | ||
| 194 | delays.every((ms) => ms <= 1000 / 30 + 1), | ||
| 195 | `delays: ${delays.join(", ")}`, | ||
| 196 | ); | ||
| 197 | }); | ||
| 198 | |||
| 199 | test("byte stream round trip with end", async () => { | ||
| 200 | const root = new progress.Root<string>(); | ||
| 201 | const a = root.start("task", { total: 3 }); | ||
| 202 | const handle = progress.decodeByteStream<string>( | ||
| 203 | progress.encodeByteStream(root), | ||
| 204 | progress.nullNode, | ||
| 205 | ); | ||
| 206 | a.inc(); | ||
| 207 | a.end(); | ||
| 208 | root.end("done"); | ||
| 209 | assert.equal(await handle, "done"); | ||
| 210 | }); | ||
| 211 | }); | ||
| 212 | |||
| 213 | test("child truncation ends with a newline", () => { | ||
| 214 | const root = new progress.Root(); | ||
| 215 | const a = root.start("many"); | ||
| 216 | for (let i = 0; i < 55; i += 1) a.start(`item ${i}`); | ||
| 217 | root.start("after"); | ||
| 218 | const lines = ansi.strip(progress.formatAnsi(0, root.active)).split("\n"); | ||
| 219 | const trunc = lines.indexOf("└─ [5 more]"); | ||
| 220 | assert.notEqual(trunc, -1, "expected a truncation indicator"); | ||
| 221 | // regression: the indicator had no trailing newline, so the next top-level | ||
| 222 | // node rendered on the same line | ||
| 223 | assert.equal(lines[trunc + 1], "⠋ after"); | ||
| 76 | }); | 224 | }); |
| 77 | 225 | ||
| 78 | // ## value formatters | 226 | // ## value formatters |
| ... | @@ -294,6 +442,7 @@ type EncodedKey = typeof progress.internals.EncodedKey; | ... | @@ -294,6 +442,7 @@ type EncodedKey = typeof progress.internals.EncodedKey; |
| 294 | import assert from "node:assert/strict"; | 442 | import assert from "node:assert/strict"; |
| 295 | import { describe, test, vi } from "vitest"; | 443 | import { describe, test, vi } from "vitest"; |
| 296 | import { UNWRAP } from "./assert.ts"; | 444 | import { UNWRAP } from "./assert.ts"; |
| 445 | import * as async from "./async.ts"; | ||
| 297 | import * as progress from "./progress.ts"; | 446 | import * as progress from "./progress.ts"; |
| 298 | import * as stream from "./stream.ts"; | 447 | import * as stream from "./stream.ts"; |
| 299 | import * as ansi from "./string/ansi.ts"; | 448 | import * as ansi from "./string/ansi.ts"; |
lib/progress.ts+75-19| ... | @@ -622,12 +622,9 @@ function endNode<R, M extends Events.Map>(owner: Root<R, M>, state: Internal) { | ... | @@ -622,12 +622,9 @@ function endNode<R, M extends Events.Map>(owner: Root<R, M>, state: Internal) { |
| 622 | state.children.forEach((child) => endNode(owner, child)); | 622 | state.children.forEach((child) => endNode(owner, child)); |
| 623 | state.detached = true; | 623 | state.detached = true; |
| 624 | globalKeyPool.recycle(state.key); | 624 | globalKeyPool.recycle(state.key); |
| 625 | let title = state.text; | ||
| 626 | if (state.parent) { | 625 | if (state.parent) { |
| 627 | const i = state.parent.children.indexOf(state); | 626 | const i = state.parent.children.indexOf(state); |
| 628 | ASSERT(i !== -1); | 627 | ASSERT(i !== -1); |
| 629 | let p: Internal | null = state; | ||
| 630 | while (p = p.parent) title = p.text + " / " + title; | ||
| 631 | state.parent.children.splice(i, 1); | 628 | state.parent.children.splice(i, 1); |
| 632 | } | 629 | } |
| 633 | owner.emit("node-end", state); | 630 | owner.emit("node-end", state); |
| ... | @@ -732,7 +729,7 @@ function renderChildren(state: ReadOnlyNode, now: number, depth: boolean[]) { | ... | @@ -732,7 +729,7 @@ function renderChildren(state: ReadOnlyNode, now: number, depth: boolean[]) { |
| 732 | } | 729 | } |
| 733 | if (truncated) { | 730 | if (truncated) { |
| 734 | out += depth.map((x) => x ? box.line : " ").join("") + box.langle; | 731 | out += depth.map((x) => x ? box.line : " ").join("") + box.langle; |
| 735 | out += ansi.style(ansi.fgBrightBlack, `[${truncated} more]`); | 732 | out += ansi.style(ansi.fgBrightBlack, `[${truncated} more]`) + "\n"; |
| 736 | } | 733 | } |
| 737 | return out; | 734 | return out; |
| 738 | } | 735 | } |
| ... | @@ -793,8 +790,11 @@ export function attachToScreen( | ... | @@ -793,8 +790,11 @@ export function attachToScreen( |
| 793 | format: ({ now }) => formatAnsi(now, root.active), | 790 | format: ({ now }) => formatAnsi(now, root.active), |
| 794 | }) ?? null; | 791 | }) ?? null; |
| 795 | if (!widget) return; | 792 | if (!widget) return; |
| 796 | widget.fps = items.some((x) => x.showTotal !== false && x.total > 0) | 793 | // scan for spinners and visible estimations |
| 794 | widget.fps = items.some((x) => !x.hidden && !(x.showTotal !== false && x.total > 0)) | ||
| 797 | ? spinnerFps | 795 | ? spinnerFps |
| 796 | : hasEstimate(items) | ||
| 797 | ? 1 | ||
| 798 | : null; | 798 | : null; |
| 799 | widget.redraw(); | 799 | widget.redraw(); |
| 800 | } else { | 800 | } else { |
| ... | @@ -817,7 +817,16 @@ export function attachToScreen( | ... | @@ -817,7 +817,16 @@ export function attachToScreen( |
| 817 | } | 817 | } |
| 818 | })); | 818 | })); |
| 819 | 819 | ||
| 820 | return ts.defer(() => stack[Symbol.dispose]); | 820 | stack.defer(() => { |
| 821 | widget?.stop(); | ||
| 822 | widget = null; | ||
| 823 | }); | ||
| 824 | |||
| 825 | return ts.defer(() => stack.dispose()); | ||
| 826 | } | ||
| 827 | |||
| 828 | function hasEstimate(nodes: readonly ReadOnlyNode[]): boolean { | ||
| 829 | return nodes.some((n) => n.estimatedTime != null || hasEstimate(n.children)); | ||
| 821 | } | 830 | } |
| 822 | 831 | ||
| 823 | const header = /* @__PURE__ */ string.encodeUtf8("clover's progress <3\n"); | 832 | const header = /* @__PURE__ */ string.encodeUtf8("clover's progress <3\n"); |
| ... | @@ -915,7 +924,7 @@ export function encodeEventStream< | ... | @@ -915,7 +924,7 @@ export function encodeEventStream< |
| 915 | let timer: async.Cancelable<void> | null = null; | 924 | let timer: async.Cancelable<void> | null = null; |
| 916 | function emitSoon() { | 925 | function emitSoon() { |
| 917 | if (timer || ready.value) return; | 926 | if (timer || ready.value) return; |
| 918 | const remaining = now() - lastEvent + throttleMs; | 927 | const remaining = lastEvent + throttleMs - now(); |
| 919 | if (remaining > 0) { | 928 | if (remaining > 0) { |
| 920 | (timer = delay(remaining)).then(() => { | 929 | (timer = delay(remaining)).then(() => { |
| 921 | timer = null; | 930 | timer = null; |
| ... | @@ -927,6 +936,13 @@ export function encodeEventStream< | ... | @@ -927,6 +936,13 @@ export function encodeEventStream< |
| 927 | lastEvent = now(); | 936 | lastEvent = now(); |
| 928 | } | 937 | } |
| 929 | } | 938 | } |
| 939 | /** terminal events skip the throttle so the stream ends promptly */ | ||
| 940 | function emitNow() { | ||
| 941 | timer?.cancel(); | ||
| 942 | timer = null; | ||
| 943 | ready.value = true; | ||
| 944 | lastEvent = now(); | ||
| 945 | } | ||
| 930 | stack.defer(() => { | 946 | stack.defer(() => { |
| 931 | if (timer) timer.cancel(); | 947 | if (timer) timer.cancel(); |
| 932 | s.changed.clear(); | 948 | s.changed.clear(); |
| ... | @@ -959,6 +975,22 @@ export function encodeEventStream< | ... | @@ -959,6 +975,22 @@ export function encodeEventStream< |
| 959 | s.deleted.add(node); // handle add and remove in same frame | 975 | s.deleted.add(node); // handle add and remove in same frame |
| 960 | emitSoon(); | 976 | emitSoon(); |
| 961 | })); | 977 | })); |
| 978 | // custom events pass through the stream verbatim | ||
| 979 | stack.use(root.onAny((channel, args) => { | ||
| 980 | if (rootEvents.has(channel)) return; | ||
| 981 | s.pendingEvents.push([channel as string, ...args as ts.Json[]]); | ||
| 982 | emitSoon(); | ||
| 983 | })); | ||
| 984 | stack.use(root.on("end", (result) => { | ||
| 985 | s.pendingEvents.push(["end", result]); | ||
| 986 | s.ended = true; | ||
| 987 | emitNow(); | ||
| 988 | })); | ||
| 989 | stack.use(root.on("error", (error) => { | ||
| 990 | s.pendingEvents.push(["error", errors.message(error)]); | ||
| 991 | s.ended = true; | ||
| 992 | emitNow(); | ||
| 993 | })); | ||
| 962 | }, | 994 | }, |
| 963 | async pull(controller) { | 995 | async pull(controller) { |
| 964 | await ready.until((x) => x === true); | 996 | await ready.until((x) => x === true); |
| ... | @@ -969,10 +1001,13 @@ export function encodeEventStream< | ... | @@ -969,10 +1001,13 @@ export function encodeEventStream< |
| 969 | } | 1001 | } |
| 970 | controller.enqueue(s.getDelta(isFirst)); | 1002 | controller.enqueue(s.getDelta(isFirst)); |
| 971 | isFirst = false; | 1003 | isFirst = false; |
| 1004 | if (s.ended) { | ||
| 1005 | controller.close(); | ||
| 1006 | stack.dispose(); | ||
| 1007 | } | ||
| 972 | }, | 1008 | }, |
| 973 | cancel() { | 1009 | cancel() { |
| 974 | stack.dispose(); | 1010 | stack.dispose(); |
| 975 | (0, performance.now)(); | ||
| 976 | }, | 1011 | }, |
| 977 | }, { highWaterMark: 0 }); | 1012 | }, { highWaterMark: 0 }); |
| 978 | } | 1013 | } |
| ... | @@ -1003,11 +1038,10 @@ export function decodeEventStream< | ... | @@ -1003,11 +1038,10 @@ export function decodeEventStream< |
| 1003 | const { value, done } = await reader.read(); | 1038 | const { value, done } = await reader.read(); |
| 1004 | signal.throwIfAborted(); | 1039 | signal.throwIfAborted(); |
| 1005 | if (done) break; | 1040 | if (done) break; |
| 1006 | decoder.processEvent(value); | 1041 | if (decoder.processEvent(value)) hasEmittedEnd = true; |
| 1007 | } | 1042 | } |
| 1008 | ASSERT(hasEmittedEnd, "Stream terminated early."); | 1043 | ASSERT(hasEmittedEnd, "Stream terminated early."); |
| 1009 | } catch (err) { | 1044 | } catch (err) { |
| 1010 | console.error(err); | ||
| 1011 | reader.cancel(err); | 1045 | reader.cancel(err); |
| 1012 | if (!signal.aborted) reject(err); | 1046 | if (!signal.aborted) reject(err); |
| 1013 | } finally { | 1047 | } finally { |
| ... | @@ -1037,6 +1071,10 @@ class Encoder< | ... | @@ -1037,6 +1071,10 @@ class Encoder< |
| 1037 | rootChildrenUpdated = true; | 1071 | rootChildrenUpdated = true; |
| 1038 | changed = new Map<ReadOnlyNode, Set<keyof ReadOnlyNode>>(); | 1072 | changed = new Map<ReadOnlyNode, Set<keyof ReadOnlyNode>>(); |
| 1039 | deleted = new Set<ReadOnlyNode>(); | 1073 | deleted = new Set<ReadOnlyNode>(); |
| 1074 | /** custom and terminal ("end"/"error") events awaiting the next delta */ | ||
| 1075 | pendingEvents: StreamCustomEvent[] = []; | ||
| 1076 | /** set when "end" or "error" was recorded; the stream closes after flushing */ | ||
| 1077 | ended = false; | ||
| 1040 | 1078 | ||
| 1041 | constructor( | 1079 | constructor( |
| 1042 | root: Root<Result, EventMap>, | 1080 | root: Root<Result, EventMap>, |
| ... | @@ -1119,8 +1157,11 @@ class Encoder< | ... | @@ -1119,8 +1157,11 @@ class Encoder< |
| 1119 | ); | 1157 | ); |
| 1120 | } | 1158 | } |
| 1121 | 1159 | ||
| 1160 | payload.push(...this.pendingEvents); | ||
| 1161 | |||
| 1122 | this.deleted.clear(); | 1162 | this.deleted.clear(); |
| 1123 | this.changed.clear(); | 1163 | this.changed.clear(); |
| 1164 | this.pendingEvents = []; | ||
| 1124 | this.rootChildrenUpdated = false; | 1165 | this.rootChildrenUpdated = false; |
| 1125 | 1166 | ||
| 1126 | return payload; | 1167 | return payload; |
| ... | @@ -1182,7 +1223,6 @@ class Decoder< | ... | @@ -1182,7 +1223,6 @@ class Decoder< |
| 1182 | events: Events<EventMap>; | 1223 | events: Events<EventMap>; |
| 1183 | resolve: (result: Result) => void; | 1224 | resolve: (result: Result) => void; |
| 1184 | active = new Map<EncodedKey, Node>(); | 1225 | active = new Map<EncodedKey, Node>(); |
| 1185 | children = new Map<Node, Node[]>(); | ||
| 1186 | pendingStart = new Map<EncodedKey, PendingStart>(); | 1226 | pendingStart = new Map<EncodedKey, PendingStart>(); |
| 1187 | pendingParents = new Map<EncodedKey, EncodedKey>(); | 1227 | pendingParents = new Map<EncodedKey, EncodedKey>(); |
| 1188 | 1228 | ||
| ... | @@ -1201,7 +1241,8 @@ class Decoder< | ... | @@ -1201,7 +1241,8 @@ class Decoder< |
| 1201 | this.active.clear(); | 1241 | this.active.clear(); |
| 1202 | } | 1242 | } |
| 1203 | 1243 | ||
| 1204 | processEvent(event: StreamEvent) { | 1244 | /** returns whether the stream signalled "end"; throws on "error" */ |
| 1245 | processEvent(event: StreamEvent): boolean { | ||
| 1205 | event = event.slice(); | 1246 | event = event.slice(); |
| 1206 | 1247 | ||
| 1207 | // delete unreferenced nodes first, since they may get re-allocated | 1248 | // delete unreferenced nodes first, since they may get re-allocated |
| ... | @@ -1236,6 +1277,8 @@ class Decoder< | ... | @@ -1236,6 +1277,8 @@ class Decoder< |
| 1236 | if (chunk[0] === "end") { | 1277 | if (chunk[0] === "end") { |
| 1237 | resolving = true; | 1278 | resolving = true; |
| 1238 | resolvingValue = chunk[1]; | 1279 | resolvingValue = chunk[1]; |
| 1280 | } else if (chunk[0] === "error") { | ||
| 1281 | throw new Error(`Progress stream error: ${chunk[1]}`); | ||
| 1239 | } else { | 1282 | } else { |
| 1240 | // custom event | 1283 | // custom event |
| 1241 | // @ts-expect-error TODO: typescript soundness | 1284 | // @ts-expect-error TODO: typescript soundness |
| ... | @@ -1300,13 +1343,21 @@ class Decoder< | ... | @@ -1300,13 +1343,21 @@ class Decoder< |
| 1300 | } | 1343 | } |
| 1301 | this.pendingParents.clear(); | 1344 | this.pendingParents.clear(); |
| 1302 | 1345 | ||
| 1346 | // validate that every referenced child became active; a failure here | ||
| 1347 | // means the stream is desynced | ||
| 1303 | for (const { key, children } of updateChildren) { | 1348 | for (const { key, children } of updateChildren) { |
| 1304 | const node = UNWRAP(this.active.get(key)); | 1349 | UNWRAP(this.active.get(key), () => `parent ${key} not active`); |
| 1305 | this.children.set( | 1350 | for (const id of children) { |
| 1306 | node, | 1351 | UNWRAP(this.active.get(id), () => `child ${id} not active`); |
| 1307 | children.map((id) => UNWRAP(this.active.get(id))), | 1352 | } |
| 1308 | ); | ||
| 1309 | } | 1353 | } |
| 1354 | |||
| 1355 | if (resolving) { | ||
| 1356 | this.reset(); | ||
| 1357 | this.resolve(resolvingValue as Result); | ||
| 1358 | return true; | ||
| 1359 | } | ||
| 1360 | return false; | ||
| 1310 | } | 1361 | } |
| 1311 | 1362 | ||
| 1312 | startRecursive(key: EncodedKey, opts: PendingStart): Ref { | 1363 | startRecursive(key: EncodedKey, opts: PendingStart): Ref { |
| ... | @@ -1526,10 +1577,14 @@ export function decodeByteStream< | ... | @@ -1526,10 +1577,14 @@ export function decodeByteStream< |
| 1526 | async start(controller) { | 1577 | async start(controller) { |
| 1527 | reader = new stream.BufferedReader(encoded.getReader()); | 1578 | reader = new stream.BufferedReader(encoded.getReader()); |
| 1528 | try { | 1579 | try { |
| 1529 | while (reader) controller.enqueue(await readStreamEvent(reader)); | 1580 | // an event boundary with no further bytes is a clean end of stream |
| 1581 | while (reader && await reader.ensureAvailableOrFalse(1)) { | ||
| 1582 | controller.enqueue(await readStreamEvent(reader)); | ||
| 1583 | } | ||
| 1584 | if (!cancelled) controller.close(); | ||
| 1530 | } catch (e) { | 1585 | } catch (e) { |
| 1531 | if (!cancelled) { | 1586 | if (!cancelled) { |
| 1532 | reader.cancel(e); | 1587 | reader?.cancel(e); |
| 1533 | throw e; | 1588 | throw e; |
| 1534 | } | 1589 | } |
| 1535 | } finally { | 1590 | } finally { |
| ... | @@ -1802,6 +1857,7 @@ export const internals: { | ... | @@ -1802,6 +1857,7 @@ export const internals: { |
| 1802 | import { ASSERT, UNWRAP } from "./assert.ts"; | 1857 | import { ASSERT, UNWRAP } from "./assert.ts"; |
| 1803 | import * as async from "./async.ts"; | 1858 | import * as async from "./async.ts"; |
| 1804 | import * as bytes from "./bytes.ts"; | 1859 | import * as bytes from "./bytes.ts"; |
| 1860 | import * as errors from "./error.ts"; | ||
| 1805 | import { Events } from "./Events.ts"; | 1861 | import { Events } from "./Events.ts"; |
| 1806 | import * as log from "./log.ts"; | 1862 | import * as log from "./log.ts"; |
| 1807 | import * as stack from "./log/stack.ts"; | 1863 | import * as stack from "./log/stack.ts"; |
lib/string.test.ts+6-4| ... | @@ -3,10 +3,12 @@ test("countNewlines", () => { | ... | @@ -3,10 +3,12 @@ test("countNewlines", () => { |
| 3 | assert.equal(string.countNewlines(""), 0); | 3 | assert.equal(string.countNewlines(""), 0); |
| 4 | }); | 4 | }); |
| 5 | test("escapeRegExp", () => { | 5 | test("escapeRegExp", () => { |
| 6 | assert.equal( | 6 | // the exact escape encoding differs between the native `RegExp.escape` |
| 7 | string.escapeRegExp("delete * from users where id = $id"), | 7 | // (hex escapes) and the fallback (backslashes); assert the behavior |
| 8 | "delete \\* from users where id = \\$id", | 8 | const input = "delete * from users where id = $id"; |
| 9 | ); | 9 | const re = new RegExp(`^${string.escapeRegExp(input)}$`); |
| 10 | assert.ok(re.test(input)); | ||
| 11 | assert.ok(!re.test("delete x from users where id = yid")); | ||
| 10 | }); | 12 | }); |
| 11 | test("escapeShellArgument", () => { | 13 | test("escapeShellArgument", () => { |
| 12 | // escapeShellArgument leaves it alone when the input is trivial | 14 | // escapeShellArgument leaves it alone when the input is trivial |
lib/string/ansi.ts+5-7| ... | @@ -164,6 +164,8 @@ export const clearToEndOfLine: string = "\x1b[K"; | ... | @@ -164,6 +164,8 @@ export const clearToEndOfLine: string = "\x1b[K"; |
| 164 | export const clearToStartOfLine: string = "\x1b[1K"; | 164 | export const clearToStartOfLine: string = "\x1b[1K"; |
| 165 | /** clear the entire line that the cursor is on without moving it. */ | 165 | /** clear the entire line that the cursor is on without moving it. */ |
| 166 | export const clearFullLine: string = "\x1b[2K"; | 166 | export const clearFullLine: string = "\x1b[2K"; |
| 167 | /** clear from and including the current cell to the end of the screen. */ | ||
| 168 | export const clearToEndOfScreen: string = "\x1b[0J"; | ||
| 167 | 169 | ||
| 168 | /** | 170 | /** |
| 169 | * Begin Synchronized Output | 171 | * Begin Synchronized Output |
| ... | @@ -257,6 +259,7 @@ export function debugAnsi(text: string): string { | ... | @@ -257,6 +259,7 @@ export function debugAnsi(text: string): string { |
| 257 | "clearToEndOfLine", | 259 | "clearToEndOfLine", |
| 258 | "clearToStartOfLine", | 260 | "clearToStartOfLine", |
| 259 | "clearFullLine", | 261 | "clearFullLine", |
| 262 | "clearToEndOfScreen", | ||
| 260 | "syncStart", | 263 | "syncStart", |
| 261 | "syncEnd", | 264 | "syncEnd", |
| 262 | "bold", | 265 | "bold", |
| ... | @@ -292,14 +295,9 @@ export function widthInTerminal(str: string): number { | ... | @@ -292,14 +295,9 @@ export function widthInTerminal(str: string): number { |
| 292 | width += 1; | 295 | width += 1; |
| 293 | continue; | 296 | continue; |
| 294 | } | 297 | } |
| 298 | // the remainder of the string is measured with the segmenter, which | ||
| 299 | // handles ascii correctly via segmentWidth (controls are zero-width). | ||
| 295 | for (const { segment } of segmenter.segment(str.slice(i))) { | 300 | for (const { segment } of segmenter.segment(str.slice(i))) { |
| 296 | if (segment.length === 1) { | ||
| 297 | if (code < 32) continue; | ||
| 298 | if (code < 127) { | ||
| 299 | width += 1; | ||
| 300 | continue; | ||
| 301 | } | ||
| 302 | } | ||
| 303 | width += segmentWidth(segment); | 301 | width += segmentWidth(segment); |
| 304 | } | 302 | } |
| 305 | return width; | 303 | return width; |
lib/testing.ts+34-17| ... | @@ -114,7 +114,7 @@ export class SyncPromise<T> implements Promise<T> { | ... | @@ -114,7 +114,7 @@ export class SyncPromise<T> implements Promise<T> { |
| 114 | export class FakeTimers { | 114 | export class FakeTimers { |
| 115 | time = 0; | 115 | time = 0; |
| 116 | entries: Array<{ | 116 | entries: Array<{ |
| 117 | duration: number; | 117 | deadline: number; |
| 118 | resolve: () => void; | 118 | resolve: () => void; |
| 119 | src: stack.Frame[]; | 119 | src: stack.Frame[]; |
| 120 | }> = []; | 120 | }> = []; |
| ... | @@ -124,21 +124,23 @@ export class FakeTimers { | ... | @@ -124,21 +124,23 @@ export class FakeTimers { |
| 124 | }; | 124 | }; |
| 125 | delay: typeof async.delay = (ms) => { | 125 | delay: typeof async.delay = (ms) => { |
| 126 | const src = stack.capture(2); | 126 | const src = stack.capture(2); |
| 127 | let entry: (typeof this.entries)[number]; | ||
| 127 | return async.makeCancelable( | 128 | return async.makeCancelable( |
| 128 | new SyncPromise((resolve) => { | 129 | new SyncPromise((resolve) => { |
| 129 | ASSERT(this.entries.length === 0); | 130 | entry = { deadline: this.time + ms, resolve, src }; |
| 130 | this.entries.push({ | 131 | this.entries.push(entry); |
| 131 | duration: ms, | ||
| 132 | resolve, | ||
| 133 | src, | ||
| 134 | }); | ||
| 135 | }), | 132 | }), |
| 136 | () => { | 133 | () => { |
| 137 | ASSERT(this.entries.length === 1); | 134 | const i = this.entries.indexOf(entry); |
| 138 | this.entries.length = 0; | 135 | if (i !== -1) this.entries.splice(i, 1); |
| 139 | }, | 136 | }, |
| 140 | ); | 137 | ); |
| 141 | }; | 138 | }; |
| 139 | |||
| 140 | /** the pending timer that fires soonest */ | ||
| 141 | next(): (typeof this.entries)[number] | undefined { | ||
| 142 | return this.entries.toSorted((a, b) => a.deadline - b.deadline)[0]; | ||
| 143 | } | ||
| 142 | } | 144 | } |
| 143 | 145 | ||
| 144 | export function removeUndefinedKeys(obj: unknown): unknown { | 146 | export function removeUndefinedKeys(obj: unknown): unknown { |
| ... | @@ -176,12 +178,16 @@ export class MockScreen implements Disposable, log.WidgetHost { | ... | @@ -176,12 +178,16 @@ export class MockScreen implements Disposable, log.WidgetHost { |
| 176 | now: log.WidgetHost["now"]; | 178 | now: log.WidgetHost["now"]; |
| 177 | 179 | ||
| 178 | hasTerminalLock: null | "locked" | "temporary-unlock" = null; | 180 | hasTerminalLock: null | "locked" | "temporary-unlock" = null; |
| 181 | #sizeObservers = new Set<(size: { columns: number; rows: number }) => void>(); | ||
| 179 | 182 | ||
| 180 | static sync(text: string[]): string { | 183 | static sync(text: string[]): string { |
| 181 | return ansi.syncStart + text.join("") + ansi.syncEnd; | 184 | return ansi.syncStart + text.join("") + ansi.syncEnd; |
| 182 | } | 185 | } |
| 183 | 186 | ||
| 184 | constructor({ temporaryUnlocking }: { temporaryUnlocking?: boolean } = {}) { | 187 | constructor({ temporaryUnlocking, outputSharesScreen }: { |
| 188 | temporaryUnlocking?: boolean; | ||
| 189 | outputSharesScreen?: boolean; | ||
| 190 | } = {}) { | ||
| 185 | const callerFile = UNWRAP(stack.capture()[0]); | 191 | const callerFile = UNWRAP(stack.capture()[0]); |
| 186 | const host = log.createTerminalWidgetHost({ | 192 | const host = log.createTerminalWidgetHost({ |
| 187 | lockTerminal: () => { | 193 | lockTerminal: () => { |
| ... | @@ -210,8 +216,10 @@ export class MockScreen implements Disposable, log.WidgetHost { | ... | @@ -210,8 +216,10 @@ export class MockScreen implements Disposable, log.WidgetHost { |
| 210 | content, | 216 | content, |
| 211 | }); | 217 | }); |
| 212 | }, | 218 | }, |
| 213 | getSize: () => { | 219 | observeSize: (callback) => { |
| 214 | return this; | 220 | callback({ columns: this.columns, rows: this.rows }); |
| 221 | this.#sizeObservers.add(callback); | ||
| 222 | return () => void this.#sizeObservers.delete(callback); | ||
| 215 | }, | 223 | }, |
| 216 | temporaryUnlock: temporaryUnlocking | 224 | temporaryUnlock: temporaryUnlocking |
| 217 | ? () => { | 225 | ? () => { |
| ... | @@ -231,6 +239,7 @@ export class MockScreen implements Disposable, log.WidgetHost { | ... | @@ -231,6 +239,7 @@ export class MockScreen implements Disposable, log.WidgetHost { |
| 231 | now: this.timers.now, | 239 | now: this.timers.now, |
| 232 | delay: this.timers.delay, | 240 | delay: this.timers.delay, |
| 233 | color: true, | 241 | color: true, |
| 242 | outputSharesScreen, | ||
| 234 | }); | 243 | }); |
| 235 | this.writeOutput = host.writeOutput; | 244 | this.writeOutput = host.writeOutput; |
| 236 | this.writeError = host.writeError; | 245 | this.writeError = host.writeError; |
| ... | @@ -240,18 +249,26 @@ export class MockScreen implements Disposable, log.WidgetHost { | ... | @@ -240,18 +249,26 @@ export class MockScreen implements Disposable, log.WidgetHost { |
| 240 | this.now = host.now; | 249 | this.now = host.now; |
| 241 | } | 250 | } |
| 242 | 251 | ||
| 252 | /** simulate a terminal resize (SIGWINCH) */ | ||
| 253 | resize(columns: number, rows: number = this.rows) { | ||
| 254 | this.columns = columns; | ||
| 255 | this.rows = rows; | ||
| 256 | this.#sizeObservers.forEach((cb) => cb({ columns, rows })); | ||
| 257 | } | ||
| 258 | |||
| 243 | expectNone() { | 259 | expectNone() { |
| 244 | ASSERT(this.timers.entries.length === 0); | 260 | ASSERT(this.timers.entries.length === 0); |
| 245 | } | 261 | } |
| 246 | 262 | ||
| 247 | expectWithoutConsume(ms: number) { | 263 | expectWithoutConsume(ms: number) { |
| 248 | const wait = UNWRAP( | 264 | const wait = UNWRAP( |
| 249 | this.timers.entries[0], | 265 | this.timers.next(), |
| 250 | () => this.out.length > 0 ? "terminal i/o did not wait" : "no terminal i/o", | 266 | () => this.out.length > 0 ? "terminal i/o did not wait" : "no terminal i/o", |
| 251 | ); | 267 | ); |
| 268 | const duration = wait.deadline - this.timers.time; | ||
| 252 | ASSERT( | 269 | ASSERT( |
| 253 | ms === wait.duration, | 270 | ms === duration, |
| 254 | `expected ${ms}ms to pass, got ${wait.duration}, from:\n${ | 271 | `expected ${ms}ms to pass, got ${duration}, from:\n${ |
| 255 | wait.src.map((frame) => stack.formatFrame(frame, true)).join("\n") | 272 | wait.src.map((frame) => stack.formatFrame(frame, true)).join("\n") |
| 256 | }`, | 273 | }`, |
| 257 | ); | 274 | ); |
| ... | @@ -265,8 +282,8 @@ export class MockScreen implements Disposable, log.WidgetHost { | ... | @@ -265,8 +282,8 @@ export class MockScreen implements Disposable, log.WidgetHost { |
| 265 | }) { | 282 | }) { |
| 266 | if (ms != null) { | 283 | if (ms != null) { |
| 267 | const wait = this.expectWithoutConsume(ms); | 284 | const wait = this.expectWithoutConsume(ms); |
| 268 | this.timers.entries.shift(); | 285 | this.timers.entries.splice(this.timers.entries.indexOf(wait), 1); |
| 269 | this.timers.time += wait.duration; | 286 | this.timers.time = wait.deadline; |
| 270 | wait.resolve(); | 287 | wait.resolve(); |
| 271 | } else { | 288 | } else { |
| 272 | ASSERT(this.timers.entries.length === 0); | 289 | ASSERT(this.timers.entries.length === 0); |