From 469515a676a786e7425ebf2360b0479dfeeeac42 Mon Sep 17 00:00:00 2001 From: averagenative Date: Sat, 12 Sep 2026 17:24:23 -0400 Subject: [PATCH 1/4] Add docked panel layouts to the suite, with a one-at-a-time accordion Five panels scattered across the screen is a lot of furniture to arrange every session, and only one helper is ever useful at a time -- you are in exactly one minigame. Two docked layouts stack them against an edge instead: 'left' in a column, 'top' in a row. Docking takes over positions but does NOT overwrite the saved px/py, so switching back to 'free' puts everything exactly where it was. 'free' stays the default, so an upgrade moves nobody's panels; the layout is picked from a segmented control in the suite panel. Two behaviours ride with it, both only active while docked, and both disabled in the UI when they cannot do anything: * "One helper at a time" -- opening a helper collapses the other helpers. The clicker is exempt, since it is useful alongside any of them, and so is the suite panel. Off in the free layout on purpose: there the panels are wherever you put them, and collapsing one you never touched looks like a bug rather than a feature. * "Auto-open active" -- opt in, off by default, and the helper whose minigame is on screen opens itself. Driven off each helper's OWN detection: hoops exposes whether it can see the platform, fishing the lane, darts the board, all variables their loops already maintain. No second copy of any detector here to drift out of step with the real one. It acts only on a change of which helper is active, so a manual collapse is not instantly undone, and waits 600ms before believing a change, because the detectors flicker while a screen loads and a layout that flickers with them is worse than one that lags. Dragging a panel out of a dock drops the layout back to 'free' rather than snapping the panel back, which would look broken. "Reset panel layout" now also returns the layout to free. Re-stacking is coalesced to one pass per frame -- collapsing one panel moves every panel below it, and chrome() is called per panel -- and runs on resize. Nothing in any helper's own code changed: the docking lives entirely in the hand-written shell, and the only per-module additions are a dockOrder, a helper flag, and the one-line active() hook in each -post.js. The generated suite diff removes exactly six lines, all of them shell. NOT YET SEEN RUNNING. The build is clean and the lifted regions are untouched, but the browser was down when this was written, so neither layout has been looked at. It should be before it is trusted. Co-Authored-By: Claude Opus 5 (1M context) --- idleon-suite.user.js | 156 +++++++++++++++++++++++++++++++++-- tools/suite/00-head.js | 81 +++++++++++++++++- tools/suite/01-clicker.js | 1 + tools/suite/02-hoops-post.js | 5 +- tools/suite/02-hoops-pre.js | 1 + tools/suite/03-fish-post.js | 5 +- tools/suite/03-fish-pre.js | 1 + tools/suite/04-darts-post.js | 5 +- tools/suite/04-darts-pre.js | 1 + tools/suite/05-hub.js | 54 ++++++++++++ 10 files changed, 299 insertions(+), 11 deletions(-) diff --git a/idleon-suite.user.js b/idleon-suite.user.js index b765e7a..76bb1fa 100644 --- a/idleon-suite.user.js +++ b/idleon-suite.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name IdleOn Helper Suite // @namespace nativerobot -// @version 1.33 +// @version 1.34 // @downloadURL https://raw.githubusercontent.com/averagenative/idleon-userscripts/main/idleon-suite.user.js // @updateURL https://raw.githubusercontent.com/averagenative/idleon-userscripts/main/idleon-suite.user.js // @description All-in-one: autoclicker + Hoops, Fishing and Darts minigame helpers for Legends of IdleOn, each one individually switchable @@ -65,7 +65,14 @@ // ---------- which helpers are on ---------- const SUITE_KEY = 'idleon_suite'; const ALL_ON = { clicker: true, hoops: true, fishing: true, darts: true }; - const suite = Object.assign({ collapsed: false, hidden: false }, + // layout: 'free' keeps the dragged-anywhere behaviour every version until now + // had, and stays the default so an upgrade moves nobody's panels. 'left' and + // 'top' dock them into one column or one row. + // solo: opening a helper closes the other helpers. Only meaningful docked, + // where they share a column; see the collapse handler. + // follow: opt in to letting the active minigame open its own helper. + const suite = Object.assign({ collapsed: false, hidden: false, + layout: 'free', solo: true, follow: false }, JSON.parse(localStorage.getItem(SUITE_KEY) || '{}')); suite.enabled = Object.assign({}, ALL_ON, suite.enabled); const saveSuite = () => localStorage.setItem(SUITE_KEY, JSON.stringify(suite)); @@ -130,6 +137,52 @@ return img; } + // ---------- docked layouts ---------- + // Five panels is a lot of furniture to arrange by hand every session, and + // only one helper is ever useful at a time — you are in exactly one minigame. + // Docking stacks them against an edge in a fixed order and takes over their + // positions; the saved px/py are left untouched so switching back to 'free' + // restores exactly where things were. + const docks = []; // { def, ui }, sorted by def.dockOrder + const DOCK_EDGE = 10, DOCK_GAP = 8; + let relayoutPending = false; + + function relayout() { + if (suite.layout === 'free') { + for (const d of docks) d.ui.place(); + return; + } + const vert = suite.layout === 'left'; + // ?? not ||: the hub is dockOrder 0, which || would treat as missing and + // sort to the bottom of its own dock. + const list = docks.slice().sort((a, b) => (a.def.dockOrder ?? 99) - (b.def.dockOrder ?? 99)); + let x = DOCK_EDGE, y = DOCK_EDGE; + for (const { ui } of list) { + if (ui.cfg.hidden) continue; // hidden panels are a nub, not a slot + const p = ui.panel; + p.style.right = 'auto'; + p.style.left = x + 'px'; + p.style.top = y + 'px'; + const r = p.getBoundingClientRect(); + if (vert) y += r.height + DOCK_GAP; + else x += r.width + DOCK_GAP; + } + } + + // The hub owns the layout controls, but a drag out of a dock has to change + // the layout from inside makePanel. This is the seam between the two. + let onLayoutChange = () => {}; + function syncLayout() { relayout(); onLayoutChange(); } + + // Collapsing a panel changes every panel below it, so the re-stack is + // coalesced to one pass per frame rather than run per panel per change. + function relayoutSoon() { + if (relayoutPending) return; + relayoutPending = true; + requestAnimationFrame(() => { relayoutPending = false; relayout(); }); + } + window.addEventListener('resize', relayoutSoon); + // ---------- panel chrome ---------- // Every panel is the same furniture around a different body: a title bar // that drags, a roll-up toggle, a hide toggle, and a nub that brings a @@ -245,12 +298,17 @@ minBtn.textContent = cfg.collapsed ? '+' : '–'; panel.style.display = cfg.hidden ? 'none' : ''; nub.style.display = cfg.hidden ? '' : 'none'; + relayoutSoon(); // heights and occupancy just changed } // drag let dx = 0, dy = 0, drag = false; $('#hd').addEventListener('mousedown', e => { if (e.target.id === 'min') return; + // Dragging out of a dock means you want it somewhere else, so the dock + // gets out of the way rather than snapping the panel back and looking + // broken. "Reset panel layout" puts it back. + if (suite.layout !== 'free') { suite.layout = 'free'; saveSuite(); syncLayout(); } drag = true; const r = panel.getBoundingClientRect(); dx = e.clientX - r.left; dy = e.clientY - r.top; @@ -278,6 +336,7 @@ ui.save(); place(); chrome(); }, dot: $('#dot'), runBtn: $('#run'), stEl: $('#st'), nub, minBtn, body, + place, // so a dock can hand positions back on the way out save: () => {}, // replaced by the module, which owns its store // Keep every control out of the tab order and drop focus as soon as it // is released, so a Space or Enter aimed at the game can't re-fire @@ -296,10 +355,28 @@ for (const [tg, ty, fn, cap] of bound) tg.removeEventListener(ty, fn, cap); roots.delete(root); host.remove(); + const i = docks.findIndex(d => d.ui === ui); + if (i >= 0) docks.splice(i, 1); + relayoutSoon(); } }; - minBtn.addEventListener('click', () => { cfg.collapsed = !cfg.collapsed; ui.save(); chrome(); }); + docks.push({ def, ui }); + minBtn.addEventListener('click', () => { + cfg.collapsed = !cfg.collapsed; + ui.save(); + // Solo closes the other HELPERS when you open one — not the clicker, + // which is useful alongside any of them, and not the suite panel. Only + // while docked: in the free layout the panels are wherever you put them + // and collapsing one you never touched would just look like a bug. + if (!cfg.collapsed && suite.solo && suite.layout !== 'free' && def.helper) { + for (const d of docks) { + if (d.ui === ui || !d.def.helper || d.ui.cfg.collapsed) continue; + d.ui.cfg.collapsed = true; d.ui.save(); d.ui.chrome(); + } + } + chrome(); + }); nub.addEventListener('click', () => { cfg.hidden = false; ui.save(); chrome(); }); return ui; } @@ -428,6 +505,7 @@ z: 2147483646, theme: { dot: '#4ade80', ac: '#2563eb', stop: '#dc2626' }, slot: { top: 12, right: 12, width: 210, nub: 24 }, + dockOrder: 1, overlay: false, hotkeys: { F8: 'toggle', F9: 'panic', F10: 'hide' }, keyHint: 'F8', @@ -751,6 +829,7 @@ z: 2147483645, theme: { dot: '#f87171', ac: '#dc2626' }, slot: { top: 12, left: 220, width: 228, nub: 42 }, + dockOrder: 2, helper: true, overlay: true, hotkeys: { F7: 'toggle', F6: 'hide' }, keyHint: 'F7', @@ -1604,7 +1683,10 @@ cfg.scale = +b.dataset.s; tracks = []; save(); sync(); })); - return { loop, sync, toggle }; + // For the suite's auto-open: the platform is found every frame the court is up. + // Reusing the loop's own state rather than testing the screen again -- + // a second detector here would be one more thing to drift. + return { loop, sync, toggle, active: () => plat != null }; } }; @@ -1670,6 +1752,7 @@ z: 2147483644, theme: { dot: '#38bdf8', ac: '#0284c7' }, slot: { top: 12, left: 460, width: 214, nub: 60 }, + dockOrder: 3, helper: true, overlay: true, hotkeys: { F4: 'toggle', F3: 'hide' }, keyHint: 'F4', @@ -2633,7 +2716,10 @@ save(); }; - return { loop, sync, toggle }; + // For the suite's auto-open: the lane goes null the moment the fishing spot is off screen. + // Reusing the loop's own state rather than testing the screen again -- + // a second detector here would be one more thing to drift. + return { loop, sync, toggle, active: () => lane != null }; } }; @@ -2729,6 +2815,7 @@ z: 2147483643, theme: { dot: '#fbbf24', ac: '#d97706' }, slot: { top: 12, left: 686, width: 216, nub: 78 }, + dockOrder: 4, helper: true, overlay: true, hotkeys: { F2: 'toggle', F1: 'hide' }, keyHint: 'F2', @@ -3681,7 +3768,10 @@ save(); }; - return { loop, sync, toggle }; + // For the suite's auto-open: the board is nulled by the wall gate and after 900ms stale. + // Reusing the loop's own state rather than testing the screen again -- + // a second detector here would be one more thing to drift. + return { loop, sync, toggle, active: () => board != null }; } }; @@ -3695,6 +3785,7 @@ z: 2147483647, theme: { dot: '#a78bfa', ac: '#7c3aed' }, slot: { top: 12, left: 12, width: 196, nub: 6 }, + dockOrder: 0, overlay: false, bodyHTML: // Each row: the helper's name, its toggle hotkey, an eye that shows or @@ -3708,6 +3799,13 @@ ` ` + `` ).join('\n ') + ` +
+
+ + +
+
+

