From 4bc961301ac6501586d95bb503d16f3cf8f5b946 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Mon, 17 Nov 2025 16:18:50 +0000 Subject: [PATCH] feat: improve scrollbar UX with auto-hide and interactive controls - Remove blue scroll indicator banner - Add interactive scrollbar with click and drag support - Click track to jump to position - Drag thumb for smooth scrolling - Implement auto-hide with fade animations - Scrollbar fades in when scrolling (200ms) - Auto-hides after 1.5 seconds of inactivity - Stays visible during drag operations - Prevent text selection during scrollbar drag - Use capture phase event handling - Set userSelect CSS during drag - Add demo/scrollbar-test.html for testing All 205 tests passing, lint and format checks pass. --- demo/scrollbar-test.html | 76 ++++++++++++ lib/renderer.ts | 54 +++----- lib/terminal.ts | 259 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 348 insertions(+), 41 deletions(-) create mode 100644 demo/scrollbar-test.html diff --git a/demo/scrollbar-test.html b/demo/scrollbar-test.html new file mode 100644 index 0000000..2686d20 --- /dev/null +++ b/demo/scrollbar-test.html @@ -0,0 +1,76 @@ + + + + + Scrollbar Test - Ghostty WASM + + + +
+

Scrollbar Test

+

✅ Blue banner removed

+

🖱️ Try clicking the scrollbar track or dragging the thumb

+

🔄 Use mouse wheel to scroll and see the scrollbar

