Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions apps/overlay/electron/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,13 +136,20 @@ function openInventoryWindow() {
inventoryWindow.on('closed', () => { inventoryWindow = null; });
}

function createWindow() {
// Which size profile this launch uses. Single source of truth so the window
// creation and the "reset to default" handler agree on key + default size.
function getSizeMode() {
const useMulti = process.env.OVERLAY_MULTI === '1' || process.argv.includes('--multi');
return {
stateKey: useMulti ? 'multi' : 'main',
defaultSize: useMulti ? MULTI_WINDOW_SIZE : DEFAULT_WINDOW_SIZE,
};
}

function createWindow() {
// Restore the last-used size for this mode (issue #69), falling back to the
// mode's default (single = 720×540).
const stateKey = useMulti ? 'multi' : 'main';
const defaultSize = useMulti ? MULTI_WINDOW_SIZE : DEFAULT_WINDOW_SIZE;
const { stateKey, defaultSize } = getSizeMode();
const { width, height } = sanitizeSize(readWindowState()[stateKey], defaultSize);

mainWindow = new BrowserWindow({
Expand All @@ -166,7 +173,7 @@ function createWindow() {
},
});

const entry = useMulti ? 'multi.html' : 'index.html';
const entry = stateKey === 'multi' ? 'multi.html' : 'index.html';
mainWindow.loadFile(path.join(__dirname, '..', 'src', entry));

mainWindow.once('ready-to-show', () => {
Expand Down Expand Up @@ -293,6 +300,21 @@ app.whenReady().then(() => {
mainWindow.setSize(clampedW, clampedH);
});

// "Reset all defaults" (settings panel) clears localStorage in the renderer,
// but the window size lives in window-state.json (per mode), so it can't be
// reached from there. Drop the persisted size and snap the live window back
// to the mode's default so a reset truly restores everything.
ipcMain.handle('reset-window-size', () => {
const { stateKey, defaultSize } = getSizeMode();
const state = readWindowState();
delete state[stateKey];
writeWindowState(state);
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.setSize(defaultSize.width, defaultSize.height);
}
return defaultSize;
});

// ── Frameless window drag (renderer grabs a non-interactive area) ──
// Renderer signals start/move/end; we compute position from the live
// cursor so the grab point stays pinned under the pointer regardless of
Expand Down
3 changes: 3 additions & 0 deletions apps/overlay/electron/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
setWindowSize: (width, height) => ipcRenderer.send('set-window-size', { width, height }),
onWindowSizeChanged: (callback) =>
ipcRenderer.on('window-size-changed', (_, size) => callback(size)),
// resetWindowSize() drops the persisted size and snaps the window back to the
// mode default; used by "Reset all defaults". Resolves to the default size.
resetWindowSize: () => ipcRenderer.invoke('reset-window-size'),

// ── Frameless window drag ──
// The renderer detects a grab on a non-interactive area and drives the
Expand Down
96 changes: 91 additions & 5 deletions apps/overlay/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,69 @@
cursor: pointer;
}

/* Floating numeric editor for the selected Edit-Layout part. Lives OUTSIDE the
settings panel so it stays visible while the panel is closed and the user
drags parts in the 3D view (that's why the inline fields felt "missing").
Shown on selection; draggable by its header so it never blocks the part. */
#layout-editor-popup {
display: none;
position: fixed;
top: 38px; left: 8px;
width: 340px;
background: rgba(30,30,35,0.95);
border: 1px solid rgba(51,136,255,0.55);
border-radius: 10px;
padding: 10px 12px 12px;
box-shadow: 0 6px 24px rgba(0,0,0,0.5);
backdrop-filter: blur(10px);
z-index: 50;
font-size: 12px;
user-select: none;
}
#layout-editor-popup .lep-header {
display: flex; align-items: center; justify-content: space-between;
gap: 8px; cursor: move; margin-bottom: 8px;
}
#layout-editor-popup .lep-title {
color: #8af; font-family: monospace; font-weight: 600;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
#layout-editor-popup .lep-close {
flex: none; width: 22px; height: 22px; line-height: 1; font-size: 14px;
border: none; border-radius: 5px; cursor: pointer;
background: rgba(255,255,255,0.08); color: #ccc;
}
#layout-editor-popup .lep-close:hover { background: rgba(255,255,255,0.2); color: #fff; }
#layout-editor-popup .lep-reset {
width: 100%; margin-top: 8px; padding: 6px; border: none; border-radius: 6px;
background: rgba(255,255,255,0.1); color: #ddd; font-size: 11px; cursor: pointer;
}
#layout-editor-popup .lep-reset:hover { background: rgba(255,255,255,0.18); }
#layout-editor-popup .lep-hint {
margin-top: 6px; font-size: 10px; color: rgba(255,255,255,0.4); line-height: 1.3;
}
/* Position/Rotation sections: a small title, then three axis-labelled inputs
that share the width evenly. Roomy enough that the full value (and its live
changes) stay readable. */
#layout-editor-popup .lep-header { margin-bottom: 10px; }
#layout-editor-popup .lep-title { font-size: 13px; }
#layout-editor-popup .lep-section { margin-bottom: 10px; }
#layout-editor-popup .lep-section-title { color: #bbb; font-size: 13px; margin-bottom: 5px; }
#layout-editor-popup .lep-axes { display: flex; gap: 8px; }
#layout-editor-popup .lep-axis { display: flex; align-items: center; gap: 5px; flex: 1; min-width: 0; }
#layout-editor-popup .lep-ax { color: #8af; font-family: monospace; font-size: 13px; font-weight: 600; }
#layout-editor-popup input[type="number"] {
flex: 1; min-width: 0;
background: rgba(255,255,255,0.08);
color: #eee;
border: 1px solid rgba(255,255,255,0.12);
border-radius: 5px;
padding: 7px 8px;
font-size: 14px;
text-align: right;
}
#layout-editor-popup input[type="number"]:focus { outline: none; border-color: #3388ff; }

