Fix the stupid terminal scaling calculations, we properly measure glyph width/height and apply linear scaling plus conservative row clamping. Good enough. Scroll could be reset to bottom after fit, but might also kill user scroll position so shrug.

This commit is contained in:
Mario Zechner 2025-06-16 22:55:54 +02:00
parent afa48c67d5
commit ddb342bdec
6 changed files with 174 additions and 280 deletions

View file

@ -1,180 +0,0 @@
/* Terminal-style CSS with 1em character grid */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--char-width: 1em;
--char-height: 1em;
--terminal-bg: #000000;
--terminal-fg: #ffffff;
--terminal-green: #00ff00;
--terminal-blue: #0080ff;
--terminal-cyan: #00ffff;
--terminal-yellow: #ffff00;
--terminal-red: #ff0000;
--terminal-gray: #808080;
}
body {
font-family: 'Courier New', monospace;
font-size: 16px;
line-height: var(--char-height);
background: var(--terminal-bg);
color: var(--terminal-fg);
padding: var(--char-height);
}
/* Terminal box styling */
.terminal-box {
border: 1px solid var(--terminal-gray);
background: var(--terminal-bg);
padding: var(--char-height);
margin: var(--char-height) 0;
}
/* Input styling */
input, textarea, select {
font-family: 'Courier New', monospace;
font-size: 16px;
line-height: var(--char-height);
background: var(--terminal-bg);
color: var(--terminal-fg);
border: 1px solid var(--terminal-gray);
padding: 0 var(--char-width);
height: calc(var(--char-height) * 2);
}
input:focus, textarea:focus, select:focus {
outline: 1px solid var(--terminal-green);
border-color: var(--terminal-green);
}
/* Button styling */
button {
font-family: 'Courier New', monospace;
font-size: 16px;
line-height: var(--char-height);
background: var(--terminal-gray);
color: var(--terminal-bg);
border: 1px solid var(--terminal-gray);
padding: 0 var(--char-width);
height: calc(var(--char-height) * 2);
cursor: pointer;
min-width: calc(var(--char-width) * 8);
}
button:hover {
background: var(--terminal-fg);
}
button.primary {
background: var(--terminal-green);
border-color: var(--terminal-green);
}
button.danger {
background: var(--terminal-red);
border-color: var(--terminal-red);
color: var(--terminal-fg);
}
/* Typography */
h1, h2, h3 {
line-height: var(--char-height);
margin: var(--char-height) 0;
}
h1 { color: var(--terminal-green); }
h2 { color: var(--terminal-cyan); }
h3 { color: var(--terminal-yellow); }
p {
margin: var(--char-height) 0;
}
/* Layout helpers */
.row {
display: flex;
gap: var(--char-width);
margin: var(--char-height) 0;
}
.col {
flex: 1;
}
.hidden {
display: none !important;
}
/* Terminal colors */
.text-green { color: var(--terminal-green); }
.text-blue { color: var(--terminal-blue); }
.text-cyan { color: var(--terminal-cyan); }
.text-yellow { color: var(--terminal-yellow); }
.text-red { color: var(--terminal-red); }
.text-gray { color: var(--terminal-gray); }
/* Session grid */
.session-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(40ch, 1fr));
gap: var(--char-height);
margin: var(--char-height) 0;
}
/* Asciinema player styling */
.asciinema-player {
width: 100% !important;
max-width: 100% !important;
}
.asciinema-player .control-bar {
display: none !important;
}
/* Modal styling */
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
padding: var(--char-height);
}
.modal-content {
background: var(--terminal-bg);
border: 1px solid var(--terminal-gray);
padding: var(--char-height);
max-width: 80ch;
max-height: 80vh;
overflow-y: auto;
}
/* File browser */
.file-item {
padding: 0 var(--char-width);
cursor: pointer;
line-height: var(--char-height);
}
.file-item:hover {
background: var(--terminal-gray);
color: var(--terminal-bg);
}
.file-item.directory {
color: var(--terminal-blue);
}
.file-item.file {
color: var(--terminal-fg);
}

View file

