diff --git a/web/src/client/renderer.ts b/web/src/client/renderer.ts index e30099de..8aa058a1 100644 --- a/web/src/client/renderer.ts +++ b/web/src/client/renderer.ts @@ -4,6 +4,7 @@ import { Terminal } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; import { WebLinksAddon } from '@xterm/addon-web-links'; +import { ScaleFitAddon } from './scale-fit-addon.js'; interface CastHeader { version: number; @@ -23,6 +24,7 @@ export class Renderer { private container: HTMLElement; private terminal: Terminal; private fitAddon: FitAddon; + private scaleFitAddon: ScaleFitAddon; private webLinksAddon: WebLinksAddon; private isPreview: boolean; @@ -72,9 +74,11 @@ export class Renderer { // Add addons this.fitAddon = new FitAddon(); + this.scaleFitAddon = new ScaleFitAddon(); this.webLinksAddon = new WebLinksAddon(); this.terminal.loadAddon(this.fitAddon); + this.terminal.loadAddon(this.scaleFitAddon); this.terminal.loadAddon(this.webLinksAddon); this.setupDOM(); @@ -96,12 +100,12 @@ export class Renderer { // Open terminal in the wrapper this.terminal.open(terminalWrapper); - // Just use FitAddon - this.fitAddon.fit(); + // Always use ScaleFitAddon for better scaling + this.scaleFitAddon.fit(); // Handle container resize const resizeObserver = new ResizeObserver(() => { - this.fitAddon.fit(); + this.scaleFitAddon.fit(); }); resizeObserver.observe(this.container); } @@ -176,8 +180,12 @@ export class Renderer { } resize(width: number, height: number): void { - // Ignore session resize and just use FitAddon - this.fitAddon.fit(); + if (this.isPreview) { + // For previews, resize to session dimensions then apply scaling + this.terminal.resize(width, height); + } + // Always use ScaleFitAddon for consistent scaling behavior + this.scaleFitAddon.fit(); } clear(): void { diff --git a/web/src/client/scale-fit-addon.ts b/web/src/client/scale-fit-addon.ts new file mode 100644 index 00000000..c52618c8 --- /dev/null +++ b/web/src/client/scale-fit-addon.ts @@ -0,0 +1,127 @@ +/** + * Custom FitAddon that scales font size to fit terminal columns to container width, + * then calculates optimal rows for the container height. + */ + +import type { Terminal, ITerminalAddon } from '@xterm/xterm'; + +interface ITerminalDimensions { + rows: number; + cols: number; +} + +const MINIMUM_ROWS = 1; + +export class ScaleFitAddon implements ITerminalAddon { + private _terminal: Terminal | undefined; + + public activate(terminal: Terminal): void { + this._terminal = terminal; + } + + public dispose(): void {} + + public fit(): void { + const dims = this.proposeDimensions(); + if (!dims || !this._terminal || isNaN(dims.cols) || isNaN(dims.rows)) { + return; + } + + // Only resize rows, keep cols the same (font scaling handles width) + if (this._terminal.rows !== dims.rows) { + this._terminal.resize(this._terminal.cols, dims.rows); + } + } + + public proposeDimensions(): ITerminalDimensions | undefined { + if (!this._terminal?.element?.parentElement) { + return undefined; + } + + // Get container dimensions + const parentElement = this._terminal.element.parentElement; + const parentStyle = window.getComputedStyle(parentElement); + const parentWidth = parseInt(parentStyle.getPropertyValue('width')); + const parentHeight = parseInt(parentStyle.getPropertyValue('height')); + + // Get terminal element padding + const elementStyle = window.getComputedStyle(this._terminal.element); + const padding = { + top: parseInt(elementStyle.getPropertyValue('padding-top')), + bottom: parseInt(elementStyle.getPropertyValue('padding-bottom')), + left: parseInt(elementStyle.getPropertyValue('padding-left')), + right: parseInt(elementStyle.getPropertyValue('padding-right')) + }; + + // Calculate available space + const availableWidth = parentWidth - padding.left - padding.right - 20; // Extra margin + const availableHeight = parentHeight - padding.top - padding.bottom - 20; + + // Current terminal dimensions + const currentCols = this._terminal.cols; + + // Calculate optimal font size to fit current cols in available width + // Character width is approximately 0.6 * fontSize for monospace fonts + const charWidthRatio = 0.6; + const optimalFontSize = Math.max(6, availableWidth / (currentCols * charWidthRatio)); + + // Apply the calculated font size (outside of proposeDimensions to avoid recursion) + setTimeout(() => this.applyFontSize(optimalFontSize), 0); + + // Calculate line height (typically 1.2 * fontSize) + const lineHeight = optimalFontSize * 1.2; + + // Calculate how many rows fit with this font size + const optimalRows = Math.max(MINIMUM_ROWS, Math.floor(availableHeight / lineHeight)); + + return { + cols: currentCols, // Keep existing cols + rows: optimalRows // Fit as many rows as possible + }; + } + + private applyFontSize(fontSize: number): void { + if (!this._terminal?.element) return; + + // Prevent infinite recursion by checking if font size changed significantly + const currentFontSize = this._terminal.options.fontSize || 14; + if (Math.abs(fontSize - currentFontSize) < 0.1) return; + + const terminalElement = this._terminal.element; + + // Update terminal's font size + this._terminal.options.fontSize = fontSize; + + // Apply CSS font size to the element + terminalElement.style.fontSize = `${fontSize}px`; + + // Force a refresh to apply the new font size + requestAnimationFrame(() => { + if (this._terminal) { + this._terminal.refresh(0, this._terminal.rows - 1); + } + }); + } + + /** + * Get the calculated font size that would fit the current columns in the container + */ + public getOptimalFontSize(): number { + if (!this._terminal?.element?.parentElement) { + return this._terminal?.options.fontSize || 14; + } + + const parentElement = this._terminal.element.parentElement; + const parentStyle = window.getComputedStyle(parentElement); + const parentWidth = parseInt(parentStyle.getPropertyValue('width')); + + const elementStyle = window.getComputedStyle(this._terminal.element); + const paddingHor = parseInt(elementStyle.getPropertyValue('padding-left')) + + parseInt(elementStyle.getPropertyValue('padding-right')); + + const availableWidth = parentWidth - paddingHor; + const charWidthRatio = 0.6; + + return availableWidth / (this._terminal.cols * charWidthRatio); + } +} \ No newline at end of file diff --git a/web/src/input.css b/web/src/input.css index 7bd0aaa2..b98e023b 100644 --- a/web/src/input.css +++ b/web/src/input.css @@ -60,8 +60,7 @@ session-view .xterm-helper-textarea { .session-preview .xterm .xterm-screen { max-width: 100% !important; max-height: 100% !important; - transform: scale(0.8); /* Scale down the content to fit better */ - transform-origin: top left; + /* Scaling now handled by ScaleFitAddon */ } .session-preview .xterm .xterm-viewport {