From 29b346250f65c0062da892bba9f039b7c21d8339 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 4 Oct 2026 20:53:16 +0200 Subject: [PATCH] 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. --- main.js | 107 ++++++++++++++++++++++++++++++++++++++++-- package.json | 2 + panel-grip-preload.js | 12 +++++ panel-grip.html | 37 +++++++++++++++ 4 files changed, 153 insertions(+), 5 deletions(-) create mode 100644 panel-grip-preload.js create mode 100644 panel-grip.html diff --git a/main.js b/main.js index 65984578..8e702709 100644 --- a/main.js +++ b/main.js @@ -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:" / "p:", 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(); } diff --git a/package.json b/package.json index 1eee05ab..ef8fcafd 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/panel-grip-preload.js b/panel-grip-preload.js new file mode 100644 index 00000000..cbc37a46 --- /dev/null +++ b/panel-grip-preload.js @@ -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"), +}); diff --git a/panel-grip.html b/panel-grip.html new file mode 100644 index 00000000..36b1aa35 --- /dev/null +++ b/panel-grip.html @@ -0,0 +1,37 @@ + + + + +
+ +