+
+
+ + + + diff --git a/lib/renderer.ts b/lib/renderer.ts index bc5d061..310c3e0 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -238,7 +238,8 @@ export class CanvasRenderer { buffer: IRenderable, forceAll: boolean = false, viewportY: number = 0, - scrollbackProvider?: IScrollbackProvider + scrollbackProvider?: IScrollbackProvider, + scrollbarOpacity: number = 1 ): void { const cursor = buffer.getCursor(); const dims = buffer.getDimensions(); @@ -410,9 +411,9 @@ export class CanvasRenderer { this.renderCursor(cursor.x, cursor.y); } - // Render scrollbar if scrolled or scrollback exists - if (scrollbackProvider) { - this.renderScrollbar(viewportY, scrollbackLength, dims.rows); + // Render scrollbar if scrolled or scrollback exists (with opacity for fade effect) + if (scrollbackProvider && scrollbarOpacity > 0) { + this.renderScrollbar(viewportY, scrollbackLength, dims.rows, scrollbarOpacity); } // Update last cursor position @@ -667,9 +668,17 @@ export class CanvasRenderer { /** * Render scrollbar (Phase 2) + * Shows scroll position and allows click/drag interaction + * @param opacity Opacity level (0-1) for fade in/out effect */ - private renderScrollbar(viewportY: number, scrollbackLength: number, visibleRows: number): void { - if (scrollbackLength === 0) return; + private renderScrollbar( + viewportY: number, + scrollbackLength: number, + visibleRows: number, + opacity: number = 1 + ): void { + // Don't render if fully transparent or no scrollback + if (opacity <= 0 || scrollbackLength === 0) return; const ctx = this.ctx; const canvasHeight = this.canvas.height / this.devicePixelRatio; @@ -689,38 +698,15 @@ export class CanvasRenderer { const scrollPosition = viewportY / scrollbackLength; // 0 to 1 const thumbY = scrollbarPadding + (scrollbarTrackHeight - thumbHeight) * (1 - scrollPosition); - // Draw scrollbar track (subtle background) - ctx.fillStyle = 'rgba(128, 128, 128, 0.1)'; + // Draw scrollbar track (subtle background) with opacity + ctx.fillStyle = `rgba(128, 128, 128, ${0.1 * opacity})`; ctx.fillRect(scrollbarX, scrollbarPadding, scrollbarWidth, scrollbarTrackHeight); - // Draw scrollbar thumb + // Draw scrollbar thumb with opacity const isScrolled = viewportY > 0; - ctx.fillStyle = isScrolled ? 'rgba(128, 128, 128, 0.5)' : 'rgba(128, 128, 128, 0.3)'; + const baseOpacity = isScrolled ? 0.5 : 0.3; + ctx.fillStyle = `rgba(128, 128, 128, ${baseOpacity * opacity})`; ctx.fillRect(scrollbarX, thumbY, scrollbarWidth, thumbHeight); - - // Draw "scrolled up" indicator if not at bottom - if (isScrolled) { - // Draw a banner at the top showing scroll position - const bannerHeight = 24; - const bannerY = 0; - - // Semi-transparent background - ctx.fillStyle = 'rgba(33, 150, 243, 0.9)'; - ctx.fillRect(0, bannerY, canvasWidth, bannerHeight); - - // Text showing position - ctx.fillStyle = '#ffffff'; - ctx.font = '12px sans-serif'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - - const linesFromBottom = viewportY; - const text = `↑ Scrolled ${linesFromBottom} lines from bottom (${scrollbackLength} total) - Scroll down or type to return`; - ctx.fillText(text, canvasWidth / 2, bannerY + bannerHeight / 2); - - ctx.textAlign = 'left'; - ctx.textBaseline = 'alphabetic'; - } } public getMetrics(): FontMetrics { return { ...this.metrics }; diff --git a/lib/terminal.ts b/lib/terminal.ts index 012e3db..ff8a8e6 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -106,6 +106,18 @@ export class Terminal implements ITerminalCore { private customWheelEventHandler?: (event: WheelEvent) => boolean; private lastCursorY: number = 0; // Track cursor position for onCursorMove + // Scrollbar interaction state + private isDraggingScrollbar: boolean = false; + private scrollbarDragStart: number | null = null; + private scrollbarDragStartViewportY: number = 0; + + // Scrollbar visibility/auto-hide state + private scrollbarVisible: boolean = false; + private scrollbarOpacity: number = 0; + private scrollbarHideTimeout?: number; + private readonly SCROLLBAR_HIDE_DELAY_MS = 1500; // Hide after 1.5 seconds + private readonly SCROLLBAR_FADE_DURATION_MS = 200; // 200ms fade animation + constructor(options: ITerminalOptions = {}) { // Set default options this.options = { @@ -257,11 +269,16 @@ export class Terminal implements ITerminalCore { // Register OSC 8 hyperlink provider this.linkDetector.registerProvider(new OSC8LinkProvider(this)); - // Setup mouse event handling for links + // Setup mouse event handling for links and scrollbar + // Use capture phase to intercept scrollbar clicks before SelectionManager + parent.addEventListener('mousedown', this.handleMouseDown, { capture: true }); parent.addEventListener('mousemove', this.handleMouseMove); parent.addEventListener('mouseleave', this.handleMouseLeave); parent.addEventListener('click', this.handleClick); + // Setup document-level mouseup for scrollbar drag (so drag works even outside canvas) + document.addEventListener('mouseup', this.handleMouseUp); + // Setup wheel event handling for scrolling (Phase 2) // Use capture phase to ensure we get the event before browser scrolling parent.addEventListener('wheel', this.handleWheel, { passive: false, capture: true }); @@ -270,7 +287,7 @@ export class Terminal implements ITerminalCore { this.isOpen = true; // Render initial blank screen - this.renderer.render(this.wasmTerm, true, this.viewportY, this); + this.renderer.render(this.wasmTerm, true, this.viewportY, this, this.scrollbarOpacity); // Start render loop this.startRenderLoop(); @@ -610,6 +627,11 @@ export class Terminal implements ITerminalCore { if (newViewportY !== this.viewportY) { this.viewportY = newViewportY; this.scrollEmitter.fire(this.viewportY); + + // Show scrollbar when scrolling (with auto-hide) + if (scrollbackLength > 0) { + this.showScrollbar(); + } } } @@ -629,6 +651,7 @@ export class Terminal implements ITerminalCore { if (scrollbackLength > 0 && this.viewportY !== scrollbackLength) { this.viewportY = scrollbackLength; this.scrollEmitter.fire(this.viewportY); + this.showScrollbar(); } } @@ -639,6 +662,10 @@ export class Terminal implements ITerminalCore { if (this.viewportY !== 0) { this.viewportY = 0; this.scrollEmitter.fire(this.viewportY); + // Show scrollbar briefly when scrolling to bottom + if (this.getScrollbackLength() > 0) { + this.showScrollbar(); + } } } @@ -653,6 +680,11 @@ export class Terminal implements ITerminalCore { if (newViewportY !== this.viewportY) { this.viewportY = newViewportY; this.scrollEmitter.fire(this.viewportY); + + // Show scrollbar when scrolling to specific line + if (scrollbackLength > 0) { + this.showScrollbar(); + } } } @@ -722,8 +754,8 @@ export class Terminal implements ITerminalCore { this.cursorMoveEmitter.fire(); } - // Render only dirty lines for 60 FPS performance - this.renderer!.render(this.wasmTerm!, false, this.viewportY, this); + // Render only dirty lines for 60 FPS performance (with scrollbar opacity) + this.renderer!.render(this.wasmTerm!, false, this.viewportY, this, this.scrollbarOpacity); // Note: onRender event is intentionally not fired in the render loop // to avoid performance issues. For now, consumers can use requestAnimationFrame @@ -784,11 +816,23 @@ export class Terminal implements ITerminalCore { // Remove event listeners if (this.element) { this.element.removeEventListener('wheel', this.handleWheel); + this.element.removeEventListener('mousedown', this.handleMouseDown, { capture: true }); this.element.removeEventListener('mousemove', this.handleMouseMove); this.element.removeEventListener('mouseleave', this.handleMouseLeave); this.element.removeEventListener('click', this.handleClick); } + // Remove document-level listeners (only if opened) + if (this.isOpen && typeof document !== 'undefined') { + document.removeEventListener('mouseup', this.handleMouseUp); + } + + // Clean up scrollbar timers + if (this.scrollbarHideTimeout) { + window.clearTimeout(this.scrollbarHideTimeout); + this.scrollbarHideTimeout = undefined; + } + // Dispose link detector if (this.linkDetector) { this.linkDetector.dispose(); @@ -820,11 +864,19 @@ export class Terminal implements ITerminalCore { } /** - * Handle mouse move for link hover detection - * Throttled to avoid blocking scroll events + * Handle mouse move for link hover detection and scrollbar dragging + * Throttled to avoid blocking scroll events (except when dragging scrollbar) */ private handleMouseMove = (e: MouseEvent): void => { - if (!this.canvas || !this.renderer || !this.linkDetector || !this.wasmTerm) return; + if (!this.canvas || !this.renderer || !this.wasmTerm) return; + + // If dragging scrollbar, handle immediately without throttling + if (this.isDraggingScrollbar) { + this.processScrollbarDrag(e); + return; + } + + if (!this.linkDetector) return; // Throttle to ~60fps (16ms) to avoid blocking scroll/other events if (this.mouseMoveThrottleTimeout) { @@ -1050,6 +1102,199 @@ export class Terminal implements ITerminalCore { } }; + /** + * Handle mouse down for scrollbar interaction + */ + private handleMouseDown = (e: MouseEvent): void => { + if (!this.canvas || !this.renderer || !this.wasmTerm) return; + + const scrollbackLength = this.wasmTerm.getScrollbackLength(); + if (scrollbackLength === 0) return; // No scrollbar if no scrollback + + const rect = this.canvas.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + + // Calculate scrollbar dimensions (match renderer's logic) + // Use rect dimensions which are already in CSS pixels + const canvasWidth = rect.width; + const canvasHeight = rect.height; + const scrollbarWidth = 8; + const scrollbarX = canvasWidth - scrollbarWidth - 4; + const scrollbarPadding = 4; + + // Check if click is in scrollbar area + if (mouseX >= scrollbarX && mouseX <= scrollbarX + scrollbarWidth) { + // Prevent default and stop propagation to prevent text selection + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); // Stop SelectionManager from seeing this event + + // Calculate scrollbar thumb position and size + const scrollbarTrackHeight = canvasHeight - scrollbarPadding * 2; + const visibleRows = this.rows; + const totalLines = scrollbackLength + visibleRows; + const thumbHeight = Math.max(20, (visibleRows / totalLines) * scrollbarTrackHeight); + const scrollPosition = this.viewportY / scrollbackLength; + const thumbY = scrollbarPadding + (scrollbarTrackHeight - thumbHeight) * (1 - scrollPosition); + + // Check if click is on thumb + if (mouseY >= thumbY && mouseY <= thumbY + thumbHeight) { + // Start dragging thumb + this.isDraggingScrollbar = true; + this.scrollbarDragStart = mouseY; + this.scrollbarDragStartViewportY = this.viewportY; + + // Prevent text selection during drag + if (this.canvas) { + this.canvas.style.userSelect = 'none'; + this.canvas.style.webkitUserSelect = 'none'; + } + } else { + // Click on track - jump to position + const relativeY = mouseY - scrollbarPadding; + const scrollFraction = 1 - relativeY / scrollbarTrackHeight; // Inverted: top = 1, bottom = 0 + const targetViewportY = Math.round(scrollFraction * scrollbackLength); + this.scrollToLine(Math.max(0, Math.min(scrollbackLength, targetViewportY))); + } + } + }; + + /** + * Handle mouse up for scrollbar drag + */ + private handleMouseUp = (): void => { + if (this.isDraggingScrollbar) { + this.isDraggingScrollbar = false; + this.scrollbarDragStart = null; + + // Restore text selection + if (this.canvas) { + this.canvas.style.userSelect = ''; + this.canvas.style.webkitUserSelect = ''; + } + + // Schedule auto-hide after drag ends + if (this.scrollbarVisible && this.getScrollbackLength() > 0) { + this.showScrollbar(); // Reset the hide timer + } + } + }; + + /** + * Process scrollbar drag movement + */ + private processScrollbarDrag(e: MouseEvent): void { + if (!this.canvas || !this.renderer || !this.wasmTerm || this.scrollbarDragStart === null) + return; + + const scrollbackLength = this.wasmTerm.getScrollbackLength(); + if (scrollbackLength === 0) return; + + const rect = this.canvas.getBoundingClientRect(); + const mouseY = e.clientY - rect.top; + + // Calculate how much the mouse moved + const deltaY = mouseY - this.scrollbarDragStart; + + // Convert mouse delta to viewport delta + // Use rect height which is already in CSS pixels + const canvasHeight = rect.height; + const scrollbarPadding = 4; + const scrollbarTrackHeight = canvasHeight - scrollbarPadding * 2; + const visibleRows = this.rows; + const totalLines = scrollbackLength + visibleRows; + const thumbHeight = Math.max(20, (visibleRows / totalLines) * scrollbarTrackHeight); + + // Calculate scroll fraction from thumb movement + // Note: thumb moves in opposite direction to viewport (thumb down = scroll down = viewportY decreases) + const scrollFraction = -deltaY / (scrollbarTrackHeight - thumbHeight); + const viewportDelta = Math.round(scrollFraction * scrollbackLength); + + const newViewportY = this.scrollbarDragStartViewportY + viewportDelta; + this.scrollToLine(Math.max(0, Math.min(scrollbackLength, newViewportY))); + } + + /** + * Show scrollbar with fade-in and schedule auto-hide + */ + private showScrollbar(): void { + // Clear any existing hide timeout + if (this.scrollbarHideTimeout) { + window.clearTimeout(this.scrollbarHideTimeout); + this.scrollbarHideTimeout = undefined; + } + + // If not visible, start fade-in + if (!this.scrollbarVisible) { + this.scrollbarVisible = true; + this.scrollbarOpacity = 0; + this.fadeInScrollbar(); + } else { + // Already visible, just ensure it's fully opaque + this.scrollbarOpacity = 1; + } + + // Schedule auto-hide (unless dragging) + if (!this.isDraggingScrollbar) { + this.scrollbarHideTimeout = window.setTimeout(() => { + this.hideScrollbar(); + }, this.SCROLLBAR_HIDE_DELAY_MS); + } + } + + /** + * Hide scrollbar with fade-out + */ + private hideScrollbar(): void { + if (this.scrollbarHideTimeout) { + window.clearTimeout(this.scrollbarHideTimeout); + this.scrollbarHideTimeout = undefined; + } + + if (this.scrollbarVisible) { + this.fadeOutScrollbar(); + } + } + + /** + * Fade in scrollbar + */ + private fadeInScrollbar(): void { + const startTime = Date.now(); + const animate = () => { + const elapsed = Date.now() - startTime; + const progress = Math.min(elapsed / this.SCROLLBAR_FADE_DURATION_MS, 1); + this.scrollbarOpacity = progress; + + if (progress < 1) { + requestAnimationFrame(animate); + } + }; + animate(); + } + + /** + * Fade out scrollbar + */ + private fadeOutScrollbar(): void { + const startTime = Date.now(); + const startOpacity = this.scrollbarOpacity; + const animate = () => { + const elapsed = Date.now() - startTime; + const progress = Math.min(elapsed / this.SCROLLBAR_FADE_DURATION_MS, 1); + this.scrollbarOpacity = startOpacity * (1 - progress); + + if (progress < 1) { + requestAnimationFrame(animate); + } else { + this.scrollbarVisible = false; + this.scrollbarOpacity = 0; + } + }; + animate(); + } + /** * Check for title changes in written data (OSC sequences) * Simplified implementation - looks for OSC 0, 1, 2