@@ -3732,6 +3830,12 @@ eye.className = 'eye' + (off ? ' off' : ''); } hub.$('#panels').textContent = anyShown() ? 'Hide all panels' : 'Show all panels'; + for (const l of ['free', 'left', 'top']) + hub.$('#lay-' + l).classList.toggle('sel', suite.layout === l); + hub.$('#solo').checked = !!suite.solo; + hub.$('#follow').checked = !!suite.follow; + // Both only bite in a dock; saying so beats leaving them looking broken. + hub.$('#solo').disabled = hub.$('#follow').disabled = suite.layout === 'free'; hub.chrome(); } @@ -3744,6 +3848,12 @@ syncHub(); }; } + for (const l of ['free', 'left', 'top']) + hub.$('#lay-' + l).onclick = () => { suite.layout = l; saveSuite(); syncLayout(); }; + hub.$('#solo').onchange = e => { suite.solo = e.target.checked; saveSuite(); }; + hub.$('#follow').onchange = e => { suite.follow = e.target.checked; saveSuite(); }; + onLayoutChange = syncHub; + hub.$('#panels').onclick = () => { const hide = anyShown(); for (const m of MODULES) { @@ -3764,13 +3874,47 @@ else { m.cfg.px = null; m.cfg.py = null; m.cfg.hidden = false; m.cfg.collapsed = false; m.save(); } } hub.reset(); + suite.layout = 'free'; saveSuite(); + syncLayout(); syncHub(); }; + // Opt-in: the helper whose minigame is on screen opens itself and the other + // helpers close. Driven off each helper's own detection -- the variable it + // already keeps for "I can see my minigame" -- so there is no second copy + // of any detector here to drift out of step. + // + // Only acts on a CHANGE of which helper is active, so a manual collapse is + // not immediately undone; and it does nothing until a helper has been + // active for a moment, because the detectors flicker while a screen loads + // and a layout that flickers with them is worse than one that lags. + let followWas = null, followSince = 0, followCand = null; + function followTick() { + if (!suite.follow || suite.layout === 'free') { followWas = null; return; } + let now = null; + for (const m of MODULES) { + const inst = live.get(m.id); + if (m.helper && inst && inst.active && inst.active()) { now = m.id; break; } + } + const t = performance.now(); + if (now !== followCand) { followCand = now; followSince = t; return; } + if (t - followSince < 600 || now === followWas) return; + followWas = now; + for (const m of MODULES) { + if (!m.helper) continue; + const inst = live.get(m.id); + if (!inst) continue; + const want = m.id === now; + if (m.cfg.collapsed !== !want) { m.cfg.collapsed = !want; m.save(); inst.ui.chrome(); } + } + } + for (const m of MODULES) if (suite.enabled[m.id]) startModule(m); hub.settle(); syncHub(); + syncLayout(); + setInterval(followTick, 250); requestAnimationFrame(driver); } diff --git a/tools/suite/00-head.js b/tools/suite/00-head.js index 4c96ce9..b907875 100644 --- a/tools/suite/00-head.js +++ b/tools/suite/00-head.js @@ -65,7 +65,14 @@ // ---------- which helpers are on ---------- const SUITE_KEY = 'idleon_suite'; const ALL_ON = { clicker: true, hoops: true, fishing: true, darts: true }; - const suite = Object.assign({ collapsed: false, hidden: false }, + // layout: 'free' keeps the dragged-anywhere behaviour every version until now + // had, and stays the default so an upgrade moves nobody's panels. 'left' and + // 'top' dock them into one column or one row. + // solo: opening a helper closes the other helpers. Only meaningful docked, + // where they share a column; see the collapse handler. + // follow: opt in to letting the active minigame open its own helper. + const suite = Object.assign({ collapsed: false, hidden: false, + layout: 'free', solo: true, follow: false }, JSON.parse(localStorage.getItem(SUITE_KEY) || '{}')); suite.enabled = Object.assign({}, ALL_ON, suite.enabled); const saveSuite = () => localStorage.setItem(SUITE_KEY, JSON.stringify(suite)); @@ -130,6 +137,52 @@ return img; } + // ---------- docked layouts ---------- + // Five panels is a lot of furniture to arrange by hand every session, and + // only one helper is ever useful at a time — you are in exactly one minigame. + // Docking stacks them against an edge in a fixed order and takes over their + // positions; the saved px/py are left untouched so switching back to 'free' + // restores exactly where things were. + const docks = []; // { def, ui }, sorted by def.dockOrder + const DOCK_EDGE = 10, DOCK_GAP = 8; + let relayoutPending = false; + + function relayout() { + if (suite.layout === 'free') { + for (const d of docks) d.ui.place(); + return; + } + const vert = suite.layout === 'left'; + // ?? not ||: the hub is dockOrder 0, which || would treat as missing and + // sort to the bottom of its own dock. + const list = docks.slice().sort((a, b) => (a.def.dockOrder ?? 99) - (b.def.dockOrder ?? 99)); + let x = DOCK_EDGE, y = DOCK_EDGE; + for (const { ui } of list) { + if (ui.cfg.hidden) continue; // hidden panels are a nub, not a slot + const p = ui.panel; + p.style.right = 'auto'; + p.style.left = x + 'px'; + p.style.top = y + 'px'; + const r = p.getBoundingClientRect(); + if (vert) y += r.height + DOCK_GAP; + else x += r.width + DOCK_GAP; + } + } + + // The hub owns the layout controls, but a drag out of a dock has to change + // the layout from inside makePanel. This is the seam between the two. + let onLayoutChange = () => {}; + function syncLayout() { relayout(); onLayoutChange(); } + + // Collapsing a panel changes every panel below it, so the re-stack is + // coalesced to one pass per frame rather than run per panel per change. + function relayoutSoon() { + if (relayoutPending) return; + relayoutPending = true; + requestAnimationFrame(() => { relayoutPending = false; relayout(); }); + } + window.addEventListener('resize', relayoutSoon); + // ---------- panel chrome ---------- // Every panel is the same furniture around a different body: a title bar // that drags, a roll-up toggle, a hide toggle, and a nub that brings a @@ -245,12 +298,17 @@ minBtn.textContent = cfg.collapsed ? '+' : '–'; panel.style.display = cfg.hidden ? 'none' : ''; nub.style.display = cfg.hidden ? '' : 'none'; + relayoutSoon(); // heights and occupancy just changed } // drag let dx = 0, dy = 0, drag = false; $('#hd').addEventListener('mousedown', e => { if (e.target.id === 'min') return; + // Dragging out of a dock means you want it somewhere else, so the dock + // gets out of the way rather than snapping the panel back and looking + // broken. "Reset panel layout" puts it back. + if (suite.layout !== 'free') { suite.layout = 'free'; saveSuite(); syncLayout(); } drag = true; const r = panel.getBoundingClientRect(); dx = e.clientX - r.left; dy = e.clientY - r.top; @@ -278,6 +336,7 @@ ui.save(); place(); chrome(); }, dot: $('#dot'), runBtn: $('#run'), stEl: $('#st'), nub, minBtn, body, + place, // so a dock can hand positions back on the way out save: () => {}, // replaced by the module, which owns its store // Keep every control out of the tab order and drop focus as soon as it // is released, so a Space or Enter aimed at the game can't re-fire @@ -296,10 +355,28 @@ for (const [tg, ty, fn, cap] of bound) tg.removeEventListener(ty, fn, cap); roots.delete(root); host.remove(); + const i = docks.findIndex(d => d.ui === ui); + if (i >= 0) docks.splice(i, 1); + relayoutSoon(); } }; - minBtn.addEventListener('click', () => { cfg.collapsed = !cfg.collapsed; ui.save(); chrome(); }); + docks.push({ def, ui }); + minBtn.addEventListener('click', () => { + cfg.collapsed = !cfg.collapsed; + ui.save(); + // Solo closes the other HELPERS when you open one — not the clicker, + // which is useful alongside any of them, and not the suite panel. Only + // while docked: in the free layout the panels are wherever you put them + // and collapsing one you never touched would just look like a bug. + if (!cfg.collapsed && suite.solo && suite.layout !== 'free' && def.helper) { + for (const d of docks) { + if (d.ui === ui || !d.def.helper || d.ui.cfg.collapsed) continue; + d.ui.cfg.collapsed = true; d.ui.save(); d.ui.chrome(); + } + } + chrome(); + }); nub.addEventListener('click', () => { cfg.hidden = false; ui.save(); chrome(); }); return ui; } diff --git a/tools/suite/01-clicker.js b/tools/suite/01-clicker.js index fbf1819..7152ca0 100644 --- a/tools/suite/01-clicker.js +++ b/tools/suite/01-clicker.js @@ -15,6 +15,7 @@ z: 2147483646, theme: { dot: '#4ade80', ac: '#2563eb', stop: '#dc2626' }, slot: { top: 12, right: 12, width: 210, nub: 24 }, + dockOrder: 1, overlay: false, hotkeys: { F8: 'toggle', F9: 'panic', F10: 'hide' }, keyHint: 'F8', diff --git a/tools/suite/02-hoops-post.js b/tools/suite/02-hoops-post.js index 4ab388b..a61d010 100644 --- a/tools/suite/02-hoops-post.js +++ b/tools/suite/02-hoops-post.js @@ -1,4 +1,7 @@ - return { loop, sync, toggle }; + // For the suite's auto-open: the platform is found every frame the court is up. + // Reusing the loop's own state rather than testing the screen again -- + // a second detector here would be one more thing to drift. + return { loop, sync, toggle, active: () => plat != null }; } }; diff --git a/tools/suite/02-hoops-pre.js b/tools/suite/02-hoops-pre.js index 9522e1f..ce8b1b8 100644 --- a/tools/suite/02-hoops-pre.js +++ b/tools/suite/02-hoops-pre.js @@ -14,6 +14,7 @@ z: 2147483645, theme: { dot: '#f87171', ac: '#dc2626' }, slot: { top: 12, left: 220, width: 228, nub: 42 }, + dockOrder: 2, helper: true, overlay: true, hotkeys: { F7: 'toggle', F6: 'hide' }, keyHint: 'F7', diff --git a/tools/suite/03-fish-post.js b/tools/suite/03-fish-post.js index 4ab388b..ce20310 100644 --- a/tools/suite/03-fish-post.js +++ b/tools/suite/03-fish-post.js @@ -1,4 +1,7 @@ - return { loop, sync, toggle }; + // For the suite's auto-open: the lane goes null the moment the fishing spot is off screen. + // Reusing the loop's own state rather than testing the screen again -- + // a second detector here would be one more thing to drift. + return { loop, sync, toggle, active: () => lane != null }; } }; diff --git a/tools/suite/03-fish-pre.js b/tools/suite/03-fish-pre.js index 3af108b..558bbce 100644 --- a/tools/suite/03-fish-pre.js +++ b/tools/suite/03-fish-pre.js @@ -14,6 +14,7 @@ z: 2147483644, theme: { dot: '#38bdf8', ac: '#0284c7' }, slot: { top: 12, left: 460, width: 214, nub: 60 }, + dockOrder: 3, helper: true, overlay: true, hotkeys: { F4: 'toggle', F3: 'hide' }, keyHint: 'F4', diff --git a/tools/suite/04-darts-post.js b/tools/suite/04-darts-post.js index 4ab388b..3b5636e 100644 --- a/tools/suite/04-darts-post.js +++ b/tools/suite/04-darts-post.js @@ -1,4 +1,7 @@ - return { loop, sync, toggle }; + // For the suite's auto-open: the board is nulled by the wall gate and after 900ms stale. + // Reusing the loop's own state rather than testing the screen again -- + // a second detector here would be one more thing to drift. + return { loop, sync, toggle, active: () => board != null }; } }; diff --git a/tools/suite/04-darts-pre.js b/tools/suite/04-darts-pre.js index 336d6a4..9b880b6 100644 --- a/tools/suite/04-darts-pre.js +++ b/tools/suite/04-darts-pre.js @@ -14,6 +14,7 @@ z: 2147483643, theme: { dot: '#fbbf24', ac: '#d97706' }, slot: { top: 12, left: 686, width: 216, nub: 78 }, + dockOrder: 4, helper: true, overlay: true, hotkeys: { F2: 'toggle', F1: 'hide' }, keyHint: 'F2', diff --git a/tools/suite/05-hub.js b/tools/suite/05-hub.js index af54dea..cc1c0bc 100644 --- a/tools/suite/05-hub.js +++ b/tools/suite/05-hub.js @@ -9,6 +9,7 @@ z: 2147483647, theme: { dot: '#a78bfa', ac: '#7c3aed' }, slot: { top: 12, left: 12, width: 196, nub: 6 }, + dockOrder: 0, overlay: false, bodyHTML: // Each row: the helper's name, its toggle hotkey, an eye that shows or @@ -22,6 +23,13 @@ ` ` + `` ).join('\n ') + ` +
+
+ + +
+
+

