← back to Desktop Dotbar
dotbar: free-position persistence — bar stays where dragged, across restarts (TK-12072)
385c3af3c8ea0171067f5b116c6951f968c254a4 · 2026-09-23 11:11:03 -0700 · Steve Abrams
Steve: "once moved, keep the nav bar where relocated" → free-position mode.
electron-main.js:
- .barcfg gains floating(bool) + pos(displayId→{x,y,w,h}); readCfg defaults both
cleanly so an old .barcfg still loads docked (backward-compatible).
- per-window 'moved' listener flips floating on and saves getBounds() once the bar
leaves its docked spot by >4px; a programmatic-move guard (progUntil, 250ms window
around every internal setBounds via setWinBounds) plus a docked-divergence check
stop our own setBounds from being mis-captured as a user drag.
- boundsFor returns the saved pos (clamped to the visible work area) while floating
and never re-snaps; createBarFor opens at the saved pos; off-screen/gone-display
positions clamp back on-screen so the bar is always grabbable.
- pollAutohide no-ops while floating (no edge to slide to).
- setOrientation/cycleOrientation exit floating and re-dock to the edge (escape hatch).
- grip setters (setBarH/W/Len) patch the saved pos w/h so a floating bar resizes and
remembers its size; flushCfg on before-quit so a drag-then-quit isn't lost.
start-bar.command: drop a stray DOTBAR_DEBUG=1 an auto-snapshot committed mid-test.
Verified by cycling the live launchd app: floating restores at exact (800,400,520,48);
docked cfg still edge-snaps ignoring pos; off-screen (5000,5000) clamps to (2040,957).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019excZ7L7VE14hbPqKQqH3i
Files touched
M electron-main.jsM start-bar.command
Diff
commit 385c3af3c8ea0171067f5b116c6951f968c254a4
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Sep 23 11:11:03 2026 -0700
dotbar: free-position persistence — bar stays where dragged, across restarts (TK-12072)
Steve: "once moved, keep the nav bar where relocated" → free-position mode.
electron-main.js:
- .barcfg gains floating(bool) + pos(displayId→{x,y,w,h}); readCfg defaults both
cleanly so an old .barcfg still loads docked (backward-compatible).
- per-window 'moved' listener flips floating on and saves getBounds() once the bar
leaves its docked spot by >4px; a programmatic-move guard (progUntil, 250ms window
around every internal setBounds via setWinBounds) plus a docked-divergence check
stop our own setBounds from being mis-captured as a user drag.
- boundsFor returns the saved pos (clamped to the visible work area) while floating
and never re-snaps; createBarFor opens at the saved pos; off-screen/gone-display
positions clamp back on-screen so the bar is always grabbable.
- pollAutohide no-ops while floating (no edge to slide to).
- setOrientation/cycleOrientation exit floating and re-dock to the edge (escape hatch).
- grip setters (setBarH/W/Len) patch the saved pos w/h so a floating bar resizes and
remembers its size; flushCfg on before-quit so a drag-then-quit isn't lost.
start-bar.command: drop a stray DOTBAR_DEBUG=1 an auto-snapshot committed mid-test.
Verified by cycling the live launchd app: floating restores at exact (800,400,520,48);
docked cfg still edge-snaps ignoring pos; off-screen (5000,5000) clamps to (2040,957).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019excZ7L7VE14hbPqKQqH3i
---
electron-main.js | 116 ++++++++++++++++++++++++++++++++++++++++++++++++------
start-bar.command | 2 -
2 files changed, 103 insertions(+), 15 deletions(-)
diff --git a/electron-main.js b/electron-main.js
index 6a65cf0..661c817 100644
--- a/electron-main.js
+++ b/electron-main.js
@@ -50,18 +50,30 @@ function clampBarLen(v) { const n = Math.round(Number(v) || 0); return n <= 0 ?
function readBarH() { try { return clampBar(parseInt(fs.readFileSync(BARH_FILE, 'utf8'), 10)); } catch { return BAR_DEFAULT; } }
function writeBarH() { try { fs.writeFileSync(BARH_FILE, String(barH)); } catch {} }
function readCfg() {
- const d = { orientation: 'top', autohide: false, barW: BARW_DEFAULT, barLen: 0 };
+ // floating = the bar has been dragged off its edge and lives at a free position;
+ // pos maps displayId -> { x, y, w, h } so EACH display's bar keeps its own free spot.
+ // Backward-compat: a .barcfg written by the pre-floating code has neither key and loads docked.
+ const d = { orientation: 'top', autohide: false, barW: BARW_DEFAULT, barLen: 0, floating: false, pos: {} };
try {
const j = JSON.parse(fs.readFileSync(CFG_FILE, 'utf8'));
if (ORIENTS.includes(j.orientation)) d.orientation = j.orientation;
d.autohide = !!j.autohide;
d.barW = clampBarW(j.barW);
d.barLen = clampBarLen(j.barLen);
+ d.floating = !!j.floating;
+ if (j.pos && typeof j.pos === 'object') {
+ for (const [k, v] of Object.entries(j.pos)) {
+ if (v && ['x', 'y', 'w', 'h'].every(f => Number.isFinite(v[f]))) {
+ d.pos[k] = { x: Math.round(v.x), y: Math.round(v.y), w: Math.round(v.w), h: Math.round(v.h) };
+ }
+ }
+ }
} catch {}
return d;
}
let _cfgTimer = null;
function writeCfg() { clearTimeout(_cfgTimer); _cfgTimer = setTimeout(() => { try { fs.writeFileSync(CFG_FILE, JSON.stringify(cfg)); } catch {} }, 200); }
+function flushCfg() { clearTimeout(_cfgTimer); try { fs.writeFileSync(CFG_FILE, JSON.stringify(cfg)); } catch {} } // sync flush so a drag-then-quit within the debounce window isn't lost
function portFromFile() { try { return parseInt(fs.readFileSync(path.join(DIR, '.port'), 'utf8'), 10); } catch { return null; } }
function alive(port) {
@@ -115,7 +127,43 @@ function boundsCore(display, open, hidden) {
return { x, y: wa.y, width, height: wa.height };
}
function isHidden(w) { return cfg.autohide && w && !revealed.get(w.id) && !openState.get(w.id) && !gripDragging; }
-function boundsFor(w, display, open) { return boundsCore(display, open, isHidden(w)); }
+
+// ---- Free-position (floating) mode ----
+// Once the user drags a bar off its edge, cfg.floating flips on and the bar lives at
+// cfg.pos[displayId]. boundsFor then returns that saved spot instead of the edge-docked math,
+// so nothing re-snaps it to the edge. A display whose bar was never dragged has no pos entry
+// and stays docked even while floating is on.
+const MOVE_THRESH = 4; // px a bar must leave its docked spot to count as a drag
+const progUntil = new Map(); // win.id -> ts through which 'moved' events are our own setBounds echoes
+function setWinBounds(w, b) { // every programmatic setBounds goes through here so 'moved' can ignore it
+ if (!w || w.isDestroyed()) return;
+ progUntil.set(w.id, Date.now() + 250);
+ w.setBounds(b);
+}
+function boundsToPos(b) { return { x: Math.round(b.x), y: Math.round(b.y), w: Math.round(b.width), h: Math.round(b.height) }; }
+// Clamp a saved pos into a display's visible work area so a bar is never opened where it can't be
+// seen or grabbed (off-screen, or its display shrank/vanished). Keeps the whole window on-screen.
+function clampPosToDisplay(pos, display) {
+ const wa = display.workArea;
+ const w = Math.min(Math.max(BAR_MIN, pos.w), wa.width);
+ const h = Math.min(Math.max(BAR_MIN, pos.h), wa.height);
+ const x = Math.min(Math.max(wa.x, pos.x), wa.x + wa.width - w);
+ const y = Math.min(Math.max(wa.y, pos.y), wa.y + wa.height - h);
+ return { x, y, w, h };
+}
+function floatingPosFor(display) {
+ const p = cfg.pos && cfg.pos[display.id];
+ return p ? clampPosToDisplay(p, display) : null;
+}
+function floatingBounds(p, open) { // grow along orientation when the dropdown opens
+ const b = { x: p.x, y: p.y, width: p.w, height: p.h };
+ if (open) { if (cfg.orientation === 'top') b.height += PANEL_H; else b.width += PANEL_W; }
+ return b;
+}
+function boundsFor(w, display, open) {
+ if (cfg.floating) { const p = floatingPosFor(display); if (p) return floatingBounds(p, open); }
+ return boundsCore(display, open, isHidden(w));
+}
function displayOf(w) {
return screen.getAllDisplays().find(d => d.id === w._displayId) || screen.getPrimaryDisplay();
@@ -123,9 +171,33 @@ function displayOf(w) {
function raise(w) { try { if (!w.isDestroyed()) { w.setAlwaysOnTop(true, 'screen-saver'); w.moveTop(); } } catch {} }
function applyBoundsFor(w) {
if (!w || w.isDestroyed()) return;
- w.setBounds(boundsFor(w, displayOf(w), !!openState.get(w.id)));
+ setWinBounds(w, boundsFor(w, displayOf(w), !!openState.get(w.id)));
raise(w);
}
+
+// A user drag ends in a 'moved' event. Ignore our own programmatic setBounds (progUntil) and any
+// jitter that hasn't actually left the docked spot; otherwise flip to floating and remember where
+// THIS display's bar now sits (re-keying to the display it was dropped on for a cross-monitor drag).
+function onWinMoved(w) {
+ if (!w || w.isDestroyed()) return;
+ if (Date.now() < (progUntil.get(w.id) || 0)) return;
+ const b = w.getBounds();
+ const d = displayOf(w);
+ if (!cfg.floating) {
+ const docked = boundsCore(d, false, false);
+ if (Math.abs(b.x - docked.x) <= MOVE_THRESH && Math.abs(b.y - docked.y) <= MOVE_THRESH) return;
+ }
+ const disp = screen.getDisplayMatching(b) || d;
+ const pos = boundsToPos(b);
+ if (openState.get(w.id)) { // dropdown was open — store the CLOSED thickness
+ if (cfg.orientation === 'top') pos.h = Math.max(BAR_MIN, pos.h - PANEL_H);
+ else pos.w = Math.max(BARW_MIN, pos.w - PANEL_W);
+ }
+ w._displayId = disp.id;
+ cfg.floating = true;
+ cfg.pos[disp.id] = pos;
+ writeCfg();
+}
function applyAll() { for (const w of wins) applyBoundsFor(w); }
function pushBarH(w) { try { if (!w.isDestroyed()) w.webContents.send('dotbar:barH', barH); } catch {} }
function pushCfg(w) { try { if (!w.isDestroyed()) w.webContents.send('dotbar:config', { orientation: cfg.orientation, autohide: cfg.autohide, barW: cfg.barW, barLen: cfg.barLen }); } catch {} }
@@ -133,22 +205,30 @@ function pushCfg(w) { try { if (!w.isDestroyed()) w.webContents.send('dotbar:con
// setBarH/setBarW fire on EVERY pointermove during a grip drag, so keep them cheap: resize each
// strip and mirror the value to its renderer live, but do NOT re-assert always-on-top per frame
// (the 2s raise interval covers that) and do NOT hit disk per frame — persist once, debounced.
+// While floating, a grip drag resizes the free-positioned bar in place, so mirror the new size onto
+// the saved pos (clamped on read) — a floating bar stays resizable and remembers its size.
+function patchFloatPos(w, patch) {
+ const id = displayOf(w).id;
+ if (cfg.floating && cfg.pos[id]) cfg.pos[id] = { ...cfg.pos[id], ...patch };
+}
let _persistTimer = null;
function setBarH(h) {
barH = clampBar(h);
for (const w of wins) {
if (w.isDestroyed()) continue;
- w.setBounds(boundsFor(w, displayOf(w), !!openState.get(w.id)));
+ patchFloatPos(w, { h: barH });
+ setWinBounds(w, boundsFor(w, displayOf(w), !!openState.get(w.id)));
pushBarH(w);
}
- clearTimeout(_persistTimer); _persistTimer = setTimeout(writeBarH, 300);
+ clearTimeout(_persistTimer); _persistTimer = setTimeout(() => { writeBarH(); if (cfg.floating) writeCfg(); }, 300);
}
let _persistWTimer = null;
function setBarW(wpx) {
cfg.barW = clampBarW(wpx);
for (const w of wins) {
if (w.isDestroyed()) continue;
- w.setBounds(boundsFor(w, displayOf(w), !!openState.get(w.id)));
+ patchFloatPos(w, { w: cfg.barW });
+ setWinBounds(w, boundsFor(w, displayOf(w), !!openState.get(w.id)));
pushCfg(w);
}
clearTimeout(_persistWTimer); _persistWTimer = setTimeout(writeCfg, 300);
@@ -161,14 +241,18 @@ function setBarLen(px) {
cfg.barLen = (Math.round(Number(px) || 0) >= full - 4) ? 0 : clampBarLen(px);
for (const w of wins) {
if (w.isDestroyed()) continue;
- w.setBounds(boundsFor(w, displayOf(w), !!openState.get(w.id)));
+ patchFloatPos(w, { w: Math.max(BARLEN_MIN, Math.round(Number(px) || 0)) }); // floating top bar: length = width
+ setWinBounds(w, boundsFor(w, displayOf(w), !!openState.get(w.id)));
pushCfg(w);
}
clearTimeout(_persistLTimer); _persistLTimer = setTimeout(writeCfg, 300);
}
function setOrientation(o) {
- if (!ORIENTS.includes(o) || o === cfg.orientation) return;
+ if (!ORIENTS.includes(o)) return;
+ const exitingFloat = cfg.floating; // orientation button also re-docks a floating bar
+ if (o === cfg.orientation && !exitingFloat) return;
+ cfg.floating = false;
cfg.orientation = o;
revealed.clear(); // recompute reveal against the new edge
applyAll();
@@ -197,7 +281,7 @@ function cursorOverRevealed(w, display, c) {
&& c.y >= b.y - REVEAL_MARGIN && c.y < b.y + b.height + REVEAL_MARGIN;
}
function pollAutohide() {
- if (!cfg.autohide) return;
+ if (!cfg.autohide || cfg.floating) return; // a floating bar has no edge to slide off, so never hide it
let c; try { c = screen.getCursorScreenPoint(); } catch { return; }
const now = Date.now();
for (const w of wins) {
@@ -213,8 +297,10 @@ function pollAutohide() {
}
function createBarFor(display) {
+ const fp = cfg.floating ? floatingPosFor(display) : null; // restore free position (clamped on-screen) if set
+ const initBounds = fp ? floatingBounds(fp, false) : boundsCore(display, false, false);
const win = new BrowserWindow({
- ...boundsCore(display, false, false),
+ ...initBounds,
type: 'panel', // NSPanel: floats above normal windows, non-activating
frame: false, resizable: false, movable: true, minimizable: false, maximizable: false,
fullscreenable: false, skipTaskbar: true, hasShadow: false, transparent: false,
@@ -229,6 +315,7 @@ function createBarFor(display) {
JSON.stringify({ tag, id: win.id, t: new Date().toISOString(), bounds: win.isDestroyed() ? null : win.getBounds() }) + '\n'); } catch {} };
win.webContents.on('did-finish-load', () => { raise(win); pushBarH(win); pushCfg(win); applyBoundsFor(win); dbg('did-finish-load'); });
win.webContents.on('did-fail-load', (_e, code, desc) => dbg('FAIL:' + code + ':' + desc));
+ win.on('moved', () => { onWinMoved(win); dbg('moved'); }); // user dragged the bar -> capture free position
wins.push(win);
return win;
}
@@ -237,7 +324,7 @@ function createBarFor(display) {
// and a removed display leaves no orphan window.
function rebuildBars() {
for (const w of wins) { try { if (!w.isDestroyed()) w.destroy(); } catch {} }
- wins = []; openState.clear(); revealed.clear(); lastInside.clear();
+ wins = []; openState.clear(); revealed.clear(); lastInside.clear(); progUntil.clear();
for (const d of screen.getAllDisplays()) createBarFor(d);
}
@@ -262,13 +349,16 @@ async function createWindows() {
ipcMain.on('dotbar:setBarLen', (_e, px) => setBarLen(px)); // right-end grip (top): horizontal length, applies to all strips
ipcMain.on('dotbar:gripDrag', (_e, on) => { gripDragging = !!on; if (!on && cfg.autohide) revealed.clear(); });
ipcMain.on('dotbar:setOrientation', (_e, o) => setOrientation(o));
- ipcMain.on('dotbar:cycleOrientation', () => setOrientation(ORIENTS[(ORIENTS.indexOf(cfg.orientation) + 1) % ORIENTS.length]));
+ ipcMain.on('dotbar:cycleOrientation', () => {
+ if (cfg.floating) setOrientation(cfg.orientation); // floating: first press re-docks to the current edge
+ else setOrientation(ORIENTS[(ORIENTS.indexOf(cfg.orientation) + 1) % ORIENTS.length]);
+ });
ipcMain.on('dotbar:setAutohide', (_e, on) => setAutohide(on));
ipcMain.on('dotbar:quit', () => app.quit());
}
app.on('window-all-closed', () => app.quit());
-app.on('before-quit', () => { if (serverProc) try { serverProc.kill(); } catch {} });
+app.on('before-quit', () => { flushCfg(); if (serverProc) try { serverProc.kill(); } catch {} });
app.whenReady().then(() => {
if (app.dock) app.dock.hide(); // accessory app: panels float above active apps
createWindows().catch(e => console.log('DOTBAR: createWindows ERROR', e && e.stack || e));
diff --git a/start-bar.command b/start-bar.command
index 0e87eea..979977c 100755
--- a/start-bar.command
+++ b/start-bar.command
@@ -14,8 +14,6 @@ pkill -f "Electron.*desktop-dotbar" 2>/dev/null
pkill -f "desktop-dotbar/server.js" 2>/dev/null
sleep 0.4
-export DOTBAR_DEBUG=1
-
# 1) start the server with real node (absolute path so pkill can find it later).
# launchd's PATH lacks homebrew, so `command -v node` is empty under `launchctl kickstart` —
# fall back to the known homebrew/usr locations so a launchd restart actually starts the server.
← 08b744a auto-data-snapshot: 2026-09-23T11:06:44 (1 data files) — sta
·
back to Desktop Dotbar
·
dotbar: ticket states as vertical chips with right-edge labe 26f22db →