feat: add layout switcher with grid preview supporting 1x1 to 4x4 layouts

This commit is contained in:
2026-03-21 21:54:33 +08:00
parent 7b1b3bce89
commit 7b71d8bee1
2 changed files with 392 additions and 43 deletions

View File

@@ -12,6 +12,7 @@
<div class="terminal-header">
<div class="terminal-title">XCTerminal</div>
<div class="terminal-controls">
<button id="layout-toggle" title="Layout"></button>
<button id="theme-toggle" title="Adjust Brightness"></button>
</div>
</div>
@@ -22,10 +23,199 @@
<span class="brightness-icon"></span>
</div>
</div>
<div id="layout-popup" class="layout-popup hidden">
<div class="layout-grid">
<div class="layout-row">
<div class="layout-option" data-cols="1" data-rows="1">
<div class="layout-preview" style="grid-template-columns: repeat(1, 1fr); grid-template-rows: repeat(1, 1fr);">
<div class="layout-cell"></div>
</div>
<span class="layout-label">1×1</span>
</div>
<div class="layout-option" data-cols="2" data-rows="1">
<div class="layout-preview" style="grid-template-columns: repeat(2, 1fr); grid-template-rows: repeat(1, 1fr);">
<div class="layout-cell"></div>
<div class="layout-cell"></div>
</div>
<span class="layout-label">1×2</span>
</div>
<div class="layout-option" data-cols="3" data-rows="1">
<div class="layout-preview" style="grid-template-columns: repeat(3, 1fr); grid-template-rows: repeat(1, 1fr);">
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
</div>
<span class="layout-label">1×3</span>
</div>
<div class="layout-option" data-cols="4" data-rows="1">
<div class="layout-preview" style="grid-template-columns: repeat(4, 1fr); grid-template-rows: repeat(1, 1fr);">
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
</div>
<span class="layout-label">1×4</span>
</div>
</div>
<div class="layout-row">
<div class="layout-option" data-cols="1" data-rows="2">
<div class="layout-preview" style="grid-template-columns: repeat(1, 1fr); grid-template-rows: repeat(2, 1fr);">
<div class="layout-cell"></div>
<div class="layout-cell"></div>
</div>
<span class="layout-label">2×1</span>
</div>
<div class="layout-option" data-cols="2" data-rows="2">
<div class="layout-preview" style="grid-template-columns: repeat(2, 1fr); grid-template-rows: repeat(2, 1fr);">
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
</div>
<span class="layout-label">2×2</span>
</div>
<div class="layout-option" data-cols="3" data-rows="2">
<div class="layout-preview" style="grid-template-columns: repeat(3, 1fr); grid-template-rows: repeat(2, 1fr);">
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
</div>
<span class="layout-label">2×3</span>
</div>
<div class="layout-option" data-cols="4" data-rows="2">
<div class="layout-preview" style="grid-template-columns: repeat(4, 1fr); grid-template-rows: repeat(2, 1fr);">
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
</div>
<span class="layout-label">2×4</span>
</div>
</div>
<div class="layout-row">
<div class="layout-option" data-cols="1" data-rows="3">
<div class="layout-preview" style="grid-template-columns: repeat(1, 1fr); grid-template-rows: repeat(3, 1fr);">
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
</div>
<span class="layout-label">3×1</span>
</div>
<div class="layout-option" data-cols="2" data-rows="3">
<div class="layout-preview" style="grid-template-columns: repeat(2, 1fr); grid-template-rows: repeat(3, 1fr);">
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
</div>
<span class="layout-label">3×2</span>
</div>
<div class="layout-option" data-cols="3" data-rows="3">
<div class="layout-preview" style="grid-template-columns: repeat(3, 1fr); grid-template-rows: repeat(3, 1fr);">
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
</div>
<span class="layout-label">3×3</span>
</div>
<div class="layout-option" data-cols="4" data-rows="3">
<div class="layout-preview" style="grid-template-columns: repeat(4, 1fr); grid-template-rows: repeat(3, 1fr);">
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
</div>
<span class="layout-label">3×4</span>
</div>
</div>
<div class="layout-row">
<div class="layout-option" data-cols="1" data-rows="4">
<div class="layout-preview" style="grid-template-columns: repeat(1, 1fr); grid-template-rows: repeat(4, 1fr);">
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
</div>
<span class="layout-label">4×1</span>
</div>
<div class="layout-option" data-cols="2" data-rows="4">
<div class="layout-preview" style="grid-template-columns: repeat(2, 1fr); grid-template-rows: repeat(4, 1fr);">
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
</div>
<span class="layout-label">4×2</span>
</div>
<div class="layout-option" data-cols="3" data-rows="4">
<div class="layout-preview" style="grid-template-columns: repeat(3, 1fr); grid-template-rows: repeat(4, 1fr);">
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
</div>
<span class="layout-label">4×3</span>
</div>
<div class="layout-option" data-cols="4" data-rows="4">
<div class="layout-preview" style="grid-template-columns: repeat(4, 1fr); grid-template-rows: repeat(4, 1fr);">
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
<div class="layout-cell"></div>
</div>
<span class="layout-label">4×4</span>
</div>
</div>
</div>
</div>
<div id="terminals-container">
<div class="terminal-pane" id="terminal-top"></div>
<div class="terminal-divider"></div>
<div class="terminal-pane" id="terminal-bottom"></div>
</div>
</div>
@@ -34,15 +224,15 @@
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-webgl@0.16.0/lib/addon-webgl.min.js"></script>
<script src="ghostty-vt.js"></script>
<script>
const terminalTop = document.getElementById('terminal-top');
const terminalBottom = document.getElementById('terminal-bottom');
const terminalsContainer = document.getElementById('terminals-container');
const urlParams = new URLSearchParams(window.location.search);
const shell = urlParams.get('shell') || (window.navigator.platform.startsWith('Win') ? 'powershell.exe' : 'bash');
let ws1, ws2;
let term1, term2;
let fitAddon1, fitAddon2;
let terminals = [];
let websockets = [];
let fitAddons = [];
let currentLayout = { cols: 1, rows: 1 };
let currentBrightness = 0;
function getThemeForBrightness(brightness) {
@@ -109,12 +299,11 @@
brightWhite: '#ffffff'
};
if (term1) {
term1.options.theme = terminalTheme;
}
if (term2) {
term2.options.theme = terminalTheme;
}
terminals.forEach(term => {
if (term) {
term.options.theme = terminalTheme;
}
});
return { bgColor, headerBg, borderColor };
}
@@ -165,6 +354,104 @@
}
}
function setupLayoutControl() {
const layoutToggle = document.getElementById('layout-toggle');
const layoutPopup = document.getElementById('layout-popup');
const layoutOptions = document.querySelectorAll('.layout-option');
layoutToggle.addEventListener('click', (e) => {
e.stopPropagation();
layoutPopup.classList.toggle('hidden');
});
layoutOptions.forEach(option => {
option.addEventListener('click', () => {
const cols = parseInt(option.dataset.cols);
const rows = parseInt(option.dataset.rows);
switchLayout(cols, rows);
layoutPopup.classList.add('hidden');
});
});
document.addEventListener('click', (e) => {
if (!layoutPopup.contains(e.target) && e.target !== layoutToggle) {
layoutPopup.classList.add('hidden');
}
});
}
function switchLayout(cols, rows) {
currentLayout = { cols, rows };
terminals.forEach(term => {
if (term) {
term.dispose();
}
});
websockets.forEach(ws => {
if (ws) {
ws.close();
}
});
terminals = [];
websockets = [];
fitAddons = [];
terminalsContainer.innerHTML = '';
terminalsContainer.style.display = 'grid';
terminalsContainer.style.gridTemplateColumns = `repeat(${cols}, 1fr)`;
terminalsContainer.style.gridTemplateRows = `repeat(${rows}, 1fr)`;
terminalsContainer.style.gap = '4px';
terminalsContainer.style.padding = '4px';
terminalsContainer.style.background = 'var(--bg-primary)';
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProtocol}//${window.location.host}?shell=${encodeURIComponent(shell)}`;
const totalTerminals = cols * rows;
for (let i = 0; i < totalTerminals; i++) {
const pane = document.createElement('div');
pane.className = 'terminal-pane';
pane.style.background = 'var(--bg-primary)';
pane.style.padding = '4px';
pane.style.overflow = 'hidden';
terminalsContainer.appendChild(pane);
try {
const ws = new WebSocket(wsUrl);
websockets.push(ws);
const { term, fitAddon } = createTerminal(pane, ws);
terminals.push(term);
fitAddons.push(fitAddon);
} catch (err) {
console.error('Failed to create WebSocket:', err);
terminals.push(null);
websockets.push(null);
fitAddons.push(null);
}
}
document.querySelectorAll('.layout-option').forEach(opt => {
opt.classList.remove('selected');
if (parseInt(opt.dataset.cols) === cols && parseInt(opt.dataset.rows) === rows) {
opt.classList.add('selected');
}
});
if (terminals.length > 0 && terminals[0]) {
terminals[0].focus();
}
window.addEventListener('resize', () => {
fitAddons.forEach(fitAddon => {
if (fitAddon) {
fitAddon.fit();
}
});
});
}
function createTerminal(container, ws) {
const term = new Terminal({
cursorBlink: true,
@@ -253,36 +540,9 @@
return { term, fitAddon };
}
function connectTerminal() {
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProtocol}//${window.location.host}?shell=${encodeURIComponent(shell)}`;
try {
ws1 = new WebSocket(wsUrl);
ws2 = new WebSocket(wsUrl);
} catch (err) {
console.error('Failed to create WebSocket:', err);
return;
}
const result1 = createTerminal(terminalTop, ws1);
const result2 = createTerminal(terminalBottom, ws2);
term1 = result1.term;
term2 = result2.term;
fitAddon1 = result1.fitAddon;
fitAddon2 = result2.fitAddon;
term1.focus();
window.addEventListener('resize', () => {
fitAddon1.fit();
fitAddon2.fit();
});
}
setupLayoutControl();
setupBrightnessControl();
connectTerminal();
switchLayout(1, 2);
updateBrightness(0);
initGhosttyVT();
</script>

View File

@@ -206,4 +206,93 @@ html, body {
border: 2px solid #333;
cursor: pointer;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
#layout-toggle {
width: 28px;
height: 28px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
font-size: 16px;
transition: color 0.2s, background 0.2s;
-webkit-app-region: no-drag;
}
#layout-toggle:hover {
color: var(--text-primary);
background: var(--bg-tertiary);
}
.layout-popup {
position: absolute;
top: 44px;
right: 16px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 1000;
}
.layout-popup.hidden {
display: none;
}
.layout-grid {
display: flex;
flex-direction: column;
gap: 12px;
}
.layout-row {
display: flex;
gap: 12px;
}
.layout-option {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
cursor: pointer;
padding: 6px;
border-radius: 6px;
transition: background 0.2s;
}
.layout-option:hover {
background: var(--bg-tertiary);
}
.layout-option.selected {
background: var(--accent);
}
.layout-preview {
display: grid;
gap: 2px;
width: 48px;
height: 48px;
background: var(--border);
border-radius: 4px;
padding: 2px;
}
.layout-cell {
background: var(--text-secondary);
border-radius: 2px;
opacity: 0.6;
}
.layout-option.selected .layout-cell {
background: var(--bg-secondary);
}
.layout-label {
font-size: 10px;
color: var(--text-secondary);
}