From 8c0675157abd4db05a0f775b193a15d0acf253a5 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Mon, 30 Mar 2026 22:22:59 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20three=20dashboard=20view=20modes=20?= =?UTF-8?q?=E2=80=94=20Auto,=20Fit,=20Compact?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- muxplex/frontend/app.js | 136 ++++++++++++++++++++++++++-- muxplex/frontend/index.html | 1 + muxplex/frontend/style.css | 18 ++++ muxplex/frontend/tests/test_app.mjs | 88 ++++++++++++++++++ muxplex/tests/test_frontend_css.py | 20 ++++ muxplex/tests/test_frontend_html.py | 13 +++ 6 files changed, 270 insertions(+), 6 deletions(-) diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index fe210be..3567dce 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -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, diff --git a/muxplex/frontend/index.html b/muxplex/frontend/index.html index e2e274a..cb375b0 100644 --- a/muxplex/frontend/index.html +++ b/muxplex/frontend/index.html @@ -22,6 +22,7 @@

muxplex

+
diff --git a/muxplex/frontend/style.css b/muxplex/frontend/style.css index 69666ef..ff5b354 100644 --- a/muxplex/frontend/style.css +++ b/muxplex/frontend/style.css @@ -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 ============================================================ */ diff --git a/muxplex/frontend/tests/test_app.mjs b/muxplex/frontend/tests/test_app.mjs index b1b8ead..0054456 100644 --- a/muxplex/frontend/tests/test_app.mjs +++ b/muxplex/frontend/tests/test_app.mjs @@ -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' + ); +}); diff --git a/muxplex/tests/test_frontend_css.py b/muxplex/tests/test_frontend_css.py index 3ff7ff8..5107a5c 100644 --- a/muxplex/tests/test_frontend_css.py +++ b/muxplex/tests/test_frontend_css.py @@ -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" + ) diff --git a/muxplex/tests/test_frontend_html.py b/muxplex/tests/test_frontend_html.py index ef0320a..56aab2d 100644 --- a/muxplex/tests/test_frontend_html.py +++ b/muxplex/tests/test_frontend_html.py @@ -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" + )