.window-size-fields { display: flex; align-items: center; gap: 4px; }
.window-size-x { color: #888; font-size: 11px; }
.setting-row input[type="number"] {
Expand Down Expand Up @@ -1062,12 +1125,8 @@ <h3>Edit Layout</h3>
<option value="absolute">Absolute (fixed)</option>
</select>
</div>
<div class="setting-row" id="edit-layout-status-row" style="display:none;">
<label>Selected</label>
<span class="combo-display" id="edit-layout-selected">none</span>
</div>
<div id="edit-layout-help" class="build-stamp" style="display:none; text-align:left; margin-top:2px; white-space:normal;">
Click a part to select. Drag to move. Rotate with arrows (pitch/yaw), Q/E (roll); Shift = fine, R = reset rotation, Esc = deselect.
Click a part to select — a numeric editor pops up. Drag to move, or type exact values. Rotate with arrows (pitch/yaw), Q/E (roll); Shift = fine, R = reset rotation, Esc = deselect.
</div>
<div class="setting-row">
<button class="settings-action-btn" id="reset-layout">RESET LAYOUT</button>
Expand Down Expand Up @@ -1099,6 +1158,33 @@ <h3>Diagnostics</h3>
<div class="build-stamp"><span id="build-version">dev build</span></div>
</div>

<!-- Floating numeric editor for the selected Edit-Layout part. Outside the
settings panel so it's usable while dragging parts with the panel closed. -->
<div id="layout-editor-popup">
<div class="lep-header" id="lep-drag">
<span class="lep-title" id="edit-layout-selected">none</span>
<button class="lep-close" id="lep-close" title="Deselect (Esc)">×</button>
</div>
<div class="lep-section">
<div class="lep-section-title" title="Position offset in model units. X mirrors left (−)/right (+), Y is up (+), Z is front (+)/back (−).">Position</div>
<div class="lep-axes">
<label class="lep-axis" title="X — left −/right +"><span class="lep-ax">X</span><input type="number" id="layout-pos-x" step="0.01" autocomplete="off"></label>
<label class="lep-axis" title="Y — up +"><span class="lep-ax">Y</span><input type="number" id="layout-pos-y" step="0.01" autocomplete="off"></label>
<label class="lep-axis" title="Z — front +/back −"><span class="lep-ax">Z</span><input type="number" id="layout-pos-z" step="0.01" autocomplete="off"></label>
</div>
</div>
<div class="lep-section">
<div class="lep-section-title" title="Rotation in degrees: pitch (X), yaw (Y), roll (Z).">Rotation°</div>
<div class="lep-axes">
<label class="lep-axis" title="Pitch — X"><span class="lep-ax">X</span><input type="number" id="layout-rot-x" step="1" autocomplete="off"></label>
<label class="lep-axis" title="Yaw — Y"><span class="lep-ax">Y</span><input type="number" id="layout-rot-y" step="1" autocomplete="off"></label>
<label class="lep-axis" title="Roll — Z"><span class="lep-ax">Z</span><input type="number" id="layout-rot-z" step="1" autocomplete="off"></label>
</div>
</div>
<button class="lep-reset" id="lep-reset-part" title="Reset just this part to its default position">Reset this part</button>
<div class="lep-hint">In a field, ↑/↓ nudges; hold <b>Shift</b> for fine steps (Position 0.001, Rotation 0.1°). Drag the header to move this editor. Tip: mirror left/right by negating X.</div>
</div>

<!-- Test Report modal — guided HID capture wizard -->
<div id="test-report-modal">
<div class="dialog">
Expand Down
118 changes: 109 additions & 9 deletions apps/overlay/src/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,8 @@ async function init() {
});
overlay.setLayoutChangeHandler((layout) => {
localStorage.setItem('overlay:partLayout:' + currentControllerType, JSON.stringify(layout));
// Keep the numeric editor in sync while the user drags or nudges with keys.
refreshLayoutNumeric();
});
overlay.setSelectHandler(onEditSelectionChange);
overlay.setLayoutMode(localStorage.getItem('overlay:layoutMode') || 'relative');
Expand Down Expand Up @@ -1828,7 +1830,7 @@ document.getElementById('btn-close-settings').addEventListener('click', () => {

// Reset every saved overlay setting (all `overlay:*` keys) and reload so the
// app re-initializes from defaults.
document.getElementById('btn-reset-defaults').addEventListener('click', () => {
document.getElementById('btn-reset-defaults').addEventListener('click', async () => {
if (!confirm('Reset ALL overlay settings to their defaults? This clears your saved customizations.')) return;
// Clear every overlay key — both the `overlay:` settings and the
// `overlay-display-prefs` blob (note: no colon, so a `overlay:` filter misses it).
Expand All @@ -1838,6 +1840,9 @@ document.getElementById('btn-reset-defaults').addEventListener('click', () => {
// Force the settings gear visible so a reset can never strand the user with a
// hidden gear (the in-handle Ctrl+Right-Click is the other way back in).
try { localStorage.removeItem('overlay-display-prefs'); } catch (e) { /* ignore */ }
// Window size lives in the main process (window-state.json), not localStorage,
// so it has to be reset over IPC. Await it so the resize lands before reload.
try { await window.electronAPI?.resetWindowSize?.(); } catch (e) { /* ignore */ }
location.reload();
});

Expand Down Expand Up @@ -1958,26 +1963,120 @@ let layoutEditing = false;
const editLayoutToggle = document.getElementById('edit-layout-toggle');
const layoutModeSelect = document.getElementById('layout-mode');
const resetLayoutBtn = document.getElementById('reset-layout');
const editLayoutStatusRow = document.getElementById('edit-layout-status-row');
const editLayoutHelp = document.getElementById('edit-layout-help');
const editLayoutSelected = document.getElementById('edit-layout-selected');

// Reflect the overlay's current selection in the settings UI.
// Precise numeric editor for the selected part (in addition to drag + Q/E/arrows).
// It's a floating popup (outside the settings panel) so it's visible while the
// panel is closed and the user is dragging parts in the 3D view.
const layoutPopup = document.getElementById('layout-editor-popup');
const layoutNumInputs = {
px: document.getElementById('layout-pos-x'), py: document.getElementById('layout-pos-y'), pz: document.getElementById('layout-pos-z'),
rx: document.getElementById('layout-rot-x'), ry: document.getElementById('layout-rot-y'), rz: document.getElementById('layout-rot-z'),
};

// Push the selected part's live values into the numeric fields. Skips a field
// the user is mid-edit in (so typing isn't clobbered by drag/key updates), and
// rounds for readability. Pass the overlay's getSelectedLayout() result.
function refreshLayoutNumeric() {
const data = overlay?.getSelectedLayout?.();
if (!data) return;
const round = (v, d) => Number.isFinite(v) ? Number(v.toFixed(d)) : 0;
const set = (el, v) => { if (el && document.activeElement !== el) el.value = v; };
set(layoutNumInputs.px, round(data.offset.x, 3));
set(layoutNumInputs.py, round(data.offset.y, 3));
set(layoutNumInputs.pz, round(data.offset.z, 3));
set(layoutNumInputs.rx, round(data.euler.x, 1));
set(layoutNumInputs.ry, round(data.euler.y, 1));
set(layoutNumInputs.rz, round(data.euler.z, 1));
}

// Read all six fields and apply them to the selected part.
function applyLayoutNumeric() {
if (!overlay?.setSelectedLayout) return;
const num = (el) => { const v = parseFloat(el?.value); return Number.isFinite(v) ? v : undefined; };
overlay.setSelectedLayout({
offset: { x: num(layoutNumInputs.px), y: num(layoutNumInputs.py), z: num(layoutNumInputs.pz) },
euler: { x: num(layoutNumInputs.rx), y: num(layoutNumInputs.ry), z: num(layoutNumInputs.rz) },
});
}
for (const [key, el] of Object.entries(layoutNumInputs)) {
if (!el) continue;
el.addEventListener('input', applyLayoutNumeric);
// Shift + ↑/↓ does a fine nudge (Position 0.001, Rotation 0.1°). Native number
// inputs have no "fine step" modifier, so handle it ourselves; plain ↑/↓ keep
// the coarse `step` from the markup (0.01 / 1).
const isPos = key.startsWith('p');
const fineStep = isPos ? 0.001 : 0.1;
const decimals = isPos ? 3 : 1;
el.addEventListener('keydown', (e) => {
if (!e.shiftKey || (e.key !== 'ArrowUp' && e.key !== 'ArrowDown')) return;
e.preventDefault();
const cur = parseFloat(el.value) || 0;
const next = cur + (e.key === 'ArrowUp' ? fineStep : -fineStep);
el.value = next.toFixed(decimals);
applyLayoutNumeric();
});
}

// Reflect the overlay's current selection: pop up the numeric editor for the
// selected part, hide it when nothing is selected.
function onEditSelectionChange(partName) {
if (editLayoutSelected) editLayoutSelected.textContent = partName || 'none';
if (layoutPopup) layoutPopup.style.display = partName ? 'block' : 'none';
if (partName) refreshLayoutNumeric();
}

if (editLayoutToggle) {
editLayoutToggle.addEventListener('change', (e) => {
const on = e.target.checked;
layoutEditing = on; // suppress window-drag while editing
if (overlay?.setEditMode) overlay.setEditMode(on);
if (editLayoutStatusRow) editLayoutStatusRow.style.display = on ? '' : 'none';
if (editLayoutHelp) editLayoutHelp.style.display = on ? '' : 'none';
if (!on) onEditSelectionChange(null);
if (!on) onEditSelectionChange(null); // editing off → close the popup
});
}

// Floating editor controls: close (×) deselects the part; "Reset this part"
// clears just its override. Both flow back through the overlay's select/layout
// handlers, which refresh the fields.
document.getElementById('lep-close')?.addEventListener('click', () => {
overlay?.clearLayoutSelection?.();
});
document.getElementById('lep-reset-part')?.addEventListener('click', () => {
overlay?.resetSelected?.();
refreshLayoutNumeric();
});

// Let the user drag the popup by its header so it never blocks the part being
// edited. Window-drag is already suppressed while editing (layoutEditing), so
// this can't fight the OS window move.
(function makeLayoutPopupDraggable() {
const handle = document.getElementById('lep-drag');
if (!layoutPopup || !handle) return;
let dragging = false, offX = 0, offY = 0;
handle.addEventListener('pointerdown', (e) => {
if (e.target.closest('button')) return; // the close button isn't a drag grip
dragging = true;
const r = layoutPopup.getBoundingClientRect();
offX = e.clientX - r.left;
offY = e.clientY - r.top;
try { handle.setPointerCapture(e.pointerId); } catch (_) { /* ignore */ }
e.preventDefault();
});
handle.addEventListener('pointermove', (e) => {
if (!dragging) return;
layoutPopup.style.left = Math.max(0, e.clientX - offX) + 'px';
layoutPopup.style.top = Math.max(0, e.clientY - offY) + 'px';
});
const stop = (e) => {
dragging = false;
try { handle.releasePointerCapture(e.pointerId); } catch (_) { /* ignore */ }
};
handle.addEventListener('pointerup', stop);
handle.addEventListener('pointercancel', stop);
})();

if (layoutModeSelect) {
layoutModeSelect.value = localStorage.getItem('overlay:layoutMode') || 'relative';
layoutModeSelect.addEventListener('change', (e) => {
Expand All @@ -1989,6 +2088,7 @@ if (layoutModeSelect) {
if (resetLayoutBtn) {
resetLayoutBtn.addEventListener('click', () => {
if (overlay?.resetLayout) overlay.resetLayout();
refreshLayoutNumeric(); // keep the popup in sync if a part is still selected
});
}

Expand Down Expand Up @@ -2101,14 +2201,14 @@ greenScreenToggle.addEventListener('change', applyGreenScreen);
greenScreenColorInput.addEventListener('input', applyGreenScreen);

// Camera presets — one selected at a time, used as calibration view.
// The last-selected preset is persisted (issue #70) so reopening the overlay
// restores the user's preferred view (e.g. Top) instead of always resetting
// to Player.
// Defaults to Top for every controller; the last-selected preset is persisted
// (issue #70) so reopening the overlay restores the user's preferred view
// instead of resetting to the default.
const CAMERA_PRESETS = ['front', 'back', 'left', 'right', 'player', 'top'];
const CAMERA_PRESET_KEY = 'overlay:cameraPreset';
function loadSavedCameraPreset() {
const saved = localStorage.getItem(CAMERA_PRESET_KEY);
return CAMERA_PRESETS.includes(saved) ? saved : 'player';
return CAMERA_PRESETS.includes(saved) ? saved : 'top';
}
let selectedCameraPreset = loadSavedCameraPreset();
const cameraPresetBtns = document.querySelectorAll('.camera-presets button');
Expand Down
Loading
Loading