mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-22 14:06:02 +00:00
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:
parent
afa48c67d5
commit
ddb342bdec
6 changed files with 174 additions and 280 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -25,7 +25,7 @@ export class SessionCard extends LitElement {
|
|||
@state() private renderer: Renderer | null = null;
|
||||
@state() private killing = false;
|
||||
@state() private killingFrame = 0;
|
||||
|
||||
|
||||
private refreshInterval: number | null = null;
|
||||
private killingInterval: number | null = null;
|
||||
|
||||
|
|
@ -55,7 +55,7 @@ export class SessionCard extends LitElement {
|
|||
if (!playerElement) return;
|
||||
|
||||
// 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
|
||||
const url = `/api/sessions/${this.session.id}/snapshot`;
|
||||
|
|
@ -107,7 +107,7 @@ export class SessionCard extends LitElement {
|
|||
private async handleKillClick(e: Event) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
|
||||
// Start killing animation
|
||||
this.killing = true;
|
||||
this.killingFrame = 0;
|
||||
|
|
@ -151,7 +151,7 @@ export class SessionCard extends LitElement {
|
|||
private async handlePidClick(e: Event) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
|
||||
if (this.session.pid) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(this.session.pid.toString());
|
||||
|
|
@ -181,7 +181,7 @@ export class SessionCard extends LitElement {
|
|||
|
||||
render() {
|
||||
const isRunning = this.session.status === 'running';
|
||||
|
||||
|
||||
return html`
|
||||
<div class="bg-vs-bg border border-vs-border rounded shadow cursor-pointer overflow-hidden ${this.killing ? 'opacity-60' : ''}"
|
||||
@click=${this.handleCardClick}>
|
||||
|
|
@ -221,7 +221,7 @@ export class SessionCard extends LitElement {
|
|||
${this.getStatusText()}
|
||||
</span>
|
||||
${this.session.pid ? html`
|
||||
<span
|
||||
<span
|
||||
class="cursor-pointer hover:text-vs-accent transition-colors"
|
||||
@click=${this.handlePidClick}
|
||||
title="Click to copy PID"
|
||||
|
|
|
|||
|
|
@ -143,8 +143,19 @@ export class SessionView extends LitElement {
|
|||
private createInteractiveTerminal() {
|
||||
if (!this.session) return;
|
||||
|
||||
const terminalElement = this.querySelector('#interactive-terminal') as HTMLElement;
|
||||
if (!terminalElement) return;
|
||||
// Look for existing interactive terminal div or create one
|
||||
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
|
||||
this.renderer = new Renderer(terminalElement);
|
||||
|
|
@ -610,7 +621,7 @@ export class SessionView extends LitElement {
|
|||
box-shadow: none !important;
|
||||
}
|
||||
</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 -->
|
||||
<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">
|
||||
|
|
@ -633,9 +644,7 @@ export class SessionView extends LitElement {
|
|||
</div>
|
||||
|
||||
<!-- Terminal Container -->
|
||||
<div class="flex-1 bg-black overflow-hidden min-h-0 relative" id="terminal-container" style="max-width: 100vw;">
|
||||
<div id="interactive-terminal" class="w-full h-full" style="max-width: 100%;"></div>
|
||||
|
||||
<div class="flex-1 bg-black overflow-hidden min-h-0 relative" id="terminal-container" style="max-width: 100vw; height: 100%;">
|
||||
${this.loading ? html`
|
||||
<!-- Loading overlay -->
|
||||
<div class="absolute inset-0 bg-black bg-opacity-80 flex items-center justify-center">
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
// Professional-grade terminal emulation with full VT compatibility
|
||||
|
||||
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';
|
||||
|
||||
|
|
@ -22,19 +21,16 @@ interface CastEvent {
|
|||
|
||||
export class Renderer {
|
||||
private static activeCount: number = 0;
|
||||
|
||||
|
||||
private container: HTMLElement;
|
||||
private terminal: Terminal;
|
||||
private fitAddon: FitAddon;
|
||||
private scaleFitAddon: ScaleFitAddon;
|
||||
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++;
|
||||
console.log(`Renderer constructor called (active: ${Renderer.activeCount})`);
|
||||
this.container = container;
|
||||
this.isPreview = isPreview;
|
||||
|
||||
// Create terminal with options similar to the custom renderer
|
||||
this.terminal = new Terminal({
|
||||
|
|
@ -74,17 +70,13 @@ export class Renderer {
|
|||
altClickMovesCursor: false,
|
||||
rightClickSelectsWord: false,
|
||||
disableStdin: true, // We handle input separately
|
||||
cursorStyle: 'block',
|
||||
cursorInactiveStyle: 'block',
|
||||
cursorWidth: 1,
|
||||
});
|
||||
|
||||
// Add addons
|
||||
this.fitAddon = new FitAddon();
|
||||
this.scaleFitAddon = new ScaleFitAddon(isPreview);
|
||||
this.scaleFitAddon = new ScaleFitAddon();
|
||||
this.webLinksAddon = new WebLinksAddon();
|
||||
|
||||
this.terminal.loadAddon(this.fitAddon);
|
||||
this.terminal.loadAddon(this.scaleFitAddon);
|
||||
this.terminal.loadAddon(this.webLinksAddon);
|
||||
|
||||
|
|
@ -94,23 +86,13 @@ export class Renderer {
|
|||
private setupDOM(): void {
|
||||
// Clear container and add CSS
|
||||
this.container.innerHTML = '';
|
||||
|
||||
// Different styling for preview vs full terminals
|
||||
if (this.isPreview) {
|
||||
// No padding for previews, let container control sizing
|
||||
this.container.style.padding = '0';
|
||||
this.container.style.backgroundColor = '#1e1e1e';
|
||||
this.container.style.overflow = 'hidden';
|
||||
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';
|
||||
}
|
||||
|
||||
// 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
|
||||
const terminalWrapper = document.createElement('div');
|
||||
|
|
@ -127,21 +109,6 @@ export class Renderer {
|
|||
// Apply to both previews and full terminals
|
||||
const containerId = `terminal-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
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
|
||||
this.scaleFitAddon.fit();
|
||||
|
|
@ -260,14 +227,14 @@ export class Renderer {
|
|||
const exitCode = data[1];
|
||||
const sessionId = data[2];
|
||||
console.log(`Session ${sessionId} exited with code ${exitCode}`);
|
||||
|
||||
|
||||
// Close the SSE connection immediately
|
||||
if (this.eventSource) {
|
||||
console.log('Closing SSE connection due to session exit');
|
||||
this.eventSource.close();
|
||||
this.eventSource = null;
|
||||
}
|
||||
|
||||
|
||||
// Dispatch custom event that session-view can listen to
|
||||
const exitEvent = new CustomEvent('session-exit', {
|
||||
detail: { sessionId, exitCode }
|
||||
|
|
@ -275,7 +242,7 @@ export class Renderer {
|
|||
this.container.dispatchEvent(exitEvent);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Regular cast event
|
||||
const castEvent: CastEvent = {
|
||||
timestamp: data[0],
|
||||
|
|
@ -352,7 +319,7 @@ export class Renderer {
|
|||
|
||||
// Method to fit terminal to container (useful for responsive layouts)
|
||||
fit(): void {
|
||||
this.fitAddon.fit();
|
||||
this.scaleFitAddon.fit();
|
||||
}
|
||||
|
||||
// Get terminal dimensions
|
||||
|
|
|
|||
|
|
@ -16,10 +16,8 @@ const MAX_FONT_SIZE = 16;
|
|||
|
||||
export class ScaleFitAddon implements ITerminalAddon {
|
||||
private _terminal: Terminal | undefined;
|
||||
private _isPreview: boolean;
|
||||
|
||||
constructor(isPreview: boolean = false) {
|
||||
this._isPreview = isPreview;
|
||||
constructor() {
|
||||
}
|
||||
|
||||
public activate(terminal: Terminal): void {
|
||||
|
|
@ -29,10 +27,6 @@ export class ScaleFitAddon implements ITerminalAddon {
|
|||
public dispose(): 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
|
||||
const dims = this.proposeDimensions();
|
||||
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) {
|
||||
this._terminal.resize(this._terminal.cols, dims.rows);
|
||||
}
|
||||
}
|
||||
|
||||
// Force responsive sizing by overriding XTerm's fixed dimensions
|
||||
this.forceResponsiveSizing();
|
||||
}
|
||||
|
||||
public proposeDimensions(): ITerminalDimensions | undefined {
|
||||
|
|
@ -75,35 +71,60 @@ export class ScaleFitAddon implements ITerminalAddon {
|
|||
// 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
|
||||
// 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));
|
||||
// Get exact character dimensions from XTerm's measurement system first
|
||||
const charDimensions = this.getXTermCharacterDimensions();
|
||||
|
||||
// Apply the calculated font size (outside of proposeDimensions to avoid recursion)
|
||||
requestAnimationFrame(() => this.applyFontSize(optimalFontSize));
|
||||
if (charDimensions) {
|
||||
// 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
|
||||
const xtermElement = this._terminal.element;
|
||||
const currentStyle = window.getComputedStyle(xtermElement);
|
||||
const actualLineHeight = parseFloat(currentStyle.lineHeight);
|
||||
// Calculate current total rendered width for all columns
|
||||
const currentRenderedWidth = currentCols * charWidth;
|
||||
|
||||
// XTerm typically uses a line height of around 1.0 for the character cell height
|
||||
// Use a more accurate fallback based on XTerm's actual behavior
|
||||
const lineHeight = (actualLineHeight && !isNaN(actualLineHeight)) ?
|
||||
actualLineHeight :
|
||||
(optimalFontSize * (this._terminal.options.lineHeight || 1.0));
|
||||
// Calculate scale factor needed to fit exactly in available width
|
||||
const scaleFactor = availableWidth / currentRenderedWidth;
|
||||
|
||||
// Calculate how many rows fit with this line height
|
||||
const optimalRows = Math.max(MINIMUM_ROWS, Math.floor(availableHeight / lineHeight));
|
||||
// Apply linear scaling to font size
|
||||
const newFontSize = currentFontSize * scaleFactor;
|
||||
const clampedFontSize = Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, newFontSize));
|
||||
|
||||
return {
|
||||
cols: currentCols, // Keep existing cols
|
||||
rows: optimalRows // Fit as many rows as possible
|
||||
};
|
||||
// Calculate actual font scaling that was applied (accounting for clamping)
|
||||
const actualFontScaling = clampedFontSize / currentFontSize;
|
||||
|
||||
// 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 {
|
||||
|
|
@ -121,10 +142,12 @@ export class ScaleFitAddon implements ITerminalAddon {
|
|||
// Apply CSS font size to the element
|
||||
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(() => {
|
||||
if (this._terminal) {
|
||||
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
|
||||
this.applyFontSize(optimalFontSize);
|
||||
|
||||
// Also force responsive sizing for previews
|
||||
this.forceResponsiveSizing();
|
||||
}
|
||||
|
||||
public getOptimalFontSize(): number {
|
||||
|
|
@ -179,4 +205,84 @@ export class ScaleFitAddon implements ITerminalAddon {
|
|||
|
||||
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%';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -60,16 +60,8 @@ session-view .xterm-helper-textarea {
|
|||
.session-preview .xterm .xterm-screen {
|
||||
max-width: 100% !important;
|
||||
max-height: 100% !important;
|
||||
/* Scaling now handled by ScaleFitAddon */
|
||||
}
|
||||
|
||||
.session-preview .xterm .xterm-viewport {
|
||||
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;
|
||||
}
|
||||
Loading…
Reference in a new issue