@@ -46,6 +54,12 @@ eye.className = 'eye' + (off ? ' off' : ''); } hub.$('#panels').textContent = anyShown() ? 'Hide all panels' : 'Show all panels'; + for (const l of ['free', 'left', 'top']) + hub.$('#lay-' + l).classList.toggle('sel', suite.layout === l); + hub.$('#solo').checked = !!suite.solo; + hub.$('#follow').checked = !!suite.follow; + // Both only bite in a dock; saying so beats leaving them looking broken. + hub.$('#solo').disabled = hub.$('#follow').disabled = suite.layout === 'free'; hub.chrome(); } @@ -58,6 +72,12 @@ syncHub(); }; } + for (const l of ['free', 'left', 'top']) + hub.$('#lay-' + l).onclick = () => { suite.layout = l; saveSuite(); syncLayout(); }; + hub.$('#solo').onchange = e => { suite.solo = e.target.checked; saveSuite(); }; + hub.$('#follow').onchange = e => { suite.follow = e.target.checked; saveSuite(); }; + onLayoutChange = syncHub; + hub.$('#panels').onclick = () => { const hide = anyShown(); for (const m of MODULES) { @@ -78,13 +98,47 @@ else { m.cfg.px = null; m.cfg.py = null; m.cfg.hidden = false; m.cfg.collapsed = false; m.save(); } } hub.reset(); + suite.layout = 'free'; saveSuite(); + syncLayout(); syncHub(); }; + // Opt-in: the helper whose minigame is on screen opens itself and the other + // helpers close. Driven off each helper's own detection -- the variable it + // already keeps for "I can see my minigame" -- so there is no second copy + // of any detector here to drift out of step. + // + // Only acts on a CHANGE of which helper is active, so a manual collapse is + // not immediately undone; and it does nothing until a helper has been + // active for a moment, because the detectors flicker while a screen loads + // and a layout that flickers with them is worse than one that lags. + let followWas = null, followSince = 0, followCand = null; + function followTick() { + if (!suite.follow || suite.layout === 'free') { followWas = null; return; } + let now = null; + for (const m of MODULES) { + const inst = live.get(m.id); + if (m.helper && inst && inst.active && inst.active()) { now = m.id; break; } + } + const t = performance.now(); + if (now !== followCand) { followCand = now; followSince = t; return; } + if (t - followSince < 600 || now === followWas) return; + followWas = now; + for (const m of MODULES) { + if (!m.helper) continue; + const inst = live.get(m.id); + if (!inst) continue; + const want = m.id === now; + if (m.cfg.collapsed !== !want) { m.cfg.collapsed = !want; m.save(); inst.ui.chrome(); } + } + } + for (const m of MODULES) if (suite.enabled[m.id]) startModule(m); hub.settle(); syncHub(); + syncLayout(); + setInterval(followTick, 250); requestAnimationFrame(driver); } From 280f9824d7059d58624d1c8caed9f5ce1793bf07 Mon Sep 17 00:00:00 2001 From: averagenative Date: Sat, 12 Sep 2026 18:20:47 -0400 Subject: [PATCH 2/4] Wrap the docked panels instead of running them off the edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docked layouts laid panels out in one straight run: five across the top, or five down the left. That holds on a maximised window and falls apart on the smaller side-by-side windows the suite is actually being used in now — a half-width window cannot fit five panels across, and a short one cannot fit them down a column once more than one is expanded. A panel past the viewport edge is the exact trap the clamping in place() exists to avoid: it cannot be reached, and a panel that cannot be reached cannot be dragged back, so there is no way to recover it short of clearing localStorage. The run now breaks into a second row (or column). `run` carries the thickness of the current one -- the tallest panel in a row, the widest in a column -- so the next one clears it rather than overlapping. The first panel of a run never wraps: if a single panel is bigger than the whole viewport there is nowhere better to put it, and wrapping on it would spin. Each panel is measured before it is placed. Its width is pinned by style.width so its height does not depend on where it lands, which is what makes measuring first safe -- and the wrap has to be decided before the position is written. Still not seen running. The build is clean and the generated diff touches only the shell, but no debug browser was up to look at either layout, so this inherits the same caveat as the commit that added the docks. Co-Authored-By: Claude Opus 5 (1M context) --- idleon-suite.user.js | 31 ++++++++++++++++++++++++++----- tools/suite/00-head.js | 29 +++++++++++++++++++++++++---- 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/idleon-suite.user.js b/idleon-suite.user.js index 76bb1fa..89bbc24 100644 --- a/idleon-suite.user.js +++ b/idleon-suite.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name IdleOn Helper Suite // @namespace nativerobot -// @version 1.34 +// @version 1.35 // @downloadURL https://raw.githubusercontent.com/averagenative/idleon-userscripts/main/idleon-suite.user.js // @updateURL https://raw.githubusercontent.com/averagenative/idleon-userscripts/main/idleon-suite.user.js // @description All-in-one: autoclicker + Hoops, Fishing and Darts minigame helpers for Legends of IdleOn, each one individually switchable @@ -156,16 +156,37 @@ // ?? not ||: the hub is dockOrder 0, which || would treat as missing and // sort to the bottom of its own dock. const list = docks.slice().sort((a, b) => (a.def.dockOrder ?? 99) - (b.def.dockOrder ?? 99)); - let x = DOCK_EDGE, y = DOCK_EDGE; + // A run wraps rather than running off the edge. Five panels do not fit + // across a half-width window, and with several expanded they do not fit + // down a short one either — and a panel past the edge is the exact trap the + // clamping in place() exists to avoid: unreachable, and unreachable means + // undraggable, so there is no way back to it. + // + // `run` is the thickness of the current row (or column): the tallest panel + // in a row, the widest in a column, which is what the next one has to clear. + // The first panel of a run never wraps — if one panel is bigger than the + // whole viewport there is nowhere better for it, and wrapping on it would + // spin. + let x = DOCK_EDGE, y = DOCK_EDGE, run = 0; for (const { ui } of list) { if (ui.cfg.hidden) continue; // hidden panels are a nub, not a slot const p = ui.panel; p.style.right = 'auto'; + // Measured before placing: the width is pinned by style.width so the + // height does not depend on where it ends up, and the wrap has to be + // decided before the position is written. + const r = p.getBoundingClientRect(); + if (vert) { + if (y > DOCK_EDGE && y + r.height > window.innerHeight - DOCK_EDGE) { + x += run + DOCK_GAP; y = DOCK_EDGE; run = 0; + } + } else if (x > DOCK_EDGE && x + r.width > window.innerWidth - DOCK_EDGE) { + y += run + DOCK_GAP; x = DOCK_EDGE; run = 0; + } p.style.left = x + 'px'; p.style.top = y + 'px'; - const r = p.getBoundingClientRect(); - if (vert) y += r.height + DOCK_GAP; - else x += r.width + DOCK_GAP; + if (vert) { y += r.height + DOCK_GAP; run = Math.max(run, r.width); } + else { x += r.width + DOCK_GAP; run = Math.max(run, r.height); } } } diff --git a/tools/suite/00-head.js b/tools/suite/00-head.js index b907875..eb129a1 100644 --- a/tools/suite/00-head.js +++ b/tools/suite/00-head.js @@ -156,16 +156,37 @@ // ?? not ||: the hub is dockOrder 0, which || would treat as missing and // sort to the bottom of its own dock. const list = docks.slice().sort((a, b) => (a.def.dockOrder ?? 99) - (b.def.dockOrder ?? 99)); - let x = DOCK_EDGE, y = DOCK_EDGE; + // A run wraps rather than running off the edge. Five panels do not fit + // across a half-width window, and with several expanded they do not fit + // down a short one either — and a panel past the edge is the exact trap the + // clamping in place() exists to avoid: unreachable, and unreachable means + // undraggable, so there is no way back to it. + // + // `run` is the thickness of the current row (or column): the tallest panel + // in a row, the widest in a column, which is what the next one has to clear. + // The first panel of a run never wraps — if one panel is bigger than the + // whole viewport there is nowhere better for it, and wrapping on it would + // spin. + let x = DOCK_EDGE, y = DOCK_EDGE, run = 0; for (const { ui } of list) { if (ui.cfg.hidden) continue; // hidden panels are a nub, not a slot const p = ui.panel; p.style.right = 'auto'; + // Measured before placing: the width is pinned by style.width so the + // height does not depend on where it ends up, and the wrap has to be + // decided before the position is written. + const r = p.getBoundingClientRect(); + if (vert) { + if (y > DOCK_EDGE && y + r.height > window.innerHeight - DOCK_EDGE) { + x += run + DOCK_GAP; y = DOCK_EDGE; run = 0; + } + } else if (x > DOCK_EDGE && x + r.width > window.innerWidth - DOCK_EDGE) { + y += run + DOCK_GAP; x = DOCK_EDGE; run = 0; + } p.style.left = x + 'px'; p.style.top = y + 'px'; - const r = p.getBoundingClientRect(); - if (vert) y += r.height + DOCK_GAP; - else x += r.width + DOCK_GAP; + if (vert) { y += r.height + DOCK_GAP; run = Math.max(run, r.width); } + else { x += r.width + DOCK_GAP; run = Math.max(run, r.height); } } } From ed8e9528ecddedccfc5753b06ab2193a543ff2ae Mon Sep 17 00:00:00 2001 From: averagenative Date: Sat, 12 Sep 2026 18:21:43 -0400 Subject: [PATCH 3/4] Hold the clicker in cursor mode when the pointer is in another window Cursor mode aims at lastX/lastY, and those only move while the pointer is over this window. Put the game in a second window -- side by side, or just not the one being pointed at -- and they go stale the moment the pointer leaves, and they are 0,0 before it has ever arrived. The clicker then clicks the top-left corner of the game forever, looking like it is running fine. That is not a harmless no-op. A click on bare ground is a walk command, so a corner click sends the character strolling, which is the same failure the fractional fixed target exists to avoid. It also cost a misdiagnosis today. Asked why the fixed-position click "was not working" in a side-by-side Firefox window, I reasoned about legacy configs, off-viewport coordinates and docked panels eating the click -- three plausible theories, all wrong. The config dump said mode: "cursor" in its very first field. Nothing was broken; the mode was simply not the one being debugged, and nothing on screen said the mode could not work from there. So: in cursor mode with the pointer elsewhere, tick() holds instead of clicking, and the readout says "cursor is in another window" rather than "(follows cursor)". The timer keeps running, so it resumes by itself when the pointer comes back, and fixed mode is untouched -- a fixed target does not care where the pointer is, which is exactly why it is the right mode for a window you are not pointing at. Presence is tracked by mouseout with a null relatedTarget, which is the pointer leaving the document altogether; leaving for a panel or any other element names that element instead and does not count. One edge worth knowing: if the pointer is sitting over the window but has not moved since load, no mousemove has fired, so it reads as absent and the clicker holds. The status line says so and the first flicker of movement clears it. Treating "never seen" as present instead would let the 0,0 case straight back in, which is the bug this is here for. Not seen running -- no debug browser was up. Co-Authored-By: Claude Opus 5 (1M context) --- idleon-clicker.user.js | 33 ++++++++++++++++++++++++++++----- idleon-suite.user.js | 27 ++++++++++++++++++++++----- tools/suite/01-clicker.js | 16 +++++++++++++--- 3 files changed, 63 insertions(+), 13 deletions(-) diff --git a/idleon-clicker.user.js b/idleon-clicker.user.js index 677b534..4d45088 100644 --- a/idleon-clicker.user.js +++ b/idleon-clicker.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name IdleOn Clicker // @namespace nativerobot -// @version 3.5 +// @version 3.6 // @downloadURL https://raw.githubusercontent.com/averagenative/idleon-userscripts/main/idleon-clicker.user.js // @updateURL https://raw.githubusercontent.com/averagenative/idleon-userscripts/main/idleon-clicker.user.js // @description Stealthy in-page autoclicker panel for Legends of IdleOn (browser) @@ -36,8 +36,23 @@ // ---------- state ---------- let on = false, timer = null, capturing = false; - let lastX = 0, lastY = 0; - document.addEventListener('mousemove', e => { lastX = e.clientX; lastY = e.clientY; }, true); + // lastX/lastY only move while the pointer is over THIS window. In a second + // window — side by side, or simply not the one being pointed at — they go + // stale the moment it leaves, and they are 0,0 before it has ever arrived. + // Cursor mode would then click the top-left corner of the game forever, + // silently, which is not a harmless no-op: a click on bare ground is a walk + // command, so the character strolls off. ptrIn is what says whether the + // coordinates mean anything. + let lastX = 0, lastY = 0, ptrIn = false, wasBlind = false; + document.addEventListener('mousemove', e => { + lastX = e.clientX; lastY = e.clientY; ptrIn = true; + }, true); + // mouseout with a null relatedTarget is the pointer leaving the document + // altogether; leaving for the panel, or for any other element, names that + // element instead and does not count. + document.addEventListener('mouseout', e => { + if (!e.relatedTarget) ptrIn = false; + }, true); // ---------- stealth UI host (closed shadow DOM, hidden from page JS) ---------- const host = document.createElement('div'); @@ -120,7 +135,8 @@ function sync() { ivMinEl.value = cfg.ivMin; ivMaxEl.value = cfg.ivMax; jpEl.value = cfg.jitterPx; root.querySelectorAll('.seg button').forEach(b => b.classList.toggle('sel', b.dataset.m === cfg.mode)); - xyEl.textContent = cfg.mode !== 'fixed' ? '(follows cursor)' + xyEl.textContent = cfg.mode !== 'fixed' + ? (ptrIn ? '(follows cursor)' : 'cursor is in another window') : hasTarget() ? fixedPoint().map(Math.round).join(', ') : 'not set'; dot.classList.toggle('on', on); runBtn.textContent = on ? 'Stop (F8)' : 'Start (F8)'; @@ -175,8 +191,15 @@ function tick() { if (!on) return; + // Cursor mode with the pointer in another window has nothing to aim at, so + // it holds rather than clicking a stale coordinate. The timer keeps running + // and it resumes by itself when the pointer comes back. Announced, because + // the failure is otherwise invisible: the clicker looks like it is running + // and the game just never responds. + const blind = cfg.mode !== 'fixed' && !ptrIn; + if (blind !== wasBlind) { wasBlind = blind; sync(); } // Resolved every tick: the canvas rect can change under a running clicker. - if (cfg.mode !== 'fixed' || hasTarget()) { + if (!blind && (cfg.mode !== 'fixed' || hasTarget())) { const [tx, ty] = cfg.mode === 'fixed' ? fixedPoint() : [lastX, lastY]; clickAt(tx, ty); } diff --git a/idleon-suite.user.js b/idleon-suite.user.js index 89bbc24..b38298d 100644 --- a/idleon-suite.user.js +++ b/idleon-suite.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name IdleOn Helper Suite // @namespace nativerobot -// @version 1.35 +// @version 1.36 // @downloadURL https://raw.githubusercontent.com/averagenative/idleon-userscripts/main/idleon-suite.user.js // @updateURL https://raw.githubusercontent.com/averagenative/idleon-userscripts/main/idleon-suite.user.js // @description All-in-one: autoclicker + Hoops, Fishing and Darts minigame helpers for Legends of IdleOn, each one individually switchable @@ -550,13 +550,23 @@ xyEl = $('#xy'), setBtn = $('#set'); let on = false, timer = null, capturing = false; - let lastX = 0, lastY = 0; - ui.on(document, 'mousemove', e => { lastX = e.clientX; lastY = e.clientY; }, true); + // lastX/lastY only move while the pointer is over THIS window, so in a + // second window they go stale on the way out and are 0,0 before it has + // ever arrived. See the standalone clicker for the whole story; ptrIn is + // what says whether the coordinates mean anything. + let lastX = 0, lastY = 0, ptrIn = false, wasBlind = false; + ui.on(document, 'mousemove', e => { + lastX = e.clientX; lastY = e.clientY; ptrIn = true; + }, true); + // A null relatedTarget is the pointer leaving the document altogether; + // leaving for a panel names that element instead and does not count. + ui.on(document, 'mouseout', e => { if (!e.relatedTarget) ptrIn = false; }, true); function sync() { ivMinEl.value = cfg.ivMin; ivMaxEl.value = cfg.ivMax; jpEl.value = cfg.jitterPx; root.querySelectorAll('.seg button').forEach(b => b.classList.toggle('sel', b.dataset.m === cfg.mode)); - xyEl.textContent = cfg.mode !== 'fixed' ? '(follows cursor)' + xyEl.textContent = cfg.mode !== 'fixed' + ? (ptrIn ? '(follows cursor)' : 'cursor is in another window') : hasTarget() ? fixedPoint().map(Math.round).join(', ') : 'not set'; dot.classList.toggle('on', on); runBtn.textContent = on ? 'Stop (F8)' : 'Start (F8)'; @@ -608,8 +618,15 @@ function tick() { if (!on) return; + // Cursor mode with the pointer in another window has nothing to aim at, so + // it holds rather than clicking a stale coordinate. The timer keeps running + // and it resumes by itself when the pointer comes back. Announced, because + // the failure is otherwise invisible: the clicker looks like it is running + // and the game just never responds. + const blind = cfg.mode !== 'fixed' && !ptrIn; + if (blind !== wasBlind) { wasBlind = blind; sync(); } // Resolved every tick: the canvas rect can change under a running clicker. - if (cfg.mode !== 'fixed' || hasTarget()) { + if (!blind && (cfg.mode !== 'fixed' || hasTarget())) { const [tx, ty] = cfg.mode === 'fixed' ? fixedPoint() : [lastX, lastY]; clickAt(tx, ty); } diff --git a/tools/suite/01-clicker.js b/tools/suite/01-clicker.js index 7152ca0..3001e6f 100644 --- a/tools/suite/01-clicker.js +++ b/tools/suite/01-clicker.js @@ -39,13 +39,23 @@ xyEl = $('#xy'), setBtn = $('#set'); let on = false, timer = null, capturing = false; - let lastX = 0, lastY = 0; - ui.on(document, 'mousemove', e => { lastX = e.clientX; lastY = e.clientY; }, true); + // lastX/lastY only move while the pointer is over THIS window, so in a + // second window they go stale on the way out and are 0,0 before it has + // ever arrived. See the standalone clicker for the whole story; ptrIn is + // what says whether the coordinates mean anything. + let lastX = 0, lastY = 0, ptrIn = false, wasBlind = false; + ui.on(document, 'mousemove', e => { + lastX = e.clientX; lastY = e.clientY; ptrIn = true; + }, true); + // A null relatedTarget is the pointer leaving the document altogether; + // leaving for a panel names that element instead and does not count. + ui.on(document, 'mouseout', e => { if (!e.relatedTarget) ptrIn = false; }, true); function sync() { ivMinEl.value = cfg.ivMin; ivMaxEl.value = cfg.ivMax; jpEl.value = cfg.jitterPx; root.querySelectorAll('.seg button').forEach(b => b.classList.toggle('sel', b.dataset.m === cfg.mode)); - xyEl.textContent = cfg.mode !== 'fixed' ? '(follows cursor)' + xyEl.textContent = cfg.mode !== 'fixed' + ? (ptrIn ? '(follows cursor)' : 'cursor is in another window') : hasTarget() ? fixedPoint().map(Math.round).join(', ') : 'not set'; dot.classList.toggle('on', on); runBtn.textContent = on ? 'Stop (F8)' : 'Start (F8)'; From f118061654487039feb0dd7a1ad95e8fd4c2573e Mon Sep 17 00:00:00 2001 From: averagenative Date: Sat, 12 Sep 2026 18:25:08 -0400 Subject: [PATCH 4/4] Add a minimise-all button, and make solo an invariant rather than a click Two gaps found by actually looking at the docked layouts running. Everything arrives expanded. Nothing enforced "one helper at a time" except the collapse button's own handler, so entering a dock with four helpers already open produced a column tall enough to need a second one -- the exact sprawl the dock exists to remove. enforceSolo() now runs on entering a docked layout and closes all but the first open helper, so the invariant holds however the state was arrived at, not only when a panel is clicked. And there was no way to roll everything up at once. "Hide all panels" hides them, leaving nubs and nothing to click; what was missing was collapsing them to their title bars, which keeps every panel on screen and reachable. The new button toggles, reading "Minimise all" or "Expand all" the way the hide button already does, so it says what it is about to do rather than what it is. Verified in a live browser at 960px wide, which is narrow enough to force both wraps: the left dock fills a column and overflows into a second, the top dock fills a row and wraps Darts below it, panels come out in dockOrder, switching layouts re-docks everything live, and the clicker reads "cursor is in another window" when the pointer is elsewhere. The button and enforceSolo are NOT yet seen running -- they postdate that injection. Co-Authored-By: Claude Opus 5 (1M context) --- idleon-suite.user.js | 35 +++++++++++++++++++++++++++++++++-- tools/suite/00-head.js | 16 +++++++++++++++- tools/suite/05-hub.js | 17 +++++++++++++++++ 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/idleon-suite.user.js b/idleon-suite.user.js index b38298d..1834945 100644 --- a/idleon-suite.user.js +++ b/idleon-suite.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name IdleOn Helper Suite // @namespace nativerobot -// @version 1.36 +// @version 1.37 // @downloadURL https://raw.githubusercontent.com/averagenative/idleon-userscripts/main/idleon-suite.user.js // @updateURL https://raw.githubusercontent.com/averagenative/idleon-userscripts/main/idleon-suite.user.js // @description All-in-one: autoclicker + Hoops, Fishing and Darts minigame helpers for Legends of IdleOn, each one individually switchable @@ -193,7 +193,21 @@ // The hub owns the layout controls, but a drag out of a dock has to change // the layout from inside makePanel. This is the seam between the two. let onLayoutChange = () => {}; - function syncLayout() { relayout(); onLayoutChange(); } + // Solo has to be an invariant, not just something the collapse button does. + // Arriving in a dock with four helpers already open gives a column that needs + // two of them to fit — which is the exact thing the dock is for avoiding. So + // entering a docked layout closes all but the first open helper. + function enforceSolo() { + if (!suite.solo || suite.layout === 'free') return; + let kept = false; + const list = docks.slice().sort((a, b) => (a.def.dockOrder ?? 99) - (b.def.dockOrder ?? 99)); + for (const d of list) { + if (!d.def.helper || d.ui.cfg.collapsed) continue; + if (!kept) { kept = true; continue; } // the first open one stays open + d.ui.cfg.collapsed = true; d.ui.save(); d.ui.chrome(); + } + } + function syncLayout() { enforceSolo(); relayout(); onLayoutChange(); } // Collapsing a panel changes every panel below it, so the re-stack is // coalesced to one pass per frame rather than run per panel per change. @@ -3845,6 +3859,7 @@

