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