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).
This commit is contained in:
Softer 2026-06-10 16:44:20 +03:00
parent 919a435a6b
commit d3ed8cd872
6 changed files with 367 additions and 6 deletions

View File

@ -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]

View File

@ -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 ""

View File

@ -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
}

View File

@ -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'),
)

View File

@ -150,6 +150,19 @@ class LoadingScreen(BaseScreen[ValueT]):
_ = 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 @@ class OptionListScreen(BaseScreen[ValueT]):
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 @@ class SelectListScreen(BaseScreen[ValueT]):
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 @@ class ConfirmationScreen(BaseScreen[ValueT]):
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 @@ class InputScreen(BaseScreen[str]):
with Center(classes='container-wrapper'):
with Vertical(classes='input-content'):
yield Input(
yield _Input(
placeholder=self._placeholder,
password=self._password,
value=self._default_value,

View File

@ -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())