+
unticking a helper stops it:
no panel, no readback, no hotkey
` @@ -3858,6 +3873,7 @@ // "all hidden" drives the button's label, so it reads as the thing it is // about to do rather than as the state it is in. const anyShown = () => MODULES.some(m => live.has(m.id) && !m.cfg.hidden); + const anyOpen = () => MODULES.some(m => live.has(m.id) && !m.cfg.collapsed); function syncHub() { for (const m of MODULES) { @@ -3868,6 +3884,7 @@ eye.className = 'eye' + (off ? ' off' : ''); } hub.$('#panels').textContent = anyShown() ? 'Hide all panels' : 'Show all panels'; + hub.$('#rollup').textContent = anyOpen() ? 'Minimise all' : 'Expand all'; for (const l of ['free', 'left', 'top']) hub.$('#lay-' + l).classList.toggle('sel', suite.layout === l); hub.$('#solo').checked = !!suite.solo; @@ -3892,6 +3909,20 @@ hub.$('#follow').onchange = e => { suite.follow = e.target.checked; saveSuite(); }; onLayoutChange = syncHub; + // Rolls every helper up to its title bar without hiding it — the panels + // stay on screen and stay clickable, which is the difference from "Hide + // all panels". In a dock that is also how you get back to one short column + // after several have been opened. + hub.$('#rollup').onclick = () => { + const roll = anyOpen(); + for (const m of MODULES) { + m.cfg.collapsed = roll; m.save(); + const inst = live.get(m.id); + if (inst) inst.ui.chrome(); + } + syncHub(); + }; + hub.$('#panels').onclick = () => { const hide = anyShown(); for (const m of MODULES) { diff --git a/tools/suite/00-head.js b/tools/suite/00-head.js index eb129a1..dc956b2 100644 --- a/tools/suite/00-head.js +++ b/tools/suite/00-head.js @@ -193,7 +193,21 @@ // The hub owns the layout controls, but a drag out of a dock has to change // the layout from inside makePanel. This is the seam between the two. let onLayoutChange = () => {}; - function syncLayout() { relayout(); onLayoutChange(); } + // Solo has to be an invariant, not just something the collapse button does. + // Arriving in a dock with four helpers already open gives a column that needs + // two of them to fit — which is the exact thing the dock is for avoiding. So + // entering a docked layout closes all but the first open helper. + function enforceSolo() { + if (!suite.solo || suite.layout === 'free') return; + let kept = false; + const list = docks.slice().sort((a, b) => (a.def.dockOrder ?? 99) - (b.def.dockOrder ?? 99)); + for (const d of list) { + if (!d.def.helper || d.ui.cfg.collapsed) continue; + if (!kept) { kept = true; continue; } // the first open one stays open + d.ui.cfg.collapsed = true; d.ui.save(); d.ui.chrome(); + } + } + function syncLayout() { enforceSolo(); relayout(); onLayoutChange(); } // Collapsing a panel changes every panel below it, so the re-stack is // coalesced to one pass per frame rather than run per panel per change. diff --git a/tools/suite/05-hub.js b/tools/suite/05-hub.js index cc1c0bc..12e471b 100644 --- a/tools/suite/05-hub.js +++ b/tools/suite/05-hub.js @@ -31,6 +31,7 @@

+
unticking a helper stops it:
no panel, no readback, no hotkey
` @@ -44,6 +45,7 @@ // "all hidden" drives the button's label, so it reads as the thing it is // about to do rather than as the state it is in. const anyShown = () => MODULES.some(m => live.has(m.id) && !m.cfg.hidden); + const anyOpen = () => MODULES.some(m => live.has(m.id) && !m.cfg.collapsed); function syncHub() { for (const m of MODULES) { @@ -54,6 +56,7 @@ eye.className = 'eye' + (off ? ' off' : ''); } hub.$('#panels').textContent = anyShown() ? 'Hide all panels' : 'Show all panels'; + hub.$('#rollup').textContent = anyOpen() ? 'Minimise all' : 'Expand all'; for (const l of ['free', 'left', 'top']) hub.$('#lay-' + l).classList.toggle('sel', suite.layout === l); hub.$('#solo').checked = !!suite.solo; @@ -78,6 +81,20 @@ hub.$('#follow').onchange = e => { suite.follow = e.target.checked; saveSuite(); }; onLayoutChange = syncHub; + // Rolls every helper up to its title bar without hiding it — the panels + // stay on screen and stay clickable, which is the difference from "Hide + // all panels". In a dock that is also how you get back to one short column + // after several have been opened. + hub.$('#rollup').onclick = () => { + const roll = anyOpen(); + for (const m of MODULES) { + m.cfg.collapsed = roll; m.save(); + const inst = live.get(m.id); + if (inst) inst.ui.chrome(); + } + syncHub(); + }; + hub.$('#panels').onclick = () => { const hide = anyShown(); for (const m of MODULES) {