Theseus: the left panel resizes, and steps aside like Opera's

Web apps in the left panel were a fixed 380 px, too narrow for WhatsApp,
and add-on panels a fixed 400. A grip on the panel's right edge now
resizes it, each app keeps its own width (settings.leftPanelWidths), a
double-click resets it, and the page always keeps at least 320 px. The
grip is a small view of Theseus's own laid over the edge, since the panel
may be a third-party page; while dragging it covers the window so the drag
keeps its events.

Like Opera's sidebar, the left panel now hides when a new tab is opened or
the right sidebar opens. It is only hidden, so reopening lands where the
user left off.

Add-on panels on the left used to get the right sidebar's grip on their
left edge, where dragging resized the right sidebar; that grip is hidden
there and its drags are ignored.
This commit is contained in:
Local Dev 2026-10-04 20:53:16 +02:00
parent 00aa37ab2b
commit 29b346250f
4 changed files with 153 additions and 5 deletions

107
main.js
View file

@ -2275,6 +2275,15 @@ let leftPanelId = null; // panelId shown, or null when closed
let leftPanelLoadedId = null; // what leftPanel has loaded (kept while closed, so reopening keeps state)
let leftPanelMax = false; // widened by the panel (silentmode.sidebar.toggleMax)
const LEFT_PANEL_W = 400;
// The left panel (a quick-link web app or an add-on panel) is resizable from
// a grip on its right edge; each app keeps its own width in
// settings.leftPanelWidths, keyed "q:<quick-link id>" / "p:<panelId>", so
// WhatsApp can stay wide while a small tool stays narrow.
const LEFT_PANEL_W_MIN = 280;
const TAB_AREA_MIN = 320; // the panel never squeezes the page below this
let panelGrip; // panel-grip.html, laid over the panel's right edge
let panelGripDragging = false;
const PANEL_GRIP_W = 6;
// Sidebar width is user-adjustable via a drag grip on the panel's left edge.
// The value below is the default; settings.sidebarWidth overrides it once
// loadSettings() runs and persists any drag adjustment made by the user.
@ -3261,7 +3270,7 @@ function layout() {
}
// Quick-link panel: a narrow mini-view just right of the strip, showing the
// active quick-link's web app. Only takes space when something is open.
const quickW = (quickPanel && activeQuickLinkId && !fsTab) ? QUICK_PANEL_W : 0;
const quickW = (quickPanel && activeQuickLinkId && !fsTab) ? leftPanelWidth(QUICK_PANEL_W, width - leftW) : 0;
if (quickPanel) {
quickPanel.setBounds({ x: leftW, y: chromeH, width: quickW, height: bodyH });
try { quickPanel.setVisible(quickW > 0); } catch {}
@ -3272,13 +3281,21 @@ function layout() {
// An add-on's left panel. Widened, it covers the tab area like a page of
// its own, up to the right sidebar, which keeps its width: the tabs keep
// their narrow-panel bounds underneath instead of being squeezed aside.
const addonLeftW = (leftPanel && leftPanelId && !fsTab) ? LEFT_PANEL_W : 0;
const addonLeftShownW = addonLeftW && leftPanelMax ? Math.max(LEFT_PANEL_W, width - leftW - sideW) : addonLeftW;
const addonLeftW = (leftPanel && leftPanelId && !fsTab) ? leftPanelWidth(LEFT_PANEL_W, width - leftW) : 0;
const addonLeftShownW = addonLeftW && leftPanelMax ? Math.max(addonLeftW, width - leftW - sideW) : addonLeftW;
if (leftPanel) {
leftPanel.setBounds({ x: leftW, y: chromeH, width: addonLeftShownW, height: bodyH });
try { leftPanel.setVisible(addonLeftShownW > 0); } catch {}
}
const panelW = quickW || addonLeftW;
if (panelGrip) {
// At rest a thin strip on the boundary; while dragging, the whole body,
// so the drag keeps its events wherever the pointer goes.
const showGrip = panelW > 0 && !(leftPanelId && leftPanelMax);
if (panelGripDragging) panelGrip.setBounds({ x: 0, y: chromeH, width, height: bodyH });
else panelGrip.setBounds({ x: leftW + panelW - PANEL_GRIP_W / 2, y: chromeH, width: PANEL_GRIP_W, height: bodyH });
try { panelGrip.setVisible(showGrip || panelGripDragging); } catch {}
}
const tabX = leftW + panelW;
const tabW = Math.max(0, width - tabX - sideW);
for (const t of tabs) t.view.setBounds({ x: tabX, y: chromeH, width: tabW, height: bodyH });
@ -3515,6 +3532,7 @@ function setSidebar(show, panelId) {
sidebarActivePanelId = panel.panelId;
try { sidebar.webContents.loadFile(panel.pageFile); } catch (e) { console.warn("sidebar loadFile failed:", e?.message); }
}
hideLeftPanels();
sidebarVisible = true;
sidebar.setVisible(true);
try { win.contentView.removeChildView(sidebar); win.contentView.addChildView(sidebar); } catch {}
@ -4725,6 +4743,17 @@ function createWindow() {
win.contentView.addChildView(leftPanel);
styleScrollbars(leftPanel.webContents);
leftPanel.setVisible(false);
// The add-on panel page gets the RIGHT sidebar's grip from sidebar-preload,
// on its left edge, where it would resize the wrong sidebar. Hide it; the
// left panel is resized from panelGrip on its right edge.
leftPanel.webContents.on("dom-ready", () => {
leftPanel?.webContents.insertCSS('div[aria-label="Resize sidebar"]{display:none!important}').catch(() => {});
});
panelGrip = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "panel-grip-preload.js") } });
try { panelGrip.setBackgroundColor("#00000000"); } catch {}
win.contentView.addChildView(panelGrip);
panelGrip.webContents.loadFile("panel-grip.html");
panelGrip.setVisible(false);
// Add-on approval overlay (approval-modal capability). Transparent view
// over the tab area, loaded once, shown per request.
approvalPop = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "approval-preload.js") } });
@ -4781,7 +4810,7 @@ function createWindow() {
// pumpApproval (and every later dapp request) until a full restart.
if (approvalCurrent) { const c = approvalCurrent; approvalCurrent = null; try { c.resolve("cancel"); } catch {} }
for (const q of approvalQueue.splice(0)) { try { q.resolve("cancel"); } catch {} }
addressPicker = null; pwFillPop = null; linkStatus = null; sidebar = null; leftPanel = null; approvalPop = null; unlockPop = null; jsDialogPop = null;
addressPicker = null; pwFillPop = null; linkStatus = null; sidebar = null; leftPanel = null; panelGrip = null; panelGripDragging = false; approvalPop = null; unlockPop = null; jsDialogPop = null;
linkStatusVisible = false;
});
layout();
@ -4984,7 +5013,7 @@ ipcMain.handle("navigate", (e, input) => {
return navigateTab(t ? t.id : activeId, input);
});
ipcMain.handle("search", (_e, q) => { if (leftPanelMax) setLeftPanelMax(false); return navigateTab(activeId, SEARCH(q)); });
ipcMain.handle("new-tab", () => createTab());
ipcMain.handle("new-tab", () => { hideLeftPanels(); return createTab(); });
ipcMain.handle("close-tab", (_e, id) => closeTab(id));
ipcMain.handle("switch-tab", (_e, id) => setActive(id));
// Tab context menu backing IPCs. All scoped to a specific tab id so the
@ -5519,6 +5548,7 @@ ipcMain.handle("sidebar-toggle", () => { toggleSidebar(); return sidebarVisible;
// grip. Delta is px per mousemove; we clamp, layout, and debounce the save.
let _sidebarSaveTimer = null;
ipcMain.handle("sidebar-drag", (_e, deltaPx) => {
if (isLeftSender(_e)) return sidebarW; // left panel has its own grip
const d = Number(deltaPx) || 0;
// Drag-to-resize exits maximize mode — the user is asking for a specific
// width. We snap out of maximize first so the delta lands on the pre-max
@ -7927,6 +7957,71 @@ function quickLinksPayload() {
function emitQuickLinksState() {
try { quicklinks?.webContents.send("quicklinks-state", quickLinksPayload()); } catch {}
}
function leftPanelKey() {
if (activeQuickLinkId) return "q:" + activeQuickLinkId;
if (leftPanelId) return "p:" + leftPanelId;
return null;
}
// The open app's width, clamped so the page keeps at least TAB_AREA_MIN.
function leftPanelWidth(def, room) {
const k = leftPanelKey();
const saved = k && settings.leftPanelWidths && Number(settings.leftPanelWidths[k]);
const max = Math.max(LEFT_PANEL_W_MIN, (room || 0) - TAB_AREA_MIN);
return Math.max(LEFT_PANEL_W_MIN, Math.min(max, saved || def));
}
// The grip has to stay above whichever panel view was raised last.
function raisePanelGrip() {
if (!panelGrip) return;
try { win.contentView.removeChildView(panelGrip); win.contentView.addChildView(panelGrip); } catch {}
}
// Opera-style: the left panel steps aside when the user opens something else
// (a new tab, the right sidebar). It is only hidden — the app keeps its
// state, so reopening it is instant and lands where the user left off.
function hideLeftPanels() {
if (activeQuickLinkId) closeQuickPanel();
if (leftPanelId) closeLeftPanel();
}
const isGripSender = (e) => !!panelGrip && e.sender === panelGrip.webContents;
let _leftPanelSaveTimer = null;
ipcMain.handle("panel-grip-start", (e) => {
if (!isGripSender(e) || !leftPanelKey()) return false;
panelGripDragging = true;
layout();
return true;
});
// windowX: the pointer's x in the window. While dragging, the grip view
// covers the window from x = 0, so its clientX is exactly that.
ipcMain.handle("panel-grip-move", (e, windowX) => {
if (!isGripSender(e) || !panelGripDragging || !winAlive()) return;
const k = leftPanelKey();
if (!k) return;
const stripW = (quicklinks && settings.quickLinksShow) ? QUICKLINKS_W : 0;
const want = Math.round(Number(windowX) - stripW);
if (!Number.isFinite(want)) return;
settings.leftPanelWidths = { ...(settings.leftPanelWidths || {}), [k]: Math.max(LEFT_PANEL_W_MIN, want) };
layout();
});
ipcMain.handle("panel-grip-end", (e) => {
if (!isGripSender(e)) return;
panelGripDragging = false;
// Store what layout actually allowed, not the raw pointer position.
const k = leftPanelKey();
if (k && settings.leftPanelWidths && settings.leftPanelWidths[k]) {
const room = win.getContentBounds().width - ((quicklinks && settings.quickLinksShow) ? QUICKLINKS_W : 0);
settings.leftPanelWidths[k] = leftPanelWidth(activeQuickLinkId ? QUICK_PANEL_W : LEFT_PANEL_W, room);
}
layout();
clearTimeout(_leftPanelSaveTimer);
_leftPanelSaveTimer = setTimeout(saveSettings, 300);
});
ipcMain.handle("panel-grip-reset", (e) => {
if (!isGripSender(e)) return;
const k = leftPanelKey();
if (!k || !settings.leftPanelWidths) return;
delete settings.leftPanelWidths[k];
layout();
saveSettings();
});
function openLeftPanel(panelId) {
const p = leftPanels().find((x) => x.panelId === panelId);
if (!leftPanel || !p) return false;
@ -7938,6 +8033,7 @@ function openLeftPanel(panelId) {
try { leftPanel.webContents.loadFile(p.pageFile); } catch (e) { console.warn("left panel loadFile failed:", e?.message); }
}
try { win.contentView.removeChildView(leftPanel); win.contentView.addChildView(leftPanel); } catch {}
raisePanelGrip();
layout();
emitQuickLinksState();
try { leftPanel.webContents.send("sidebar-visibility", true); } catch {}
@ -7963,6 +8059,7 @@ function openQuickPanel(id) {
const sameHost = (() => { try { return cur && new URL(cur).host === new URL(L.url).host; } catch { return false; } })();
if (!sameHost) quickPanel.webContents.loadURL(L.url);
} catch (e) { console.warn("[quicklinks] loadURL failed:", e?.message); }
raisePanelGrip();
layout();
emitQuickLinksState();
}

View file

@ -101,6 +101,8 @@
"js-dialog-preload.js",
"quicklinks.html",
"quicklinks-preload.js",
"panel-grip-preload.js",
"panel-grip.html",
"engine-icons/**/*",
"lib/**/*",
"package.json",

12
panel-grip-preload.js Normal file
View file

@ -0,0 +1,12 @@
// Resize grip for the left panel (quick-link web apps and add-on panels).
// The panel itself may be a third-party page (WhatsApp, Telegram…), so the
// grip is a separate view of Theseus's own laid over the panel's right edge.
// While dragging, main stretches this view over the whole window so every
// mousemove and the final mouseup land here, wherever the pointer goes.
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("grip", {
start: () => ipcRenderer.invoke("panel-grip-start"),
move: (windowX) => ipcRenderer.invoke("panel-grip-move", windowX),
end: () => ipcRenderer.invoke("panel-grip-end"),
reset: () => ipcRenderer.invoke("panel-grip-reset"),
});

37
panel-grip.html Normal file
View file

@ -0,0 +1,37 @@
<!doctype html>
<html><head><meta charset="utf-8">
<style>
/* Transparent everywhere except the thin line at the panel edge. At rest
the view is a few pixels wide and sits on the boundary; while dragging
main widens it to the whole window and the line follows the pointer. */
html, body { margin: 0; height: 100%; background: transparent; overflow: hidden; cursor: col-resize; user-select: none; }
#line { position: fixed; top: 0; bottom: 0; width: 2px; left: 50%; transform: translateX(-1px);
background: rgba(140,150,170,.45); transition: background .12s; }
body:hover #line { background: rgba(214,255,61,.45); }
body.drag #line { display: none; }
</style></head>
<body title="Drag to resize · double-click to reset">
<div id="line"></div>
<script>
let dragging = false;
document.addEventListener("mousedown", (e) => {
if (e.button !== 0) return;
e.preventDefault();
dragging = true;
document.body.classList.add("drag");
window.grip.start();
});
// While dragging, this view covers the window from its left edge, so clientX
// is the pointer's x in the window.
document.addEventListener("mousemove", (e) => { if (dragging) window.grip.move(e.clientX); });
const stop = () => {
if (!dragging) return;
dragging = false;
document.body.classList.remove("drag");
window.grip.end();
};
document.addEventListener("mouseup", stop);
window.addEventListener("blur", stop);
document.addEventListener("dblclick", () => window.grip.reset());
</script>
</body></html>