From 457ae8c79c2e3c570a36d305095afa18d3791dd1 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:05:47 +0200 Subject: [PATCH] feat(tui): alt mode scrollbar but prettier (#8801) --- packages/coding-agent/CHANGELOG.md | 2 +- packages/coding-agent/docs/settings.md | 2 +- packages/coding-agent/docs/themes.md | 14 ++- .../extensions/dynamic-resources/dynamic.json | 2 + .../src/experimental/client-tui.ts | 3 +- .../src/modes/interactive/chat-viewport.ts | 6 +- .../src/modes/interactive/interactive-mode.ts | 3 +- .../src/modes/interactive/theme/dark.json | 3 +- .../src/modes/interactive/theme/light.json | 3 +- .../src/modes/interactive/theme/theme-json.ts | 8 +- .../modes/interactive/theme/theme-schema.json | 8 +- .../src/modes/interactive/theme/theme.ts | 17 +-- .../coding-agent/test/scrollbar-theme.test.ts | 24 ++-- packages/tui/CHANGELOG.md | 1 + packages/tui/README.md | 2 +- packages/tui/src/components/scroll-view.ts | 14 ++- packages/tui/src/layout.ts | 44 +++++-- packages/tui/src/tui-alt-screen.ts | 49 +++++--- packages/tui/src/utils.ts | 11 ++ packages/tui/test/layout.test.ts | 111 ++++++++++++++---- packages/tui/test/tui-alt-screen.test.ts | 105 +++++++++-------- 21 files changed, 292 insertions(+), 140 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1442dbf1f..7e465d9e0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -40,7 +40,7 @@ - Added `fullscreenCopyOnSelect` to disable automatic fullscreen selection copy; when disabled, `Ctrl+X` copies the active text selection before falling back to the last assistant message, while `/tree` still copies the selected message ([#7720](https://github.com/earendil-works/pi/issues/7720)). ### Changed - +- Changed fullscreen scrollbars to reveal on pointer entry, support optional `scrollbarTrack` and `scrollbarThumb` theme colors with muted and text fallbacks, keep one thumb color across normal and expanded states, and support track-click jumping. - Changed fullscreen transcript search arrows to underline on hover and capitalized the search placeholder. - Changed selectors in `/thinking`, `/model`, `/scoped-models`, `/trust`, per-model thinking settings, and theme settings to keep active options marked while browsing. `/scoped-models` now uses consistent per-item toggles and strikes through unavailable models ([#8900](https://github.com/earendil-works/pi/pull/8900)). diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index d070d895f..ee6ba60de 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -68,7 +68,7 @@ Use `/trust` in interactive mode to save a project trust decision for future ses | `showHardwareCursor` | boolean | `false` | Show the terminal cursor while TUI positions it for IME support | | `tuiMode` | string | `"regular"` | Interactive TUI mode: `"regular"` or experimental `"fullscreen"`. Changes from `/settings` apply immediately; `--tui-mode` overrides this setting at startup | | `fullscreenExitOutput` | string | `"transcript"` | Fullscreen exit output: `"transcript"` prints the final transcript and resume hint, while `"resume-hint"` restores the previous screen and prints only the resume hint. Has no effect in regular TUI mode | -| `fullscreenScrollbar` | string | `"auto"` | Fullscreen transcript scrollbar: `"auto"` shows it temporarily while scrolling, `"always"` reserves the rightmost column and keeps it visible, and `"hidden"` hides it. Has no effect in regular TUI mode | +| `fullscreenScrollbar` | string | `"auto"` | Fullscreen transcript scrollbar: `"auto"` shows it temporarily while scrolling or while the pointer is over its rightmost-column track, `"always"` reserves that column and keeps it visible, and `"hidden"` hides it. Has no effect in regular TUI mode | | `fullscreenCopyOnSelect` | boolean | `true` | Automatically copy selected text in fullscreen mode. When disabled, selections stay highlighted and `Ctrl+X` copies the active selection | For VS Code, include `--wait` so pi resumes after the editor exits: diff --git a/packages/coding-agent/docs/themes.md b/packages/coding-agent/docs/themes.md index 99a2b9496..3259877e2 100644 --- a/packages/coding-agent/docs/themes.md +++ b/packages/coding-agent/docs/themes.md @@ -88,7 +88,8 @@ vim ~/.pi/agent/themes/my-theme.json "text": "", "thinkingText": "secondary", "selectedBg": "#2d2d30", - "scrollbarThumb": "#555566", + "scrollbarTrack": "secondary", + "scrollbarThumb": "", "searchMatchBg": "#2d2d30", "searchMatchText": "", "userMessageBg": "#2d2d30", @@ -160,15 +161,15 @@ vim ~/.pi/agent/themes/my-theme.json - `name` is required, must be unique, and must not contain `/`. - `vars` is optional. Define reusable colors here, then reference them in `colors`. -- `colors` must define all 51 required tokens. `thinkingMax`, `scrollbarThumb`, and the two search highlight tokens are optional and use the fallbacks listed below. +- `colors` must define all 53 required tokens. `thinkingMax` and the two search highlight tokens are optional and use the fallbacks listed below. The `$schema` field enables editor auto-completion and validation. ## Color Tokens -Every theme must define all 51 required color tokens. The optional tokens preserve compatibility with existing themes: `thinkingMax` falls back to `thinkingXhigh`, `scrollbarThumb` and `searchMatchBg` fall back to `selectedBg`, and `searchMatchText` falls back to `text`. Other search matches use `searchMatchText` on `searchMatchBg` with an underline; the current match reverses that foreground/background pair and uses bold text. +Every theme must define all 53 required color tokens. The optional tokens preserve compatibility with existing themes: `thinkingMax` falls back to `thinkingXhigh`, `searchMatchBg` falls back to `selectedBg`, and `searchMatchText` falls back to `text`. Other search matches use `searchMatchText` on `searchMatchBg` with an underline; the current match reverses that foreground/background pair and uses bold text. -### Core UI (11 colors) +### Core UI (13 colors) | Token | Purpose | |-------|---------| @@ -183,13 +184,14 @@ Every theme must define all 51 required color tokens. The optional tokens preser | `dim` | Tertiary text | | `text` | Default text (usually `""`) | | `thinkingText` | Thinking block text | +| `scrollbarTrack` | Fullscreen scrollbar track foreground | +| `scrollbarThumb` | Fullscreen scrollbar thumb foreground, shared by normal and expanded states | -### Backgrounds & Content (11 required, 3 optional) +### Backgrounds & Content (11 required, 2 optional) | Token | Purpose | |-------|---------| | `selectedBg` | Selected line background | -| `scrollbarThumb` | Fullscreen scrollbar thumb background; optional, falls back to `selectedBg` | | `searchMatchBg` | Transcript search match background and current-match text; optional, falls back to `selectedBg` | | `searchMatchText` | Transcript search match text and current-match background; optional, falls back to `text` | | `userMessageBg` | User message background | diff --git a/packages/coding-agent/examples/extensions/dynamic-resources/dynamic.json b/packages/coding-agent/examples/extensions/dynamic-resources/dynamic.json index e7b01a859..c2ba55396 100644 --- a/packages/coding-agent/examples/extensions/dynamic-resources/dynamic.json +++ b/packages/coding-agent/examples/extensions/dynamic-resources/dynamic.json @@ -30,6 +30,8 @@ "dim": "dimGray", "text": "", "thinkingText": "gray", + "scrollbarTrack": "darkGray", + "scrollbarThumb": "", "selectedBg": "selectedBg", "userMessageBg": "userMsgBg", "userMessageText": "", diff --git a/packages/coding-agent/src/experimental/client-tui.ts b/packages/coding-agent/src/experimental/client-tui.ts index b582d2f67..0f0915dba 100644 --- a/packages/coding-agent/src/experimental/client-tui.ts +++ b/packages/coding-agent/src/experimental/client-tui.ts @@ -146,7 +146,8 @@ export class ExperimentalClientTui implements Component { status: this.#statusContainer, editor: this.#editorContainer, footer: this.#footerComponent, - scrollbarStyle: (text) => theme.bg("scrollbarThumb", text), + scrollbarTrackStyle: (text) => theme.fg("scrollbarTrack", text), + scrollbarThumbStyle: (text) => theme.fg("scrollbarThumb", text), }).root; this.#rebuild(); } diff --git a/packages/coding-agent/src/modes/interactive/chat-viewport.ts b/packages/coding-agent/src/modes/interactive/chat-viewport.ts index 9af0528ae..f3619b1fd 100644 --- a/packages/coding-agent/src/modes/interactive/chat-viewport.ts +++ b/packages/coding-agent/src/modes/interactive/chat-viewport.ts @@ -9,7 +9,8 @@ export interface ChatViewportOptions { readonly widgetsAbove?: Component; readonly widgetsBelow?: Component; readonly scrollbar?: ScrollViewScrollbar; - readonly scrollbarStyle?: (text: string) => string; + readonly scrollbarTrackStyle?: (text: string) => string; + readonly scrollbarThumbStyle?: (text: string) => string; } export interface ChatViewport { @@ -24,7 +25,8 @@ export function createChatViewport(options: ChatViewportOptions): ChatViewport { primary: true, overscroll: "chain", scrollbar: options.scrollbar ?? "auto", - ...(options.scrollbarStyle === undefined ? {} : { scrollbarStyle: options.scrollbarStyle }), + ...(options.scrollbarTrackStyle === undefined ? {} : { scrollbarTrackStyle: options.scrollbarTrackStyle }), + ...(options.scrollbarThumbStyle === undefined ? {} : { scrollbarThumbStyle: options.scrollbarThumbStyle }), }); const dock = new VStack([ { component: options.pendingMessages, shrink: 1, minSize: 0 }, diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index d8e387e21..8db1b8027 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -882,7 +882,8 @@ export class InteractiveMode { widgetsBelow: this.widgetContainerBelow, footer: this.footerContainer, scrollbar: this.settingsManager.getFullscreenScrollbar(), - scrollbarStyle: (text) => theme.bg("scrollbarThumb", text), + scrollbarTrackStyle: (text) => theme.fg("scrollbarTrack", text), + scrollbarThumbStyle: (text) => theme.fg("scrollbarThumb", text), }); this.transcriptScrollView = viewport.transcript; this.fullscreenLayoutRoot = viewport.root; diff --git a/packages/coding-agent/src/modes/interactive/theme/dark.json b/packages/coding-agent/src/modes/interactive/theme/dark.json index 01d1e02a8..3f4c698a7 100644 --- a/packages/coding-agent/src/modes/interactive/theme/dark.json +++ b/packages/coding-agent/src/modes/interactive/theme/dark.json @@ -33,7 +33,8 @@ "thinkingText": "gray", "selectedBg": "selectedBg", - "scrollbarThumb": "selectedBg", + "scrollbarTrack": "darkGray", + "scrollbarThumb": "text", "searchMatchBg": "selectedBg", "searchMatchText": "text", "userMessageBg": "userMsgBg", diff --git a/packages/coding-agent/src/modes/interactive/theme/light.json b/packages/coding-agent/src/modes/interactive/theme/light.json index 0fde42b8b..20f67835a 100644 --- a/packages/coding-agent/src/modes/interactive/theme/light.json +++ b/packages/coding-agent/src/modes/interactive/theme/light.json @@ -32,7 +32,8 @@ "thinkingText": "mediumGray", "selectedBg": "selectedBg", - "scrollbarThumb": "selectedBg", + "scrollbarTrack": "lightGray", + "scrollbarThumb": "text", "searchMatchBg": "selectedBg", "searchMatchText": "text", "userMessageBg": "userMsgBg", diff --git a/packages/coding-agent/src/modes/interactive/theme/theme-json.ts b/packages/coding-agent/src/modes/interactive/theme/theme-json.ts index 915f9d305..256cd4ea6 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme-json.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme-json.ts @@ -20,7 +20,7 @@ const ThemeJsonSchema = Type.Object({ name: Type.String(), vars: Type.Optional(Type.Record(Type.String(), ColorValueSchema)), colors: Type.Object({ - // Core UI (10 colors) + // Core UI (11 colors) accent: ColorValueSchema, border: ColorValueSchema, borderAccent: ColorValueSchema, @@ -32,9 +32,11 @@ const ThemeJsonSchema = Type.Object({ dim: ColorValueSchema, text: ColorValueSchema, thinkingText: ColorValueSchema, - // Backgrounds & Content Text (11 required, 3 optional) - selectedBg: ColorValueSchema, + // Scrollbar (2 optional colors) + scrollbarTrack: Type.Optional(ColorValueSchema), scrollbarThumb: Type.Optional(ColorValueSchema), + // Backgrounds & Content Text (11 required, 2 optional) + selectedBg: ColorValueSchema, searchMatchBg: Type.Optional(ColorValueSchema), searchMatchText: Type.Optional(ColorValueSchema), userMessageBg: ColorValueSchema, diff --git a/packages/coding-agent/src/modes/interactive/theme/theme-schema.json b/packages/coding-agent/src/modes/interactive/theme/theme-schema.json index 5348ab7de..323bca8f5 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme-schema.json +++ b/packages/coding-agent/src/modes/interactive/theme/theme-schema.json @@ -34,7 +34,7 @@ }, "colors": { "type": "object", - "description": "Theme color definitions (thinkingMax, scrollbarThumb, and search highlight colors are optional and use compatible fallbacks)", + "description": "Theme color definitions (scrollbar, thinkingMax, and search highlight colors are optional and use compatible fallbacks)", "required": [ "accent", "border", @@ -137,9 +137,13 @@ "$ref": "#/$defs/colorValue", "description": "Selected item background" }, + "scrollbarTrack": { + "$ref": "#/$defs/colorValue", + "description": "Fullscreen scrollbar track foreground (falls back to muted when omitted)" + }, "scrollbarThumb": { "$ref": "#/$defs/colorValue", - "description": "Fullscreen scrollbar thumb background (falls back to selectedBg when omitted)" + "description": "Fullscreen scrollbar thumb foreground (falls back to text when omitted)" }, "searchMatchBg": { "$ref": "#/$defs/colorValue", diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index 3a8c596b7..f000a5d7a 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -50,6 +50,8 @@ export type ThemeColor = | "dim" | "text" | "thinkingText" + | "scrollbarTrack" + | "scrollbarThumb" | "searchMatchText" | "userMessageText" | "customMessageText" @@ -89,7 +91,6 @@ export type ThemeColor = export type ThemeBg = | "selectedBg" - | "scrollbarThumb" | "searchMatchBg" | "userMessageBg" | "customMessageBg" @@ -97,8 +98,8 @@ export type ThemeBg = | "toolSuccessBg" | "toolErrorBg"; -type OptionalThemeColor = "thinkingMax" | "searchMatchText"; -type OptionalThemeBg = "scrollbarThumb" | "searchMatchBg"; +type OptionalThemeColor = "scrollbarTrack" | "scrollbarThumb" | "thinkingMax" | "searchMatchText"; +type OptionalThemeBg = "searchMatchBg"; type ColorMode = "truecolor" | "256color"; @@ -258,15 +259,17 @@ function resolveThemeColors>( } function withThemeColorFallbacks(colors: ThemeJson["colors"]): ThemeJson["colors"] & { - thinkingMax: ColorValue; + scrollbarTrack: ColorValue; scrollbarThumb: ColorValue; + thinkingMax: ColorValue; searchMatchBg: ColorValue; searchMatchText: ColorValue; } { return { ...colors, + scrollbarTrack: colors.scrollbarTrack ?? colors.muted, + scrollbarThumb: colors.scrollbarThumb ?? colors.text, thinkingMax: colors.thinkingMax ?? colors.thinkingXhigh, - scrollbarThumb: colors.scrollbarThumb ?? colors.selectedBg, searchMatchBg: colors.searchMatchBg ?? colors.selectedBg, searchMatchText: colors.searchMatchText ?? colors.text, }; @@ -299,6 +302,8 @@ export class Theme { this.fgColors = new Map(); const colors = { ...fgColors, + scrollbarTrack: fgColors.scrollbarTrack ?? fgColors.muted, + scrollbarThumb: fgColors.scrollbarThumb ?? fgColors.text, thinkingMax: fgColors.thinkingMax ?? fgColors.thinkingXhigh, searchMatchText: fgColors.searchMatchText ?? fgColors.text, }; @@ -308,7 +313,6 @@ export class Theme { this.bgColors = new Map(); const backgrounds = { ...bgColors, - scrollbarThumb: bgColors.scrollbarThumb ?? bgColors.selectedBg, searchMatchBg: bgColors.searchMatchBg ?? bgColors.selectedBg, }; for (const [key, value] of Object.entries(backgrounds) as [ThemeBg, string | number][]) { @@ -528,7 +532,6 @@ function createTheme(themeJson: ThemeJson, mode?: ColorMode, sourcePath?: string const bgColors: Record = {} as Record; const bgColorKeys: Set = new Set([ "selectedBg", - "scrollbarThumb", "searchMatchBg", "userMessageBg", "customMessageBg", diff --git a/packages/coding-agent/test/scrollbar-theme.test.ts b/packages/coding-agent/test/scrollbar-theme.test.ts index f07bb9c8a..02d618451 100644 --- a/packages/coding-agent/test/scrollbar-theme.test.ts +++ b/packages/coding-agent/test/scrollbar-theme.test.ts @@ -2,7 +2,10 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { loadThemeFromPath } from "../src/modes/interactive/theme/theme.ts"; +import { loadThemeFromPath, setThemeJsonValidator } from "../src/modes/interactive/theme/theme.ts"; +import { validateThemeJson } from "../src/modes/interactive/theme/theme-json.ts"; + +setThemeJsonValidator(validateThemeJson); const tempDirs: string[] = []; @@ -27,23 +30,28 @@ afterEach(() => { } }); -describe("optional fullscreen theme colors", () => { - it("falls back to selectedBg when scrollbarThumb is omitted", () => { +describe("fullscreen theme colors", () => { + it.each([ + ["scrollbarTrack", "muted"], + ["scrollbarThumb", "text"], + ] as const)("falls back to %s when omitted", (token, fallback) => { const themeJson = loadDarkTheme(); - themeJson.name = "legacy-scrollbar-theme"; - delete themeJson.colors.scrollbarThumb; + themeJson.name = `missing-${token}-theme`; + delete themeJson.colors[token]; const loadedTheme = loadThemeFromPath(writeTheme(themeJson), "truecolor"); - expect(loadedTheme.getBgAnsi("scrollbarThumb")).toBe(loadedTheme.getBgAnsi("selectedBg")); + expect(loadedTheme.getFgAnsi(token)).toBe(loadedTheme.getFgAnsi(fallback)); }); - it("uses an explicitly configured scrollbarThumb", () => { + it("uses explicitly configured scrollbar colors", () => { const themeJson = loadDarkTheme(); themeJson.name = "custom-scrollbar-theme"; + themeJson.colors.scrollbarTrack = "#654321"; themeJson.colors.scrollbarThumb = "#123456"; const loadedTheme = loadThemeFromPath(writeTheme(themeJson), "truecolor"); - expect(loadedTheme.getBgAnsi("scrollbarThumb")).toBe("\x1b[48;2;18;52;86m"); + expect(loadedTheme.getFgAnsi("scrollbarTrack")).toBe("\x1b[38;2;101;67;33m"); + expect(loadedTheme.getFgAnsi("scrollbarThumb")).toBe("\x1b[38;2;18;52;86m"); }); it("falls back to existing selection and text colors for search highlights", () => { diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 5838564b9..1f868b499 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -19,6 +19,7 @@ ### Changed +- Changed fullscreen scrollbars to render muted thin tracks with contrasting proportional two-cell-minimum thumbs, preserve underlying backgrounds without inheriting foreground styles, reserve an unstyled column in `always` mode, reveal hidden `auto` tracks on pointer entry, expand the same-colored thumb on hover, and support track-click jumping in addition to thumb dragging. - Changed fullscreen transcript search to use a bordered, placeholder-based input with a muted result count, right-aligned clickable key-and-arrow buttons with configurable hover styling, and open-shortcut toggling. ### Fixed diff --git a/packages/tui/README.md b/packages/tui/README.md index b075308fc..c5e8b1825 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -706,7 +706,7 @@ if (matchesKey(data, Key.enter)) { 2. **Width Changed or Change Above Viewport**: Clear screen and fully re-render 3. **Normal Update**: Move the cursor to the first changed line, clear to the end, and render changed lines -`TuiAltScreen` owns a terminal-height viewport. Without an explicit layout root it preserves the legacy single-document scrolling behavior. With `setLayoutRoot()`, `VStack`, `HStack`, and nested `ScrollView` components can reserve fixed regions and independently scroll constrained regions. It updates changed viewport rows in place, follows streaming output while at the bottom, and preserves a manually selected scroll position while content grows. Mouse-wheel and configurable keyboard navigation scroll without modifying terminal scrollback, including jumps between OSC 133 semantic prompt markers. Clicking an OSC 8 hyperlink opens it with the configured URL handler. Dragging with the primary mouse button selects text and, unless `TuiAltScreenOptions.copyOnSelect` is `false`, copies it to the clipboard with OSC 52; holding the drag at a scroll view's top or bottom edge auto-scrolls and extends the selection into off-screen content. Kitty images support vertical viewport cropping; iTerm2 inline images fall back to text because the iTerm2 protocol cannot delete or crop placements during viewport repainting. +`TuiAltScreen` owns a terminal-height viewport. Without an explicit layout root it preserves the legacy single-document scrolling behavior. With `setLayoutRoot()`, `VStack`, `HStack`, and nested `ScrollView` components can reserve fixed regions and independently scroll constrained regions. It updates changed viewport rows in place, follows streaming output while at the bottom, and preserves a manually selected scroll position while content grows. Mouse-wheel and configurable keyboard navigation scroll without modifying terminal scrollback, including jumps between OSC 133 semantic prompt markers. Scrollbars support hover expansion, thumb dragging, and track-click jumping. Clicking an OSC 8 hyperlink opens it with the configured URL handler. Dragging with the primary mouse button selects text and, unless `TuiAltScreenOptions.copyOnSelect` is `false`, copies it to the clipboard with OSC 52; holding the drag at a scroll view's top or bottom edge auto-scrolls and extends the selection into off-screen content. Kitty images support vertical viewport cropping; iTerm2 inline images fall back to text because the iTerm2 protocol cannot delete or crop placements during viewport repainting. Both renderers wrap updates in **synchronized output** (`\x1b[?2026h` ... `\x1b[?2026l`) for atomic, flicker-free rendering. diff --git a/packages/tui/src/components/scroll-view.ts b/packages/tui/src/components/scroll-view.ts index 27018aba1..b8f8a51ff 100644 --- a/packages/tui/src/components/scroll-view.ts +++ b/packages/tui/src/components/scroll-view.ts @@ -9,7 +9,8 @@ export interface ScrollViewOptions { primary?: boolean; overscroll?: "chain" | "contain"; scrollbar?: ScrollViewScrollbar; - scrollbarStyle?: (text: string) => string; + scrollbarTrackStyle?: (text: string) => string; + scrollbarThumbStyle?: (text: string) => string; scrollbarHideDelayMs?: number; } @@ -23,7 +24,8 @@ export class ScrollView extends Container { readonly followEnd: boolean; readonly primary: boolean; readonly overscroll: "chain" | "contain"; - readonly scrollbarStyle: (text: string) => string; + readonly scrollbarTrackStyle: (text: string) => string; + readonly scrollbarThumbStyle: (text: string) => string; private currentScrollbar: ScrollViewScrollbar; private readonly scrollbarHideDelayMs: number; private currentScrollTop = 0; @@ -48,7 +50,8 @@ export class ScrollView extends Container { this.primary = options.primary ?? false; this.overscroll = options.overscroll ?? "chain"; this.currentScrollbar = options.scrollbar ?? "hidden"; - this.scrollbarStyle = options.scrollbarStyle ?? ((text) => `\x1b[100m${text}\x1b[49m`); + this.scrollbarTrackStyle = options.scrollbarTrackStyle ?? ((text) => `\x1b[90m${text}\x1b[39m`); + this.scrollbarThumbStyle = options.scrollbarThumbStyle ?? ((text) => `\x1b[37m${text}\x1b[39m`); this.scrollbarHideDelayMs = Math.max(0, Math.floor(options.scrollbarHideDelayMs ?? 1000)); } @@ -75,6 +78,10 @@ export class ScrollView extends Container { ); } + get isScrollbarActive(): boolean { + return this.scrollbarActive; + } + setScrollbar(scrollbar: ScrollViewScrollbar): void { if (scrollbar === this.currentScrollbar) return; this.currentScrollbar = scrollbar; @@ -114,6 +121,7 @@ export class ScrollView extends Container { if (active === this.scrollbarActive) return; this.scrollbarActive = active; this.markScrollbarActivity(); + this.requestRenderCallback?.(); } scrollTo(scrollTop: number, options: ScrollViewScrollToOptions = {}): void { diff --git a/packages/tui/src/layout.ts b/packages/tui/src/layout.ts index 9d6b99e30..897c51de2 100644 --- a/packages/tui/src/layout.ts +++ b/packages/tui/src/layout.ts @@ -3,7 +3,13 @@ import { allocateStackSizes, visibleStackEntries } from "./components/stack.ts"; import { getLayoutNode } from "./layout-node.ts"; import { cropKittyImageLine, getKittyImageMetadata, isImageLine } from "./terminal-image.ts"; import { type Component, CURSOR_MARKER, compositeTuiLine } from "./tui.ts"; -import { extractAnsiCode, getGraphemeCellRange, sliceByColumn, visibleWidth } from "./utils.ts"; +import { + extractAnsiCode, + getActiveBackgroundAnsi, + getGraphemeCellRange, + sliceByColumn, + visibleWidth, +} from "./utils.ts"; const OSC133_ZONE_PREFIX = /^(?:\x1b\]133;[ABC](?:\x07|\x1b\\))+/; @@ -240,7 +246,13 @@ function layoutComponent( return box; } -function styleScrollbarCell(line: string, column: number, totalWidth: number, style: (text: string) => string): string { +function replaceScrollbarCell( + line: string, + column: number, + totalWidth: number, + replacement: string, + preserveTargetBackground: boolean, +): string { if (isImageLine(line)) return line; const graphemeRange = getGraphemeCellRange(line, column); @@ -258,16 +270,20 @@ function styleScrollbarCell(line: string, column: number, totalWidth: number, st targetPrefix += ansi.code; targetIndex += ansi.length; } - const targetText = target.slice(targetIndex) || " ".repeat(end - start); const beforePadding = " ".repeat(Math.max(0, start - visibleWidth(before))); - return `${before}${beforePadding}${targetPrefix}${style(targetText)}${after}`; + const cellPaddingBefore = " ".repeat(Math.max(0, column - start)); + const cellPaddingAfter = " ".repeat(Math.max(0, end - column - 1)); + const targetStyle = `\x1b[0m\x1b]8;;\x07${preserveTargetBackground ? getActiveBackgroundAnsi(targetPrefix) : ""}`; + return `${before}${beforePadding}${targetStyle}${cellPaddingBefore}${replacement}${cellPaddingAfter}${after}`; } -export function getScrollbarGeometry(box: LayoutBox): ScrollbarGeometry | undefined { - if (!box.scrollView?.isScrollbarVisible || box.rect.width <= 0 || box.rect.height <= 0) return undefined; +export function getScrollbarGeometry(box: LayoutBox, includeHiddenAuto = false): ScrollbarGeometry | undefined { + if (!box.scrollView || box.rect.width <= 0 || box.rect.height <= 0) return undefined; const contentHeight = box.children[0]?.rect.height ?? box.scrollContentLines?.length ?? 0; const trackHeight = box.rect.height; + const canRevealHiddenAuto = includeHiddenAuto && box.scrollView.scrollbar === "auto" && contentHeight > trackHeight; + if (!box.scrollView.isScrollbarVisible && !canRevealHiddenAuto) return undefined; const minThumbHeight = Math.min(2, trackHeight); const thumbHeight = Math.max( @@ -294,10 +310,20 @@ function paintScrollbar(box: LayoutBox, screen: string[], totalWidth: number): v const geometry = getScrollbarGeometry(box); if (!geometry || !box.scrollView) return; - for (let offset = 0; offset < geometry.thumbHeight; offset++) { - const row = geometry.thumbTop + offset; + for (let offset = 0; offset < geometry.trackHeight; offset++) { + const row = geometry.trackTop + offset; if (row < box.clip.y || row >= box.clip.y + box.clip.height || row < 0 || row >= screen.length) continue; - screen[row] = styleScrollbarCell(screen[row] ?? "", geometry.column, totalWidth, box.scrollView.scrollbarStyle); + const isThumb = row >= geometry.thumbTop && row < geometry.thumbTop + geometry.thumbHeight; + const replacement = isThumb + ? box.scrollView.scrollbarThumbStyle(box.scrollView.isScrollbarActive ? "█" : "┃") + : box.scrollView.scrollbarTrackStyle("│"); + screen[row] = replaceScrollbarCell( + screen[row] ?? "", + geometry.column, + totalWidth, + replacement, + box.scrollView.scrollbar !== "always", + ); } } diff --git a/packages/tui/src/tui-alt-screen.ts b/packages/tui/src/tui-alt-screen.ts index 344bb7fa6..24716ee46 100644 --- a/packages/tui/src/tui-alt-screen.ts +++ b/packages/tui/src/tui-alt-screen.ts @@ -1002,16 +1002,16 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { return true; } - private getScrollbarTargetAt(x: number, y: number): ScrollbarTarget | undefined { + private getScrollbarTargetAt(x: number, y: number, includeHiddenAuto = false): ScrollbarTarget | undefined { if (this.hasOverlay() || !this.currentLayout) return undefined; for (const scrollView of getScrollViewsAt(this.currentLayout, x, y)) { const box = getScrollViewBox(this.currentLayout, scrollView); - const geometry = box ? getScrollbarGeometry(box) : undefined; + const geometry = box ? getScrollbarGeometry(box, includeHiddenAuto) : undefined; if ( geometry && x === geometry.column && - y >= geometry.thumbTop && - y < geometry.thumbTop + geometry.thumbHeight + y >= geometry.trackTop && + y < geometry.trackTop + geometry.trackHeight ) { return { scrollView, geometry }; } @@ -1027,13 +1027,25 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { } private updateScrollbarHover(x: number, y: number): void { - this.setScrollbarHover(this.getScrollbarTargetAt(x, y)?.scrollView); + this.setScrollbarHover(this.getScrollbarTargetAt(x, y, true)?.scrollView); } private stopScrollbarHover(): void { this.setScrollbarHover(undefined); } + private scrollScrollbarToPointer( + scrollView: ScrollView, + geometry: ScrollbarGeometry, + pointerY: number, + grabOffset: number, + ): void { + const maxThumbOffset = geometry.trackHeight - geometry.thumbHeight; + const thumbOffset = Math.max(0, Math.min(maxThumbOffset, pointerY - geometry.trackTop - grabOffset)); + const scrollTop = maxThumbOffset === 0 ? 0 : Math.round((thumbOffset / maxThumbOffset) * geometry.maxScrollTop); + scrollView.scrollTo(scrollTop); + } + private handleScrollbarMouseEvent(event: SgrMouseEvent): boolean { if (this.scrollbarDrag) { if (event.release) { @@ -1045,14 +1057,12 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { : undefined; const geometry = box ? getScrollbarGeometry(box) : undefined; if (geometry) { - const maxThumbOffset = geometry.trackHeight - geometry.thumbHeight; - const thumbOffset = Math.max( - 0, - Math.min(maxThumbOffset, event.y - geometry.trackTop - this.scrollbarDrag.grabOffset), + this.scrollScrollbarToPointer( + this.scrollbarDrag.scrollView, + geometry, + event.y, + this.scrollbarDrag.grabOffset, ); - const scrollTop = - maxThumbOffset === 0 ? 0 : Math.round((thumbOffset / maxThumbOffset) * geometry.maxScrollTop); - this.scrollbarDrag.scrollView.scrollTo(scrollTop); } return true; } @@ -1070,9 +1080,13 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { this.pressedUrl = undefined; this.selectionDragged = false; this.setScrollbarHover(target.scrollView); + const onThumb = + event.y >= target.geometry.thumbTop && event.y < target.geometry.thumbTop + target.geometry.thumbHeight; + const grabOffset = onThumb ? event.y - target.geometry.thumbTop : Math.floor(target.geometry.thumbHeight / 2); + if (!onThumb) this.scrollScrollbarToPointer(target.scrollView, target.geometry, event.y, grabOffset); this.scrollbarDrag = { scrollView: target.scrollView, - grabOffset: event.y - target.geometry.thumbTop, + grabOffset, }; return true; } @@ -1572,14 +1586,17 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { this.scrollToEndIndicatorRect = undefined; const scrollView = layout.primaryScrollView ?? this.implicitScrollView; if (!this.scrollToEndIndicator || !scrollView.followEnd || scrollView.isFollowingEnd) return screen; - const clip = getScrollViewBox(layout, scrollView)?.clip; + const box = getScrollViewBox(layout, scrollView); + const clip = box?.clip; if (!clip || clip.width <= 0 || clip.height <= 0) return screen; const row = clip.y + clip.height - 1; if (row >= screen.length || isImageLine(screen[row] ?? "")) return screen; - const text = truncateToWidth(this.scrollToEndIndicator(), clip.width, ""); + const scrollbarColumn = box ? getScrollbarGeometry(box)?.column : undefined; + const availableWidth = Math.max(0, (scrollbarColumn ?? clip.x + clip.width) - clip.x); + const text = truncateToWidth(this.scrollToEndIndicator(), availableWidth, ""); const textWidth = visibleWidth(text); if (textWidth === 0) return screen; - const column = clip.x + Math.floor((clip.width - textWidth) / 2); + const column = clip.x + Math.floor((availableWidth - textWidth) / 2); const result = [...screen]; result[row] = compositeTuiLine(result[row] ?? "", text, column, textWidth, width); this.scrollToEndIndicatorRect = { row, column, width: textWidth }; diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index 6de4b9d3f..419c52fb7 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -692,6 +692,10 @@ class AnsiCodeTracker { return result; } + getActiveBackgroundCode(): string { + return this.bgColor ? `\x1b[${this.bgColor}m` : ""; + } + hasActiveCodes(): boolean { return ( this.bold || @@ -739,6 +743,13 @@ function updateTrackerFromText(text: string, tracker: AnsiCodeTracker): void { } } +/** Return only the background color active at the end of an ANSI-styled string. */ +export function getActiveBackgroundAnsi(text: string): string { + const tracker = new AnsiCodeTracker(); + updateTrackerFromText(text, tracker); + return tracker.getActiveBackgroundCode(); +} + /** * Split text into words while keeping ANSI codes attached. */ diff --git a/packages/tui/test/layout.test.ts b/packages/tui/test/layout.test.ts index 856954271..5aa1cd347 100644 --- a/packages/tui/test/layout.test.ts +++ b/packages/tui/test/layout.test.ts @@ -190,79 +190,112 @@ describe("viewport layout", () => { assert.strictEqual(scrollView.isFollowingEnd, true); }); - it("renders a transient proportional scrollbar without replacing cell content", async () => { + it("renders a proportional glyph scrollbar with an expanded active thumb", async () => { const sourceLines = ["abcd界", "abcde2", "abcde3", "abcde4", "abcde5", "abcde6", "abcde7", "abcde8"]; const contentBackground = "\x1b[42m"; - const scrollbarBackground = "\x1b[48;5;1m"; - const scrollbarStyle = (text: string) => `${scrollbarBackground}${text}\x1b[49m`; + const trackColor = "\x1b[38;5;2m"; + const thumbColor = "\x1b[38;5;1m"; + const scrollbarTrackStyle = (text: string) => `${trackColor}${text}\x1b[39m`; + const scrollbarThumbStyle = (text: string) => `${thumbColor}${text}\x1b[39m`; const content = new Text(sourceLines.join("\n"), 0, 0, (text) => `${contentBackground}${text}\x1b[49m`); const scrollView = new ScrollView(content, { scrollbar: "auto", - scrollbarStyle, + scrollbarTrackStyle, + scrollbarThumbStyle, scrollbarHideDelayMs: 10, }); const render = () => renderLayoutFrame(scrollView, 6, 4, () => {}).lines; - const thumbRows = (lines: string[]) => lines.map((line) => line.includes(scrollbarBackground)); + const visible = (lines: string[]) => lines.map(stripTerminalSequences); let lines = render(); - assert.deepStrictEqual(thumbRows(lines), [false, false, false, false]); - assert.deepStrictEqual(lines.map(stripTerminalSequences), sourceLines.slice(0, 4)); + assert.deepStrictEqual(visible(lines), sourceLines.slice(0, 4)); scrollView.scrollBy(2); lines = render(); - assert.deepStrictEqual(thumbRows(lines), [false, true, true, false]); - assert.deepStrictEqual(lines.map(stripTerminalSequences), sourceLines.slice(2, 6)); - assert.ok(lines[1]!.lastIndexOf(contentBackground) < lines[1]!.lastIndexOf(scrollbarBackground)); + assert.deepStrictEqual(visible(lines), ["abcde│", "abcde┃", "abcde┃", "abcde│"]); + assert.deepStrictEqual( + lines.map((line) => line.includes(trackColor)), + [true, false, false, true], + ); + assert.deepStrictEqual( + lines.map((line) => line.includes(thumbColor)), + [false, true, true, false], + ); + scrollView.setScrollbarActive(true); + lines = render(); + assert.deepStrictEqual(visible(lines), ["abcde│", "abcde█", "abcde█", "abcde│"]); + assert.deepStrictEqual( + lines.map((line) => line.includes(thumbColor)), + [false, true, true, false], + ); + assert.ok(lines[1]!.lastIndexOf(contentBackground) < lines[1]!.lastIndexOf(thumbColor)); + + scrollView.setScrollbarActive(false); await new Promise((resolve) => setTimeout(resolve, 30)); lines = render(); - assert.deepStrictEqual(thumbRows(lines), [false, false, false, false]); + assert.deepStrictEqual(visible(lines), sourceLines.slice(2, 6)); scrollView.scrollToEnd(); lines = render(); - assert.deepStrictEqual(thumbRows(lines), [false, false, true, true]); - assert.deepStrictEqual(lines.map(stripTerminalSequences), sourceLines.slice(4)); + assert.deepStrictEqual(visible(lines), ["abcde│", "abcde│", "abcde┃", "abcde┃"]); + + scrollView.scrollToStart(); + lines = render(); + assert.strictEqual(visible(lines)[0], "abcd ┃"); const followedContent = new Text(sourceLines.join("\n"), 0, 0); const followed = new ScrollView(followedContent, { follow: "end", scrollbar: "auto", - scrollbarStyle, + scrollbarTrackStyle, + scrollbarThumbStyle, }); renderLayoutFrame(followed, 6, 4, () => {}); assert.strictEqual(followed.scrollTop, 4); followedContent.setText(`${sourceLines.join("\n")}\nabcde9`); const growthFrame = renderLayoutFrame(followed, 6, 4, () => {}); assert.strictEqual(followed.scrollTop, 5); - assert.ok(growthFrame.lines.every((line) => !line.includes(scrollbarBackground))); + assert.ok(growthFrame.lines.every((line) => !/[│┃]/.test(stripTerminalSequences(line)))); const fittingContent = new Text("1\n2", 0, 0); - const automatic = new ScrollView(fittingContent, { scrollbar: "auto", scrollbarStyle }); + const automatic = new ScrollView(fittingContent, { scrollbar: "auto", scrollbarThumbStyle }); renderLayoutFrame(automatic, 6, 4, () => {}); automatic.scrollBy(1); assert.ok( - renderLayoutFrame(automatic, 6, 4, () => {}).lines.every((line) => !line.includes(scrollbarBackground)), + renderLayoutFrame(automatic, 6, 4, () => {}).lines.every((line) => !/[│┃]/.test(stripTerminalSequences(line))), ); - const alwaysFitting = new ScrollView(fittingContent, { scrollbar: "always", scrollbarStyle }); + const alwaysFitting = new ScrollView(fittingContent, { scrollbar: "always", scrollbarThumbStyle }); const alwaysFittingFrame = renderLayoutFrame(alwaysFitting, 6, 4, () => {}); assert.strictEqual(alwaysFittingFrame.root.children[0]?.rect.width, 5); - assert.ok(alwaysFittingFrame.lines.every((line) => line.includes(scrollbarBackground))); + assert.ok(visible(alwaysFittingFrame.lines).every((line) => line.endsWith("┃"))); - const alwaysOverflowing = new ScrollView(content, { scrollbar: "always", scrollbarStyle }); + const alwaysOverflowing = new ScrollView(content, { + scrollbar: "always", + scrollbarTrackStyle, + scrollbarThumbStyle, + }); const alwaysOverflowingFrame = renderLayoutFrame(alwaysOverflowing, 6, 4, () => {}); assert.strictEqual(alwaysOverflowingFrame.root.children[0]?.rect.width, 5); - assert.strictEqual(alwaysOverflowingFrame.lines.filter((line) => line.includes(scrollbarBackground)).length, 2); + assert.strictEqual(visible(alwaysOverflowingFrame.lines).filter((line) => line.endsWith("┃")).length, 2); + assert.strictEqual(visible(alwaysOverflowingFrame.lines).filter((line) => line.endsWith("│")).length, 2); + for (const line of alwaysOverflowingFrame.lines) { + const scrollbarStyleIndex = Math.max(line.lastIndexOf(trackColor), line.lastIndexOf(thumbColor)); + const reservedColumnResetIndex = line.lastIndexOf("\x1b[0m\x1b]8;;\x07", scrollbarStyleIndex); + assert.ok(reservedColumnResetIndex > line.lastIndexOf(contentBackground)); + } const thumbHeightFor = (contentHeight: number) => { const sized = new ScrollView(new Text(Array.from({ length: contentHeight }, () => "x").join("\n"), 0, 0), { scrollbar: "auto", - scrollbarStyle, + scrollbarThumbStyle, }); renderLayoutFrame(sized, 6, 20, () => {}); sized.scrollBy(1); - return renderLayoutFrame(sized, 6, 20, () => {}).lines.filter((line) => line.includes(scrollbarBackground)) - .length; + return renderLayoutFrame(sized, 6, 20, () => {}).lines.filter((line) => + stripTerminalSequences(line).endsWith("┃"), + ).length; }; assert.strictEqual(thumbHeightFor(21), 19); assert.strictEqual(thumbHeightFor(40), 10); @@ -270,11 +303,39 @@ describe("viewport layout", () => { assert.strictEqual(thumbHeightFor(400), 2); }); + it("preserves only the underlying background beneath overlay scrollbar glyphs", () => { + const background = "\x1b[42m"; + const borderForeground = "\x1b[31m"; + const content = { + render: (width: number) => + Array.from( + { length: 8 }, + () => `${background}${"x".repeat(width - 1)}${borderForeground}│\x1b[39m\x1b[49m`, + ), + invalidate: () => {}, + }; + const scrollView = new ScrollView(content, { + scrollbar: "auto", + scrollbarTrackStyle: (text) => text, + scrollbarThumbStyle: (text) => text, + }); + renderLayoutFrame(scrollView, 6, 4, () => {}); + scrollView.scrollBy(1); + const frame = renderLayoutFrame(scrollView, 6, 4, () => {}); + + assert.deepStrictEqual(frame.lines.map(stripTerminalSequences), ["xxxxx│", "xxxxx┃", "xxxxx┃", "xxxxx│"]); + for (const line of frame.lines) { + assert.ok(line.includes(background)); + assert.ok(!line.includes(borderForeground)); + assert.ok(line.includes(`\x1b[0m\x1b]8;;\x07${background}`)); + } + }); + it("updates reserved scrollbar layout at runtime", () => { const scrollView = new ScrollView(new Text("123456", 0, 0), { scrollbar: "always" }); const render = () => renderLayoutFrame(new HStack([scrollView], { align: "start" }), 6, 2, () => {}); const always = render(); - assert.deepStrictEqual(visibleLines(always.lines), ["12345", "6"]); + assert.deepStrictEqual(visibleLines(always.lines), ["12345┃", "6 ┃"]); assert.strictEqual(always.root.children[0]?.rect.width, 6); assert.strictEqual(always.root.children[0]?.children[0]?.rect.width, 5); diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index 3b9342616..a6d05d454 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -134,6 +134,36 @@ describe("TuiAltScreen", () => { tui.stop(); }); + it("leaves the scrollbar clickable when the jump-to-end indicator spans the transcript", async () => { + const terminal = new VirtualTerminal(30, 6); + const tui = new TuiAltScreen(terminal, undefined, undefined, { + scrollToEndIndicator: () => "↓".repeat(30), + }); + const transcript = new ScrollView( + new Text(Array.from({ length: 12 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0), + { follow: "end", primary: true, scrollbar: "always" }, + ); + tui.setLayoutRoot( + new VStack([ + { component: transcript, basis: 0, grow: 1, minSize: 1 }, + { component: new Text("editor\nfooter", 0, 0), basis: "auto", minSize: 1 }, + ]), + ); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[<64;1;1M"); + await terminal.waitForRender(); + assert.strictEqual(transcript.isFollowingEnd, false); + + // The indicator must not intercept a press on the scrollbar's last column. + terminal.sendInput("\x1b[<0;30;4M"); + terminal.sendInput("\x1b[<0;30;4m"); + await terminal.waitForRender(); + assert.strictEqual(transcript.isFollowingEnd, false); + tui.stop(); + }); + it("never shows the jump-to-end indicator for a primary scroll view without follow-end", async () => { const terminal = new VirtualTerminal(30, 3); const tui = new TuiAltScreen(terminal, undefined, undefined, { @@ -358,83 +388,54 @@ describe("TuiAltScreen", () => { } }); - it("drags a visible scrollbar thumb and keeps it visible until release", async () => { + it("reveals an auto scrollbar when the pointer enters its hidden track", async () => { const terminal = new RecordingTerminal(10, 5); const tui = new TuiAltScreen(terminal); const scrollView = new ScrollView( new Text(Array.from({ length: 20 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0), - { - primary: true, - scrollbar: "auto", - scrollbarHideDelayMs: 50, - }, + { primary: true, scrollbar: "auto", scrollbarHideDelayMs: 20 }, ); tui.setLayoutRoot(scrollView); tui.start(); await terminal.waitForRender(); assert.strictEqual(scrollView.isScrollbarVisible, false); - terminal.sendInput("\x1b[<65;10;1M"); + terminal.sendInput("\x1b[<35;10;3M"); await terminal.waitForRender(); - assert.strictEqual(scrollView.scrollTop, 1); assert.strictEqual(scrollView.isScrollbarVisible, true); + assert.strictEqual(scrollView.isScrollbarActive, true); + assert.ok(terminal.getViewport().some((line) => /[│█]/.test(line))); - terminal.sendInput("\x1b[<0;10;1M"); + terminal.sendInput("\x1b[<35;9;3M"); + await new Promise((resolve) => setTimeout(resolve, 40)); await terminal.waitForRender(); - await new Promise((resolve) => setTimeout(resolve, 70)); - assert.strictEqual(scrollView.isScrollbarVisible, true); - - terminal.sendInput("\x1b[<32;10;4M"); - await terminal.waitForRender(); - assert.strictEqual(scrollView.scrollTop, 15); - assert.deepStrictEqual( - terminal.getViewport().map((line) => line.trimEnd()), - ["line 16", "line 17", "line 18", "line 19", "line 20"], - ); - - terminal.sendInput("\x1b[<0;10;4m"); - await terminal.waitForRender(); - assert.strictEqual(scrollView.isScrollbarVisible, true); - await new Promise((resolve) => setTimeout(resolve, 70)); - assert.strictEqual(scrollView.isScrollbarVisible, true); - terminal.sendInput("\x1b[<35;9;4M"); - await new Promise((resolve) => setTimeout(resolve, 70)); assert.strictEqual(scrollView.isScrollbarVisible, false); - - terminal.sendInput("\x1b[<64;10;5M"); - await terminal.waitForRender(); - assert.strictEqual(scrollView.scrollTop, 14); - await new Promise((resolve) => setTimeout(resolve, 70)); - assert.strictEqual(scrollView.isScrollbarVisible, true); - terminal.sendInput("\x1b[<35;9;5M"); - await new Promise((resolve) => setTimeout(resolve, 70)); - assert.strictEqual(scrollView.isScrollbarVisible, false); - - assert.ok(terminal.events.every((event) => event.type !== "write" || !event.data.includes("\x1b]52;c;"))); tui.stop(); }); - it("keeps the scrollbar column selectable while the thumb is hidden", async () => { - const terminal = new RecordingTerminal(10, 2); + it("jumps to a scrollbar track position and continues dragging from there", async () => { + const terminal = new RecordingTerminal(10, 10); const tui = new TuiAltScreen(terminal); - const scrollView = new ScrollView(new Text("123456789A\nabcdefghij\nmore\nlines", 0, 0), { - scrollbar: "auto", - }); + const scrollView = new ScrollView( + new Text(Array.from({ length: 50 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0), + { primary: true, scrollbar: "always" }, + ); tui.setLayoutRoot(scrollView); tui.start(); await terminal.waitForRender(); - assert.strictEqual(scrollView.isScrollbarVisible, false); + assert.strictEqual(scrollView.scrollTop, 0); - terminal.sendInput("\x1b[<0;10;1M"); - terminal.sendInput("\x1b[<32;10;2M"); - terminal.sendInput("\x1b[<0;10;2m"); + terminal.sendInput("\x1b[<0;10;6M"); await terminal.waitForRender(); + assert.strictEqual(scrollView.scrollTop, 20); - const expected = `\x1b]52;c;${Buffer.from("A\nabcdefghij").toString("base64")}\x07`; - assert.ok( - terminal.events.some((event) => event.type === "write" && event.data.includes(expected)), - JSON.stringify(terminal.events.filter((event) => event.type === "write" && event.data.includes("\x1b]52;c;"))), - ); + terminal.sendInput("\x1b[<32;10;10M"); + await terminal.waitForRender(); + assert.strictEqual(scrollView.scrollTop, 40); + + terminal.sendInput("\x1b[<0;10;10m"); + await terminal.waitForRender(); + assert.ok(terminal.events.every((event) => event.type !== "write" || !event.data.includes("\x1b]52;c;"))); tui.stop(); });