@runwisp/ui 1.5.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -7
- package/src/lib/actions/focusTrap.ts +3 -3
- package/src/lib/actions/fold-to-fit.test.ts +36 -0
- package/src/lib/actions/fold-to-fit.ts +89 -0
- package/src/lib/actions/modal-dialog.test.ts +47 -0
- package/src/lib/actions/modal-dialog.ts +48 -0
- package/src/lib/components/AccordionItem.svelte +16 -5
- package/src/lib/components/Badge.svelte +4 -25
- package/src/lib/components/Button.svelte +2 -9
- package/src/lib/components/CodeBlock.svelte +7 -6
- package/src/lib/components/DataGrid.svelte +5 -5
- package/src/lib/components/Drawer.svelte +22 -30
- package/src/lib/components/DurationInput.svelte +6 -25
- package/src/lib/components/FilterBar.svelte +3 -7
- package/src/lib/components/FilterPill.svelte +2 -2
- package/src/lib/components/FormField.svelte +1 -1
- package/src/lib/components/Link.svelte +9 -4
- package/src/lib/components/LogConsole.svelte +101 -424
- package/src/lib/components/Modal.svelte +7 -30
- package/src/lib/components/SearchInput.svelte +1 -1
- package/src/lib/components/Select.svelte +3 -2
- package/src/lib/components/Sparkline.svelte +2 -2
- package/src/lib/components/Spinner.svelte +10 -5
- package/src/lib/components/StatusDot.svelte +1 -1
- package/src/lib/components/StatusIndicator.svelte +37 -104
- package/src/lib/components/Tabs.svelte +15 -1
- package/src/lib/components/Tooltip.svelte +1 -1
- package/src/lib/components/dashboard/RunActions.svelte +156 -0
- package/src/lib/components/dashboard/RunConsoleFrame.svelte +319 -0
- package/src/lib/components/dashboard/RunDetailPanel.svelte +137 -725
- package/src/lib/components/dashboard/RunFacts.svelte +119 -0
- package/src/lib/components/dashboard/RunFilterChips.svelte +52 -0
- package/src/lib/components/dashboard/RunFilterPopover.svelte +9 -10
- package/src/lib/components/dashboard/RunHeaderLine.svelte +295 -0
- package/src/lib/components/dashboard/RunListSkeleton.svelte +43 -0
- package/src/lib/components/dashboard/RunRow.svelte +202 -0
- package/src/lib/components/dashboard/RunsList.svelte +267 -545
- package/src/lib/components/dashboard/bulk-selection.svelte.test.ts +42 -0
- package/src/lib/components/dashboard/bulk-selection.svelte.ts +50 -0
- package/src/lib/components/dashboard/cron-format.test.ts +10 -1
- package/src/lib/components/dashboard/cron-format.ts +7 -3
- package/src/lib/components/dashboard/run-filters.test.ts +62 -2
- package/src/lib/components/dashboard/run-filters.ts +104 -99
- package/src/lib/components/dashboard/run-helpers.test.ts +131 -12
- package/src/lib/components/dashboard/run-helpers.ts +150 -44
- package/src/lib/components/dashboard/status-config.ts +18 -0
- package/src/lib/components/dashboard/types.ts +6 -0
- package/src/lib/components/data-grid-selection.ts +2 -9
- package/src/lib/components/filter-spec.ts +3 -3
- package/src/lib/index.ts +13 -2
- package/src/lib/log-console/AnsiLine.svelte +12 -0
- package/src/lib/log-console/FrameHistoryBlock.svelte +57 -0
- package/src/lib/log-console/LogCache.svelte.ts +1 -1
- package/src/lib/log-console/LogConsoleFooter.svelte +88 -0
- package/src/lib/log-console/ansi.ts +1 -1
- package/src/lib/log-console/frame-history.svelte.test.ts +47 -0
- package/src/lib/log-console/frame-history.svelte.ts +71 -0
- package/src/lib/log-console/wrap-layout.svelte.test.ts +92 -0
- package/src/lib/log-console/wrap-layout.svelte.ts +188 -0
- package/src/lib/theme-tokens.css +21 -21
- package/src/lib/theme.css +13 -13
- package/src/lib/utils/format.test.ts +125 -3
- package/src/lib/utils/format.ts +101 -8
- package/src/lib/utils/toast.svelte.ts +2 -3
- package/src/lib/utils/typing-target.ts +10 -0
- package/src/stories/Accordion.stories.svelte +60 -1
- package/src/stories/CodeBlock.stories.svelte +13 -0
- package/src/stories/Link.stories.svelte +25 -1
- package/src/stories/LogConsole.stories.svelte +2 -2
- package/src/stories/Tabs.stories.svelte +17 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
|
|
2
|
+
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
3
|
+
|
|
4
|
+
<script lang="ts">
|
|
5
|
+
import AnsiLine from "./AnsiLine.svelte";
|
|
6
|
+
import { FRAME_BLOCK_PAD, FRAME_GAP, type FrameHistory } from "./frame-history.svelte.js";
|
|
7
|
+
import { LINE_HEIGHT } from "./wrap-layout.svelte.js";
|
|
8
|
+
|
|
9
|
+
let {
|
|
10
|
+
history,
|
|
11
|
+
top,
|
|
12
|
+
gutterWidth,
|
|
13
|
+
}: {
|
|
14
|
+
history: FrameHistory;
|
|
15
|
+
/** Distance from the top of the virtual surface, px. */
|
|
16
|
+
top: number;
|
|
17
|
+
gutterWidth: number;
|
|
18
|
+
} = $props();
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<div
|
|
22
|
+
class="frame-history absolute right-0 left-0 overflow-y-auto border-y border-[var(--rw-con-gutter)] bg-[var(--rw-con-panel)]"
|
|
23
|
+
style="top: {top}px; height: {history.blockHeight}px;"
|
|
24
|
+
>
|
|
25
|
+
<div style="padding: {FRAME_BLOCK_PAD}px 0;">
|
|
26
|
+
{#if history.frames}
|
|
27
|
+
{#each history.frames as frame, fi (fi)}
|
|
28
|
+
<div style="margin-top: {fi > 0 ? FRAME_GAP : 0}px;">
|
|
29
|
+
<div
|
|
30
|
+
class="px-3 text-xs text-[var(--rw-con-dim)] select-none"
|
|
31
|
+
style="height: {LINE_HEIGHT}px; line-height: {LINE_HEIGHT}px;"
|
|
32
|
+
>
|
|
33
|
+
Frame {fi + 1} of {history.frames.length}
|
|
34
|
+
</div>
|
|
35
|
+
{#each frame as row, ri (ri)}
|
|
36
|
+
<div class="flex items-center" style="height: {LINE_HEIGHT}px;">
|
|
37
|
+
<div
|
|
38
|
+
class="flex-shrink-0 pr-3 text-right text-[var(--rw-con-gutter)] select-none"
|
|
39
|
+
style="width: {gutterWidth}px;"
|
|
40
|
+
></div>
|
|
41
|
+
<div class="flex-1 pr-4 text-[var(--rw-con-text)]">
|
|
42
|
+
<AnsiLine text={row} />
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
{/each}
|
|
46
|
+
</div>
|
|
47
|
+
{/each}
|
|
48
|
+
{:else}
|
|
49
|
+
<div
|
|
50
|
+
class="px-3 text-xs text-[var(--rw-con-dim)] italic"
|
|
51
|
+
style="line-height: {LINE_HEIGHT}px; padding-left: {gutterWidth}px;"
|
|
52
|
+
>
|
|
53
|
+
{history.failed ? "Failed to load frame history" : "Loading frames…"}
|
|
54
|
+
</div>
|
|
55
|
+
{/if}
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
@@ -14,7 +14,7 @@ import { visibleColumns } from "./ansi.js";
|
|
|
14
14
|
*/
|
|
15
15
|
// Streams pinned to the front of the overlay, in render order. The companion
|
|
16
16
|
// Set backs the membership test in `overlayRows`. Both are module-level,
|
|
17
|
-
// immutable, and non-reactive
|
|
17
|
+
// immutable, and non-reactive, a fixed lookup table, not component state.
|
|
18
18
|
const PREFERRED_STREAM_ORDER = ["stdout", "stderr"] as const;
|
|
19
19
|
const PREFERRED_STREAMS: ReadonlySet<string> = new Set(PREFERRED_STREAM_ORDER);
|
|
20
20
|
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
<!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
|
|
2
|
+
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
3
|
+
|
|
4
|
+
<script lang="ts">
|
|
5
|
+
import { formatBytes } from "../utils/format.js";
|
|
6
|
+
|
|
7
|
+
let {
|
|
8
|
+
followTail,
|
|
9
|
+
streaming,
|
|
10
|
+
finished,
|
|
11
|
+
fetching,
|
|
12
|
+
totalLines,
|
|
13
|
+
totalBytes,
|
|
14
|
+
onFollow,
|
|
15
|
+
}: {
|
|
16
|
+
followTail: boolean;
|
|
17
|
+
streaming: boolean;
|
|
18
|
+
finished: boolean;
|
|
19
|
+
fetching: boolean;
|
|
20
|
+
totalLines: number;
|
|
21
|
+
totalBytes: number;
|
|
22
|
+
onFollow: () => void;
|
|
23
|
+
} = $props();
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
{#if !followTail && totalLines > 0}
|
|
27
|
+
<button
|
|
28
|
+
class="absolute right-4 bottom-12 z-10 flex items-center gap-2 rounded-[3px] border
|
|
29
|
+
border-[var(--rw-con-gutter)] bg-[var(--rw-con-panel)]
|
|
30
|
+
px-3 py-1.5 text-xs
|
|
31
|
+
text-[var(--rw-con-text)]
|
|
32
|
+
hover:border-term-teal hover:text-term-teal active:translate-y-[1px]"
|
|
33
|
+
onclick={onFollow}
|
|
34
|
+
>
|
|
35
|
+
<svg
|
|
36
|
+
class="h-3.5 w-3.5"
|
|
37
|
+
viewBox="0 0 24 24"
|
|
38
|
+
fill="none"
|
|
39
|
+
stroke="currentColor"
|
|
40
|
+
stroke-width="2"
|
|
41
|
+
>
|
|
42
|
+
<path d="M12 5v14M19 12l-7 7-7-7" />
|
|
43
|
+
</svg>
|
|
44
|
+
Scroll to bottom
|
|
45
|
+
</button>
|
|
46
|
+
{/if}
|
|
47
|
+
|
|
48
|
+
<div
|
|
49
|
+
class="flex flex-shrink-0 items-center justify-between border-t border-[var(--rw-con-gutter)]
|
|
50
|
+
bg-[var(--rw-con-panel)] px-3.5 py-2 text-[11px] tracking-wide text-[var(--rw-con-dim)]"
|
|
51
|
+
>
|
|
52
|
+
<div class="flex items-center gap-3">
|
|
53
|
+
{#if streaming}
|
|
54
|
+
<div class="flex items-center gap-1.5 text-aurora-400">
|
|
55
|
+
<div class="h-1.5 w-1.5 animate-pulse rounded-full bg-aurora-400"></div>
|
|
56
|
+
Streaming
|
|
57
|
+
</div>
|
|
58
|
+
{:else if finished}
|
|
59
|
+
<span>Stream ended</span>
|
|
60
|
+
{/if}
|
|
61
|
+
{#if fetching}
|
|
62
|
+
<span>Fetching...</span>
|
|
63
|
+
{/if}
|
|
64
|
+
</div>
|
|
65
|
+
<div class="flex items-center gap-4">
|
|
66
|
+
<span>{totalLines.toLocaleString()} lines</span>
|
|
67
|
+
{#if totalBytes > 0}
|
|
68
|
+
<span>{formatBytes(totalBytes)}</span>
|
|
69
|
+
{/if}
|
|
70
|
+
{#if followTail}
|
|
71
|
+
<span class="inline-flex items-center gap-1.5 text-term-ok">
|
|
72
|
+
<svg
|
|
73
|
+
class="h-3 w-3"
|
|
74
|
+
viewBox="0 0 24 24"
|
|
75
|
+
fill="none"
|
|
76
|
+
stroke="currentColor"
|
|
77
|
+
stroke-width="2.2"
|
|
78
|
+
stroke-linecap="round"
|
|
79
|
+
stroke-linejoin="round"
|
|
80
|
+
>
|
|
81
|
+
<line x1="12" y1="5" x2="12" y2="19" />
|
|
82
|
+
<polyline points="6 13 12 19 18 13" />
|
|
83
|
+
</svg>
|
|
84
|
+
Auto-scroll
|
|
85
|
+
</span>
|
|
86
|
+
{/if}
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
@@ -45,7 +45,7 @@ const ANSI_ESCAPE = /\x1b\[[0-9;?]*[A-Za-z]/g;
|
|
|
45
45
|
const TAB_WIDTH = 8;
|
|
46
46
|
|
|
47
47
|
// Count the visible columns a log line occupies once ANSI escape sequences
|
|
48
|
-
// are stripped. Tabs are charged the full tab width
|
|
48
|
+
// are stripped. Tabs are charged the full tab width, an over-estimate that
|
|
49
49
|
// only adds trailing slack to the horizontal scroll surface, never clips.
|
|
50
50
|
export function visibleColumns(text: string): number {
|
|
51
51
|
let columns = 0;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { FrameHistory } from "./frame-history.svelte.js";
|
|
6
|
+
import { LINE_HEIGHT } from "./wrap-layout.svelte.js";
|
|
7
|
+
|
|
8
|
+
describe("FrameHistory", () => {
|
|
9
|
+
it("takes no space while closed", () => {
|
|
10
|
+
const history = new FrameHistory(() => undefined);
|
|
11
|
+
expect(history.blockHeight).toBe(0);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("reserves a placeholder while frames load, then sizes to them", async () => {
|
|
15
|
+
const history = new FrameHistory(() => () => Promise.resolve([["a", "b"], ["c"]]));
|
|
16
|
+
const done = history.toggle(4);
|
|
17
|
+
expect(history.line).toBe(4);
|
|
18
|
+
expect(history.blockHeight).toBe(LINE_HEIGHT * 2);
|
|
19
|
+
await done;
|
|
20
|
+
// padding 16 + (label + 2 rows) + (label + 1 row) + one 6px gap.
|
|
21
|
+
expect(history.blockHeight).toBe(16 + LINE_HEIGHT * 5 + 6);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("collapses when the open line is toggled again", async () => {
|
|
25
|
+
const history = new FrameHistory(() => () => Promise.resolve([["a"]]));
|
|
26
|
+
await history.toggle(1);
|
|
27
|
+
await history.toggle(1);
|
|
28
|
+
expect(history.line).toBeNull();
|
|
29
|
+
expect(history.frames).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("flags a failed fetch and ignores a superseded one", async () => {
|
|
33
|
+
const failing = new FrameHistory(() => () => Promise.reject(new Error("boom")));
|
|
34
|
+
await failing.toggle(2);
|
|
35
|
+
expect(failing.failed).toBe(true);
|
|
36
|
+
|
|
37
|
+
let release: (frames: string[][]) => void = () => {};
|
|
38
|
+
const slow = new FrameHistory(
|
|
39
|
+
() => () => new Promise<string[][]>((resolve) => (release = resolve)),
|
|
40
|
+
);
|
|
41
|
+
const first = slow.toggle(1);
|
|
42
|
+
slow.collapse();
|
|
43
|
+
release([["late"]]);
|
|
44
|
+
await first;
|
|
45
|
+
expect(slow.frames).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { LINE_HEIGHT } from "./wrap-layout.svelte.js";
|
|
5
|
+
|
|
6
|
+
type FetchLineHistory = (lineNum: number) => Promise<string[][]>;
|
|
7
|
+
|
|
8
|
+
/** Padding above and below the block content, px. */
|
|
9
|
+
export const FRAME_BLOCK_PAD = 8;
|
|
10
|
+
/** Gap between consecutive frames, px. */
|
|
11
|
+
export const FRAME_GAP = 6;
|
|
12
|
+
// The block scrolls internally beyond this height, px.
|
|
13
|
+
const FRAME_BLOCK_MAX = 400;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Inline expansion of a settled progress bar / multi-line redraw: the prior
|
|
17
|
+
* whole-region frames it passed through, shown under its committed line. One
|
|
18
|
+
* expansion at a time.
|
|
19
|
+
*/
|
|
20
|
+
export class FrameHistory {
|
|
21
|
+
/** Absolute line number whose history block is open. */
|
|
22
|
+
line = $state<number | null>(null);
|
|
23
|
+
/** The fetched frames; null while loading. */
|
|
24
|
+
frames = $state<string[][] | null>(null);
|
|
25
|
+
failed = $state(false);
|
|
26
|
+
readonly #fetch: () => FetchLineHistory | undefined;
|
|
27
|
+
|
|
28
|
+
constructor(fetch: () => FetchLineHistory | undefined) {
|
|
29
|
+
this.#fetch = fetch;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Height the open block occupies in the virtual surface. Later lines are
|
|
34
|
+
* shifted down by exactly this, so the layout math stays a single offset.
|
|
35
|
+
*/
|
|
36
|
+
readonly blockHeight = $derived.by(() => {
|
|
37
|
+
if (this.line === null) return 0;
|
|
38
|
+
if (!this.frames) return LINE_HEIGHT * 2; // loading / error placeholder
|
|
39
|
+
let h = FRAME_BLOCK_PAD * 2;
|
|
40
|
+
for (const frame of this.frames) {
|
|
41
|
+
h += LINE_HEIGHT; // per-frame label
|
|
42
|
+
h += frame.length * LINE_HEIGHT; // the frame's rows
|
|
43
|
+
}
|
|
44
|
+
if (this.frames.length > 1) h += FRAME_GAP * (this.frames.length - 1);
|
|
45
|
+
return Math.min(h, FRAME_BLOCK_MAX);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
collapse(): void {
|
|
49
|
+
this.line = null;
|
|
50
|
+
this.frames = null;
|
|
51
|
+
this.failed = false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async toggle(lineNum: number): Promise<void> {
|
|
55
|
+
if (this.line === lineNum) {
|
|
56
|
+
this.collapse();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
this.line = lineNum;
|
|
60
|
+
this.frames = null;
|
|
61
|
+
this.failed = false;
|
|
62
|
+
const fetchHistory = this.#fetch();
|
|
63
|
+
if (!fetchHistory) return;
|
|
64
|
+
try {
|
|
65
|
+
const frames = await fetchHistory(lineNum);
|
|
66
|
+
if (this.line === lineNum) this.frames = frames;
|
|
67
|
+
} catch {
|
|
68
|
+
if (this.line === lineNum) this.failed = true;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { LogCache } from "./LogCache.svelte.js";
|
|
6
|
+
import { WrapLayout, wrapColumns } from "./wrap-layout.svelte.js";
|
|
7
|
+
|
|
8
|
+
// 10 columns: (width 96 - gutter 0 - padding 16) / charWidth 8.
|
|
9
|
+
const WIDTH = 96;
|
|
10
|
+
const CHAR_WIDTH = 8;
|
|
11
|
+
|
|
12
|
+
// Effects do not run under the node test environment, so lines are measured the
|
|
13
|
+
// way the console does for streamed text: through measureRange.
|
|
14
|
+
function setup(lines: string[], wrap: boolean) {
|
|
15
|
+
const cache = new LogCache();
|
|
16
|
+
cache.applyEvent({
|
|
17
|
+
lines: Object.fromEntries(lines.map((text, i) => [i, text])),
|
|
18
|
+
sizeLines: lines.length,
|
|
19
|
+
finished: true,
|
|
20
|
+
});
|
|
21
|
+
const layout = new WrapLayout(cache, {
|
|
22
|
+
wrap: () => wrap,
|
|
23
|
+
width: () => WIDTH,
|
|
24
|
+
gutterWidth: () => 0,
|
|
25
|
+
charWidth: () => CHAR_WIDTH,
|
|
26
|
+
});
|
|
27
|
+
layout.measureRange(0, lines.length - 1);
|
|
28
|
+
return { cache, layout };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("wrapColumns", () => {
|
|
32
|
+
it("is the columns left after the gutter and padding", () => {
|
|
33
|
+
expect(wrapColumns(true, WIDTH, 0, CHAR_WIDTH)).toBe(10);
|
|
34
|
+
expect(wrapColumns(true, 200, 40, 8)).toBe(18);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("keeps one count across the pixels of a resize, so it alone can drive a remeasure", () => {
|
|
38
|
+
const counts = new Set([96, 97, 100, 103].map((w) => wrapColumns(true, w, 0, CHAR_WIDTH)));
|
|
39
|
+
expect([...counts]).toEqual([10]);
|
|
40
|
+
expect(wrapColumns(true, 104, 0, CHAR_WIDTH)).toBe(11);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("is 1 with wrapping off or before the font is measured", () => {
|
|
44
|
+
expect(wrapColumns(false, WIDTH, 0, CHAR_WIDTH)).toBe(1);
|
|
45
|
+
expect(wrapColumns(true, WIDTH, 0, 0)).toBe(1);
|
|
46
|
+
expect(wrapColumns(true, 10, 0, CHAR_WIDTH)).toBe(1);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("WrapLayout", () => {
|
|
51
|
+
it("treats every line as one row when wrapping is off", () => {
|
|
52
|
+
const { layout } = setup(["a".repeat(100), "b"], false);
|
|
53
|
+
expect(layout.rowCount(0)).toBe(1);
|
|
54
|
+
expect(layout.rowsAbove(1)).toBe(1);
|
|
55
|
+
expect(layout.totalRows).toBe(2);
|
|
56
|
+
expect(layout.offsetAt(1.5)).toBe(1);
|
|
57
|
+
expect(layout.offsetEnd(0, 1.2)).toBe(2);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("counts wrapped rows per line and accumulates positions", () => {
|
|
61
|
+
// 25 columns wrap into 3 rows of 10; 5 columns stay on 1.
|
|
62
|
+
const { layout } = setup(["a".repeat(25), "b".repeat(5), "c".repeat(10)], true);
|
|
63
|
+
expect(layout.rowCount(0)).toBe(3);
|
|
64
|
+
expect(layout.rowCount(1)).toBe(1);
|
|
65
|
+
expect(layout.rowCount(2)).toBe(1);
|
|
66
|
+
expect(layout.rowsAbove(0)).toBe(0);
|
|
67
|
+
expect(layout.rowsAbove(1)).toBe(3);
|
|
68
|
+
expect(layout.rowsAbove(2)).toBe(4);
|
|
69
|
+
expect(layout.totalRows).toBe(5);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("maps a scroll offset back to the line covering it", () => {
|
|
73
|
+
const { layout } = setup(["a".repeat(25), "b".repeat(5), "c".repeat(10)], true);
|
|
74
|
+
expect(layout.offsetAt(0)).toBe(0);
|
|
75
|
+
expect(layout.offsetAt(2.9)).toBe(0);
|
|
76
|
+
expect(layout.offsetAt(3)).toBe(1);
|
|
77
|
+
expect(layout.offsetAt(4.5)).toBe(2);
|
|
78
|
+
expect(layout.offsetEnd(0, 3.5)).toBe(2);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("measures a range of lines that arrive later", () => {
|
|
82
|
+
const { cache, layout } = setup([], true);
|
|
83
|
+
const merged = cache.applyEvent({
|
|
84
|
+
lines: { 0: "a".repeat(25) },
|
|
85
|
+
sizeLines: 1,
|
|
86
|
+
finished: false,
|
|
87
|
+
});
|
|
88
|
+
layout.measureRange(merged.min, merged.max);
|
|
89
|
+
expect(layout.rowCount(0)).toBe(3);
|
|
90
|
+
expect(layout.totalRows).toBe(3);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { untrack } from "svelte";
|
|
5
|
+
import { visibleColumns } from "./ansi.js";
|
|
6
|
+
import type { LogCache } from "./LogCache.svelte.js";
|
|
7
|
+
|
|
8
|
+
/** Pixels per rendered row. */
|
|
9
|
+
export const LINE_HEIGHT = 20;
|
|
10
|
+
|
|
11
|
+
// Right padding on the text cell (pr-4); reserved when computing how many
|
|
12
|
+
// columns a wrapped line can use.
|
|
13
|
+
const TEXT_PADDING = 16;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* How many columns a wrapped line can use: 1 with wrapping off or before the
|
|
17
|
+
* font is measured. Many widths map to the same count, so a resize only costs
|
|
18
|
+
* a remeasure when this number changes.
|
|
19
|
+
*/
|
|
20
|
+
export function wrapColumns(
|
|
21
|
+
wrap: boolean,
|
|
22
|
+
width: number,
|
|
23
|
+
gutter: number,
|
|
24
|
+
charWidth: number,
|
|
25
|
+
): number {
|
|
26
|
+
if (!wrap || charWidth <= 0) return 1;
|
|
27
|
+
return Math.max(1, Math.floor((width - gutter - TEXT_PADDING) / charWidth));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface WrapLayoutInputs {
|
|
31
|
+
wrap: () => boolean;
|
|
32
|
+
/** Width of the scroll container in px. */
|
|
33
|
+
width: () => number;
|
|
34
|
+
gutterWidth: () => number;
|
|
35
|
+
/** Width of one monospace column in px. */
|
|
36
|
+
charWidth: () => number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Row geometry for the virtualized log. With wrapping off every line is one
|
|
41
|
+
* row. With wrapping on, each line occupies as many rows as its text wraps
|
|
42
|
+
* into at the current column width, so positions come from a prefix sum of the
|
|
43
|
+
* per-line row counts instead of a multiplication. Construct during component
|
|
44
|
+
* init: it registers effects.
|
|
45
|
+
*/
|
|
46
|
+
export class WrapLayout {
|
|
47
|
+
// rowCounts remembers the wrapped row count for every line whose text has
|
|
48
|
+
// been observed; lines never loaded (pruned or out-of-window) count as 1.
|
|
49
|
+
// A plain Map is deliberate: reactivity is driven by the `#version` counter
|
|
50
|
+
// (bumped on any change), not by per-key subscriptions, iterating a
|
|
51
|
+
// SvelteMap of tens of thousands of lines on every streamed line would be
|
|
52
|
+
// wasteful.
|
|
53
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
54
|
+
readonly #rowCounts = new Map<number, number>();
|
|
55
|
+
#version = $state(0);
|
|
56
|
+
readonly #cache: LogCache;
|
|
57
|
+
readonly #inputs: WrapLayoutInputs;
|
|
58
|
+
|
|
59
|
+
// How many columns a wrapped line can use at the current width. Derived,
|
|
60
|
+
// so the full remeasure below depends on the column count alone: a resize
|
|
61
|
+
// that keeps the count (most pixels of a drag) doesn't re-walk every line.
|
|
62
|
+
readonly #availableColumns = $derived.by(() => {
|
|
63
|
+
const { wrap, width, gutterWidth, charWidth } = this.#inputs;
|
|
64
|
+
return wrapColumns(wrap(), width(), gutterWidth(), charWidth());
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// prefixSums[i] is the total rows consumed by lines
|
|
68
|
+
// [firstAvailableLine, firstAvailableLine + i). Empty when wrapping is off
|
|
69
|
+
// (the fixed-height path ignores it).
|
|
70
|
+
readonly #prefixSums = $derived.by(() => {
|
|
71
|
+
const first = this.#cache.firstAvailableLine;
|
|
72
|
+
const total = this.#cache.totalLines;
|
|
73
|
+
// Subscribe to the counter that signals rowCounts changed.
|
|
74
|
+
// eslint-disable-next-line @typescript-eslint/no-meaningless-void-operator
|
|
75
|
+
void this.#version;
|
|
76
|
+
if (!this.#inputs.wrap()) return [];
|
|
77
|
+
const count = Math.max(0, total - first);
|
|
78
|
+
const sums = new Array<number>(count + 1);
|
|
79
|
+
sums[0] = 0;
|
|
80
|
+
let acc = 0;
|
|
81
|
+
for (let i = 0; i < count; i++) {
|
|
82
|
+
acc += this.#rowCounts.get(first + i) ?? 1;
|
|
83
|
+
sums[i + 1] = acc;
|
|
84
|
+
}
|
|
85
|
+
return sums;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
constructor(cache: LogCache, inputs: WrapLayoutInputs) {
|
|
89
|
+
this.#cache = cache;
|
|
90
|
+
this.#inputs = inputs;
|
|
91
|
+
|
|
92
|
+
// Full remeasure of every loaded line, needed only when wrapping turns
|
|
93
|
+
// on or the available column width changes (wrap width affects every
|
|
94
|
+
// line's row count). Line appends/backfills are measured incrementally
|
|
95
|
+
// via measureRange; reading `cache.lines` in this effect's tracked scope
|
|
96
|
+
// would resubscribe it to the SvelteMap's shared version signal and
|
|
97
|
+
// re-run this O(n) walk on every appended line, hence `untrack`. Pruned
|
|
98
|
+
// lines keep their last-computed count so scroll geometry stays stable.
|
|
99
|
+
$effect(() => {
|
|
100
|
+
if (!this.#inputs.wrap()) return;
|
|
101
|
+
const cols = this.#availableColumns;
|
|
102
|
+
untrack(() => {
|
|
103
|
+
let changed = false;
|
|
104
|
+
for (const [num, text] of this.#cache.lines) {
|
|
105
|
+
changed = this.#measure(num, text, cols) || changed;
|
|
106
|
+
}
|
|
107
|
+
if (changed) this.#version++;
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Drop wrap geometry when wrapping turns off so memory doesn't linger.
|
|
112
|
+
$effect(() => {
|
|
113
|
+
if (!this.#inputs.wrap()) this.#rowCounts.clear();
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Measure the wrapped row counts of the lines in [min, max]. Called where
|
|
119
|
+
* new line text lands in the cache (streamed append, on-demand backfill),
|
|
120
|
+
* each of which knows the touched range, so one appended line costs O(1).
|
|
121
|
+
*/
|
|
122
|
+
measureRange(min: number, max: number): void {
|
|
123
|
+
if (!this.#inputs.wrap() || min > max) return;
|
|
124
|
+
const cols = this.#availableColumns;
|
|
125
|
+
let changed = false;
|
|
126
|
+
for (let num = min; num <= max; num++) {
|
|
127
|
+
changed = this.#measure(num, this.#cache.lines.get(num), cols) || changed;
|
|
128
|
+
}
|
|
129
|
+
if (changed) this.#version++;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Rows a line occupies. */
|
|
133
|
+
rowCount(lineNum: number): number {
|
|
134
|
+
return this.#inputs.wrap() ? (this.#rowCounts.get(lineNum) ?? 1) : 1;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Rows consumed by the lines before `lineNum`. */
|
|
138
|
+
rowsAbove(lineNum: number): number {
|
|
139
|
+
const i = lineNum - this.#cache.firstAvailableLine;
|
|
140
|
+
if (!this.#inputs.wrap()) return i;
|
|
141
|
+
const sums = this.#prefixSums;
|
|
142
|
+
if (i <= 0) return 0;
|
|
143
|
+
if (i >= sums.length) return sums.at(-1) ?? 0;
|
|
144
|
+
return sums[i] ?? 0;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Rows consumed by every loaded line. */
|
|
148
|
+
get totalRows(): number {
|
|
149
|
+
const { firstAvailableLine, totalLines } = this.#cache;
|
|
150
|
+
if (!this.#inputs.wrap()) return totalLines - firstAvailableLine;
|
|
151
|
+
return this.#prefixSums.at(-1) ?? Math.max(0, totalLines - firstAvailableLine);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Offset from firstAvailableLine of the line whose rows cover `yRows`. */
|
|
155
|
+
offsetAt(yRows: number): number {
|
|
156
|
+
if (!this.#inputs.wrap()) return Math.floor(yRows);
|
|
157
|
+
const sums = this.#prefixSums;
|
|
158
|
+
if (sums.length === 0) return 0;
|
|
159
|
+
if (yRows <= (sums[0] ?? 0)) return 0;
|
|
160
|
+
const last = sums.length - 1;
|
|
161
|
+
if (yRows >= (sums[last] ?? Number.POSITIVE_INFINITY)) return last;
|
|
162
|
+
let lo = 0;
|
|
163
|
+
let hi = last;
|
|
164
|
+
while (lo < hi) {
|
|
165
|
+
const mid = (lo + hi + 1) >> 1;
|
|
166
|
+
if ((sums[mid] ?? Number.POSITIVE_INFINITY) <= yRows) lo = mid;
|
|
167
|
+
else hi = mid - 1;
|
|
168
|
+
}
|
|
169
|
+
return lo;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Offset of the first line starting at or past `limitRows`, scanning on from `yRows`. */
|
|
173
|
+
offsetEnd(yRows: number, limitRows: number): number {
|
|
174
|
+
if (!this.#inputs.wrap()) return Math.ceil(limitRows);
|
|
175
|
+
const sums = this.#prefixSums;
|
|
176
|
+
const count = sums.length - 1;
|
|
177
|
+
let end = this.offsetAt(yRows);
|
|
178
|
+
while (end < count && (sums[end] ?? Number.POSITIVE_INFINITY) < limitRows) end++;
|
|
179
|
+
return Math.max(0, end);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
#measure(num: number, text: string | undefined, cols: number): boolean {
|
|
183
|
+
const rows = text && cols > 1 ? Math.max(1, Math.ceil(visibleColumns(text) / cols)) : 1;
|
|
184
|
+
if (this.#rowCounts.get(num) === rows) return false;
|
|
185
|
+
this.#rowCounts.set(num, rows);
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
}
|