Theseus: link-hover status bar (bottom-left href pill)

Firefox / Chrome-style small pill at the bottom-left of the window
showing the href when the pointer hovers a link, hidden when the
pointer leaves. Fed by webContents.update-target-url on every tab;
auto-sizes to its text via an IPC resize channel.

Cleared on tab switch so a lingering hover pill from tab A doesn't
carry across to tab B. Positioned by layout() so it tracks window
resizes.

New files link-status.html + link-status-preload.js registered in
build.files (GOTCHAS.md rule: missing entries silently omit from
the packaged app).

Not shipped yet — bundles into the next Theseus release.
This commit is contained in:
Local Dev 2026-08-29 15:49:30 +02:00
parent cc563b0037
commit 78d20d6197
4 changed files with 71 additions and 0 deletions

5
link-status-preload.js Normal file
View file

@ -0,0 +1,5 @@
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("linkstatus", {
onUrl: (cb) => ipcRenderer.on("link-status-url", (_e, url) => cb(url)),
resize: (w, h) => ipcRenderer.invoke("link-status-resize", w, h),
});

22
link-status.html Normal file
View file

@ -0,0 +1,22 @@
<!doctype html>
<html><head><meta charset="utf-8">
<style>
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
html, body { margin: 0; background: transparent; overflow: hidden; }
.pill { background: #1c222c; border: 1px solid #ffffff26; border-top: none; border-left: none;
border-radius: 0 8px 0 0; box-shadow: 0 -2px 12px #0006;
color: #b9c2d0; font-size: 11.5px; padding: 4px 10px 4px 8px;
display: inline-block; max-width: 100%; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; direction: ltr; }
@media (prefers-color-scheme: light) {
.pill { background: #ffffff; color: #3c4453; border-color: rgba(0,0,0,.15); }
}
</style></head>
<body><span class="pill" id="p"></span>
<script>
const p = document.getElementById("p");
window.linkstatus.onUrl((url) => {
p.textContent = String(url || "");
requestAnimationFrame(() => { try { window.linkstatus.resize(p.offsetWidth + 2, p.offsetHeight + 2); } catch (e) {} });
});
</script></body></html>

42
main.js
View file

@ -916,6 +916,12 @@ let apW = 520; let apH = 60;
// site has matching credentials. // site has matching credentials.
let pwFillPop, pwfVisible = false, pwfPos = { x: 8, y: 90 }; let pwFillPop, pwfVisible = false, pwfPos = { x: 8, y: 90 };
const PWF_W = 280; let pwfH = 80; const PWF_W = 280; let pwfH = 80;
// Link-hover status bar — small pill at the bottom-left of the window
// showing the href when the mouse hovers a link (Chrome / Firefox style).
// Hidden when hover leaves. Fed by webContents.update-target-url on every
// tab; the pill auto-sizes to its text.
let linkStatus, linkStatusVisible = false;
let linkStatusW = 100, linkStatusH = 22;
// In-memory download list. Not persisted: closing the browser clears history // In-memory download list. Not persisted: closing the browser clears history
// (the files are still on disk; only the list of "recent downloads" is dropped). // (the files are still on disk; only the list of "recent downloads" is dropped).
const downloads = []; let nextDlId = 1; const dlItems = new Map(); // id -> DownloadItem const downloads = []; let nextDlId = 1; const dlItems = new Map(); // id -> DownloadItem
@ -951,6 +957,7 @@ function layout() {
positionDownloads(); positionDownloads();
positionAddressPicker(); positionAddressPicker();
positionPwFill(); positionPwFill();
if (linkStatusVisible) positionLinkStatus();
} }
function positionPopover() { function positionPopover() {
if (!popover) return; if (!popover) return;
@ -1013,6 +1020,26 @@ function positionPwFill() {
const x = Math.max(6, Math.min(pwfPos.x, width - PWF_W - 6)); const x = Math.max(6, Math.min(pwfPos.x, width - PWF_W - 6));
pwFillPop.setBounds({ x, y: pwfPos.y, width: PWF_W, height: pwfH }); pwFillPop.setBounds({ x, y: pwfPos.y, width: PWF_W, height: pwfH });
} }
function positionLinkStatus() {
if (!linkStatus || !win) return;
const { width, height } = win.getContentBounds();
const w = Math.min(Math.max(120, linkStatusW), Math.max(200, width - 20));
const h = Math.max(20, linkStatusH);
linkStatus.setBounds({ x: 0, y: Math.max(0, height - h), width: w, height: h });
}
function showLinkStatus(url) {
if (!linkStatus) return;
const s = String(url || "");
if (!s) {
if (linkStatusVisible) { linkStatus.setVisible(false); linkStatusVisible = false; }
return;
}
positionLinkStatus();
// Raise the pill above any tab view that was added after it.
try { win.contentView.removeChildView(linkStatus); win.contentView.addChildView(linkStatus); } catch {}
linkStatus.setVisible(true); linkStatusVisible = true;
try { linkStatus.webContents.send("link-status-url", s); } catch {}
}
function showPwFill(show, matches) { function showPwFill(show, matches) {
if (!pwFillPop) return; if (!pwFillPop) return;
if (show) { if (show) {
@ -1134,6 +1161,7 @@ function setActive(id) {
activeId = id; activeId = id;
if (popVisible) showPopover(false); // don't carry a stale popover across tabs if (popVisible) showPopover(false); // don't carry a stale popover across tabs
if (epVisible) showEnginePicker(false); if (epVisible) showEnginePicker(false);
if (linkStatusVisible) showLinkStatus(""); // clear any lingering hover pill
for (const t of tabs) t.view.setVisible(t.id === id); for (const t of tabs) t.view.setVisible(t.id === id);
const t = activeTab(); const t = activeTab();
if (t?.prov) pushNav(t.prov); if (t?.prov) pushNav(t.prov);
@ -1197,6 +1225,9 @@ function createTab(initial, opts = {}) {
wc.on("did-navigate-in-page", () => { refreshTabUrl(tab); emitTabs(); historyAdd(tab.url, tab.title); }); wc.on("did-navigate-in-page", () => { refreshTabUrl(tab); emitTabs(); historyAdd(tab.url, tab.title); });
wc.on("did-start-loading", () => setLoading(tab, true)); wc.on("did-start-loading", () => setLoading(tab, true));
wc.on("did-stop-loading", () => setLoading(tab, false)); wc.on("did-stop-loading", () => setLoading(tab, false));
// Firefox / Chrome-style bottom-left link preview: fires with the href
// when the pointer enters/leaves an anchor. Empty string = no hover.
wc.on("update-target-url", (_e, url) => { if (tab.id === activeId) showLinkStatus(url); });
wc.on("will-navigate", (e, u) => { wc.on("will-navigate", (e, u) => {
try { try {
// Skip our own programmatic loads. fallbackToWeb calls loadURL("https://<host>/") // Skip our own programmatic loads. fallbackToWeb calls loadURL("https://<host>/")
@ -1328,6 +1359,12 @@ function createWindow() {
win.contentView.addChildView(pwFillPop); win.contentView.addChildView(pwFillPop);
pwFillPop.webContents.loadFile("pw-fill.html"); pwFillPop.webContents.loadFile("pw-fill.html");
pwFillPop.setVisible(false); pwFillPop.setVisible(false);
// Link-hover status pill (bottom-left of window).
linkStatus = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "link-status-preload.js") } });
try { linkStatus.setBackgroundColor("#00000000"); } catch {}
win.contentView.addChildView(linkStatus);
linkStatus.webContents.loadFile("link-status.html");
linkStatus.setVisible(false);
chrome.webContents.once("did-finish-load", () => { chrome.webContents.once("did-finish-load", () => {
const saved = settings.restoreSession ? loadSession() : []; const saved = settings.restoreSession ? loadSession() : [];
if (saved.length) saved.forEach((u) => createTab(u)); else createTab(); if (saved.length) saved.forEach((u) => createTab(u)); else createTab();
@ -1801,6 +1838,11 @@ ipcMain.handle("toggle-pw-fill", async (_e, rect) => {
showPwFill(true, matches); showPwFill(true, matches);
}); });
ipcMain.handle("close-pw-fill", () => showPwFill(false)); ipcMain.handle("close-pw-fill", () => showPwFill(false));
ipcMain.handle("link-status-resize", (_e, w, h) => {
linkStatusW = Math.max(60, Math.min(2000, Math.round(w) || 100));
linkStatusH = Math.max(20, Math.min(60, Math.round(h) || 22));
if (linkStatusVisible) positionLinkStatus();
});
ipcMain.handle("pw-fill-resize", (_e, h) => { ipcMain.handle("pw-fill-resize", (_e, h) => {
pwfH = Math.max(60, Math.min(300, Math.round(h) || 80)); pwfH = Math.max(60, Math.min(300, Math.round(h) || 80));
if (pwfVisible) positionPwFill(); if (pwfVisible) positionPwFill();

View file

@ -44,6 +44,8 @@
"pw-fill.html", "pw-fill.html",
"pw-fill-preload.js", "pw-fill-preload.js",
"home-preload.js", "home-preload.js",
"link-status.html",
"link-status-preload.js",
"collision.html", "collision.html",
"collision-preload.js", "collision-preload.js",
"messages.html", "messages.html",