Python: Added support for run-time translations from .slint files

Fixes #7956
This commit is contained in:
Simon Hausmann 2025-09-26 16:17:52 +02:00 committed by Simon Hausmann
parent ca4e337146
commit 2bbe5e8786
12 changed files with 221 additions and 5 deletions

View file

@ -43,7 +43,7 @@ accessibility = ["slint-interpreter/accessibility"]
[dependencies]
i-slint-backend-selector = { workspace = true }
i-slint-core = { workspace = true }
i-slint-core = { workspace = true, features = ["tr"] }
slint-interpreter = { workspace = true, features = ["default", "display-diagnostics", "internal"] }
i-slint-compiler = { workspace = true }
pyo3 = { version = "0.26", features = ["extension-module", "indexmap", "chrono", "abi3-py311"] }
@ -53,6 +53,7 @@ spin_on = { workspace = true }
css-color-parser2 = { workspace = true }
pyo3-stub-gen = { version = "0.9.0", default-features = false }
smol = { version = "2.0.0" }
tr = { workspace = true }
[package.metadata.maturin]
python-source = "slint"

View file

@ -78,6 +78,80 @@ fn invoke_from_event_loop(callable: Py<PyAny>) -> Result<(), errors::PyEventLoop
.map_err(|e| e.into())
}
#[gen_stub_pyfunction]
#[pyfunction]
fn init_translations(_py: Python<'_>, translations: Bound<PyAny>) -> PyResult<()> {
i_slint_backend_selector::with_global_context(|ctx| {
ctx.set_external_translator(if translations.is_none() {
None
} else {
Some(Box::new(PyGettextTranslator(translations.unbind())))
});
i_slint_core::translations::mark_all_translations_dirty();
})
.map_err(|e| errors::PyPlatformError(e))?;
Ok(())
}
struct PyGettextTranslator(Py<PyAny>);
impl tr::Translator for PyGettextTranslator {
fn translate<'a>(
&'a self,
string: &'a str,
context: Option<&'a str>,
) -> std::borrow::Cow<'a, str> {
Python::attach(|py| {
match if let Some(context) = context {
self.0.call_method(py, pyo3::intern!(py, "pgettext"), (context, string), None)
} else {
self.0.call_method(py, pyo3::intern!(py, "gettext"), (string,), None)
} {
Ok(translation) => Some(translation),
Err(err) => {
handle_unraisable(py, "calling pgettext/gettext".into(), err);
None
}
}
.and_then(|maybe_str| maybe_str.extract::<String>(py).ok())
.map(std::borrow::Cow::Owned)
})
.unwrap_or(std::borrow::Cow::Borrowed(string))
.into()
}
fn ntranslate<'a>(
&'a self,
n: u64,
singular: &'a str,
plural: &'a str,
context: Option<&'a str>,
) -> std::borrow::Cow<'a, str> {
Python::attach(|py| {
match if let Some(context) = context {
self.0.call_method(
py,
pyo3::intern!(py, "npgettext"),
(context, singular, plural, n),
None,
)
} else {
self.0.call_method(py, pyo3::intern!(py, "ngettext"), (singular, plural, n), None)
} {
Ok(translation) => Some(translation),
Err(err) => {
handle_unraisable(py, "calling npgettext/ngettext".into(), err);
None
}
}
.and_then(|maybe_str| maybe_str.extract::<String>(py).ok())
.map(std::borrow::Cow::Owned)
})
.unwrap_or(std::borrow::Cow::Borrowed(singular))
.into()
}
}
use pyo3::prelude::*;
#[pymodule]
@ -107,6 +181,7 @@ fn slint(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(quit_event_loop, m)?)?;
m.add_function(wrap_pyfunction!(set_xdg_app_id, m)?)?;
m.add_function(wrap_pyfunction!(invoke_from_event_loop, m)?)?;
m.add_function(wrap_pyfunction!(init_translations, m)?)?;
Ok(())
}

View file

@ -20,6 +20,7 @@ from .loop import SlintEventLoop
from pathlib import Path
from collections.abc import Coroutine
import asyncio
import gettext
Struct = native.PyStruct
@ -493,6 +494,25 @@ def quit_event_loop() -> None:
quit_event.set()
def init_translations(translations: typing.Optional[gettext.GNUTranslations]) -> None:
"""Installs the specified translations object to handle translations originating from the Slint code.
Example:
```python
import gettext
import slint
translations_dir = os.path.join(os.path.dirname(__file__), "lang")
try:
translations = gettext.translation("my_app", translations_dir, ["de"])
slint.install_translations(translations)
except OSError:
pass
```
"""
native.init_translations(translations)
__all__ = [
"CompileError",
"Component",
@ -509,4 +529,5 @@ __all__ = [
"callback",
"run_event_loop",
"quit_event_loop",
"init_translations",
]

View file

@ -12,6 +12,7 @@ import typing
from typing import Any, List
from collections.abc import Callable, Buffer, Coroutine
from enum import Enum, auto
import gettext
class RgbColor:
red: int
@ -146,6 +147,9 @@ def set_xdg_app_id(app_id: str) -> None: ...
def invoke_from_event_loop(callable: typing.Callable[[], None]) -> None: ...
def run_event_loop() -> None: ...
def quit_event_loop() -> None: ...
def init_translations(
translations: typing.Optional[gettext.GNUTranslations],
) -> None: ...
class PyModelBase:
def init_self(self, *args: Any) -> None: ...

View file

@ -56,6 +56,8 @@ export component App inherits Window {
Rectangle {
color: red;
}
in-out property <string> translated: @tr("Yes");
}
component Diag inherits Window { }

View file

@ -0,0 +1,39 @@
# Copyright © SixtyFPS GmbH <info@slint.dev>
# SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
from slint import load_file, init_translations
from pathlib import Path
import gettext
import typing
def base_dir() -> Path:
origin = __spec__.origin
assert origin is not None
base_dir = Path(origin).parent
assert base_dir is not None
return base_dir
class DummyTranslation:
def gettext(self, message: str) -> str:
if message == "Yes":
return "Ja"
return message
def pgettext(self, context: str, message: str) -> str:
return self.gettext(message)
def test_load_file() -> None:
module = load_file(base_dir() / "test-load-file.slint")
testcase = module.App()
assert testcase.translated == "Yes"
init_translations(typing.cast(gettext.GNUTranslations, DummyTranslation()))
try:
assert testcase.translated == "Ja"
finally:
init_translations(None)
assert testcase.translated == "Yes"