Theseus: language picker is a floating overlay now, not a native menu

The globe-chip menu was a native Electron menu — square corners, system
font, no theming beyond the OS's own context-menu paint. Replaces it with
a floating overlay WebContentsView (lang-picker.html + preload),
following the same pattern the engine picker and the popover already
use: rounded 12px surface, acid-tint accents on the current pin, soft
shadow, dark + light scheme, flush under the chip's bottom-right.

The content is organised around the user's intent — translate first,
pick a language second. The "Translate this page" row sits at the top
when it is actionable (web tab + supported source + supported target),
with the detected source and the target under the label so the user can
tell what the backend will do before they click. Once a page is
translated, that row flips to "Show original (<source>)". Below the
action strip is Automatic + the six supported languages, each with a
two-letter code chip in the left slot and a ✓ on the current pin.
Unsupported languages are hidden by default inside a collapsible "More
languages (translator coming later)" group — click to expand, click to
collapse; a pinned-unsupported auto-expands so its ✓ stays visible.

Plumbing matches engine-picker: deferred-load WebContentsView,
closeOnClickAway, setBounds anchored under the chip, picker renderer
reports its own content height after each render so the overlay
contracts and expands with the "More languages" toggle. State pushes
come from emitTranslateState (so the Translate / Show-original row
updates when a page finishes auto-translating with the picker open)
and from broadcastSettings (so a Settings-side language change
re-paints the ✓).

Verified end-to-end: picker loads, shows Automatic + 6 supported in the
default list and 18 greyed in the "More" group, repaints to "⟲ Show
original (Spanish)" after an auto-translate completes.
This commit is contained in:
Silent Mode 2026-10-03 21:33:16 +02:00
parent 987aca57a8
commit fa50f97e6f
5 changed files with 276 additions and 58 deletions

View file

@ -757,7 +757,13 @@
}
langBtn.onclick = () => {
const r = langBtn.getBoundingClientRect();
T.websiteLanguageMenu && T.websiteLanguageMenu({ x: Math.round(r.left), y: Math.round(r.bottom + 4) });
// Pass the chip's full rect — main anchors the dropdown's TOP-RIGHT
// under the chip's bottom-right, so x+width is where we want the
// picker's right edge to land, and y+height sets its top.
T.websiteLanguageMenu && T.websiteLanguageMenu({
x: Math.round(r.left), y: Math.round(r.top),
width: Math.round(r.width), height: Math.round(r.height),
});
};
// Locale is optional (older main.js won't expose it) — render on settings
// either way so the chip works even without it.

10
lang-picker-preload.js Normal file
View file

@ -0,0 +1,10 @@
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("langpicker", {
onState: (cb) => ipcRenderer.on("lang-picker-state", (_e, d) => cb(d)),
pick: (tag) => ipcRenderer.invoke("lp-pick", String(tag || "")),
translate: () => ipcRenderer.invoke("lp-translate"),
revert: () => ipcRenderer.invoke("lp-revert"),
openSettings: () => ipcRenderer.invoke("lp-open-settings"),
close: () => ipcRenderer.invoke("lp-close"),
resize: (h, w) => ipcRenderer.invoke("lp-resize", h, w),
});

157
lang-picker.html Normal file
View file

