mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(tui): alt mode scrollbar but prettier (#8801)
This commit is contained in:
parent
79680533c6
commit
457ae8c79c
21 changed files with 292 additions and 140 deletions
|
|
@ -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)).
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@
|
|||
"dim": "dimGray",
|
||||
"text": "",
|
||||
"thinkingText": "gray",
|
||||
"scrollbarTrack": "darkGray",
|
||||
"scrollbarThumb": "",
|
||||
"selectedBg": "selectedBg",
|
||||
"userMessageBg": "userMsgBg",
|
||||
"userMessageText": "",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -33,7 +33,8 @@
|
|||
"thinkingText": "gray",
|
||||
|
||||
"selectedBg": "selectedBg",
|
||||
"scrollbarThumb": "selectedBg",
|
||||
"scrollbarTrack": "darkGray",
|
||||
"scrollbarThumb": "text",
|
||||
"searchMatchBg": "selectedBg",
|
||||
"searchMatchText": "text",
|
||||
"userMessageBg": "userMsgBg",
|
||||
|
|
|
|||
|
|
@ -32,7 +32,8 @@
|
|||
"thinkingText": "mediumGray",
|
||||
|
||||
"selectedBg": "selectedBg",
|
||||
"scrollbarThumb": "selectedBg",
|
||||
"scrollbarTrack": "lightGray",
|
||||
"scrollbarThumb": "text",
|
||||
"searchMatchBg": "selectedBg",
|
||||
"searchMatchText": "text",
|
||||
"userMessageBg": "userMsgBg",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<T extends Record<string, ColorValue>>(
|
|||
}
|
||||
|
||||
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<ThemeBg, string | number> = {} as Record<ThemeBg, string | number>;
|
||||
const bgColorKeys: Set<string> = new Set([
|
||||
"selectedBg",
|
||||
"scrollbarThumb",
|
||||
"searchMatchBg",
|
||||
"userMessageBg",
|
||||
"customMessageBg",
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue