From d3ed8cd8724b5eaa711765bad95296c3cedccb55 Mon Sep 17 00:00:00 2001 From: Softer Date: Wed, 10 Jun 2026 16:44:20 +0300 Subject: [PATCH] Extract binding descriptions into base.pot and translate raw widget bindings (#4584) xgettext only knew the tr keyword, so binding descriptions - translated at runtime via tr(b.description) in _translate_bindings() - never made it into base.pot. The strings added manually in #4363 were wiped by the regeneration in #4557. Teach the generator two more keywords: Binding:3 picks up our own binding definitions, and tr_noop marks strings that only exist inside the textual package, listed in the new tui/binding_descriptions.py. The list walks the full MRO of the textual classes used in components.py, since _translate_bindings() operates on the merged bindings map (e.g. Scroll Up comes from ScrollView and carries live translations in 18 locales today). Raw Input and Button widgets never went through _translate_bindings(), so their help-panel descriptions stayed English regardless of .po content - subclass them (_Input/_Button) following the _OptionList pattern to make those translations real. test_tooling/check_binding_descriptions.py verifies the list against the installed textual after upgrades (manual, not wired into CI). --- archinstall/lib/translationhandler.py | 9 + archinstall/locales/base.pot | 190 +++++++++++++++++++++ archinstall/locales/locales_generator.sh | 8 +- archinstall/tui/binding_descriptions.py | 77 +++++++++ archinstall/tui/components.py | 23 ++- test_tooling/check_binding_descriptions.py | 66 +++++++ 6 files changed, 367 insertions(+), 6 deletions(-) create mode 100644 archinstall/tui/binding_descriptions.py create mode 100755 test_tooling/check_binding_descriptions.py diff --git a/archinstall/lib/translationhandler.py b/archinstall/lib/translationhandler.py index c2736f3551..e59ba69620 100644 --- a/archinstall/lib/translationhandler.py +++ b/archinstall/lib/translationhandler.py @@ -277,6 +277,15 @@ def tr(message: str) -> str: return str(_DeferredTranslation(message)) +def tr_noop(message: str) -> str: + """Mark a string for xgettext extraction without translating it here. + + Use for strings that are translated later from a variable, e.g. + binding descriptions passed through tr() at runtime. + """ + return message + + builtins._ = _DeferredTranslation # type: ignore[attr-defined] diff --git a/archinstall/locales/base.pot b/archinstall/locales/base.pot index 23df29fb19..2f2a2a0b81 100644 --- a/archinstall/locales/base.pot +++ b/archinstall/locales/base.pot @@ -209,6 +209,9 @@ msgstr "" msgid "Install to removable location" msgstr "" +msgid "Plymouth" +msgstr "" + msgid "Will install to /EFI/BOOT/ (removable location, safe default)" msgstr "" @@ -248,6 +251,9 @@ msgstr "" msgid "UEFI is not detected and some options are disabled" msgstr "" +msgid "Select Plymouth theme" +msgstr "" + msgid "The specified configuration will be applied" msgstr "" @@ -882,6 +888,10 @@ msgstr "" msgid "Removable" msgstr "" +#, python-brace-format +msgid "Plymouth \"{}\"" +msgstr "" + msgid "Use a best-effort default partition layout" msgstr "" @@ -1196,12 +1206,192 @@ msgstr "" msgid "Starting device modifications in " msgstr "" +msgid "Bottom" +msgstr "" + +msgid "Copy selected text" +msgstr "" + +msgid "Cursor down" +msgstr "" + +msgid "Cursor left" +msgstr "" + +msgid "Cursor right" +msgstr "" + +msgid "Cursor up" +msgstr "" + +msgid "Cut selected text" +msgstr "" + +msgid "Delete all to the left" +msgstr "" + +msgid "Delete all to the right" +msgstr "" + +msgid "Delete character left" +msgstr "" + +msgid "Delete character right" +msgstr "" + +msgid "Delete left to start of word" +msgstr "" + +msgid "Delete right to start of word" +msgstr "" + +msgid "Down" +msgstr "" + +msgid "End" +msgstr "" + +msgid "First" +msgstr "" + +msgid "Focus Next" +msgstr "" + +msgid "Focus Previous" +msgstr "" + +msgid "Go to end" +msgstr "" + +msgid "Go to start" +msgstr "" + +msgid "Home" +msgstr "" + +msgid "Last" +msgstr "" + +msgid "Move cursor left" +msgstr "" + +msgid "Move cursor left a word" +msgstr "" + +msgid "Move cursor left a word and select" +msgstr "" + +msgid "Move cursor left and select" +msgstr "" + +msgid "Move cursor right a word" +msgstr "" + +msgid "Move cursor right a word and select" +msgstr "" + +msgid "Move cursor right and select" +msgstr "" + +msgid "Move cursor right or accept the completion suggestion" +msgstr "" + +msgid "Page Down" +msgstr "" + +msgid "Page Left" +msgstr "" + +msgid "Page Right" +msgstr "" + +msgid "Page Up" +msgstr "" + +msgid "Page down" +msgstr "" + +msgid "Page up" +msgstr "" + +msgid "Paste text from the clipboard" +msgstr "" + +msgid "Press button" +msgstr "" + +msgid "Quit" +msgstr "" + +msgid "Scroll Down" +msgstr "" + +msgid "Scroll End" +msgstr "" + +msgid "Scroll Home" +msgstr "" + +msgid "Scroll Left" +msgstr "" + +msgid "Scroll Right" +msgstr "" + +msgid "Scroll Up" +msgstr "" + +msgid "Select" +msgstr "" + +msgid "Select all" +msgstr "" + +msgid "Select line end" +msgstr "" + +msgid "Select line start" +msgstr "" + +msgid "Submit" +msgstr "" + +msgid "Toggle option" +msgstr "" + +msgid "Top" +msgstr "" + +msgid "Up" +msgstr "" + +msgid "Reset" +msgstr "" + +msgid "Search" +msgstr "" + +msgid "Toggle" +msgstr "" + +msgid "Confirm" +msgstr "" + +msgid "Focus right" +msgstr "" + +msgid "Focus left" +msgstr "" + msgid "Ok" msgstr "" msgid "Input cannot be empty" msgstr "" +msgid "Show/Hide help" +msgstr "" + msgid "Yes" msgstr "" diff --git a/archinstall/locales/locales_generator.sh b/archinstall/locales/locales_generator.sh index cc8d1b02e1..90818eb435 100755 --- a/archinstall/locales/locales_generator.sh +++ b/archinstall/locales/locales_generator.sh @@ -14,8 +14,14 @@ usage() { } generate_pot() { + # tr - regular translation calls + # tr_noop - extraction-only marker for strings translated later from a variable + # Binding:3 - the description argument of textual Binding() definitions, + # translated at runtime by _translate_bindings(). The description + # must be passed positionally - xgettext cannot see keyword arguments. find . -type f -iname '*.py' | sort \ - | xargs xgettext --no-location --omit-header --keyword='tr' \ + | xargs xgettext --no-location --omit-header \ + --keyword='tr' --keyword='tr_noop' --keyword='Binding:3' \ -d base -o locales/base.pot } diff --git a/archinstall/tui/binding_descriptions.py b/archinstall/tui/binding_descriptions.py new file mode 100644 index 0000000000..d432fd2a99 --- /dev/null +++ b/archinstall/tui/binding_descriptions.py @@ -0,0 +1,77 @@ +"""Textual's built-in key binding descriptions, listed for xgettext. + +These strings live inside the textual package, so xgettext cannot find them +when scanning this code base. At runtime _translate_bindings() in +components.py translates every binding description with tr(), including the +ones inherited from textual widgets - this module only makes those strings +visible to the extraction in locales_generator.sh (via the tr_noop keyword). + +The list covers the MRO-merged bindings of the textual classes used in +components.py: _translate_bindings() operates on the merged bindings map, so +descriptions inherited from ancestors (e.g. Scroll Up from ScrollView) are +translated at runtime too and must be listed here. + +Verify or regenerate the list after a textual upgrade or after introducing +a new textual widget in components.py: + + python3 test_tooling/check_binding_descriptions.py +""" + +from archinstall.lib.translationhandler import tr_noop + +# textual 8.2.7 +TEXTUAL_BINDING_DESCRIPTIONS: tuple[str, ...] = ( + tr_noop('Bottom'), + tr_noop('Copy selected text'), + tr_noop('Cursor down'), + tr_noop('Cursor left'), + tr_noop('Cursor right'), + tr_noop('Cursor up'), + tr_noop('Cut selected text'), + tr_noop('Delete all to the left'), + tr_noop('Delete all to the right'), + tr_noop('Delete character left'), + tr_noop('Delete character right'), + tr_noop('Delete left to start of word'), + tr_noop('Delete right to start of word'), + tr_noop('Down'), + tr_noop('End'), + tr_noop('First'), + tr_noop('Focus Next'), + tr_noop('Focus Previous'), + tr_noop('Go to end'), + tr_noop('Go to start'), + tr_noop('Home'), + tr_noop('Last'), + tr_noop('Move cursor left'), + tr_noop('Move cursor left a word'), + tr_noop('Move cursor left a word and select'), + tr_noop('Move cursor left and select'), + tr_noop('Move cursor right a word'), + tr_noop('Move cursor right a word and select'), + tr_noop('Move cursor right and select'), + tr_noop('Move cursor right or accept the completion suggestion'), + tr_noop('Page Down'), + tr_noop('Page Left'), + tr_noop('Page Right'), + tr_noop('Page Up'), + tr_noop('Page down'), + tr_noop('Page up'), + tr_noop('Paste text from the clipboard'), + tr_noop('Press button'), + tr_noop('Quit'), + tr_noop('Scroll Down'), + tr_noop('Scroll End'), + tr_noop('Scroll Home'), + tr_noop('Scroll Left'), + tr_noop('Scroll Right'), + tr_noop('Scroll Up'), + tr_noop('Select'), + tr_noop('Select all'), + tr_noop('Select line end'), + tr_noop('Select line start'), + tr_noop('Submit'), + tr_noop('Toggle option'), + tr_noop('Top'), + tr_noop('Up'), +) diff --git a/archinstall/tui/components.py b/archinstall/tui/components.py index a2e6b1322e..03349c9022 100644 --- a/archinstall/tui/components.py +++ b/archinstall/tui/components.py @@ -150,6 +150,19 @@ def action_pop_screen(self) -> None: _ = self.dismiss() +class _Input(Input): + @override + def on_mount(self) -> None: + _translate_bindings(self._merged_bindings, self._bindings) + + +class _Button(Button): + # no @override: Button has no on_mount in its MRO, + # textual dispatches the handler by naming convention + def on_mount(self) -> None: + _translate_bindings(self._merged_bindings, self._bindings) + + class _OptionList(OptionList): BINDINGS: ClassVar = [ Binding('down', 'cursor_down', 'Down', show=True), @@ -309,7 +322,7 @@ def compose(self) -> ComposeResult: yield ScrollableContainer(preview_label) if self._filter: - yield Input(placeholder='/filter', id='filter-input') + yield _Input(placeholder='/filter', id='filter-input') yield Footer() @@ -540,7 +553,7 @@ def compose(self) -> ComposeResult: yield ScrollableContainer(preview_label) if self._filter: - yield Input(placeholder='/filter', id='filter-input') + yield _Input(placeholder='/filter', id='filter-input') yield Footer() @@ -702,12 +715,12 @@ def compose(self) -> ComposeResult: with Vertical(classes='content-container'): with Horizontal(classes='buttons-container'): for item in self._group.items: - yield Button(item.text, id=item.key) + yield _Button(item.text, id=item.key) else: with Vertical(): with Horizontal(classes='buttons-container'): for item in self._group.items: - yield Button(item.text, id=item.key) + yield _Button(item.text, id=item.key) yield Rule(orientation='horizontal') if self._preview_header is not None: @@ -848,7 +861,7 @@ def compose(self) -> ComposeResult: with Center(classes='container-wrapper'): with Vertical(classes='input-content'): - yield Input( + yield _Input( placeholder=self._placeholder, password=self._password, value=self._default_value, diff --git a/test_tooling/check_binding_descriptions.py b/test_tooling/check_binding_descriptions.py new file mode 100755 index 0000000000..0354f15aee --- /dev/null +++ b/test_tooling/check_binding_descriptions.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Verify archinstall/tui/binding_descriptions.py against the installed textual. + +Run manually after a textual upgrade or after introducing a new textual +widget in archinstall/tui/components.py: + + python3 test_tooling/check_binding_descriptions.py + +The script parses the tr_noop() entries from binding_descriptions.py (no +archinstall import needed) and compares them with the MRO-merged binding +descriptions of the textual classes used in components.py. Exits non-zero +on any difference and prints the exact missing/stale entries. +""" + +import re +import sys +from pathlib import Path + +from textual.app import App +from textual.screen import Screen +from textual.widgets import Button, DataTable, HelpPanel, Input, OptionList, SelectionList + +# Must mirror the textual widget classes used in archinstall/tui/components.py. +# Extend when a new widget is introduced there. +TEXTUAL_CLASSES = (App, Screen, Button, DataTable, HelpPanel, Input, OptionList, SelectionList) + +MODULE_PATH = Path(__file__).parent.parent / 'archinstall/tui/binding_descriptions.py' + + +def live_descriptions() -> set[str]: + descriptions: set[str] = set() + # Walk the full MRO: _translate_bindings() operates on the merged bindings + # map, so descriptions inherited from ancestors are translated too. + for cls in TEXTUAL_CLASSES: + for klass in cls.__mro__: + for binding in klass.__dict__.get('BINDINGS', []): + description = getattr(binding, 'description', None) + if not description and isinstance(binding, tuple) and len(binding) > 2: + description = binding[2] + if description: + descriptions.add(str(description)) + return descriptions + + +def listed_descriptions() -> set[str]: + content = MODULE_PATH.read_text(encoding='utf-8') + return set(re.findall(r"tr_noop\('([^']+)'\)", content)) + + +def main() -> int: + live = live_descriptions() + listed = listed_descriptions() + + if live == listed: + print(f'OK: {len(listed)} descriptions in sync with installed textual') + return 0 + + for description in sorted(live - listed): + print(f'missing from binding_descriptions.py: {description}') + for description in sorted(listed - live): + print(f'stale in binding_descriptions.py: {description}') + return 1 + + +if __name__ == '__main__': + sys.exit(main())