@ -0,0 +1,157 @@
<!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; }
.menu { background: #1c222c; border: 1px solid #ffffff26; border-radius: 12px;
box-shadow: 0 16px 44px #000a, 0 2px 6px #0006; overflow: hidden;
color: #e7eaf1; min-width: 280px; }
.section { padding: 6px 0; }
.hdr { font-size: 10.5px; letter-spacing: .07em; text-transform: uppercase;
color: #8b98a9; padding: 10px 14px 6px; font-weight: 600; }
.item { display: flex; align-items: center; gap: 10px; padding: 9px 14px;
cursor: pointer; font-size: 13.5px; line-height: 1.3; user-select: none; }
.item:hover { background: #ffffff14; }
.item.active { background: rgba(214,255,61,.08); }
.item .ic { width: 20px; flex: none; text-align: center; }
.item .nm { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.item .sub { color: #8b98a9; font-size: 11.5px; margin-top: 1px; }
.item .wrap { flex: 1; min-width: 0; }
.item .chk { color: #d6ff3d; font-size: 15px; margin-left: auto; flex: none; }
.item.disabled { opacity: .45; cursor: default; }
.item.disabled:hover { background: transparent; }
.item.disabled .tag { font-size: 10.5px; color: #8b98a9; padding: 1px 6px;
border: 1px solid #ffffff22; border-radius: 10px;
margin-left: auto; flex: none; letter-spacing: .02em; }
.item.action { color: #d6ff3d; font-weight: 500; }
.item.action .ic { color: #d6ff3d; }
.sep { height: 1px; background: #ffffff16; margin: 2px 0; }
.more { padding: 7px 14px; font-size: 12px; color: #8b98a9; cursor: pointer;
display: flex; align-items: center; gap: 6px; user-select: none; }
.more:hover { color: #e7eaf1; }
.more .arrow { transition: transform .15s; display: inline-block; }
.more.open .arrow { transform: rotate(90deg); }
.more-list { max-height: 0; overflow: hidden; transition: max-height .18s ease; }
.more-list.open { max-height: 420px; overflow-y: auto; }
.foot { padding: 4px 0 6px; }
.foot .item { color: #b9c2d0; font-size: 12.5px; padding: 8px 14px; }
@media (prefers-color-scheme: light) {
.menu { background: #ffffff; border-color: rgba(0,0,0,.14); color: #1a1f28;
box-shadow: 0 14px 36px rgba(0,0,0,.18), 0 2px 6px rgba(0,0,0,.08); }
.hdr { color: #6b7584; } .item:hover { background: rgba(0,0,0,.055); }
.item.active { background: rgba(10,193,142,.09); }
.item .chk { color: #0ac18e; } .item.action { color: #0ac18e; }
.item.action .ic { color: #0ac18e; }
.item.disabled .tag { color: #6b7584; border-color: rgba(0,0,0,.14); }
.sep { background: rgba(0,0,0,.09); } .foot .item { color: #3c4453; }
.more { color: #6b7584; } .more:hover { color: #1a1f28; }
}
/* Thin scrollbar inside the expandable "More languages" area */
.more-list::-webkit-scrollbar { width: 6px; }
.more-list::-webkit-scrollbar-thumb { background: #ffffff22; border-radius: 3px; }
</style></head>
<body>
<div class="menu" id="menu">
<div id="actions"></div>
<div id="langs" class="section"></div>
<div class="sep"></div>
<div class="more" id="moreToggle" hidden>
<span class="arrow">▸</span><span>More languages (translator coming later)</span>
</div>
<div class="more-list" id="moreList"></div>
<div class="sep"></div>
<div class="foot">
<div class="item" id="openSettings">
<span class="ic">⚙</span><span class="nm">Language settings…</span>
</div>
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s || "").replace(/[&<>"']/g, (c) => ({ "&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;" })[c]);
function report() {
requestAnimationFrame(() => {
try { window.langpicker.resize($("menu").offsetHeight, $("menu").offsetWidth); } catch {}
});
}
// One payload builds the whole menu. State lives in main; the picker just
// paints what it is given — a pick action calls back into main and the
// picker closes.
window.langpicker.onState((d) => {
const curTag = (d.currentTag || "").toLowerCase();
const isAuto = !!d.auto;
// Translate / Show-original actions at the top when applicable.
const actionRows = [];
if (d.translated) {
actionRows.push(`<div class="item action" data-act="revert">
<span class="ic">⟲</span><span class="nm">Show original (${esc(d.sourceName || "")})</span>
</div>`);
} else if (d.canTranslate) {
actionRows.push(`<div class="item action" data-act="translate">
<span class="ic">⇄</span>
<div class="wrap"><div>Translate this page</div><div class="sub">${esc(d.pageLangName || "")} → ${esc(d.targetName || "")}</div></div>
</div>`);
}
if (actionRows.length) actionRows.push(`<div class="sep"></div>`);
$("actions").innerHTML = actionRows.join("");
// Supported languages + Automatic. Current pin gets a ✓ and an accent
// background. "Automatic" sits at the top with the OS language in its
// subtitle so the user sees what it will do today.
const langRows = [];
langRows.push(`<div class="item ${isAuto ? "active" : ""}" data-pick="__auto__">
<span class="ic">⌂</span>
<div class="wrap"><div>Automatic</div><div class="sub">follow your OS (${esc(d.osLangName || "")})</div></div>
${isAuto ? '<span class="chk">✓</span>' : ""}
</div>`);
for (const L of (d.supported || [])) {
const active = curTag === L.tag.toLowerCase();
langRows.push(`<div class="item ${active ? "active" : ""}" data-pick="${esc(L.tag)}">
<span class="ic">${esc(L.code || "")}</span><span class="nm">${esc(L.label)}</span>
${active ? '<span class="chk">✓</span>' : ""}
</div>`);
}
$("langs").innerHTML = langRows.join("");
// Unsupported languages collapsible. Current pin, if it is one of these,
// is shown as active even inside the collapsed group so the state is not
// hidden. Clicking an unsupported one still sets Accept-Language.
const unsup = (d.unsupported || []);
const moreRows = unsup.map((L) => {
const active = curTag === L.tag.toLowerCase();
return `<div class="item disabled" data-pick="${esc(L.tag)}" title="Translator doesn't support ${esc(L.label)} yet. Picking it sets Accept-Language only.">
<span class="ic">${esc(L.code || "")}</span><span class="nm">${esc(L.label)}</span>
${active ? '<span class="chk">✓</span>' : '<span class="tag">soon</span>'}
</div>`;
}).join("");
$("moreList").innerHTML = moreRows;
$("moreToggle").hidden = unsup.length === 0;
// Any pinned-unsupported state auto-expands the group so the ✓ is in view.
const pinnedUnsup = unsup.some((L) => curTag === L.tag.toLowerCase());
if (pinnedUnsup) {
$("moreToggle").classList.add("open");
$("moreList").classList.add("open");
}
// Wire clicks (fresh handlers after innerHTML).
document.querySelectorAll("[data-pick]").forEach((el) => el.onclick = () => {
if (el.classList.contains("disabled")) {
// Still allow setting Accept-Language for unsupported tags; the user
// is told "no translation" via the sub-label + tooltip.
}
window.langpicker.pick(el.dataset.pick);
});
document.querySelectorAll("[data-act]").forEach((el) => el.onclick = () => {
window.langpicker[el.dataset.act === "revert" ? "revert" : "translate"]();
});
report();
});
$("openSettings").onclick = () => window.langpicker.openSettings();
$("moreToggle").onclick = () => {
$("moreToggle").classList.toggle("open");
$("moreList").classList.toggle("open");
report();
};
</script>
</body></html>

157
main.js
View file

@ -2103,6 +2103,12 @@ const POP_W = 360; let popH = 210; // popH is updated to fit the popover's conte
// engine favicons, like Firefox — a native <select> can't render images).
let enginePicker, epVisible = false, epPos = { x: 8, y: 90 };
const EP_W = 250; let epH = 320;
// Language picker: a floating overlay VIEW used instead of a native menu
// so the dropdown matches the rest of the UI (dark surface, acid accents,
// rounded corners, a collapsible "more languages" group). Anchored under
// the globe chip in the toolbar.
let langPicker, lpVisible = false, lpPos = { x: 8, y: 90 };
let LP_W = 300; let lpH = 360;
// Downloads popover — a third floating overlay VIEW showing in-flight and
// recently-finished downloads. Anchored under the toolbar's download button.
let downloadsPop, dlVisible = false, dlPos = { x: 8, y: 90 };
@ -3030,6 +3036,52 @@ function showEnginePicker(show) {
enginePicker.webContents.send("engines", { engines: enabledEnginesList(), current: settings.searchEngine, detected: activeTab()?.detected || null });
} else { cancelOverlayShow(enginePicker); enginePicker.setVisible(false); epVisible = false; }
}
// Floating language picker — same shape as the engine picker above. The
// payload builder below reads the current language setting + the active
// tab's translate state so the dropdown has everything it needs in one
// shot; a re-render only needs a fresh payload, not a full reset.
function langPickerPayload() {
const osLoc = app.getLocale() || "en-US";
const isAuto = settings.languageMode === "show";
const current = isAuto ? "" : (settings.languageValue || "");
const t = activeTab();
const target = translationTargetBase();
const canTranslate = t && !t.settings && !t.addonId && !t.pending && t.pageLang &&
t.pageLang !== target &&
isTranslatorSupported(t.pageLang) && isTranslatorSupported(target);
const st = t ? tabTranslateState(t) : null;
const row = (L) => ({ tag: L.tag, label: L.label, code: String(L.tag).split("-")[0].toUpperCase() });
return {
osLangName: languageNameFor(osLoc),
auto: isAuto, currentTag: current,
supported: WEBSITE_LANGUAGE_QUICK.filter((L) => isTranslatorSupported(L.tag)).map(row),
unsupported: WEBSITE_LANGUAGE_QUICK.filter((L) => !isTranslatorSupported(L.tag)).map(row),
canTranslate: !!canTranslate,
translated: !!st?.translated,
sourceName: st?.source ? languageNameFor(st.source) : (t?.pageLang ? languageNameFor(t.pageLang) : ""),
pageLangName: t?.pageLang ? languageNameFor(t.pageLang) : "",
targetName: languageNameFor(target),
};
}
function positionLangPicker() {
if (!langPicker) return;
const { width } = win.getContentBounds();
const x = Math.max(6, Math.min(lpPos.x, width - LP_W - 6));
langPicker.setBounds({ x, y: lpPos.y, width: LP_W, height: lpH });
}
function showLangPicker(show) {
if (!langPicker) return;
if (show) {
if (deferUntilOverlayLoaded(langPicker, () => showLangPicker(true))) return;
positionLangPicker();
win.contentView.removeChildView(langPicker);
win.contentView.addChildView(langPicker);
langPicker.setVisible(true); lpVisible = true;
if (win.isFocused()) try { langPicker.webContents.focus(); } catch {}
langPicker.webContents.send("lang-picker-state", langPickerPayload());
} else { cancelOverlayShow(langPicker); langPicker.setVisible(false); lpVisible = false; }
}
function pushLangPickerState() { if (lpVisible) try { langPicker.webContents.send("lang-picker-state", langPickerPayload()); } catch {} }
function positionDownloads() {
if (!downloadsPop) return;
const { width } = win.getContentBounds();
@ -3450,6 +3502,7 @@ function setActive(id) {
if (switching && fsTabId != null && fsTabId !== id) leaveHtmlFullscreen(tabs.find((t) => t.id === fsTabId), true);
if (popVisible) showPopover(false); // don't carry a stale popover across tabs
if (epVisible) showEnginePicker(false);
if (lpVisible) showLangPicker(false);
if (linkStatusVisible) showLinkStatus(""); // clear any lingering hover pill
// A user action that switches to a different tab (New Tab, Settings,
// address-bar nav that opens elsewhere, tab-strip click) shouldn't leave
@ -4150,6 +4203,15 @@ function createWindow() {
styleScrollbars(enginePicker.webContents);
deferOverlayLoad(enginePicker, "engine-picker.html");
enginePicker.setVisible(false);
// Floating language picker overlay — the globe chip's dropdown. Shares
// the same floating-overlay plumbing (deferred load, click-away close,
// addChildView bring-to-front) as the engine picker.
langPicker = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "lang-picker-preload.js") } });
try { langPicker.setBackgroundColor("#00000000"); } catch {}
win.contentView.addChildView(langPicker);
styleScrollbars(langPicker.webContents);
deferOverlayLoad(langPicker, "lang-picker.html");
langPicker.setVisible(false);
// Floating downloads panel — shows active + recent downloads.
downloadsPop = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "downloads-preload.js") } });
try { downloadsPop.setBackgroundColor("#00000000"); } catch {}
@ -4160,6 +4222,7 @@ function createWindow() {
clickAwayPopups.length = 0;
closeOnClickAway(popover, () => popVisible, () => showPopover(false));
closeOnClickAway(enginePicker, () => epVisible, () => showEnginePicker(false));
closeOnClickAway(langPicker, () => lpVisible, () => showLangPicker(false));
closeOnClickAway(downloadsPop, () => dlVisible, () => showDownloads(false));
win.on("blur", closePopupsOnWindowBlur);
// Floating address-bar suggestions dropdown.
@ -4845,6 +4908,9 @@ function tabTranslateState(t) {
function emitTranslateState(t) {
if (!t || t.id !== activeId) return;
try { chrome?.webContents.send("page-translate-state", { ...tabTranslateState(t), pageLang: t.pageLang || "" }); } catch {}
// The language picker (if open) uses this same state to decide whether
// to show "Translate" / "Show original" at the top, so repaint it too.
pushLangPickerState();
}
async function translateActiveTab(target) {
const t = activeTab(); if (!t) return { ok: false, error: "no tab" };
@ -4915,66 +4981,40 @@ ipcMain.handle("page-translate-menu-popup", (e, rect) => {
return true;
});
ipcMain.handle("system-locale", () => app.getLocale() || "en-US");
// Globe-chip dropdown — a floating overlay, not the native Windows menu.
// The chrome passes the chip's viewport-rect; we anchor the picker's top-
// right corner under it so the dropdown sits flush with the toolbar. The
// chip toggles — a second click (or any click outside) closes it.
ipcMain.handle("website-language-menu-popup", (e, rect) => {
if (chrome && e.sender !== chrome.webContents) throw new Error("website-language-menu-popup: untrusted sender");
const osLoc = app.getLocale() || "en-US";
const isAuto = settings.languageMode === "show";
const current = isAuto ? null : (settings.languageValue || "").toLowerCase();
// Translate action goes at the top when the active page can actually be
// translated: a web tab, with the page's declared language different from
// the user's target, both languages on the translator backend. The two
// chips (globe + translate) merged into one — the globe menu covers both.
const t = activeTab();
const canTranslate = t && !t.settings && !t.addonId && !t.pending && t.pageLang &&
t.pageLang !== translationTargetBase() &&
isTranslatorSupported(t.pageLang) && isTranslatorSupported(translationTargetBase());
const st = t ? tabTranslateState(t) : null;
const translateItems = [];
if (st?.translated) {
translateItems.push(
{ label: `Show original (${languageNameFor(st.source || t.pageLang || "auto")})`, click: () => revertActiveTab() },
{ type: "separator" },
);
} else if (canTranslate) {
translateItems.push(
{ label: `Translate this page from ${languageNameFor(t.pageLang)} to ${languageNameFor(translationTargetBase())}`, click: () => translateActiveTab() },
{ type: "separator" },
);
}
// Top-level menu stays short: Automatic + the supported-by-the-translator
// languages only. Unsupported entries live inside a "More languages"
// submenu, greyed out — the top strip reads at a glance instead of being
// a 25-row wall. If the current pin is one of the unsupported ones, we
// still include it in the top strip so the ✓ is visible without opening
// the submenu.
const supported = WEBSITE_LANGUAGE_QUICK.filter((L) => isTranslatorSupported(L.tag));
const unsupported = WEBSITE_LANGUAGE_QUICK.filter((L) => !isTranslatorSupported(L.tag));
const pinnedUnsupported = unsupported.find((L) => current === L.tag.toLowerCase());
const topList = pinnedUnsupported ? [...supported, pinnedUnsupported] : supported;
const mkItem = (L, enabled) => {
const check = current === L.tag.toLowerCase() ? " ✓" : "";
return {
label: enabled ? `${L.label}${check}` : `${L.label}${check}`,
enabled,
click: () => setWebsiteLanguage("manual", L.tag),
};
};
const template = [
...translateItems,
{ label: `Automatic (${languageNameFor(osLoc)})${isAuto ? " ✓" : ""}`,
click: () => setWebsiteLanguage("show") },
{ type: "separator" },
...topList.map((L) => mkItem(L, isTranslatorSupported(L.tag))),
{ type: "separator" },
{ label: "More languages (translator coming later)",
submenu: unsupported.map((L) => mkItem(L, false)) },
{ label: "Language settings…", click: () => { try { openSettingsTab("language"); } catch {} } },
];
const popup = Menu.buildFromTemplate(template);
if (lpVisible) { showLangPicker(false); return true; }
// Anchor top-right of the picker under the chip. The chrome WebContentsView
// sits at y=0; add the chip's bottom-y and sub-pixel offset to clear it.
const chromeBounds = chrome ? chrome.getBounds() : { x: 0, y: 0 };
const x = Math.max(0, Math.round(chromeBounds.x + (rect?.x || 0)));
const y = Math.max(0, Math.round(chromeBounds.y + (rect?.y || 0)));
popup.popup({ window: win, x, y });
const anchorRight = Math.round(chromeBounds.x + (rect?.x || 0) + (rect?.width || 24));
lpPos = { x: Math.max(6, anchorRight - LP_W), y: Math.round(chromeBounds.y + (rect?.y || 0) + (rect?.height || 24) + 4) };
showLangPicker(true);
return true;
});
ipcMain.handle("lp-pick", (_e, tag) => {
if (tag === "__auto__") setWebsiteLanguage("show");
else if (tag) setWebsiteLanguage("manual", String(tag));
showLangPicker(false);
return true;
});
ipcMain.handle("lp-translate", () => { showLangPicker(false); return translateActiveTab(); });
ipcMain.handle("lp-revert", () => { showLangPicker(false); return revertActiveTab(); });
ipcMain.handle("lp-open-settings", () => { showLangPicker(false); try { openSettingsTab("language"); } catch {} return true; });
ipcMain.handle("lp-close", () => { showLangPicker(false); return true; });
// Picker renderer measures its own content height after each render and
// asks us to resize to fit — a short "automatic + 6 languages + settings"
// menu stays short, expanding the "More languages" group grows it.
ipcMain.handle("lp-resize", (_e, h, w) => {
const nextH = Math.max(80, Math.min(560, Number(h) || lpH));
const nextW = Math.max(220, Math.min(360, Number(w) || LP_W));
const changed = nextH !== lpH || nextW !== LP_W;
lpH = nextH; LP_W = nextW;
if (changed && lpVisible) positionLangPicker();
return true;
});
ipcMain.handle("move-tab", (_e, id, targetId, place) => {
@ -7311,6 +7351,9 @@ function broadcastSettings() {
try { t.view.webContents.send("settings-update", settings); } catch {}
}
}
// Language picker (if open) reflects the current languageMode/Value — a
// Settings-side change should re-paint the ✓.
pushLangPickerState();
}
ipcMain.handle("set-chrome-height", (_e, h) => {
const next = Math.max(74, Math.min(260, Math.round(h) || 84));

View file

@ -64,6 +64,8 @@
"popover-preload.js",
"engine-picker.html",
"engine-picker-preload.js",
"lang-picker.html",
"lang-picker-preload.js",
"downloads.html",
"downloads-preload.js",
"address-picker.html",