From eaa81450b5dc11e3acba29b0135872f2921be49d Mon Sep 17 00:00:00 2001 From: Aleksander Date: Sat, 2 Aug 2025 23:31:23 +0200 Subject: [PATCH] wgui: basic i18n support, refactoring: use `LayoutState`, translation framework (LLM-based generator) --- .gitignore | 3 +- Cargo.lock | 5 +- scripts/translator/bun.lock | 37 ++++ scripts/translator/description.txt | 13 ++ scripts/translator/main.ts | 186 ++++++++++++++++++ scripts/translator/package-lock.json | 108 ++++++++++ scripts/translator/package.json | 16 ++ scripts/translator/run.sh | 12 ++ scripts/translator/templates/de.json | 20 ++ scripts/translator/templates/es.json | 20 ++ scripts/translator/templates/ja.json | 20 ++ scripts/translator/templates/pl.json | 20 ++ scripts/translator/tsconfig.json | 21 ++ uidev/assets/gui/various_widgets.xml | 3 +- uidev/assets/lang/de.json | 9 + uidev/assets/lang/en.json | 9 + uidev/assets/lang/es.json | 9 + uidev/assets/lang/ja.json | 9 + uidev/assets/lang/pl.json | 9 + uidev/src/testbed/testbed_any.rs | 10 +- uidev/src/testbed/testbed_generic.rs | 9 +- wgui/Cargo.toml | 1 + wgui/src/animation.rs | 22 +-- wgui/src/components/button.rs | 44 +++-- wgui/src/components/mod.rs | 11 +- wgui/src/components/slider.rs | 68 +++---- wgui/src/drawing.rs | 14 +- wgui/src/event.rs | 22 ++- wgui/src/globals.rs | 34 ++++ wgui/src/i18n.rs | 110 +++++++++++ wgui/src/layout.rs | 102 +++++----- wgui/src/lib.rs | 2 + wgui/src/parser/component_button.rs | 11 +- wgui/src/parser/mod.rs | 7 +- wgui/src/parser/widget_label.rs | 14 +- wgui/src/parser/widget_sprite.rs | 13 +- wgui/src/widget/mod.rs | 8 +- wgui/src/widget/text.rs | 19 +- wlx-overlay-s/src/assets/gui/bar.xml | 4 +- wlx-overlay-s/src/assets/lang/en.json | 1 + wlx-overlay-s/src/gui/panel.rs | 9 +- .../src/overlays/keyboard/builder.rs | 7 +- wlx-overlay-s/src/overlays/toast.rs | 36 ++-- wlx-overlay-s/src/overlays/watch.rs | 24 ++- wlx-overlay-s/src/state.rs | 8 +- 45 files changed, 916 insertions(+), 223 deletions(-) create mode 100644 scripts/translator/bun.lock create mode 100644 scripts/translator/description.txt create mode 100644 scripts/translator/main.ts create mode 100644 scripts/translator/package-lock.json create mode 100644 scripts/translator/package.json create mode 100755 scripts/translator/run.sh create mode 100644 scripts/translator/templates/de.json create mode 100644 scripts/translator/templates/es.json create mode 100644 scripts/translator/templates/ja.json create mode 100644 scripts/translator/templates/pl.json create mode 100644 scripts/translator/tsconfig.json create mode 100644 uidev/assets/lang/de.json create mode 100644 uidev/assets/lang/en.json create mode 100644 uidev/assets/lang/es.json create mode 100644 uidev/assets/lang/ja.json create mode 100644 uidev/assets/lang/pl.json create mode 100644 wgui/src/globals.rs create mode 100644 wgui/src/i18n.rs create mode 100644 wlx-overlay-s/src/assets/lang/en.json diff --git a/.gitignore b/.gitignore index 781403f..5cd5747 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /target .gdb_history .vscode -.cargo \ No newline at end of file +.cargo +scripts/translator/node_modules \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 3b72c5b..2ff2af2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4391,9 +4391,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.141" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3" dependencies = [ "itoa", "memchr", @@ -5649,6 +5649,7 @@ dependencies = [ "resvg", "roxmltree 0.20.0", "rustc-hash 2.1.1", + "serde_json", "slotmap", "smallvec", "taffy", diff --git a/scripts/translator/bun.lock b/scripts/translator/bun.lock new file mode 100644 index 0000000..8df74b6 --- /dev/null +++ b/scripts/translator/bun.lock @@ -0,0 +1,37 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "llm_translator", + "dependencies": { + "ollama": "^0.5.14", + }, + "devDependencies": { + "@types/bun": "latest", + "@types/node": "^22.13.13", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="], + + "@types/node": ["@types/node@22.17.0", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-bbAKTCqX5aNVryi7qXVMi+OkB3w/OyblodicMbvE38blyAz7GxXf6XYhklokijuPwwVg9sDLKRxt0ZHXQwZVfQ=="], + + "@types/react": ["@types/react@19.1.9", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA=="], + + "bun-types": ["bun-types@1.2.19", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-uAOTaZSPuYsWIXRpj7o56Let0g/wjihKCkeRqUBhlLVM/Bt+Fj9xTo+LhC1OV1XDaGkz4hNC80et5xgy+9KTHQ=="], + + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + + "ollama": ["ollama@0.5.16", "", { "dependencies": { "whatwg-fetch": "^3.6.20" } }, "sha512-OEbxxOIUZtdZgOaTPAULo051F5y+Z1vosxEYOoABPnQKeW7i4O8tJNlxCB+xioyoorVqgjkdj+TA1f1Hy2ug/w=="], + + "typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="], + } +} diff --git a/scripts/translator/description.txt b/scripts/translator/description.txt new file mode 100644 index 0000000..46bbc78 --- /dev/null +++ b/scripts/translator/description.txt @@ -0,0 +1,13 @@ +You are a translator for the VR application which translates strings from English to {TARGET_LANG} language. +Info: This program is intended to be used as an utility to easily access your desktop display from within VR. + +Glossary: +wlx-overlay-s: The name of this software (also called WlxOverlay-S) +WayVR: A Wayland compositor intended to be used in VR +WayVR Dashboard: An application (and game) launcher which is displayed in front of the user +OpenVR: API made by Valve +OpenXR: API made by Khronos +OSC: OpenSoundControl + +You will be given the input in the code blocks which needs to be translated to {TARGET_LANG} language. Write only the result in codeblocks, do not explain. Keep any newlines and other important formatting-required identifiers as-is. + diff --git a/scripts/translator/main.ts b/scripts/translator/main.ts new file mode 100644 index 0000000..1a6cdcf --- /dev/null +++ b/scripts/translator/main.ts @@ -0,0 +1,186 @@ +import { exit } from "process"; +import * as fsp from "fs/promises"; +import path from "path"; +import * as fs from "fs"; +import ollama from 'ollama' + +const model_name = process.env["MODEL"] as string; +const template_name = process.env["TEMPLATE"] as string; +let lang_path = process.env["LANG_PATH"] as string; + +if (model_name === undefined) { + console.log("MODEL not set"); + exit(-1); +} + +if (template_name === undefined) { + console.log("TEMPLATE not set"); + exit(-1); +} + +if (lang_path === undefined) { + console.log("LANG_PATH not set. Try \"LANG_PATH=../../uidev/assets/lang/ ./run.sh\""); + exit(-1); +} + +lang_path = path.resolve(__dirname + "/" + lang_path); +if (lang_path === undefined || !fs.existsSync(lang_path)) { + console.log("Invalid or non-existent LANG_PATH"); + exit(-1); +} + +const current_path = path.resolve(__dirname); +const templates_path = path.resolve(__dirname + "/templates"); + +async function loop_object(obj: any, initial_str: string, callback: (key: string, value: string) => Promise) { + for (var key in obj) { + let full_key = initial_str + key; + if (typeof obj[key] === "object" && obj[key] !== null) { + await loop_object(obj[key], full_key + ".", callback) + } else if (obj.hasOwnProperty(key)) { + await callback(full_key, obj[key]) + } + } +} + +function extract_backticks(str: string) { + const regex = /`([^`]+)`/g; + return str.match(regex)?.map(match => match.slice(1, -1).trim()); +} + +function set_i18n_key(obj: any, key: string, value: string | undefined) { + const parts = key.split('.'); + let cur_level = obj; + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]!; + if (!cur_level[part]) { + cur_level[part] = {}; + } + cur_level = cur_level[part]; + } + cur_level[parts[parts.length - 1]!] = value; +} + +function key_exists(obj: any, key: string) { + const parts = key.split('.'); + let level = obj; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]!; + if (!level || !level[part]) { + return false; + } + level = level[part]; + } + + return true; +}; + +interface Example { + key: string; + en: string; + translated: string; +} + +interface Template { + full_name: string; // "Polish" + examples: Example[] +} + +function gen_prompt(description: string, template: Template, key: string, english_translation: string) { + let num = 1; + for (const example of template.examples) { + description += "\nExample " + num + ":\n\n"; + description += "Translate key `" + example.key + "` from English to " + template.full_name + ":\n\n"; + description += "```\n"; + description += example.en + "\n"; + description += "```\n\n"; + description += "Result:\n\n"; + description += "```\n"; + description += example.translated + "\n"; + description += "```\n"; + num += 1; + } + description += "\nEnd of examples.\n\n"; + description += "Translate key `" + key + "` from English to " + template.full_name + ":\n\n"; + description += "```\n"; + description += english_translation + "\n"; + description += "```\n"; + return description; +} + +async function run() { + const template = JSON.parse(await fsp.readFile(templates_path + "/" + template_name + ".json", "utf-8")) as Template; + + let description_txt = await fsp.readFile(current_path + "/description.txt", "utf-8"); + description_txt = description_txt.replaceAll("{TARGET_LANG}", template.full_name); + + const orig_english_json = JSON.parse(await fsp.readFile(lang_path + "/en.json") as any); + + let orig_translated_json = {}; + try { + orig_translated_json = JSON.parse((await fsp.readFile(lang_path + "/" + template_name + ".json")).toString()); + } + catch (_e) { } + + let llm_translated_json = {}; + const translated_json_path = lang_path + "/" + template_name + ".json"; + if (await fsp.exists(translated_json_path)) { + llm_translated_json = JSON.parse((await fsp.readFile(translated_json_path)).toString()); + } + + let human = 0; + let llm = 0; + + let total_count = 0; + await loop_object(orig_english_json, "", async () => { + total_count += 1; + }); + + await loop_object(llm_translated_json, "", async (key, _) => { + if (!key_exists(orig_english_json, key)) { + console.log("Removing key", key); + set_i18n_key(llm_translated_json, key, undefined); + fsp.writeFile(translated_json_path, JSON.stringify(llm_translated_json, undefined, 2)); + } + }); + + await loop_object(orig_english_json, "", async (key, english_translation) => { + if (key_exists(orig_translated_json, key)) { + human += 1; + return; + } + + if (key_exists(llm_translated_json, key)) { + llm += 1; + return; + } + + console.log("Translating", key, "..."); + llm++; + + const prompt = gen_prompt(description_txt, template, key, english_translation); + + const response = await ollama.chat({ + model: model_name, + messages: [{ role: "user", content: prompt }], + }) + + const msg = extract_backticks(response.message.content); + if (msg === undefined || msg[0] === undefined) { + throw new Error("backticks failed. Raw content: " + response.message.content); + } + + console.log(" >>>", msg); + + set_i18n_key(llm_translated_json, key, msg[0]); + fsp.writeFile(translated_json_path, JSON.stringify(llm_translated_json, undefined, 2)); + }); + + console.log("\"" + template_name + "\" translation finished,", human, "were already human translated,", llm, "llm-translated (" + Math.round((llm / total_count) * 100.0) + "% machine-translated)"); +} + +run().catch((e) => { + console.log("Fatal error:", e); + exit(-1); +}); \ No newline at end of file diff --git a/scripts/translator/package-lock.json b/scripts/translator/package-lock.json new file mode 100644 index 0000000..0834aac --- /dev/null +++ b/scripts/translator/package-lock.json @@ -0,0 +1,108 @@ +{ + "name": "llm_translator", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "llm_translator", + "dependencies": { + "ollama": "^0.5.14" + }, + "devDependencies": { + "@types/bun": "latest", + "@types/node": "^22.13.13" + }, + "peerDependencies": { + "typescript": "^5" + } + }, + "node_modules/@types/bun": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.2.19.tgz", + "integrity": "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bun-types": "1.2.19" + } + }, + "node_modules/@types/node": { + "version": "22.17.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.17.0.tgz", + "integrity": "sha512-bbAKTCqX5aNVryi7qXVMi+OkB3w/OyblodicMbvE38blyAz7GxXf6XYhklokijuPwwVg9sDLKRxt0ZHXQwZVfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.1.9", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.9.tgz", + "integrity": "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/bun-types": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.2.19.tgz", + "integrity": "sha512-uAOTaZSPuYsWIXRpj7o56Let0g/wjihKCkeRqUBhlLVM/Bt+Fj9xTo+LhC1OV1XDaGkz4hNC80et5xgy+9KTHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "peerDependencies": { + "@types/react": "^19" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/ollama": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/ollama/-/ollama-0.5.16.tgz", + "integrity": "sha512-OEbxxOIUZtdZgOaTPAULo051F5y+Z1vosxEYOoABPnQKeW7i4O8tJNlxCB+xioyoorVqgjkdj+TA1f1Hy2ug/w==", + "license": "MIT", + "dependencies": { + "whatwg-fetch": "^3.6.20" + } + }, + "node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "license": "MIT" + } + } +} diff --git a/scripts/translator/package.json b/scripts/translator/package.json new file mode 100644 index 0000000..d01cf03 --- /dev/null +++ b/scripts/translator/package.json @@ -0,0 +1,16 @@ +{ + "devDependencies": { + "@types/bun": "latest", + "@types/node": "^22.13.13" + }, + "name": "llm_translator", + "module": "index.ts", + "type": "module", + "private": true, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "ollama": "^0.5.14" + } +} diff --git a/scripts/translator/run.sh b/scripts/translator/run.sh new file mode 100755 index 0000000..93261e6 --- /dev/null +++ b/scripts/translator/run.sh @@ -0,0 +1,12 @@ +#!/bin/bash +set -e +cd "$(dirname "$0")" + +bun install + +export MODEL="gemma3:12b" + +TEMPLATE="pl" bun main.ts +TEMPLATE="de" bun main.ts +TEMPLATE="ja" bun main.ts +TEMPLATE="es" bun main.ts diff --git a/scripts/translator/templates/de.json b/scripts/translator/templates/de.json new file mode 100644 index 0000000..4f617fa --- /dev/null +++ b/scripts/translator/templates/de.json @@ -0,0 +1,20 @@ +{ + "full_name": "German", + "examples": [ + { + "key": "BAR.OPACITY", + "en": "Opacity", + "translated": "Undurchsichtigkeit" + }, + { + "key": "PANEL.CLOSE", + "en": "Close panel", + "translated": "Schließen Sie das Panel" + }, + { + "key": "SETTINGS.LEFT_HANDED_MODE", + "en": "Left-handed mode", + "translated": "Linkshändige Modus" + } + ] +} diff --git a/scripts/translator/templates/es.json b/scripts/translator/templates/es.json new file mode 100644 index 0000000..f2eb766 --- /dev/null +++ b/scripts/translator/templates/es.json @@ -0,0 +1,20 @@ +{ + "full_name": "Spanish", + "examples": [ + { + "key": "BAR.OPACITY", + "en": "Opacity", + "translated": "Opacidad" + }, + { + "key": "PANEL.CLOSE", + "en": "Close panel", + "translated": "Cerrar panel" + }, + { + "key": "SETTINGS.LEFT_HANDED_MODE", + "en": "Left-handed mode", + "translated": "Modo para zurdos" + } + ] +} diff --git a/scripts/translator/templates/ja.json b/scripts/translator/templates/ja.json new file mode 100644 index 0000000..c4e657c --- /dev/null +++ b/scripts/translator/templates/ja.json @@ -0,0 +1,20 @@ +{ + "full_name": "Japanese", + "examples": [ + { + "key": "BAR.OPACITY", + "en": "Opacity", + "translated": "不透明度" + }, + { + "key": "PANEL.CLOSE", + "en": "Close panel", + "translated": "パネルを閉じる" + }, + { + "key": "SETTINGS.LEFT_HANDED_MODE", + "en": "Left-handed mode", + "translated": "左利きモード" + } + ] +} diff --git a/scripts/translator/templates/pl.json b/scripts/translator/templates/pl.json new file mode 100644 index 0000000..6e7866f --- /dev/null +++ b/scripts/translator/templates/pl.json @@ -0,0 +1,20 @@ +{ + "full_name": "Polish", + "examples": [ + { + "key": "BAR.OPACITY", + "en": "Opacity", + "translated": "Przezroczystość" + }, + { + "key": "PANEL.CLOSE", + "en": "Close panel", + "translated": "Zamknij panel" + }, + { + "key": "SETTINGS.LEFT_HANDED_MODE", + "en": "Left-handed mode", + "translated": "Tryb leworęczny" + } + ] +} diff --git a/scripts/translator/tsconfig.json b/scripts/translator/tsconfig.json new file mode 100644 index 0000000..7b72024 --- /dev/null +++ b/scripts/translator/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "lib": ["esnext"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/uidev/assets/gui/various_widgets.xml b/uidev/assets/gui/various_widgets.xml index 981c259..3cbddb9 100644 --- a/uidev/assets/gui/various_widgets.xml +++ b/uidev/assets/gui/various_widgets.xml @@ -5,7 +5,8 @@ width="1000" height="500" min_width="1000" min_height="500" gap="4" flex_direction="column" overflow_y="scroll"> -