@ -55,7 +55,7 @@ export class SessionCard extends LitElement {
if (!playerElement) return; if (!playerElement) return;
// Create single renderer for this card // Create single renderer for this card
this.renderer = new Renderer(playerElement, 80, 24, 10000, 4, true); this.renderer = new Renderer(playerElement, 80, 24, 10000, 4, false);
// Always use snapshot endpoint for cards // Always use snapshot endpoint for cards
const url = `/api/sessions/${this.session.id}/snapshot`; const url = `/api/sessions/${this.session.id}/snapshot`;

View file

@ -143,8 +143,19 @@ export class SessionView extends LitElement {
private createInteractiveTerminal() { private createInteractiveTerminal() {
if (!this.session) return; if (!this.session) return;
const terminalElement = this.querySelector('#interactive-terminal') as HTMLElement; // Look for existing interactive terminal div or create one
if (!terminalElement) return; let terminalElement = this.querySelector('#interactive-terminal') as HTMLElement;
if (!terminalElement) {
// Create the interactive terminal div inside the container
const container = this.querySelector('#terminal-container') as HTMLElement;
if (!container) return;
terminalElement = document.createElement('div');
terminalElement.id = 'interactive-terminal';
terminalElement.className = 'w-full h-full';
terminalElement.style.cssText = 'max-width: 100%; height: 100%;';
container.appendChild(terminalElement);
}
// Create renderer once and connect to current session // Create renderer once and connect to current session
this.renderer = new Renderer(terminalElement); this.renderer = new Renderer(terminalElement);
@ -610,7 +621,7 @@ export class SessionView extends LitElement {
box-shadow: none !important; box-shadow: none !important;
} }
</style> </style>
<div class="flex flex-col bg-vs-bg font-mono" style="height: 100vh; outline: none !important; box-shadow: none !important;"> <div class="flex flex-col bg-vs-bg font-mono" style="height: 100vh; height: 100dvh; outline: none !important; box-shadow: none !important;">
<!-- Compact Header --> <!-- Compact Header -->
<div class="flex items-center justify-between px-3 py-2 border-b border-vs-border bg-vs-bg-secondary text-sm"> <div class="flex items-center justify-between px-3 py-2 border-b border-vs-border bg-vs-bg-secondary text-sm">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@ -633,9 +644,7 @@ export class SessionView extends LitElement {
</div> </div>
<!-- Terminal Container --> <!-- Terminal Container -->
<div class="flex-1 bg-black overflow-hidden min-h-0 relative" id="terminal-container" style="max-width: 100vw;"> <div class="flex-1 bg-black overflow-hidden min-h-0 relative" id="terminal-container" style="max-width: 100vw; height: 100%;">
<div id="interactive-terminal" class="w-full h-full" style="max-width: 100%;"></div>
${this.loading ? html` ${this.loading ? html`
<!-- Loading overlay --> <!-- Loading overlay -->
<div class="absolute inset-0 bg-black bg-opacity-80 flex items-center justify-center"> <div class="absolute inset-0 bg-black bg-opacity-80 flex items-center justify-center">

View file

@ -2,7 +2,6 @@
// Professional-grade terminal emulation with full VT compatibility // Professional-grade terminal emulation with full VT compatibility
import { Terminal } from '@xterm/xterm'; import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { WebLinksAddon } from '@xterm/addon-web-links'; import { WebLinksAddon } from '@xterm/addon-web-links';
import { ScaleFitAddon } from './scale-fit-addon.js'; import { ScaleFitAddon } from './scale-fit-addon.js';
@ -25,16 +24,13 @@ export class Renderer {
private container: HTMLElement; private container: HTMLElement;
private terminal: Terminal; private terminal: Terminal;
private fitAddon: FitAddon;
private scaleFitAddon: ScaleFitAddon; private scaleFitAddon: ScaleFitAddon;
private webLinksAddon: WebLinksAddon; private webLinksAddon: WebLinksAddon;
private isPreview: boolean;
constructor(container: HTMLElement, width: number = 80, height: number = 20, scrollback: number = 1000000, fontSize: number = 14, isPreview: boolean = false) { constructor(container: HTMLElement, width: number = 80, height: number = 20, scrollback: number = 1000000, fontSize: number = 14) {
Renderer.activeCount++; Renderer.activeCount++;
console.log(`Renderer constructor called (active: ${Renderer.activeCount})`); console.log(`Renderer constructor called (active: ${Renderer.activeCount})`);
this.container = container; this.container = container;
this.isPreview = isPreview;
// Create terminal with options similar to the custom renderer // Create terminal with options similar to the custom renderer
this.terminal = new Terminal({ this.terminal = new Terminal({
@ -74,17 +70,13 @@ export class Renderer {
altClickMovesCursor: false, altClickMovesCursor: false,
rightClickSelectsWord: false, rightClickSelectsWord: false,
disableStdin: true, // We handle input separately disableStdin: true, // We handle input separately
cursorStyle: 'block',
cursorInactiveStyle: 'block',
cursorWidth: 1, cursorWidth: 1,
}); });
// Add addons // Add addons
this.fitAddon = new FitAddon(); this.scaleFitAddon = new ScaleFitAddon();
this.scaleFitAddon = new ScaleFitAddon(isPreview);
this.webLinksAddon = new WebLinksAddon(); this.webLinksAddon = new WebLinksAddon();
this.terminal.loadAddon(this.fitAddon);
this.terminal.loadAddon(this.scaleFitAddon); this.terminal.loadAddon(this.scaleFitAddon);
this.terminal.loadAddon(this.webLinksAddon); this.terminal.loadAddon(this.webLinksAddon);
@ -95,22 +87,12 @@ export class Renderer {
// Clear container and add CSS // Clear container and add CSS
this.container.innerHTML = ''; this.container.innerHTML = '';
// Different styling for preview vs full terminals // Full terminals get padding
if (this.isPreview) { this.container.style.padding = '10px';
// No padding for previews, let container control sizing this.container.style.backgroundColor = '#1e1e1e';
this.container.style.padding = '0'; this.container.style.overflow = 'hidden';
this.container.style.backgroundColor = '#1e1e1e'; this.container.style.maxWidth = '100%';
this.container.style.overflow = 'hidden'; this.container.style.boxSizing = 'border-box';
this.container.style.maxWidth = '100%';
this.container.style.boxSizing = 'border-box';
} else {
// Full terminals get padding
this.container.style.padding = '10px';
this.container.style.backgroundColor = '#1e1e1e';
this.container.style.overflow = 'hidden';
this.container.style.maxWidth = '100%';
this.container.style.boxSizing = 'border-box';
}
// Create terminal wrapper // Create terminal wrapper
const terminalWrapper = document.createElement('div'); const terminalWrapper = document.createElement('div');
@ -128,21 +110,6 @@ export class Renderer {
const containerId = `terminal-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const containerId = `terminal-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
this.container.id = containerId; this.container.id = containerId;
const style = document.createElement('style');
style.textContent = `
#${containerId} .xterm-screen {
width: 100% !important;
height: 100% !important;
max-width: 100% !important;
max-height: 100% !important;
}
#${containerId} .xterm-viewport {
width: 100% !important;
max-width: 100% !important;
}
`;
document.head.appendChild(style);
// Always use ScaleFitAddon for better scaling // Always use ScaleFitAddon for better scaling
this.scaleFitAddon.fit(); this.scaleFitAddon.fit();
@ -352,7 +319,7 @@ export class Renderer {
// Method to fit terminal to container (useful for responsive layouts) // Method to fit terminal to container (useful for responsive layouts)
fit(): void { fit(): void {
this.fitAddon.fit(); this.scaleFitAddon.fit();
} }
// Get terminal dimensions // Get terminal dimensions

View file

@ -16,10 +16,8 @@ const MAX_FONT_SIZE = 16;
export class ScaleFitAddon implements ITerminalAddon { export class ScaleFitAddon implements ITerminalAddon {
private _terminal: Terminal | undefined; private _terminal: Terminal | undefined;
private _isPreview: boolean;
constructor(isPreview: boolean = false) { constructor() {
this._isPreview = isPreview;
} }
public activate(terminal: Terminal): void { public activate(terminal: Terminal): void {
@ -29,10 +27,6 @@ export class ScaleFitAddon implements ITerminalAddon {
public dispose(): void {} public dispose(): void {}
public fit(): void { public fit(): void {
if (this._isPreview) {
// For previews, only scale font size, don't change terminal dimensions
this.scaleFontOnly();
} else {
// For full terminals, resize both font and dimensions // For full terminals, resize both font and dimensions
const dims = this.proposeDimensions(); const dims = this.proposeDimensions();
if (!dims || !this._terminal || isNaN(dims.cols) || isNaN(dims.rows)) { if (!dims || !this._terminal || isNaN(dims.cols) || isNaN(dims.rows)) {
@ -43,7 +37,9 @@ export class ScaleFitAddon implements ITerminalAddon {
if (this._terminal.rows !== dims.rows) { if (this._terminal.rows !== dims.rows) {
this._terminal.resize(this._terminal.cols, dims.rows); this._terminal.resize(this._terminal.cols, dims.rows);
} }
}
// Force responsive sizing by overriding XTerm's fixed dimensions
this.forceResponsiveSizing();
} }
public proposeDimensions(): ITerminalDimensions | undefined { public proposeDimensions(): ITerminalDimensions | undefined {
@ -75,35 +71,60 @@ export class ScaleFitAddon implements ITerminalAddon {
// Current terminal dimensions // Current terminal dimensions
const currentCols = this._terminal.cols; const currentCols = this._terminal.cols;
// Calculate optimal font size to fit current cols in available width // Get exact character dimensions from XTerm's measurement system first
// Character width is approximately 0.6 * fontSize for monospace fonts const charDimensions = this.getXTermCharacterDimensions();
// For 80 cols exactly, we need to be more conservative to prevent wrapping
const charWidthRatio = 0.63;
// Calculate font size and round down for precision
const calculatedFontSize = Math.floor((availableWidth / (currentCols * charWidthRatio)) * 10) / 10;
const optimalFontSize = Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, calculatedFontSize));
// Apply the calculated font size (outside of proposeDimensions to avoid recursion) if (charDimensions) {
requestAnimationFrame(() => this.applyFontSize(optimalFontSize)); // Use actual measured dimensions for linear scaling calculation
const { charWidth, lineHeight } = charDimensions;
const currentFontSize = this._terminal.options.fontSize || 14;
// Get the actual line height from the rendered XTerm element // Calculate current total rendered width for all columns
const xtermElement = this._terminal.element; const currentRenderedWidth = currentCols * charWidth;
const currentStyle = window.getComputedStyle(xtermElement);
const actualLineHeight = parseFloat(currentStyle.lineHeight);
// XTerm typically uses a line height of around 1.0 for the character cell height // Calculate scale factor needed to fit exactly in available width
// Use a more accurate fallback based on XTerm's actual behavior const scaleFactor = availableWidth / currentRenderedWidth;
const lineHeight = (actualLineHeight && !isNaN(actualLineHeight)) ?
actualLineHeight :
(optimalFontSize * (this._terminal.options.lineHeight || 1.0));
// Calculate how many rows fit with this line height // Apply linear scaling to font size
const optimalRows = Math.max(MINIMUM_ROWS, Math.floor(availableHeight / lineHeight)); const newFontSize = currentFontSize * scaleFactor;
const clampedFontSize = Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, newFontSize));
return { // Calculate actual font scaling that was applied (accounting for clamping)
cols: currentCols, // Keep existing cols const actualFontScaling = clampedFontSize / currentFontSize;
rows: optimalRows // Fit as many rows as possible
}; // Apply the actual font scaling to line height
const newLineHeight = lineHeight * actualFontScaling;
// Calculate how many rows fit with the scaled line height
const optimalRows = Math.max(MINIMUM_ROWS, Math.floor(availableHeight / newLineHeight));
// Apply the new font size
requestAnimationFrame(() => this.applyFontSize(clampedFontSize));
// Log all calculations for debugging
console.log(`ScaleFitAddon: ${availableWidth}×${availableHeight}px available, ${currentCols}×${this._terminal.rows} terminal, charWidth=${charWidth.toFixed(2)}px, lineHeight=${lineHeight.toFixed(2)}px, currentRenderedWidth=${currentRenderedWidth.toFixed(2)}px, scaleFactor=${scaleFactor.toFixed(3)}, actualFontScaling=${actualFontScaling.toFixed(3)}, fontSize ${currentFontSize}px→${clampedFontSize.toFixed(2)}px, lineHeight ${lineHeight.toFixed(2)}px→${newLineHeight.toFixed(2)}px, rows ${this._terminal.rows}${optimalRows}`);
return {
cols: currentCols, // ALWAYS keep exact column count
rows: optimalRows // Maximize rows that fit
};
} else {
// Fallback: estimate font size and dimensions if measurements aren't available
const charWidthRatio = 0.63;
const calculatedFontSize = Math.floor((availableWidth / (currentCols * charWidthRatio)) * 10) / 10;
const optimalFontSize = Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, calculatedFontSize));
// Apply the calculated font size
requestAnimationFrame(() => this.applyFontSize(optimalFontSize));
const lineHeight = optimalFontSize * (this._terminal.options.lineHeight || 1.2);
const optimalRows = Math.max(MINIMUM_ROWS, Math.floor(availableHeight / lineHeight));
return {
cols: currentCols,
rows: optimalRows
};
}
} }
private applyFontSize(fontSize: number): void { private applyFontSize(fontSize: number): void {
@ -121,10 +142,12 @@ export class ScaleFitAddon implements ITerminalAddon {
// Apply CSS font size to the element // Apply CSS font size to the element
terminalElement.style.fontSize = `${fontSize}px`; terminalElement.style.fontSize = `${fontSize}px`;
// Force a refresh to apply the new font size // Force a refresh to apply the new font size and ensure responsive sizing
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (this._terminal) { if (this._terminal) {
this._terminal.refresh(0, this._terminal.rows - 1); this._terminal.refresh(0, this._terminal.rows - 1);
// Force responsive sizing after refresh since XTerm might reset dimensions
this.forceResponsiveSizing();
} }
}); });
} }
@ -158,6 +181,9 @@ export class ScaleFitAddon implements ITerminalAddon {
// Apply the font size without changing terminal dimensions // Apply the font size without changing terminal dimensions
this.applyFontSize(optimalFontSize); this.applyFontSize(optimalFontSize);
// Also force responsive sizing for previews
this.forceResponsiveSizing();
} }
public getOptimalFontSize(): number { public getOptimalFontSize(): number {
@ -179,4 +205,84 @@ export class ScaleFitAddon implements ITerminalAddon {
return Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, calculatedFontSize)); return Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, calculatedFontSize));
} }
/**
* Get exact character dimensions from XTerm's built-in measurement system
*/
private getXTermCharacterDimensions(): { charWidth: number; lineHeight: number } | null {
if (!this._terminal?.element) return null;
// XTerm has a built-in character measurement system with multiple font styles
const measureContainer = this._terminal.element.querySelector('.xterm-width-cache-measure-container');
// Find the first measurement element (normal weight, usually 'm' characters)
// This is what XTerm uses for baseline character width calculations
const firstMeasureElement = measureContainer?.querySelector('.xterm-char-measure-element');
if (firstMeasureElement) {
const measureRect = firstMeasureElement.getBoundingClientRect();
const measureText = firstMeasureElement.textContent || '';
if (measureText.length > 0 && measureRect.width > 0) {
// Calculate actual character width from the primary measurement element
const actualCharWidth = measureRect.width / measureText.length;
// Get line height from the first row in .xterm-rows
const xtermRows = this._terminal.element.querySelector('.xterm-rows');
const firstRow = xtermRows?.querySelector('div');
let lineHeight = 21.5; // fallback
if (firstRow) {
const rowStyle = window.getComputedStyle(firstRow);
const rowLineHeight = parseFloat(rowStyle.lineHeight);
if (!isNaN(rowLineHeight) && rowLineHeight > 0) {
lineHeight = rowLineHeight;
}
}
return {
charWidth: actualCharWidth,
lineHeight: lineHeight
};
}
}
// Fallback: try to measure from the xterm-screen dimensions and terminal cols/rows
const xtermScreen = this._terminal.element.querySelector('.xterm-screen') as HTMLElement;
if (xtermScreen) {
const screenRect = xtermScreen.getBoundingClientRect();
const charWidth = screenRect.width / this._terminal.cols;
const lineHeight = screenRect.height / this._terminal.rows;
if (charWidth > 0 && lineHeight > 0) {
return { charWidth, lineHeight };
}
}
return null;
}
/**
* Force XTerm elements to use responsive sizing instead of fixed dimensions
*/
private forceResponsiveSizing(): void {
if (!this._terminal?.element) return;
// Find the xterm-screen element within the terminal
const xtermScreen = this._terminal.element.querySelector('.xterm-screen') as HTMLElement;
const xtermViewport = this._terminal.element.querySelector('.xterm-viewport') as HTMLElement;
if (xtermScreen) {
// Remove any fixed width/height styles and force responsive sizing
xtermScreen.style.width = '100%';
xtermScreen.style.height = '100%';
xtermScreen.style.maxWidth = '100%';
xtermScreen.style.maxHeight = '100%';
}
if (xtermViewport) {
xtermViewport.style.width = '100%';
xtermViewport.style.maxWidth = '100%';
}
}
} }

View file

@ -60,16 +60,8 @@ session-view .xterm-helper-textarea {
.session-preview .xterm .xterm-screen { .session-preview .xterm .xterm-screen {
max-width: 100% !important; max-width: 100% !important;
max-height: 100% !important; max-height: 100% !important;
/* Scaling now handled by ScaleFitAddon */
} }
.session-preview .xterm .xterm-viewport { .session-preview .xterm .xterm-viewport {
overflow: hidden !important; overflow: hidden !important;
} }
/* Hide the helper textarea in session previews too */
.session-preview .xterm-helper-textarea {
display: none !important;
opacity: 0 !important;
pointer-events: none !important;
}