feat: three dashboard view modes — Auto, Fit, Compact
Auto: current behavior (auto-fill grid, fixed tile height, scrollable). Fit: calculates cols × rows to fill viewport exactly, zero scroll. Compact: 80px tiles, high density, auto-fill with 200px min-width. Toggle button (▦) in overview header cycles modes. Fit recalculates on window resize. View mode persisted in localStorage alongside other display settings (viewMode: 'auto' default in DISPLAY_DEFAULTS). - CSS: .session-grid--fit and .session-grid--compact modifiers - JS: VIEW_MODES, cycleViewMode(), applyFitLayout() functions - HTML: #view-mode-btn between + and ⚙ in overview header - Tests: 8 new JS tests, 3 CSS tests, 1 HTML test all passing
This commit is contained in:
+129
-5
@@ -140,7 +140,10 @@ const DISPLAY_DEFAULTS = {
|
|||||||
gridColumns: 'auto',
|
gridColumns: 'auto',
|
||||||
bellSound: false,
|
bellSound: false,
|
||||||
notificationPermission: 'default',
|
notificationPermission: 'default',
|
||||||
|
viewMode: 'auto',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var VIEW_MODES = ['auto', 'fit', 'compact'];
|
||||||
const NEW_SESSION_DEFAULT_TEMPLATE = 'tmux new-session -d -s {name}';
|
const NEW_SESSION_DEFAULT_TEMPLATE = 'tmux new-session -d -s {name}';
|
||||||
const DELETE_SESSION_DEFAULT_TEMPLATE = 'tmux kill-session -t {name}';
|
const DELETE_SESSION_DEFAULT_TEMPLATE = 'tmux kill-session -t {name}';
|
||||||
|
|
||||||
@@ -644,6 +647,17 @@ function renderGrid(sessions) {
|
|||||||
updatePillBell();
|
updatePillBell();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reapply view mode layout after grid HTML is rebuilt
|
||||||
|
var currentDs = loadDisplaySettings();
|
||||||
|
var currentMode = currentDs.viewMode || 'auto';
|
||||||
|
if (currentMode === 'fit' && grid) {
|
||||||
|
grid.classList.add('session-grid--fit');
|
||||||
|
applyFitLayout(grid);
|
||||||
|
} else if (currentMode === 'compact' && grid) {
|
||||||
|
grid.classList.add('session-grid--compact');
|
||||||
|
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(200px, 1fr))';
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1071,29 +1085,121 @@ function saveDisplaySettings(settings) {
|
|||||||
} catch (_) { /* blocked — ok */ }
|
} catch (_) { /* blocked — ok */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate and apply grid layout to fill the viewport exactly (Fit mode).
|
||||||
|
* Determines optimal cols × rows based on tile count and available space.
|
||||||
|
* @param {Element} grid - The session grid element
|
||||||
|
*/
|
||||||
|
function applyFitLayout(grid) {
|
||||||
|
var count = grid.querySelectorAll('.session-tile').length;
|
||||||
|
if (count === 0) return;
|
||||||
|
|
||||||
|
// Available space — use grid's parent container
|
||||||
|
var parent = grid.parentElement;
|
||||||
|
var availH = parent ? parent.clientHeight : window.innerHeight;
|
||||||
|
var availW = grid.clientWidth;
|
||||||
|
|
||||||
|
// Subtract padding and gap
|
||||||
|
var style = getComputedStyle(grid);
|
||||||
|
var padT = parseFloat(style.paddingTop) || 0;
|
||||||
|
var padB = parseFloat(style.paddingBottom) || 0;
|
||||||
|
var padL = parseFloat(style.paddingLeft) || 0;
|
||||||
|
var padR = parseFloat(style.paddingRight) || 0;
|
||||||
|
var gap = parseFloat(style.gap) || 8;
|
||||||
|
|
||||||
|
var innerW = availW - padL - padR;
|
||||||
|
var innerH = availH - padT - padB;
|
||||||
|
|
||||||
|
// Calculate optimal cols/rows — start with square root
|
||||||
|
var cols = Math.ceil(Math.sqrt(count));
|
||||||
|
var rows = Math.ceil(count / cols);
|
||||||
|
|
||||||
|
// Prefer wider layouts (more cols, fewer rows) since tiles are landscape
|
||||||
|
if (rows > 1 && cols < count) {
|
||||||
|
var altCols = cols + 1;
|
||||||
|
var altRows = Math.ceil(count / altCols);
|
||||||
|
if (altRows < rows) {
|
||||||
|
cols = altCols;
|
||||||
|
rows = altRows;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tile height from available space
|
||||||
|
var tileH = (innerH - gap * (rows - 1)) / rows;
|
||||||
|
|
||||||
|
grid.style.gridTemplateColumns = 'repeat(' + cols + ', 1fr)';
|
||||||
|
grid.style.gridTemplateRows = 'repeat(' + rows + ', 1fr)';
|
||||||
|
|
||||||
|
// Override tile height so tiles fill the grid rows
|
||||||
|
grid.querySelectorAll('.session-tile').forEach(function(t) {
|
||||||
|
t.style.height = tileH + 'px';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cycle the dashboard view mode: auto → fit → compact → auto.
|
||||||
|
* Persists to localStorage and reapplies display settings.
|
||||||
|
*/
|
||||||
|
function cycleViewMode() {
|
||||||
|
var ds = loadDisplaySettings();
|
||||||
|
var idx = VIEW_MODES.indexOf(ds.viewMode || 'auto');
|
||||||
|
ds.viewMode = VIEW_MODES[(idx + 1) % VIEW_MODES.length];
|
||||||
|
saveDisplaySettings(ds);
|
||||||
|
applyDisplaySettings(ds);
|
||||||
|
|
||||||
|
// Update button label
|
||||||
|
var btn = document.getElementById('view-mode-btn');
|
||||||
|
if (btn) btn.title = 'View: ' + ds.viewMode;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply display settings to the live DOM.
|
* Apply display settings to the live DOM.
|
||||||
* Sets --preview-font-size CSS custom property and updates #session-grid
|
* Sets --preview-font-size CSS custom property and updates #session-grid
|
||||||
* grid-template-columns based on the gridColumns setting.
|
* grid-template-columns based on the gridColumns setting and viewMode.
|
||||||
* @param {object} ds - display settings object
|
* @param {object} ds - display settings object
|
||||||
*/
|
*/
|
||||||
function applyDisplaySettings(ds) {
|
function applyDisplaySettings(ds) {
|
||||||
// Apply font size as CSS custom property (tile previews)
|
// Apply font size as CSS custom property (tile previews)
|
||||||
|
if (document.documentElement) {
|
||||||
document.documentElement.style.setProperty('--preview-font-size', ds.fontSize + 'px');
|
document.documentElement.style.setProperty('--preview-font-size', ds.fontSize + 'px');
|
||||||
|
}
|
||||||
|
|
||||||
// Apply font size to the live xterm.js terminal without reconnecting
|
// Apply font size to the live xterm.js terminal without reconnecting
|
||||||
if (window._setTerminalFontSize) {
|
if (window._setTerminalFontSize) {
|
||||||
window._setTerminalFontSize(ds.fontSize);
|
window._setTerminalFontSize(ds.fontSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply grid columns
|
// Apply view mode to grid
|
||||||
var grid = document.getElementById('session-grid');
|
var grid = document.getElementById('session-grid');
|
||||||
if (grid) {
|
if (!grid) return;
|
||||||
if (ds.gridColumns === 'auto') {
|
|
||||||
|
var mode = ds.viewMode || 'auto';
|
||||||
|
|
||||||
|
// Remove all mode classes
|
||||||
|
grid.classList.remove('session-grid--fit', 'session-grid--compact');
|
||||||
|
|
||||||
|
// Reset any inline styles from previous fit calculation
|
||||||
|
grid.style.removeProperty('grid-template-rows');
|
||||||
|
grid.querySelectorAll('.session-tile').forEach(function(t) {
|
||||||
|
t.style.removeProperty('height');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mode === 'auto') {
|
||||||
|
// Restore grid columns setting
|
||||||
|
if (ds.gridColumns === 'auto' || !ds.gridColumns) {
|
||||||
grid.style.removeProperty('grid-template-columns');
|
grid.style.removeProperty('grid-template-columns');
|
||||||
} else {
|
} else {
|
||||||
grid.style.gridTemplateColumns = 'repeat(' + ds.gridColumns + ', 1fr)';
|
grid.style.gridTemplateColumns = 'repeat(' + ds.gridColumns + ', 1fr)';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
} else if (mode === 'fit') {
|
||||||
|
grid.classList.add('session-grid--fit');
|
||||||
|
applyFitLayout(grid);
|
||||||
|
|
||||||
|
} else if (mode === 'compact') {
|
||||||
|
grid.classList.add('session-grid--compact');
|
||||||
|
// Use auto-fill but with smaller minmax for higher density
|
||||||
|
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(200px, 1fr))';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1590,6 +1696,7 @@ function bindStaticEventListeners() {
|
|||||||
on($('sheet-backdrop'), 'click', closeBottomSheet);
|
on($('sheet-backdrop'), 'click', closeBottomSheet);
|
||||||
|
|
||||||
// Settings dialog bindings
|
// Settings dialog bindings
|
||||||
|
on($('view-mode-btn'), 'click', cycleViewMode);
|
||||||
on($('settings-btn'), 'click', openSettings);
|
on($('settings-btn'), 'click', openSettings);
|
||||||
on($('settings-btn-expanded'), 'click', openSettings);
|
on($('settings-btn-expanded'), 'click', openSettings);
|
||||||
on($('settings-close-btn'), 'click', closeSettings);
|
on($('settings-close-btn'), 'click', closeSettings);
|
||||||
@@ -1754,9 +1861,24 @@ function _setViewMode(mode) {
|
|||||||
_viewMode = mode;
|
_viewMode = mode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recalculate fit layout on window resize
|
||||||
|
window.addEventListener('resize', function() {
|
||||||
|
var ds = loadDisplaySettings();
|
||||||
|
if ((ds.viewMode || 'auto') === 'fit') {
|
||||||
|
var grid = document.getElementById('session-grid');
|
||||||
|
if (grid) applyFitLayout(grid);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
initDeviceId();
|
initDeviceId();
|
||||||
applyDisplaySettings(loadDisplaySettings());
|
var _initDs = loadDisplaySettings();
|
||||||
|
applyDisplaySettings(_initDs);
|
||||||
|
|
||||||
|
// Initialize view mode button title
|
||||||
|
var vmBtn = document.getElementById('view-mode-btn');
|
||||||
|
if (vmBtn) vmBtn.title = 'View: ' + (_initDs.viewMode || 'auto');
|
||||||
|
|
||||||
document.addEventListener('keydown', trackInteraction);
|
document.addEventListener('keydown', trackInteraction);
|
||||||
document.addEventListener('click', trackInteraction);
|
document.addEventListener('click', trackInteraction);
|
||||||
document.addEventListener('touchstart', trackInteraction);
|
document.addEventListener('touchstart', trackInteraction);
|
||||||
@@ -1822,6 +1944,8 @@ if (typeof module !== 'undefined' && module.exports) {
|
|||||||
loadDisplaySettings,
|
loadDisplaySettings,
|
||||||
saveDisplaySettings,
|
saveDisplaySettings,
|
||||||
applyDisplaySettings,
|
applyDisplaySettings,
|
||||||
|
applyFitLayout,
|
||||||
|
cycleViewMode,
|
||||||
onDisplaySettingChange,
|
onDisplaySettingChange,
|
||||||
openSettings,
|
openSettings,
|
||||||
closeSettings,
|
closeSettings,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
<h1 class="app-wordmark"><img src="/wordmark-on-dark.svg" alt="muxplex" height="24" /></h1>
|
<h1 class="app-wordmark"><img src="/wordmark-on-dark.svg" alt="muxplex" height="24" /></h1>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button id="new-session-btn" class="header-btn" aria-label="New session">+</button>
|
<button id="new-session-btn" class="header-btn" aria-label="New session">+</button>
|
||||||
|
<button id="view-mode-btn" class="header-btn" aria-label="Toggle view mode" title="View: auto">▦</button>
|
||||||
<button id="settings-btn" class="header-btn" aria-label="Settings">⚙</button>
|
<button id="settings-btn" class="header-btn" aria-label="Settings">⚙</button>
|
||||||
<span id="connection-status"></span>
|
<span id="connection-status"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1371,6 +1371,24 @@ body {
|
|||||||
z-index: 200;
|
z-index: 200;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Dashboard view modes — Compact and Fit
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* Compact view — small tiles, high density */
|
||||||
|
.session-grid--compact .session-tile {
|
||||||
|
height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-grid--compact .tile-body {
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fit view — tiles fill viewport, no scroll */
|
||||||
|
.session-grid--fit {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
Responsive overlay sidebar at <960px
|
Responsive overlay sidebar at <960px
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|||||||
@@ -2118,3 +2118,91 @@ test('bindStaticEventListeners wires delete template reset button', () => {
|
|||||||
'bindStaticEventListeners must wire #setting-delete-template-reset click handler'
|
'bindStaticEventListeners must wire #setting-delete-template-reset click handler'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- View mode cycling (Auto / Fit / Compact) ---
|
||||||
|
|
||||||
|
test('app.js has VIEW_MODES array with auto, fit, compact', () => {
|
||||||
|
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||||
|
assert.ok(source.includes("'auto'"), "must include 'auto' mode");
|
||||||
|
assert.ok(source.includes("'fit'"), "must include 'fit' mode");
|
||||||
|
assert.ok(source.includes("'compact'"), "must include 'compact' mode");
|
||||||
|
assert.ok(source.includes('VIEW_MODES'), 'must define VIEW_MODES');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('app.js exports cycleViewMode function', () => {
|
||||||
|
assert.ok('cycleViewMode' in app, 'app.js must export cycleViewMode');
|
||||||
|
assert.strictEqual(typeof app.cycleViewMode, 'function', 'cycleViewMode must be a function');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('app.js exports applyFitLayout function', () => {
|
||||||
|
assert.ok('applyFitLayout' in app, 'app.js must export applyFitLayout');
|
||||||
|
assert.strictEqual(typeof app.applyFitLayout, 'function', 'applyFitLayout must be a function');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DISPLAY_DEFAULTS includes viewMode: auto', () => {
|
||||||
|
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||||
|
// DISPLAY_DEFAULTS should define viewMode
|
||||||
|
const defaultsStart = source.indexOf('DISPLAY_DEFAULTS');
|
||||||
|
assert.ok(defaultsStart !== -1, 'DISPLAY_DEFAULTS must exist');
|
||||||
|
const defaultsEnd = source.indexOf('};', defaultsStart);
|
||||||
|
const defaultsBody = source.substring(defaultsStart, defaultsEnd + 2);
|
||||||
|
assert.ok(defaultsBody.includes('viewMode'), 'DISPLAY_DEFAULTS must include viewMode');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applyDisplaySettings handles fit mode by adding session-grid--fit class', () => {
|
||||||
|
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||||
|
const fnStart = source.indexOf('function applyDisplaySettings(');
|
||||||
|
assert.ok(fnStart !== -1, 'applyDisplaySettings must exist');
|
||||||
|
const fnEnd = source.indexOf('\nfunction ', fnStart + 1);
|
||||||
|
const fnBody = source.substring(fnStart, fnEnd > fnStart ? fnEnd : fnStart + 2000);
|
||||||
|
assert.ok(
|
||||||
|
fnBody.includes('session-grid--fit'),
|
||||||
|
'applyDisplaySettings must apply session-grid--fit class for fit mode'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applyDisplaySettings handles compact mode by adding session-grid--compact class', () => {
|
||||||
|
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||||
|
const fnStart = source.indexOf('function applyDisplaySettings(');
|
||||||
|
assert.ok(fnStart !== -1, 'applyDisplaySettings must exist');
|
||||||
|
const fnEnd = source.indexOf('\nfunction ', fnStart + 1);
|
||||||
|
const fnBody = source.substring(fnStart, fnEnd > fnStart ? fnEnd : fnStart + 2000);
|
||||||
|
assert.ok(
|
||||||
|
fnBody.includes('session-grid--compact'),
|
||||||
|
'applyDisplaySettings must apply session-grid--compact class for compact mode'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cycleViewMode cycles through auto -> fit -> compact -> auto', () => {
|
||||||
|
// Reset display settings to auto
|
||||||
|
const ds = app.loadDisplaySettings();
|
||||||
|
ds.viewMode = 'auto';
|
||||||
|
app.saveDisplaySettings(ds);
|
||||||
|
|
||||||
|
// First cycle: auto -> fit
|
||||||
|
app.cycleViewMode();
|
||||||
|
const ds1 = app.loadDisplaySettings();
|
||||||
|
assert.strictEqual(ds1.viewMode, 'fit', 'first cycle should go auto -> fit');
|
||||||
|
|
||||||
|
// Second cycle: fit -> compact
|
||||||
|
app.cycleViewMode();
|
||||||
|
const ds2 = app.loadDisplaySettings();
|
||||||
|
assert.strictEqual(ds2.viewMode, 'compact', 'second cycle should go fit -> compact');
|
||||||
|
|
||||||
|
// Third cycle: compact -> auto
|
||||||
|
app.cycleViewMode();
|
||||||
|
const ds3 = app.loadDisplaySettings();
|
||||||
|
assert.strictEqual(ds3.viewMode, 'auto', 'third cycle should wrap compact -> auto');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bindStaticEventListeners wires view-mode-btn click to cycleViewMode', () => {
|
||||||
|
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||||
|
const fnStart = source.indexOf('function bindStaticEventListeners(');
|
||||||
|
assert.ok(fnStart !== -1, 'bindStaticEventListeners must exist');
|
||||||
|
const fnEnd = source.indexOf('\nfunction ', fnStart + 1);
|
||||||
|
const fnBody = source.substring(fnStart, fnEnd > fnStart ? fnEnd : fnStart + 6000);
|
||||||
|
assert.ok(
|
||||||
|
fnBody.includes('view-mode-btn'),
|
||||||
|
'bindStaticEventListeners must wire #view-mode-btn click handler'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1681,3 +1681,23 @@ def test_css_sidebar_footer() -> None:
|
|||||||
css = read_css()
|
css = read_css()
|
||||||
for cls in (".sidebar-footer", ".sidebar-new-btn"):
|
for cls in (".sidebar-footer", ".sidebar-new-btn"):
|
||||||
assert cls in css, f"Missing CSS selector '{cls}'"
|
assert cls in css, f"Missing CSS selector '{cls}'"
|
||||||
|
|
||||||
|
|
||||||
|
def test_css_compact_view_exists() -> None:
|
||||||
|
""".session-grid--compact CSS modifier must exist for compact view mode."""
|
||||||
|
css = read_css()
|
||||||
|
assert ".session-grid--compact" in css, "Missing .session-grid--compact CSS selector for compact view mode"
|
||||||
|
|
||||||
|
|
||||||
|
def test_css_fit_view_exists() -> None:
|
||||||
|
""".session-grid--fit CSS modifier must exist for fit view mode."""
|
||||||
|
css = read_css()
|
||||||
|
assert ".session-grid--fit" in css, "Missing .session-grid--fit CSS selector for fit view mode"
|
||||||
|
|
||||||
|
|
||||||
|
def test_css_compact_tile_height() -> None:
|
||||||
|
""".session-grid--compact .session-tile must set a compact height."""
|
||||||
|
css = read_css()
|
||||||
|
assert ".session-grid--compact .session-tile" in css, (
|
||||||
|
"Missing .session-grid--compact .session-tile height override"
|
||||||
|
)
|
||||||
|
|||||||
@@ -1153,3 +1153,16 @@ def test_html_new_session_tab_controls_with_delete() -> None:
|
|||||||
"setting-delete-template-reset",
|
"setting-delete-template-reset",
|
||||||
):
|
):
|
||||||
assert soup.find(id=id_), f"Missing element with id='{id_}'"
|
assert soup.find(id=id_), f"Missing element with id='{id_}'"
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_view_mode_button_exists() -> None:
|
||||||
|
"""#view-mode-btn must exist in the overview header for cycling view modes."""
|
||||||
|
soup = _SOUP
|
||||||
|
btn = soup.find(id="view-mode-btn")
|
||||||
|
assert btn is not None, "Missing element with id='view-mode-btn' (view mode toggle button)"
|
||||||
|
# Must be inside the overview header area
|
||||||
|
overview = soup.find(id="view-overview")
|
||||||
|
assert overview is not None, "Missing #view-overview"
|
||||||
|
assert overview.find(id="view-mode-btn") is not None, (
|
||||||
|
"#view-mode-btn must be inside #view-overview header"
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user