Theseus: one language chip, auto-translate, picker owns up to what works
Two chips carried the same word in two shapes — a globe (Accept-Language) and a translate chip (chip lights when page lang differs) — both labelled "RU" at the same time for a Russian user. The chip for translation is gone. The globe menu now covers both: a "Translate this page from X to Y" item appears at the top when the loaded page is in another supported language, flipping to "Show original" while a translation is on screen. The chip's own code still shows the user's language (EN, RU, …); its tooltip switches to "Translated to <X>. Menu: Show original." when a translation is up, so the one chip reads the whole state. With "Translate automatically" on, Theseus translates in place on did-finish-load the first time it sees a supported source + target mismatch for the active tab — no chip-click needed. A `_tr.autoTried` latch keeps it to one attempt per document (a failing backend doesn't retry on every reflow), and the latch resets on did-start-navigation so the next page gets a fresh shot. The setting copy in Settings › Language now says "Translate automatically" instead of "Offer to translate", so the switch's label matches the behaviour. The picker (both in Settings and in the globe menu) still lists every language in WEBSITE_LANGUAGE_QUICK, but entries whose base code isn't on the translator backend (en, es, fr, de, el, ru today) are shown greyed out with "— translator coming later", and "Other… (Accept-Language only, no translation)" is explicit about what free-form tags buy you. The menu is a roadmap, not a lie: a user picking one of the greyed entries sets Accept-Language and nothing else surprises them.
This commit is contained in:
parent
8186407b61
commit
3536cd89bd
3 changed files with 111 additions and 72 deletions
87
chrome.html
87
chrome.html
|
|
@ -506,21 +506,19 @@
|
|||
makes it installable (Chrome's omnibox icon). Filled once the
|
||||
app is installed; click then opens it or removes it. -->
|
||||
<button class="appchip" id="appchip" title="Install app" hidden><svg viewBox="0 0 16 16" aria-hidden="true"><rect x="2" y="2.5" width="12" height="9" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M8 5 V9.5 M6 7.8 L8 9.8 L10 7.8" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M5.5 14 H10.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></button>
|
||||
<!-- Website-language chip: click to change the language sites serve
|
||||
you in. Icon = globe with meridian + equator (universal "language"
|
||||
glyph); label = 2-letter code, or "AUTO" when we follow the OS. -->
|
||||
<!-- Language chip: one control for both the Accept-Language that
|
||||
sites see AND the translator. Icon = globe with meridian +
|
||||
equator (universal "language" glyph); label = 2-letter code of
|
||||
the user's language, or "AUTO" when we follow the OS. Click
|
||||
opens a menu that offers "Translate this page" (when the
|
||||
loaded page is in another supported language), the language
|
||||
picker (unsupported languages are present but greyed), and a
|
||||
jump to the full language list in Settings. The "on" state
|
||||
means the page is currently showing a translation. -->
|
||||
<button class="langchip" id="langBtn" title="Language websites serve you in">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="6"/><path d="M2 8 H14"/><path d="M8 2 C5 5 5 11 8 14 C11 11 11 5 8 2"/></svg>
|
||||
<span class="lcode" id="langCode">AUTO</span>
|
||||
</button>
|
||||
<!-- Translate chip: lights up when the page's declared language
|
||||
differs from the user's preferred one. Click translates in
|
||||
place; click again reverts. Right-click opens the chip menu
|
||||
(change target, translator settings). -->
|
||||
<button class="langchip" id="trBtn" title="Translate this page" hidden>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2 4 H8 M5 2 V4 M3 4 C3 7 5 10 8 10 M8 10 C5 10 3 11 2 13 M3 10 C6 10 8 7 8 4"/><path d="M8.5 14 L11 7 L13.5 14 M9.3 12 H12.7"/></svg>
|
||||
<span class="lcode" id="trCode">EN</span>
|
||||
</button>
|
||||
<button class="star" id="star" title="Save this page">☆</button>
|
||||
</div>
|
||||
<div class="bardrag" id="bardrag" title="Drag to shift the address / search bar ratio"></div>
|
||||
|
|
@ -768,57 +766,30 @@
|
|||
.then(renderLang).catch(() => {});
|
||||
T.onSettingsUpdate && T.onSettingsUpdate(renderLang);
|
||||
|
||||
// ---- page-translate chip ----
|
||||
// Shows when settings.translateAutoOffer is on and the loaded page's
|
||||
// declared language (<html lang>) differs from the user's target. Click
|
||||
// toggles between translated and original. Right-click (or long-press)
|
||||
// opens the chip menu.
|
||||
const trBtn = $("trBtn"), trCode = $("trCode");
|
||||
let trSettingsCached = null, trStateCached = null;
|
||||
function trTargetBase() {
|
||||
const s = trSettingsCached || {};
|
||||
const tag = s.languageMode === "manual" && s.languageValue ? s.languageValue : systemLocaleCached;
|
||||
return String(tag || "en").split("-")[0].toLowerCase();
|
||||
// The language chip's tooltip also carries the translation state: when
|
||||
// main broadcasts `translated: true` the tooltip switches to "Translated
|
||||
// to <X> — menu offers Show original". The actual translate / revert /
|
||||
// change-language actions live in the menu that opens on click (the
|
||||
// chip has one surface, not two).
|
||||
let trStateCached = null;
|
||||
function trCurrentTarget() {
|
||||
const el = $("langCode"); const code = el ? el.textContent : "AUTO";
|
||||
if (code === "AUTO") return String(systemLocaleCached || "en").split("-")[0].toLowerCase();
|
||||
return code.toLowerCase();
|
||||
}
|
||||
function renderTr() {
|
||||
if (!trBtn) return;
|
||||
const s = trSettingsCached || {};
|
||||
function applyTranslateHint() {
|
||||
const st = trStateCached || {};
|
||||
const target = trTargetBase();
|
||||
const page = String(st.pageLang || "").toLowerCase();
|
||||
const translated = !!st.translated;
|
||||
// Hide when auto-offer is off AND we aren't already translated, OR when
|
||||
// the page is already in the user's target language.
|
||||
const shouldShow = translated || (s.translateAutoOffer !== false && page && page !== target);
|
||||
trBtn.hidden = !shouldShow;
|
||||
if (!shouldShow) return;
|
||||
trCode.textContent = (target || "??").toUpperCase().slice(0, 2);
|
||||
if (translated) {
|
||||
trBtn.classList.add("on");
|
||||
trBtn.title = `Translated to ${langName(target)}. Click to show the original.`;
|
||||
} else if (st.error) {
|
||||
trBtn.classList.remove("on");
|
||||
trBtn.title = `Translation failed: ${st.error}. Right-click for settings.`;
|
||||
} else {
|
||||
trBtn.classList.remove("on");
|
||||
trBtn.title = `Translate this page from ${langName(page) || page} to ${langName(target)}`;
|
||||
const target = trCurrentTarget();
|
||||
if (st.translated) {
|
||||
langBtn.title = `Translated to ${langName(target)}. Menu: Show original.`;
|
||||
} else if (st.pageLang && st.pageLang !== target) {
|
||||
langBtn.title = `Page is in ${langName(st.pageLang)}. Menu: translate to ${langName(target)}.`;
|
||||
}
|
||||
// When page lang matches the user's target, the base renderLang tooltip
|
||||
// already covers it — we leave it alone.
|
||||
}
|
||||
if (trBtn) {
|
||||
trBtn.onclick = () => {
|
||||
if (trStateCached?.translated) T.pageTranslateRevert && T.pageTranslateRevert();
|
||||
else T.pageTranslate && T.pageTranslate();
|
||||
};
|
||||
trBtn.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
const r = trBtn.getBoundingClientRect();
|
||||
T.pageTranslateMenu && T.pageTranslateMenu({ x: Math.round(r.left), y: Math.round(r.bottom + 4) });
|
||||
});
|
||||
}
|
||||
T.onPageTranslateState && T.onPageTranslateState((d) => { trStateCached = d; renderTr(); });
|
||||
T.onSettingsUpdate && T.onSettingsUpdate((s) => { trSettingsCached = s; renderTr(); });
|
||||
T.getSettings().then((s) => { trSettingsCached = s; renderTr(); }).catch(() => {});
|
||||
if (T.pageTranslateState) T.pageTranslateState().then((d) => { trStateCached = d; renderTr(); }).catch(() => {});
|
||||
T.onPageTranslateState && T.onPageTranslateState((d) => { trStateCached = d; applyTranslateHint(); });
|
||||
if (T.pageTranslateState) T.pageTranslateState().then((d) => { trStateCached = d; applyTranslateHint(); }).catch(() => {});
|
||||
|
||||
// ---- extension dock ----
|
||||
// One toolbar button per registered addon sidebar-panel, plus a static
|
||||
|
|
|
|||
72
main.js
72
main.js
|
|
@ -3734,8 +3734,9 @@ function createTab(initial, opts = {}) {
|
|||
wc.on("did-navigate", () => { if (tab.id === activeId) { notifyTabChange(); emitPwAvailability(); } });
|
||||
wc.on("did-navigate-in-page", () => { if (tab.id === activeId) notifyTabChange(); });
|
||||
// Translator state is per-document: a new navigation drops any "translated"
|
||||
// flag and clears the cached page language. The chip then re-decides on
|
||||
// the next pageLang read whether to light up for this new page.
|
||||
// flag, the autoTried latch, and the cached page language. The chip then
|
||||
// re-decides on the next pageLang read whether to light up — and auto-
|
||||
// translate gets one more shot on the new page.
|
||||
wc.on("did-start-navigation", (_e, _url, _ihr, isMainFrame) => {
|
||||
if (!isMainFrame) return;
|
||||
tab._tr = null; tab.pageLang = "";
|
||||
|
|
@ -3743,13 +3744,30 @@ function createTab(initial, opts = {}) {
|
|||
});
|
||||
// After the page has committed, read <html lang> once so the chip knows
|
||||
// what language the server actually served (which may not match whatever
|
||||
// we asked for via Accept-Language).
|
||||
// we asked for via Accept-Language). If auto-translate is on and the page
|
||||
// is in a language different from the user's, both of them supported, fire
|
||||
// the translation now so the user reads the page in their own language
|
||||
// without clicking anything.
|
||||
wc.on("did-finish-load", async () => {
|
||||
try {
|
||||
const lang = await wc.executeJavaScript(`document.documentElement.lang || ""`);
|
||||
tab.pageLang = String(lang || "").toLowerCase().split("-")[0];
|
||||
} catch { tab.pageLang = ""; }
|
||||
if (tab.id === activeId) emitTranslateState(tab);
|
||||
try {
|
||||
const target = translationTargetBase();
|
||||
if (settings.translateAutoOffer &&
|
||||
tab.pageLang && tab.pageLang !== target &&
|
||||
isTranslatorSupported(tab.pageLang) && isTranslatorSupported(target) &&
|
||||
!tabTranslateState(tab).translated &&
|
||||
!tabTranslateState(tab).autoTried) {
|
||||
tabTranslateState(tab).autoTried = true;
|
||||
// Only auto-translate when this is the active tab, to spare a cold
|
||||
// backend the N parallel requests of a many-tab restore; a background
|
||||
// tab gets translated when the user focuses it (same hook, next load).
|
||||
if (tab.id === activeId) await translateActiveTab();
|
||||
}
|
||||
} catch (e) { console.warn("[translate] auto failed:", e?.message); }
|
||||
});
|
||||
// Ctrl+wheel / pinch: Chromium only reports the intent on Windows and
|
||||
// Linux, the zoom itself is up to us.
|
||||
|
|
@ -4558,6 +4576,16 @@ const WEBSITE_LANGUAGE_QUICK = [
|
|||
{ tag: "he-IL", label: "עברית" },
|
||||
{ tag: "zh-TW", label: "中文(繁體)" },
|
||||
];
|
||||
// Base BCP-47 codes the translator backend (silentmode.st/libre) currently has
|
||||
// models for. The picker shows every entry in WEBSITE_LANGUAGE_QUICK but
|
||||
// greys out the ones not on this list — a user who picks an unsupported one
|
||||
// still gets its Accept-Language sent, but the translator stays dark on
|
||||
// pages served in another language because there's no model path from the
|
||||
// server's language to theirs. Grow this when the backend --load-only grows.
|
||||
const TRANSLATOR_SUPPORTED = new Set(["en", "es", "fr", "de", "el", "ru"]);
|
||||
function isTranslatorSupported(tag) {
|
||||
return TRANSLATOR_SUPPORTED.has(String(tag || "").split("-")[0].toLowerCase());
|
||||
}
|
||||
function setWebsiteLanguage(mode, value) {
|
||||
const before = { mode: settings.languageMode, value: settings.languageValue };
|
||||
if (mode === "show") {
|
||||
|
|
@ -4817,16 +4845,46 @@ ipcMain.handle("website-language-menu-popup", (e, rect) => {
|
|||
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" },
|
||||
);
|
||||
}
|
||||
// Languages the translator can actually handle appear enabled; the rest
|
||||
// show but are greyed out with a "coming later" hint so the user knows the
|
||||
// picker will grow, and that picking one today only sets Accept-Language.
|
||||
const template = [
|
||||
...translateItems,
|
||||
{ label: `Automatic (${languageNameFor(osLoc)})${isAuto ? " ✓" : ""}`,
|
||||
click: () => setWebsiteLanguage("show") },
|
||||
{ type: "separator" },
|
||||
...WEBSITE_LANGUAGE_QUICK.map((L) => ({
|
||||
label: `${L.label}${current === L.tag.toLowerCase() ? " ✓" : ""}`,
|
||||
...WEBSITE_LANGUAGE_QUICK.map((L) => {
|
||||
const supported = isTranslatorSupported(L.tag);
|
||||
const check = current === L.tag.toLowerCase() ? " ✓" : "";
|
||||
return {
|
||||
label: supported ? `${L.label}${check}` : `${L.label} — translator coming later${check}`,
|
||||
enabled: supported,
|
||||
click: () => setWebsiteLanguage("manual", L.tag),
|
||||
})),
|
||||
};
|
||||
}),
|
||||
{ type: "separator" },
|
||||
{ label: "More languages…", click: () => { try { openSettingsTab("general"); } catch {} } },
|
||||
{ label: "More languages…", click: () => { try { openSettingsTab("language"); } catch {} } },
|
||||
];
|
||||
const popup = Menu.buildFromTemplate(template);
|
||||
const chromeBounds = chrome ? chrome.getBounds() : { x: 0, y: 0 };
|
||||
|
|
|
|||
|
|
@ -373,7 +373,7 @@
|
|||
<h2 class="sub">Page translation</h2>
|
||||
<p class="subd">When the page's declared language is different from yours, Theseus can translate its visible text in place. The source stays untouched — click the chip again to revert.</p>
|
||||
<div class="row">
|
||||
<div class="txt"><div class="t">Offer to translate</div><div class="d">Light up a translate chip in the address bar when the page's language differs from yours.</div></div>
|
||||
<div class="txt"><div class="t">Translate automatically</div><div class="d">When the page's declared language is different from yours (and both are supported by the translator), Theseus translates it in place as soon as it loads. Turn this off to leave pages in their original language — the globe chip's menu still offers a one-click translation.</div></div>
|
||||
<label class="sw"><input type="checkbox" id="translateAutoOffer"><span class="track"><span class="knob"></span></span></label>
|
||||
</div>
|
||||
<div class="row" style="flex-direction:column;align-items:stretch;gap:8px">
|
||||
|
|
@ -1174,12 +1174,22 @@
|
|||
if (!webLangPick) return;
|
||||
let osLoc = "en-US";
|
||||
try { osLoc = (C.systemLocale && await C.systemLocale()) || "en-US"; } catch {}
|
||||
// Build options: "Automatic" first, then the quick list, then "Other…".
|
||||
// No BCP-47 tag in visible labels — the tag surfaces only as the 2-letter
|
||||
// chip in the URL bar.
|
||||
// Supported-by-the-translator languages come enabled; the rest are
|
||||
// shown but disabled with a "— coming later" suffix, so the picker is
|
||||
// a roadmap, not a lie — the user sees which languages the chip will
|
||||
// actually translate pages into today. "Other…" is still here for a
|
||||
// user who needs to pin an unlisted BCP-47 tag for Accept-Language
|
||||
// alone, but it's marked so they know translation won't follow.
|
||||
const SUPPORTED = new Set(["en", "es", "fr", "de", "el", "ru"]);
|
||||
const baseOf = (t) => String(t || "").split("-")[0].toLowerCase();
|
||||
const opts = [`<option value="__auto__">Automatic (${langNameFor(osLoc)})</option>`,
|
||||
...WEB_LANG_QUICK.map((L) => `<option value="${L.tag}">${L.label}</option>`),
|
||||
`<option value="__other__">Other…</option>`];
|
||||
...WEB_LANG_QUICK.map((L) => {
|
||||
const supported = SUPPORTED.has(baseOf(L.tag));
|
||||
return supported
|
||||
? `<option value="${L.tag}">${L.label}</option>`
|
||||
: `<option value="${L.tag}" disabled>${L.label} — translator coming later</option>`;
|
||||
}),
|
||||
`<option value="__other__">Other… (Accept-Language only, no translation)</option>`];
|
||||
webLangPick.innerHTML = opts.join("");
|
||||
const paint = (cur) => {
|
||||
const mode = cur.languageMode || "show";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue