diff --git a/shells/desktop/src/system-ui/app.tsx b/shells/desktop/src/system-ui/app.tsx index cfa5648..d8b508e 100644 --- a/shells/desktop/src/system-ui/app.tsx +++ b/shells/desktop/src/system-ui/app.tsx @@ -31,6 +31,7 @@ import { Image, View } from "@pocketjs/framework/components"; import { onFrame } from "@pocketjs/framework/lifecycle"; import { virtualNow } from "@pocketjs/framework/clock"; import { connectSvc, type CursorKind, type HostEvent } from "./svc.ts"; +import { applyLang, appTitle, lw } from "./words.ts"; import { cascadePos, clampMove, @@ -149,7 +150,7 @@ import { } from "./chrome.tsx"; /** Label of every About entry and the About window's title. */ -const ABOUT_LABEL = `About ${DESKTOP_NAME}`; +const aboutLabel = () => lw().about(DESKTOP_NAME); const WELCOME = [ `Welcome to ${DESKTOP_NAME}.`, @@ -364,6 +365,7 @@ export default function App() { function closeWin(id: number) { layouts.get(id)?.dispose(); layouts.delete(id); + padNames.delete(id); wins.set(wins().filter((w) => w.id !== id)); stack = stack.filter((x) => x !== id); applyZ(); @@ -464,7 +466,13 @@ export default function App() { // ---- programs --------------------------------------------------------------- - function openNotepad(title: string, content: string[]) { + /** Each Notepad window's document name; null for an untitled one. The + * window's title is made from it, again when the language changes. */ + const padNames = new Map(); + const padTitle = (name: string | null) => lw().notepadTitle(name ?? lw().untitled); + + function openNotepad(name: string | null, content: string[]) { + const title = padTitle(name); const existing = wins().find( (w) => w.kind === "notepad" && w.title() === title, ); @@ -497,11 +505,11 @@ export default function App() { minH: 140, menus: [ { - label: "File", - width: menuW("File"), + label: lw().file, + width: menuW(lw().file), items: () => [ { - label: "New", + label: lw().new, act: () => { applyEdit(w, "other", { lines: [""], @@ -511,7 +519,7 @@ export default function App() { }, { sep: true, label: "" }, { - label: "Exit", + label: lw().exit, shortcut: "Cmd+W", act: () => { closeWin(w.id); @@ -520,11 +528,11 @@ export default function App() { ], }, { - label: "Edit", - width: menuW("Edit"), + label: lw().edit, + width: menuW(lw().edit), items: () => [ { - label: "Undo", + label: lw().undo, shortcut: "Cmd+Z", disabled: data.hist.undo.length === 0, act: () => { @@ -532,7 +540,7 @@ export default function App() { }, }, { - label: "Redo", + label: lw().redo, shortcut: "Cmd+Shift+Z", disabled: data.hist.redo.length === 0, act: () => { @@ -541,20 +549,20 @@ export default function App() { }, { sep: true, label: "" }, { - label: "Cut", + label: lw().cut, shortcut: "Cmd+X", disabled: !hasSel(data.doc()), act: cutSel, }, { - label: "Copy", + label: lw().copy, shortcut: "Cmd+C", disabled: !hasSel(data.doc()), act: copySel, }, - { label: "Paste", shortcut: "Cmd+V", act: pasteReq }, + { label: lw().paste, shortcut: "Cmd+V", act: pasteReq }, { - label: "Select All", + label: lw().selectAll, shortcut: "Cmd+A", act: () => { selectAllIn(w); @@ -562,7 +570,7 @@ export default function App() { }, { sep: true, label: "" }, { - label: "Time/Date", + label: lw().timeDate, shortcut: "F5", act: () => { insertTimeDate(w); @@ -570,7 +578,7 @@ export default function App() { }, { sep: true, label: "" }, { - label: "Word Wrap", + label: lw().wordWrap, checked: data.wrap(), act: () => { data.wrap.set(!data.wrap()); @@ -580,14 +588,15 @@ export default function App() { ], }, { - label: "Help", - width: menuW("Help"), - items: () => [{ label: ABOUT_LABEL, act: openAbout }], + label: lw().help, + width: menuW(lw().help), + items: () => [{ label: aboutLabel(), act: openAbout }], }, ], data, }); layouts.set(w.id, createPadLayout(w)); + padNames.set(w.id, name); addWin(w); } @@ -604,14 +613,14 @@ export default function App() { const outer = reframeGeo( { x: 0, y: 0, w: MINES_GEO.w, h: MINES_GEO.h }, { - menuWidths: [menuW("Game"), menuW("Help")], + menuWidths: [menuW(lw().game), menuW(lw().help)], }, CLASSIC_THEME.metrics, metrics(), ); const w = createWin({ kind: "mines", - title: "Minesweeper", + title: lw().minesweeper, icon: "mines", geo: { ...cascadePos( @@ -627,11 +636,11 @@ export default function App() { resizable: false, menus: [ { - label: "Game", - width: menuW("Game"), + label: lw().game, + width: menuW(lw().game), items: () => [ { - label: "New", + label: lw().new, shortcut: "F2", act: () => { minesNew(w); @@ -639,7 +648,7 @@ export default function App() { }, { sep: true, label: "" }, { - label: "Exit", + label: lw().exit, shortcut: "Cmd+W", act: () => { closeWin(w.id); @@ -648,9 +657,9 @@ export default function App() { ], }, { - label: "Help", - width: menuW("Help"), - items: () => [{ label: ABOUT_LABEL, act: openAbout }], + label: lw().help, + width: menuW(lw().help), + items: () => [{ label: aboutLabel(), act: openAbout }], }, ], data, @@ -675,7 +684,7 @@ export default function App() { app.viewport[1] + contentTop({ menuWidths: [] }, m) + m.frame; const w = createWin({ kind: "pocket", - title: appWindowTitle(app.title), + title: appWindowTitle(appTitle(app)), icon: "pocket", geo: cascadePos( wins().length, @@ -711,37 +720,37 @@ export default function App() { icon: "drive", name: "(C:)", size: "", - type: "Local Disk", + type: lw().localDisk, open: () => { navigate(w, "drivec"); }, }, - { icon: "cdrom", name: "(D:)", size: "", type: "CD-ROM Disc" }, - { icon: "folder", name: "Control Panel", size: "", type: "System Folder" }, - { icon: "folder", name: "Printers", size: "", type: "System Folder" }, + { icon: "cdrom", name: "(D:)", size: "", type: lw().cdromDisc }, + { icon: "folder", name: lw().controlPanel, size: "", type: lw().systemFolder }, + { icon: "folder", name: lw().printers, size: "", type: lw().systemFolder }, ]; case "drivec": return [ - { icon: "folder", name: "Program Files", size: "", type: "File Folder" }, - { icon: "folder", name: "System", size: "", type: "File Folder" }, + { icon: "folder", name: "Program Files", size: "", type: lw().fileFolder }, + { icon: "folder", name: "System", size: "", type: lw().fileFolder }, { icon: "folder", - name: "My Documents", + name: lw().myDocuments, size: "", - type: "File Folder", + type: lw().fileFolder, open: () => { navigate(w, "documents"); }, }, - { icon: "file", name: "AUTOEXEC.BAT", size: "1 KB", type: "Batch File" }, - { icon: "file", name: "CONFIG.SYS", size: "1 KB", type: "System file" }, + { icon: "file", name: "AUTOEXEC.BAT", size: "1 KB", type: lw().batchFile }, + { icon: "file", name: "CONFIG.SYS", size: "1 KB", type: lw().systemFile }, { icon: "notepad", name: "README.TXT", size: "2 KB", - type: "Text Document", + type: lw().textDocument, open: () => { - openNotepad("README.TXT - Notepad", WELCOME); + openNotepad("README.TXT", WELCOME); }, }, ]; @@ -751,9 +760,9 @@ export default function App() { icon: "notepad", name: "welcome.txt", size: "1 KB", - type: "Text Document", + type: lw().textDocument, open: () => { - openNotepad("welcome.txt - Notepad", WELCOME); + openNotepad("welcome.txt", WELCOME); }, }, ]; @@ -766,6 +775,14 @@ export default function App() { return PLACES.find((p) => p.id === id) ?? PLACES[0]; } + /** A place's name in the active language: its window's title and its sidebar entry. */ + function placeLabel(id: PlaceId): string { + if (id === "computer") return lw().myComputer; + if (id === "documents") return lw().myDocuments; + if (id === "recycle") return lw().recycleBin; + return placeOf(id).label; + } + /** Point a folder window at another place: title, icon, rows, selection. * Pushes onto the history unless the move IS a history step. */ function navigate(w: WinCtl, id: PlaceId, push = true) { @@ -778,7 +795,7 @@ export default function App() { d.place.set(id); d.rows.set(placeRows(id, w)); d.selected.set(-1); - w.title.set(place.label); + w.title.set(placeLabel(id)); w.icon.set(place.icon); } @@ -820,7 +837,7 @@ export default function App() { }; const w = createWin({ kind: "folder", - title: place.label, + title: placeLabel(id), icon: place.icon, geo: cascadePos(wins().length, vp().w, vp().h, 560, 320, metrics()), minW: 380, @@ -841,7 +858,7 @@ export default function App() { const data: AboutData = { kind: "about", armed: createState(null) }; const w = createWin({ kind: "about", - title: ABOUT_LABEL, + title: aboutLabel(), icon: "computer", geo: centered(ABOUT_GEO.w, ABOUT_GEO.h), buttons: ["close"], @@ -864,7 +881,7 @@ export default function App() { // No name after the verb: the dialog is 300px wide, and Aqua's // centered caption has room for about twenty characters between its // control cluster and the balancing spacer. - title: "Shut Down", + title: lw().shutDown, icon: "shutdown", geo: centered(SHUTDOWN_GEO.w, SHUTDOWN_GEO.h), buttons: ["close"], @@ -907,21 +924,22 @@ export default function App() { // ---- desktop icons + start menu ---------------------------------------------- - const icons: DeskIcon[] = [ - { icon: "computer", label: "My Computer", open: openMyComputer }, - { icon: "documents", label: "My Documents", open: openDocuments }, - { icon: "recycle", label: "Recycle Bin", open: openRecycle }, + /** The desktop's icons, labelled in the active language. */ + const icons = (): DeskIcon[] => [ + { icon: "computer", label: lw().myComputer, open: openMyComputer }, + { icon: "documents", label: lw().myDocuments, open: openDocuments }, + { icon: "recycle", label: lw().recycleBin, open: openRecycle }, { icon: "notepad", - label: "Notepad", + label: lw().notepad, open: () => { - openNotepad("Untitled - Notepad", [""]); + openNotepad(null, [""]); }, }, - { icon: "mines", label: "Minesweeper", open: openMines }, + { icon: "mines", label: lw().minesweeper, open: openMines }, ...POCKET_APPS.map((app): DeskIcon => ({ icon: "pocket", - label: app.title, + label: appTitle(app), open: () => openPocketApp(app), })), ]; @@ -930,7 +948,7 @@ export default function App() { return desktopIconAt( x, y, - icons.length, + icons().length, desktopIconRows(vp().h, metrics()), metrics(), vp().w, @@ -941,15 +959,15 @@ export default function App() { * panel's "All Programs" share one list. */ const programItems = (): PopupItem[] => [ { - label: "Notepad", + label: lw().notepad, icon: "notepad", act: () => { - openNotepad("Untitled - Notepad", [""]); + openNotepad(null, [""]); }, }, - { label: "Minesweeper", icon: "mines", act: openMines }, + { label: lw().minesweeper, icon: "mines", act: openMines }, ...POCKET_APPS.map((app): PopupItem => ({ - label: app.title, + label: appTitle(app), icon: "pocket", act: () => openPocketApp(app), })), @@ -969,7 +987,7 @@ export default function App() { label: "welcome.txt", icon: "notepad", act: () => { - openNotepad("welcome.txt - Notepad", WELCOME); + openNotepad("welcome.txt", WELCOME); }, }, ]; @@ -978,60 +996,60 @@ export default function App() { * system entries on the right, Turn Off Computer in the bottom strip. */ const xpStartItems = (): PopupItem[] => [ { - label: "Notepad", + label: lw().notepad, icon: "notepad", act: () => { - openNotepad("Untitled - Notepad", [""]); + openNotepad(null, [""]); }, }, - { label: "Minesweeper", icon: "mines", act: openMines }, + { label: lw().minesweeper, icon: "mines", act: openMines }, { sep: true, label: "" }, ...POCKET_APPS.slice(0, 5).map((app): PopupItem => ({ - label: app.title, + label: appTitle(app), icon: "pocket", act: () => openPocketApp(app), })), { sep: true, label: "", bottom: true }, { - label: "All Programs", + label: lw().allPrograms, icon: "folder", bottom: true, sub: programItems(), }, { - label: "My Documents", + label: lw().myDocuments, icon: "folder", col: "right", sub: documentItems(), }, { - label: "My Computer", + label: lw().myComputer, icon: "computer", col: "right", act: openMyComputer, }, { sep: true, label: "", col: "right" }, { - label: "Settings", + label: lw().settings, icon: "settings", col: "right", sub: themeItems(), }, { - label: "Help", + label: lw().help, icon: "help", col: "right", act: openAbout, }, { sep: true, label: "", col: "right" }, { - label: "Run...", + label: lw().run, icon: "run", col: "right", disabled: true, }, { - label: "Turn Off Computer", + label: lw().turnOff, icon: "power", foot: true, act: openShutdown, @@ -1040,39 +1058,39 @@ export default function App() { const classicStartItems = (): PopupItem[] => [ { - label: "Programs", + label: lw().programs, icon: "folder", sub: [ { - label: "Notepad", + label: lw().notepad, icon: "notepad", act: () => { - openNotepad("Untitled - Notepad", [""]); + openNotepad(null, [""]); }, }, - { label: "Minesweeper", icon: "mines", act: openMines }, + { label: lw().minesweeper, icon: "mines", act: openMines }, ...POCKET_APPS.map((app): PopupItem => ({ - label: app.title, + label: appTitle(app), icon: "pocket", act: () => openPocketApp(app), })), ], }, { - label: "Documents", + label: lw().documents, icon: "folder", sub: [ { label: "welcome.txt", icon: "notepad", act: () => { - openNotepad("welcome.txt - Notepad", WELCOME); + openNotepad("welcome.txt", WELCOME); }, }, ], }, { - label: "Settings", + label: lw().settings, icon: "settings", sub: THEMES.map((item) => ({ label: item.label, @@ -1082,25 +1100,25 @@ export default function App() { }, })), }, - { label: "Find", icon: "find", disabled: true }, - { label: "Help", icon: "help", act: openAbout }, - { label: "Run...", icon: "run", disabled: true }, + { label: lw().find, icon: "find", disabled: true }, + { label: lw().help, icon: "help", act: openAbout }, + { label: lw().run, icon: "run", disabled: true }, { sep: true, label: "" }, - { label: "Shut Down...", icon: "shutdown", act: openShutdown }, + { label: lw().shutDownMenu, icon: "shutdown", act: openShutdown }, ]; /** Aqua's logo menu: About first, the places and the theme picker, the * session commands last — the shape of the menu under the mark. */ const aquaStartItems = (): PopupItem[] => [ - { label: ABOUT_LABEL, act: openAbout }, + { label: aboutLabel(), act: openAbout }, { sep: true, label: "" }, - { label: "Programs", icon: "folder", sub: programItems() }, - { label: "Documents", icon: "documents", sub: documentItems() }, - { label: "My Computer", icon: "computer", act: openMyComputer }, + { label: lw().programs, icon: "folder", sub: programItems() }, + { label: lw().documents, icon: "documents", sub: documentItems() }, + { label: lw().myComputer, icon: "computer", act: openMyComputer }, { sep: true, label: "" }, - { label: "Settings", icon: "settings", sub: themeItems() }, + { label: lw().settings, icon: "settings", sub: themeItems() }, { sep: true, label: "" }, - { label: "Shut Down...", icon: "shutdown", act: openShutdown }, + { label: lw().shutDownMenu, icon: "shutdown", act: openShutdown }, ]; const startItems = (): PopupItem[] => { @@ -1185,10 +1203,10 @@ export default function App() { * when nothing is focused. */ function appNameOf(w: WinCtl | undefined): string { if (!w) return DESKTOP_NAME; - if (w.kind === "notepad") return "Notepad"; - if (w.kind === "mines") return "Minesweeper"; - if (w.kind === "folder") return "Files"; - if (w.kind === "pocket") return pocketOf(w).app.title; + if (w.kind === "notepad") return lw().notepad; + if (w.kind === "mines") return lw().minesweeper; + if (w.kind === "folder") return lw().files; + if (w.kind === "pocket") return appTitle(pocketOf(w).app); return DESKTOP_NAME; } @@ -1424,7 +1442,7 @@ export default function App() { const icon = iconAt(mx, my); iconSel.set(icon); focusId.set(-1); - if (icon >= 0 && isDblClick(`icon:${icon}`)) icons[icon].open(); + if (icon >= 0 && isDblClick(`icon:${icon}`)) icons()[icon].open(); } function routeContentDown(w: WinCtl, cx: number, cy: number, shift: boolean) { @@ -1537,12 +1555,12 @@ export default function App() { const has = hasSel(d.doc()); popup.set({ popup: buildPopup(mx, my, [ - { label: "Cut", shortcut: "Cmd+X", disabled: !has, act: cutSel }, - { label: "Copy", shortcut: "Cmd+C", disabled: !has, act: copySel }, - { label: "Paste", shortcut: "Cmd+V", act: pasteReq }, + { label: lw().cut, shortcut: "Cmd+X", disabled: !has, act: cutSel }, + { label: lw().copy, shortcut: "Cmd+C", disabled: !has, act: copySel }, + { label: lw().paste, shortcut: "Cmd+V", act: pasteReq }, { sep: true, label: "" }, { - label: "Select All", + label: lw().selectAll, shortcut: "Cmd+A", act: () => { selectAllIn(w); @@ -1557,14 +1575,14 @@ export default function App() { popup.set({ popup: buildPopup(mx, my, [ { - label: "Minimize", + label: lw().minimize, shortcut: "Cmd+M", act: () => { minimize(w.id); }, }, { - label: w.maximized() ? "Restore" : "Maximize", + label: w.maximized() ? lw().restore : lw().maximize, disabled: !w.resizable, act: () => { toggleMax(w); @@ -1572,7 +1590,7 @@ export default function App() { }, { sep: true, label: "" }, { - label: "Close", + label: lw().close, shortcut: "Cmd+W", act: () => { closeWin(w.id); @@ -1589,17 +1607,17 @@ export default function App() { iconSel.set(icon); popup.set({ popup: buildPopup(mx, my, [ - { label: "Arrange Icons", act: () => {} }, - { label: "Refresh", act: () => {} }, + { label: lw().arrangeIcons, act: () => {} }, + { label: lw().refresh, act: () => {} }, { sep: true, label: "" }, { - label: "New Text Document", + label: lw().newTextDocument, act: () => { - openNotepad("Untitled - Notepad", [""]); + openNotepad(null, [""]); }, }, { sep: true, label: "" }, - { label: "Properties", disabled: true }, + { label: lw().properties, disabled: true }, ]), }); } @@ -1900,7 +1918,7 @@ export default function App() { cycleWindows(); return; case "n": - openNotepad("Untitled - Notepad", [""]); + openNotepad(null, [""]); return; case "w": { const w = focused(); @@ -2177,11 +2195,30 @@ export default function App() { if (isThemeId(ev.id)) setTheme(ev.id); break; } + case "lang": { + // The host names the language and its apps' titles in it. Labels + // read the catalog as they draw; window titles were set when each + // window opened, so they are made again here. + if (applyLang(ev.id, ev.titles)) retitleWindows(); + break; + } + } + } + + /** Give every open window its title in the active language. */ + function retitleWindows() { + for (const win of wins()) { + if (win.kind === "pocket") win.title.set(appWindowTitle(appTitle(pocketOf(win).app))); + else if (win.kind === "notepad") win.title.set(padTitle(padNames.get(win.id) ?? null)); + else if (win.kind === "mines") win.title.set(lw().minesweeper); + else if (win.kind === "folder") win.title.set(placeLabel(folderOf(win).place())); + else if (win.kind === "about") win.title.set(aboutLabel()); + else if (win.kind === "shutdown") win.title.set(lw().shutDown); } } function boot() { - openNotepad("welcome.txt - Notepad", WELCOME); + openNotepad("welcome.txt", WELCOME); if (!svc) { // Standalone (sim, goldens): a lively static arrangement. openMines(); @@ -2254,7 +2291,7 @@ export default function App() { ))} ) : null} @@ -552,6 +554,40 @@ export function StartMenu(props: { ); } +/** How far a desktop label wider than its cell moves from the cell's centre, + * so that it stays on the screen: a label is centred under its icon, and one + * wider than the cell (a long name, "マイ コンピュータ") would otherwise run + * past the screen's edge beside the first column. `cellX` is the cell's + * left edge, `width` the label's. */ +export function desktopLabelShift(cellX: number, width: number, viewportW: number): number { + if (width <= DESK_ICON_W) return 0; + const centred = (DESK_ICON_W - width) / 2; + const leftmost = 2 - cellX; + const rightmost = viewportW > 0 ? viewportW - 2 - cellX - width : centred; + return Math.round(Math.max(leftmost, Math.min(centred, rightmost)) - centred); +} + +/** The widest label a column of icons has room for: a wider one would run + * under the next column's. */ +export const DESK_LABEL_MAX_W = DESK_ICON_X_STRIDE - 2; + +/** A desktop label as it is drawn: whole when it fits the column, when it + * is selected or when no icon stands beside it in the next column on + * either side (no label to run under), else cut with "..." to fit, the way + * a desktop shortens a long name until it is selected. */ +export function desktopLabelText( + label: string, + selected: boolean, + measure: (text: string) => number, + crowded = true, +): string { + if (selected || !crowded || measure(label) <= DESK_LABEL_MAX_W) return label; + const chars = [...label]; + let n = chars.length; + while (n > 1 && measure(`${chars.slice(0, n).join("").trimEnd()}...`) > DESK_LABEL_MAX_W) n--; + return `${chars.slice(0, n).join("").trimEnd()}...`; +} + /** Desktop icons: column-major 32px art + theme-selected labels, anchored * to the theme's screen edge. */ export function DesktopIcons(props: { @@ -561,6 +597,18 @@ export function DesktopIcons(props: { viewportW: number; theme: DesktopTheme; }) { + const measure = (text: string) => { + const ops = getOps(); + return ops.measureText ? ops.measureText(text, props.theme.fontSlot("ui")) : 0; + }; + // Column-major: the icon beside index i in the next column is i + rows. + const crowded = (i: number) => { + const rows = Math.max(1, props.rows); + return i - rows >= 0 || i + rows < props.icons.length; + }; + const shown = (i: number, label: string) => desktopLabelText(label, props.selected === i, measure, crowded(i)); + // The label's box: its text and the selection's 2px on each side. + const labelW = (label: string) => Math.ceil(measure(label)) + 4; return ( {props.icons.map((icon, i) => ( @@ -588,8 +636,15 @@ export function DesktopIcons(props: { ? props.theme.desktopSelection : props.theme.desktopLabelPlain } + style={{ + translateX: desktopLabelShift( + desktopIconPosition(i, props.rows, props.theme.metrics, props.viewportW).x, + labelW(shown(i, icon.label)), + props.viewportW, + ), + }} > - + ))} diff --git a/shells/desktop/src/system-ui/pocket-apps.ts b/shells/desktop/src/system-ui/pocket-apps.ts index 576de9f..02ab26a 100644 --- a/shells/desktop/src/system-ui/pocket-apps.ts +++ b/shells/desktop/src/system-ui/pocket-apps.ts @@ -25,25 +25,44 @@ export interface DesktopAbout { body: readonly string[]; /** Shown as text under the body; "" leaves the row out. */ link: string; + /** The body in other languages, by language id ("ja"); a language it + * leaves out shows `body`. */ + translations: Readonly>; } /** Read a pocket.about.json value. The System manifest schema has no field - * for product copy, so the text lives in this file beside the manifest. Both + * for product copy, so the text lives in this file beside the manifest. All * fields are optional: a missing `body` gives no lines and a missing `link` - * no link row. Entries of `body` that are not strings are dropped. */ + * no link row. Entries of `body` that are not strings are dropped. + * `translations` holds the body in other languages: + * `{ "ja": { "body": [...] } }`. */ export function readAbout(value: unknown): DesktopAbout { const record = value !== null && typeof value === "object" - ? (value as { body?: unknown; link?: unknown }) + ? (value as { body?: unknown; link?: unknown; translations?: unknown }) : {}; + const lines = (body: unknown): string[] => + Array.isArray(body) ? body.filter((line): line is string => typeof line === "string") : []; + const translations: Record = {}; + if (record.translations !== null && typeof record.translations === "object") { + for (const [lang, entry] of Object.entries(record.translations as Record)) { + if (entry !== null && typeof entry === "object" && Array.isArray((entry as { body?: unknown }).body)) { + translations[lang] = lines((entry as { body: unknown }).body); + } + } + } return { - body: Array.isArray(record.body) - ? record.body.filter((line): line is string => typeof line === "string") - : [], + body: lines(record.body), link: typeof record.link === "string" ? record.link : "", + translations, }; } +/** The About body in a language: its translation, else the body as written. */ +export function aboutBody(about: DesktopAbout, lang: string): readonly string[] { + return about.translations[lang] ?? about.body; +} + /** The About text of the product that installs this System UI. This * repository's file describes the shell; a product that bundles the System * UI under its own pocket.system.json puts its own pocket.about.json beside diff --git a/shells/desktop/src/system-ui/programs.tsx b/shells/desktop/src/system-ui/programs.tsx index 61799b6..b374483 100644 --- a/shells/desktop/src/system-ui/programs.tsx +++ b/shells/desktop/src/system-ui/programs.tsx @@ -7,6 +7,7 @@ // (cx, cy) from wm.ts hitRegion — origin at the frame's inner top-left, // below caption (and menu bar if present). +import { appTitle, lang, lw, themeWord } from "./words.ts"; import { createMemo } from "solid-js"; import { CompositorSurface, @@ -22,7 +23,7 @@ import { type VSeg, } from "./notepad.ts"; import { MINES_W, type Cell } from "./mines.ts"; -import { DESKTOP_ABOUT, DESKTOP_NAME } from "./pocket-apps.ts"; +import { DESKTOP_ABOUT, DESKTOP_NAME, aboutBody } from "./pocket-apps.ts"; import { PLACES, type AboutData, @@ -119,8 +120,8 @@ export function NotepadView(props: { }; return ( - {d.layout().status === "companion-required" ? : null} - {d.layout().status === "error" ? : null} + {d.layout().status === "companion-required" ? : null} + {d.layout().status === "error" ? : null} - - + + - + ) : null} @@ -513,13 +522,13 @@ export function FolderView(props: { {props.theme.folderSearch !== "" ? ( - + ) : null} @@ -534,7 +543,7 @@ export function FolderView(props: { theme={props.theme} bold cls={props.theme.folderSideHeadingText} - t={props.theme.folderSideHeadingLabel} + t={themeWord(props.theme.folderSideHeadingLabel)} /> ) : null} @@ -546,7 +555,7 @@ export function FolderView(props: { @@ -556,13 +565,13 @@ export function FolderView(props: { - + - + - + {d.rows().map((row, i) => ( @@ -607,7 +616,7 @@ export function FolderView(props: { ))} {d.rows().length === 0 ? ( - + ) : null} @@ -680,7 +689,7 @@ export function AboutView(props: { - {DESKTOP_ABOUT.body.map((line) => ( + {aboutBody(DESKTOP_ABOUT, lang()).map((line) => ( ))} {DESKTOP_ABOUT.link !== "" ? ( @@ -693,7 +702,7 @@ export function AboutView(props: { - + - {radio(0, "Shut down")} - {radio(1, "Restart")} + {radio(0, lw().shutDownChoice)} + {radio(1, lw().restart)} diff --git a/shells/desktop/src/system-ui/svc.ts b/shells/desktop/src/system-ui/svc.ts index c9bc78b..555661a 100644 --- a/shells/desktop/src/system-ui/svc.ts +++ b/shells/desktop/src/system-ui/svc.ts @@ -23,6 +23,11 @@ // {t:"theme", id} select the theme with that id ("classic", // "xp" or "aqua"). An id that names no theme // is ignored +// {t:"lang", id, titles} the language the shell draws in ("en" or +// "ja", words.ts), and the installed apps' +// titles in it by package id (optional; an app +// it leaves out keeps its manifest's title). An +// id with no catalog is ignored // // guest → host intents: // {t:"theme", id} the active theme: sent once after boot and @@ -50,7 +55,8 @@ export interface HostEvent { | "paste" | "ime" | "open" - | "theme"; + | "theme" + | "lang"; w?: number; h?: number; epoch?: number; @@ -73,8 +79,10 @@ export interface HostEvent { c?: number | null; /** Package id of the installed app an "open" line names. */ package?: string; - /** Theme id a "theme" line selects. */ + /** Theme id a "theme" line selects, or the language a "lang" line names. */ id?: string; + /** A "lang" line's app titles in that language, by package id. */ + titles?: Record; } export type CursorKind = diff --git a/shells/desktop/src/system-ui/words.ts b/shells/desktop/src/system-ui/words.ts new file mode 100644 index 0000000..3669c4f --- /dev/null +++ b/shells/desktop/src/system-ui/words.ts @@ -0,0 +1,228 @@ +// SPDX-License-Identifier: GPL-3.0-only +// src/system-ui/words.ts — the System UI's words in English and Japanese. +// +// The host names the language on the svc "lang" line (svc.ts), with the +// titles of its installed apps in that language when it has them; the +// shell draws every label it owns from the active catalog through `lw()`, so +// the Start menu, the desktop icons, the menus and the dialogs follow a +// switch. Without a lang line the shell is English. File names (README.TXT, +// Program Files) and shortcut names (Cmd+W) are the same in every language. +// +// Japanese follows the names Japanese desktop systems of the period used +// (マイ コンピュータ, ごみ箱, 右端で折り返す), in です・ます where a sentence +// is a sentence. + +import { createSignal } from "solid-js"; + +export type Lang = "en" | "ja"; + +const EN = { + start: "Start", + myComputer: "My Computer", + myDocuments: "My Documents", + recycleBin: "Recycle Bin", + notepad: "Notepad", + minesweeper: "Minesweeper", + /** The program name Aqua's screen bar shows for a folder window. */ + files: "Files", + untitled: "Untitled", + /** A Notepad window's title: the document's name, then the program's. */ + notepadTitle: (name: string) => `${name} - Notepad`, + about: (name: string) => `About ${name}`, + programs: "Programs", + allPrograms: "All Programs", + documents: "Documents", + settings: "Settings", + find: "Find", + help: "Help", + run: "Run...", + shutDownMenu: "Shut Down...", + turnOff: "Turn Off Computer", + // menus + file: "File", + edit: "Edit", + game: "Game", + new: "New", + exit: "Exit", + undo: "Undo", + redo: "Redo", + cut: "Cut", + copy: "Copy", + paste: "Paste", + selectAll: "Select All", + timeDate: "Time/Date", + wordWrap: "Word Wrap", + minimize: "Minimize", + maximize: "Maximize", + restore: "Restore", + close: "Close", + arrangeIcons: "Arrange Icons", + refresh: "Refresh", + newTextDocument: "New Text Document", + properties: "Properties", + // folders + search: "Search", + address: "Address", + folders: "Folders", + otherPlaces: "Other Places", + name: "Name", + size: "Size", + type: "Type", + empty: "(empty)", + controlPanel: "Control Panel", + printers: "Printers", + localDisk: "Local Disk", + cdromDisc: "CD-ROM Disc", + systemFolder: "System Folder", + fileFolder: "File Folder", + batchFile: "Batch File", + systemFile: "System file", + textDocument: "Text Document", + // dialogs + shutDown: "Shut Down", + shutDownAsk: "What do you want the computer to do?", + shutDownChoice: "Shut down", + restart: "Restart", + ok: "OK", + cancel: "Cancel", + // Pocket apps and Notepad's layout + starting: (title: string) => `Starting ${title}...`, + controlsHint: "Arrow keys + Z/X/A/S + Q/W", + layoutUnavailable: "Text layout unavailable", + layoutCompanion: "Pair a companion to enable text layout.", +} as const; + +type Catalog = { + [K in keyof typeof EN]: (typeof EN)[K] extends (...args: infer A) => string ? (...args: A) => string : string; +}; + +/** `text` and the half-width space Japanese puts after a Latin word or a number. */ +const spaced = (text: string) => (/[A-Za-z0-9.)]$/.test(text) ? `${text} ` : text); + +const JA: Catalog = { + start: "スタート", + myComputer: "マイ コンピュータ", + myDocuments: "マイ ドキュメント", + recycleBin: "ごみ箱", + notepad: "メモ帳", + minesweeper: "マインスイーパ", + files: "ファイル", + untitled: "無題", + notepadTitle: (name) => `${name} - メモ帳`, + about: (name) => `${spaced(name)}について`, + programs: "プログラム", + allPrograms: "すべてのプログラム", + documents: "書類", + settings: "設定", + find: "検索", + help: "ヘルプ", + run: "実行...", + shutDownMenu: "シャットダウン...", + turnOff: "電源を切る", + file: "ファイル", + edit: "編集", + game: "ゲーム", + new: "新規", + exit: "終了", + undo: "元に戻す", + redo: "やり直し", + cut: "切り取り", + copy: "コピー", + paste: "貼り付け", + selectAll: "すべて選択", + timeDate: "日付と時刻", + wordWrap: "右端で折り返す", + minimize: "最小化", + maximize: "最大化", + restore: "元のサイズに戻す", + close: "閉じる", + arrangeIcons: "アイコンの整列", + refresh: "最新の情報に更新", + newTextDocument: "新規テキスト ドキュメント", + properties: "プロパティ", + search: "検索", + address: "アドレス", + folders: "フォルダ", + otherPlaces: "その他", + name: "名前", + size: "サイズ", + type: "種類", + empty: "(空)", + controlPanel: "コントロール パネル", + printers: "プリンタ", + localDisk: "ローカル ディスク", + cdromDisc: "CD-ROM ディスク", + systemFolder: "システム フォルダ", + fileFolder: "ファイル フォルダ", + batchFile: "バッチ ファイル", + systemFile: "システム ファイル", + textDocument: "テキスト ドキュメント", + shutDown: "シャットダウン", + shutDownAsk: "コンピュータをどうしますか?", + shutDownChoice: "シャットダウン", + restart: "再起動", + ok: "OK", + cancel: "キャンセル", + starting: (title) => `${spaced(title)}を起動しています...`, + controlsHint: "方向キー + Z/X/A/S + Q/W", + layoutUnavailable: "テキストのレイアウトを使えません", + layoutCompanion: "テキストのレイアウトには、コンパニオンとの接続が必要です。", +}; + +const CATALOGS: Record = { en: EN as unknown as Catalog, ja: JA }; + +const [active, setActive] = createSignal("en"); +const [titles, setTitles] = createSignal>>({}); + +/** The active language. */ +export const lang = active; + +/** The active catalog. Read it where a label is computed, so a switch redraws it. */ +export const lw = (): Catalog => CATALOGS[active()]; + +/** The catalog of a language, for code that is not reactive (tests, one-off titles). */ +export const wordsIn = (language: Lang): Catalog => CATALOGS[language]; + +/** A value a lang line names, or null when this shell has no catalog for it. */ +export function langOf(value: unknown): Lang | null { + return value === "en" || value === "ja" ? value : null; +} + +/** + * Follow a lang line: its language and its apps' titles by package id. A line + * naming a language the shell has no catalog for changes nothing. Returns + * true when anything changed. + */ +export function applyLang(id: unknown, appTitles?: unknown): boolean { + const next = langOf(id); + if (!next) return false; + const named: Record = {}; + if (appTitles && typeof appTitles === "object") { + for (const [pkg, title] of Object.entries(appTitles as Record)) { + if (typeof title === "string" && title !== "") named[pkg] = title; + } + } + const same = next === active() && JSON.stringify(named) === JSON.stringify(titles()); + if (same) return false; + setActive(next); + setTitles(named); + return true; +} + +/** Labels the themes carry as data (theme.ts), by their English text. */ +const THEME_WORDS: Record = { + Address: "address", + Folders: "folders", + "Other Places": "otherPlaces", +}; + +/** A label a theme carries, in the active language; text this catalog does not know stays as it is. */ +export function themeWord(text: string): string { + const key = THEME_WORDS[text]; + return key ? (lw()[key] as string) : text; +} + +/** An installed app's title in the active language: the host's, else the manifest's. */ +export function appTitle(app: { package: string; title: string }): string { + return titles()[app.package] ?? app.title; +} diff --git a/shells/desktop/test/system-ui.test.ts b/shells/desktop/test/system-ui.test.ts index 69e8976..69437ab 100644 --- a/shells/desktop/test/system-ui.test.ts +++ b/shells/desktop/test/system-ui.test.ts @@ -82,11 +82,14 @@ import { DESKTOP_NAME, POCKET_APPS, WINDOW_TITLE_PREFIX, + aboutBody, appWindowTitle, pocketAppByPackage, readAbout, readWindowTitlePrefix, } from "../src/system-ui/pocket-apps.ts"; +import { appTitle, applyLang, lw, themeWord, wordsIn } from "../src/system-ui/words.ts"; +import { DESK_LABEL_MAX_W, desktopLabelShift, desktopLabelText } from "../src/system-ui/chrome.tsx"; import about from "../pocket.about.json"; import system from "../pocket.system.json"; import { @@ -161,7 +164,7 @@ describe("Pocket app desktop catalog", () => { }); test("the About dialog takes its text from pocket.about.json", () => { - expect(DESKTOP_ABOUT).toEqual(about); + expect(DESKTOP_ABOUT).toEqual({ ...about, translations: {} }); // This repository's file describes the shell. expect(DESKTOP_ABOUT).toEqual({ body: [ @@ -171,6 +174,7 @@ describe("Pocket app desktop catalog", () => { "live in the guest.", ], link: "github.com/pocket-nexus/pocket-shell", + translations: {}, }); }); @@ -212,31 +216,88 @@ describe("Pocket app desktop catalog", () => { link: "example.org", windowTitlePrefix: "Desk", }), - ).toEqual({ body: ["One line."], link: "example.org" }); + ).toEqual({ body: ["One line."], link: "example.org", translations: {} }); }); test("an About file may leave out its body, its link or both", () => { expect(readAbout({ body: ["One line."], link: "example.org" })).toEqual({ body: ["One line."], link: "example.org", + translations: {}, }); expect(readAbout({ body: ["One line."] })).toEqual({ body: ["One line."], link: "", + translations: {}, }); expect(readAbout({ link: "example.org" })).toEqual({ body: [], link: "example.org", + translations: {}, }); // Entries that are not strings are dropped; a body or link of another // type reads as absent. expect(readAbout({ body: ["a", 1, null, "b"], link: 2 })).toEqual({ body: ["a", "b"], link: "", + translations: {}, }); - expect(readAbout({ body: "One line." })).toEqual({ body: [], link: "" }); + expect(readAbout({ body: "One line." })).toEqual({ body: [], link: "", translations: {} }); for (const value of [{}, [], null, undefined, "text", 3]) - expect(readAbout(value)).toEqual({ body: [], link: "" }); + expect(readAbout(value)).toEqual({ body: [], link: "", translations: {} }); + }); + + test("an About file may carry its body in other languages", () => { + const read = readAbout({ + body: ["One line."], + translations: { ja: { body: ["一行。", 2] }, fr: "texte", de: { link: "x" } }, + }); + expect(read.translations).toEqual({ ja: ["一行。"] }); + expect(aboutBody(read, "ja")).toEqual(["一行。"]); + // A language the file does not carry shows the body as written. + expect(aboutBody(read, "en")).toEqual(["One line."]); + expect(aboutBody(read, "fr")).toEqual(["One line."]); + }); + + test("the shell's words: one key set in each language, and the lang line", () => { + expect(Object.keys(wordsIn("ja")).sort()).toEqual(Object.keys(wordsIn("en")).sort()); + for (const [key, value] of Object.entries(wordsIn("ja"))) { + if (typeof value === "string") expect([key, value.length > 0]).toEqual([key, true]); + } + expect(wordsIn("ja").notepadTitle("README.TXT")).toBe("README.TXT - メモ帳"); + expect(wordsIn("ja").about("Pocket Nexus")).toBe("Pocket Nexus について"); + expect(wordsIn("en").about("Pocket Nexus")).toBe("About Pocket Nexus"); + + expect(lw().start).toBe("Start"); + expect(applyLang("de")).toBe(false); + expect(applyLang("ja", { "dev.pocket-nexus.hero": "ヒーロー", other: 3 })).toBe(true); + expect(lw().start).toBe("スタート"); + expect(appTitle({ package: "dev.pocket-nexus.hero", title: "Hero" })).toBe("ヒーロー"); + expect(appTitle({ package: "dev.pocket-nexus.other", title: "Other" })).toBe("Other"); + expect(themeWord("Address")).toBe("アドレス"); + expect(themeWord("Classic 98")).toBe("Classic 98"); + // The same line again changes nothing; English gives the manifest titles back. + expect(applyLang("ja", { "dev.pocket-nexus.hero": "ヒーロー" })).toBe(false); + expect(applyLang("en")).toBe(true); + expect(appTitle({ package: "dev.pocket-nexus.hero", title: "Hero" })).toBe("Hero"); + }); + + test("a desktop label wider than its column is cut until it is selected, and stays on the screen", () => { + const measure = (text: string) => [...text].length * 11; + expect(desktopLabelText("ごみ箱", false, measure)).toBe("ごみ箱"); + const cut = desktopLabelText("インストールガイド", false, measure); + expect(cut.endsWith("...")).toBe(true); + expect(measure(cut)).toBeLessThanOrEqual(DESK_LABEL_MAX_W); + expect(desktopLabelText("インストールガイド", true, measure)).toBe("インストールガイド"); + // With no icon beside it in the next column: the whole name, moved onto the screen. + expect(desktopLabelText("インストールガイド", false, measure, false)).toBe("インストールガイド"); + // A label as wide as its cell or narrower stays centred. + expect(desktopLabelShift(8, 74, 800)).toBe(0); + // A wide label beside the left edge moves right until it starts 2px in. + expect(8 + (74 - 100) / 2 + desktopLabelShift(8, 100, 800)).toBe(2); + // Beside the right edge it moves left until it ends 2px in. + const x = 800 - 8 - 74; + expect(x + (74 - 100) / 2 + desktopLabelShift(x, 100, 800) + 100).toBe(798); }); test("a host open line resolves installed apps only", () => {