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:
+130
-6
@@ -140,7 +140,10 @@ const DISPLAY_DEFAULTS = {
|
||||
gridColumns: 'auto',
|
||||
bellSound: false,
|
||||
notificationPermission: 'default',
|
||||
viewMode: 'auto',
|
||||
};
|
||||
|
||||
var VIEW_MODES = ['auto', 'fit', 'compact'];
|
||||
const NEW_SESSION_DEFAULT_TEMPLATE = 'tmux new-session -d -s {name}';
|
||||
const DELETE_SESSION_DEFAULT_TEMPLATE = 'tmux kill-session -t {name}';
|
||||
|
||||
@@ -644,6 +647,17 @@ function renderGrid(sessions) {
|
||||
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 */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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
|
||||
*/
|
||||
function applyDisplaySettings(ds) {
|
||||
// Apply font size as CSS custom property (tile previews)
|
||||
document.documentElement.style.setProperty('--preview-font-size', ds.fontSize + 'px');
|
||||
if (document.documentElement) {
|
||||
document.documentElement.style.setProperty('--preview-font-size', ds.fontSize + 'px');
|
||||
}
|
||||
|
||||
// Apply font size to the live xterm.js terminal without reconnecting
|
||||
if (window._setTerminalFontSize) {
|
||||
window._setTerminalFontSize(ds.fontSize);
|
||||
}
|
||||
|
||||
// Apply grid columns
|
||||
// Apply view mode to grid
|
||||
var grid = document.getElementById('session-grid');
|
||||
if (grid) {
|
||||
if (ds.gridColumns === 'auto') {
|
||||
if (!grid) return;
|
||||
|
||||
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');
|
||||
} else {
|
||||
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);
|
||||
|
||||
// Settings dialog bindings
|
||||
on($('view-mode-btn'), 'click', cycleViewMode);
|
||||
on($('settings-btn'), 'click', openSettings);
|
||||
on($('settings-btn-expanded'), 'click', openSettings);
|
||||
on($('settings-close-btn'), 'click', closeSettings);
|
||||
@@ -1754,9 +1861,24 @@ function _setViewMode(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', () => {
|
||||
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('click', trackInteraction);
|
||||
document.addEventListener('touchstart', trackInteraction);
|
||||
@@ -1822,6 +1944,8 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||
loadDisplaySettings,
|
||||
saveDisplaySettings,
|
||||
applyDisplaySettings,
|
||||
applyFitLayout,
|
||||
cycleViewMode,
|
||||
onDisplaySettingChange,
|
||||
openSettings,
|
||||
closeSettings,
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<h1 class="app-wordmark"><img src="/wordmark-on-dark.svg" alt="muxplex" height="24" /></h1>
|
||||
<div class="header-actions">
|
||||
<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>
|
||||
<span id="connection-status"></span>
|
||||
</div>
|
||||
|
||||
@@ -1371,6 +1371,24 @@ body {
|
||||
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
|
||||
============================================================ */
|
||||
|
||||
@@ -2118,3 +2118,91 @@ test('bindStaticEventListeners wires delete template reset button', () => {
|
||||
'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()
|
||||
for cls in (".sidebar-footer", ".sidebar-new-btn"):
|
||||
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",
|
||||
):
|
||||
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