[object Object]

← 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

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 →