feat(theseus): tabs in the title bar, link-opened tabs next to their opener, clearer active tab

Three tab-strip changes from use:

- A tab opened from a link (target=_blank, middle-click, the context menu,
  Duplicate) now goes right after the tab it came from — and after any
  siblings that tab already opened — instead of at the end of the strip.
  The + button, session restore and add-on requests still append.
- The selected tab gets an accent stripe and outline on top of its brighter
  fill; with a dozen same-size tabs the fill alone was easy to lose. A
  grouped tab keeps its group colour on the stripe.
- On Windows the tab row is the title bar: the native frame is hidden, the
  minimise/maximise/close buttons are drawn as an overlay over the chrome
  (colours follow the theme), the row is a drag region with every control
  in it opted out, and 140px (or the overlay's real width when the API is
  exposed) is kept clear on the right. The page gains the old title bar's
  height. Other platforms keep the native frame.
This commit is contained in:
Local Dev 2026-09-20 02:19:19 +02:00
parent cd098bc9f3
commit dbc18da36e
2 changed files with 65 additions and 11 deletions

View file

@ -36,7 +36,14 @@
content means scrollHeight is the natural chrome height, and syncHeight
shrinks correctly after every menu-close. */
body { margin: 0; background: var(--bg); color: var(--ink); user-select: none; overflow: hidden; }
.tabs { display: flex; align-items: flex-end; gap: 4px; padding: 6px 8px 0; height: 34px; overflow: hidden; }
/* The tab row doubles as the window's title bar on Windows (main.js hides
the native frame): the row is a drag region, everything clickable in it
opts out, and --wco-reserve keeps tabs clear of the native minimise /
maximise / close overlay (set from navigator.windowControlsOverlay, or a
fixed 140px when that API isn't exposed). */
.tabs { display: flex; align-items: flex-end; gap: 4px; padding: 6px 8px 0; padding-right: calc(8px + var(--wco-reserve, 0px));
height: 34px; overflow: hidden; -webkit-app-region: drag; }
.tabs > *, .grouppop, .ctxmenu { -webkit-app-region: no-drag; }
/* Same-size tabs: each tab claims an equal share of the row, capped at
200px so 2 tabs don't stretch across the whole window. min-width lets
many tabs shrink cleanly while still showing a couple letters. */
@ -46,7 +53,12 @@
.tab.dragging { opacity: .45; cursor: grabbing; }
.tab.dropbefore { box-shadow: -2px 0 0 var(--acid); }
.tab.dropafter { box-shadow: 2px 0 0 var(--acid); }
.tab.active { background: var(--active); color: var(--ink); }
/* The selected tab: brighter fill plus an accent stripe and outline, so it
reads at a glance among a dozen same-size tabs. A grouped tab keeps its
group colour on the stripe and still gets the accent outline. */
.tab.active { background: var(--active); color: var(--ink); border-color: rgb(from var(--acid) r g b / .7);
box-shadow: inset 0 2px 0 var(--acid); }
.tab.active.grp { box-shadow: inset 0 2px 0 var(--gc, var(--acid)); }
.tab .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
.tab .fav { width: 14px; height: 14px; flex: none; border-radius: 2px; object-fit: contain; }
.tab .spin { width: 10px; height: 10px; flex: none; border: 1.5px solid #ffffff2e; border-top-color: var(--acid); border-radius: 50%; animation: spin .7s linear infinite; }
@ -467,6 +479,24 @@
const $ = (id) => document.getElementById(id);
const T = window.theseus;
function syncHeight() { requestAnimationFrame(() => T.setChromeHeight(document.body.scrollHeight)); }
// Reserve room for the native window-controls overlay at the right of the
// tab row (Windows only — the frame is hidden there, see main.js). Use the
// overlay's real geometry when Chromium exposes it to this view; otherwise
// the standard three 46px buttons.
(function reserveWindowControls() {
if (!/Windows/i.test(navigator.userAgent)) return;
const apply = () => {
const wco = navigator.windowControlsOverlay;
let reserve = 140;
try {
if (wco && wco.visible) { const r = wco.getTitlebarAreaRect(); reserve = Math.max(0, Math.round(window.innerWidth - (r.x + r.width))); }
} catch {}
document.documentElement.style.setProperty("--wco-reserve", reserve + "px");
};
apply();
try { navigator.windowControlsOverlay?.addEventListener("geometrychange", apply); } catch {}
window.addEventListener("resize", apply);
})();
const RELOAD_SVG = `<svg viewBox="0 0 16 16"><path d="M13 8 a5 5 0 1 1 -1.5 -3.6"/><path d="M13 2.5 L13 5 L10.5 5"/></svg>`;
const STOP_SVG = `<svg viewBox="0 0 16 16"><path d="M4.5 4.5 L11.5 11.5 M11.5 4.5 L4.5 11.5"/></svg>`;

42
main.js
View file

@ -2800,8 +2800,18 @@ function createTab(initial, opts = {}) {
try { wc.setWebRTCIPHandlingPolicy(webrtcPolicy()); } catch {}
try { wc.setBackgroundThrottling(settings.backgroundThrottle); } catch {}
applyFingerprint(wc);
const tab = { id, view, title: opts.settings ? "Settings" : "New Tab", url: "", favicon: null, prov: null, settings: !!opts.settings, muted: false, group: null };
tabs.push(tab);
const tab = { id, view, title: opts.settings ? "Settings" : "New Tab", url: "", favicon: null, prov: null, settings: !!opts.settings, muted: false, group: null, openerId: opts.after ?? null };
// A tab opened from a link goes right after the tab it came from — and
// after any siblings that tab already opened, so a run of links from one
// page reads left-to-right — instead of at the far end of the strip.
// Everything else (+ button, restore, add-on requests) appends as before.
const openerIdx = opts.after != null ? tabs.findIndex((t) => t.id === opts.after) : -1;
if (openerIdx < 0) tabs.push(tab);
else {
let at = openerIdx + 1;
while (at < tabs.length && tabs[at].openerId === opts.after) at++;
tabs.splice(at, 0, tab);
}
win.contentView.addChildView(view);
wc.on("page-title-updated", (_e, title) => {
tab.title = title; emitTabs();
@ -2910,7 +2920,7 @@ function createTab(initial, opts = {}) {
});
// Links that open a new tab: target="_blank", window.open, Ctrl/middle-click.
wc.setWindowOpenHandler(({ url, disposition }) => {
if (url && url !== "about:blank") createTab(url, { background: disposition === "background-tab" });
if (url && url !== "about:blank") createTab(url, { background: disposition === "background-tab", after: tab.id });
return { action: "deny" };
});
// Right-click context menu.
@ -2918,8 +2928,8 @@ function createTab(initial, opts = {}) {
const items = [];
if (p.linkURL) {
items.push(
{ label: "Open link in new tab", click: () => createTab(p.linkURL) },
{ label: "Open link in new background tab", click: () => createTab(p.linkURL, { background: true }) },
{ label: "Open link in new tab", click: () => createTab(p.linkURL, { after: tab.id }) },
{ label: "Open link in new background tab", click: () => createTab(p.linkURL, { background: true, after: tab.id }) },
{ label: "Open link in new window", click: () => openLinkWindow(p.linkURL) },
{ label: "Copy link address", click: () => clipboard.writeText(p.linkURL) },
{ type: "separator" },
@ -2930,7 +2940,7 @@ function createTab(initial, opts = {}) {
// preset savePath, so Electron shows the native Save As dialog.
if (p.mediaType === "image" && p.srcURL) {
items.push(
{ label: "Open image in new tab", click: () => createTab(p.srcURL) },
{ label: "Open image in new tab", click: () => createTab(p.srcURL, { after: tab.id }) },
{ label: "Save image as…", click: () => wc.downloadURL(p.srcURL) },
{ label: "Copy image", click: () => { try { wc.copyImageAt(p.x, p.y); } catch {} } },
{ label: "Copy image address", click: () => clipboard.writeText(p.srcURL) },
@ -2947,7 +2957,7 @@ function createTab(initial, opts = {}) {
if (raw) {
const excerpt = raw.length > 40 ? raw.slice(0, 40) + "…" : raw;
items.push(
{ label: `Search for "${excerpt.replace(/&/g, "&&")}"`, click: () => createTab(SEARCH(raw)) },
{ label: `Search for "${excerpt.replace(/&/g, "&&")}"`, click: () => createTab(SEARCH(raw), { after: tab.id }) },
{ type: "separator" },
);
}
@ -3000,14 +3010,28 @@ function createWindow() {
// until chrome.html painted, which is the "white strip over a dark
// window" users saw on a slow launch.
const uiBg = nativeTheme.shouldUseDarkColors ? "#0f1420" : "#e6e8ec";
// On Windows the tab strip lives in the title bar: the frame is hidden and
// the native minimise/maximise/close buttons are drawn as an overlay over
// our chrome, whose tab row is a drag region (chrome.html). That returns
// the OS title bar's height to the page. Kept native elsewhere.
const titleBarOverlay = { color: uiBg, symbolColor: nativeTheme.shouldUseDarkColors ? "#e7eaf1" : "#1a1f28", height: 40 };
const frameOpts = process.platform === "win32" ? { titleBarStyle: "hidden", titleBarOverlay } : {};
win = new BrowserWindow({
width: 1220, height: 840, title: "Theseus Navigator", backgroundColor: uiBg,
width: 1220, height: 840, title: "Theseus Navigator", backgroundColor: uiBg, ...frameOpts,
// Taskbar / titlebar icon. Packaged builds ship build/icon.ico as
// extraResource; dev reads the source file directly.
icon: app.isPackaged
? path.join(process.resourcesPath, "icon.ico")
: path.join(__dirname, "build", "icon.ico"),
});
// Keep the overlay buttons on the theme when it flips at runtime.
if (process.platform === "win32") {
nativeTheme.on("updated", () => {
if (!win || win.isDestroyed()) return;
const dark = nativeTheme.shouldUseDarkColors;
try { win.setTitleBarOverlay({ color: dark ? "#0f1420" : "#e6e8ec", symbolColor: dark ? "#e7eaf1" : "#1a1f28", height: 40 }); } catch {}
});
}
chrome = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "preload.js") } });
try { chrome.setBackgroundColor(uiBg); } catch {}
win.contentView.addChildView(chrome);
@ -3332,7 +3356,7 @@ ipcMain.handle("tab-context-menu-popup", (e, tabId, rect) => {
const bmDisabled = !t.url;
const template = [
{ label: "Reload", click: () => { try { t.view.webContents.reload(); } catch {} } },
{ label: "Duplicate", click: () => { if (t.url) createTab(t.url); else createTab(); } },
{ label: "Duplicate", click: () => { if (t.url) createTab(t.url, { after: t.id }); else createTab(null, { after: t.id }); } },
{ label: "Group", submenu: groupSubmenu },
{ label: "Add to Bookmarks", enabled: !bmDisabled, click: () => {
if (bmDisabled) return;