Added translation base

This commit is contained in:
2026-07-11 16:53:25 +01:00
parent 614c597d65
commit 2735e6e96b
17 changed files with 517 additions and 41 deletions

58
locales/dynalab.pot Normal file
View File

@@ -0,0 +1,58 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-09 11:20+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=CHARSET\n"
"Content-Transfer-Encoding: 8bit\n"
#: src/dynalab/app.py:53
msgid "Fetched DynaLab config"
msgstr ""
#: src/dynalab/app.py:56
msgid "Starting JSON data server"
msgstr ""
#: src/dynalab/app.py:66
msgid "JSON data server started"
msgstr ""
#: src/dynalab/app.py:68
msgid "DynaLab Ready"
msgstr ""
#: src/dynalab/ui/windows.py:101
msgid "File"
msgstr ""
#: src/dynalab/ui/windows.py:102
msgid "Quit"
msgstr ""
#: src/dynalab/ui/windows.py:103 src/dynalab/ui/windows.py:110
msgid "Record"
msgstr ""
#: src/dynalab/ui/windows.py:105
msgid "Configuration"
msgstr ""
#: src/dynalab/ui/windows.py:107
msgid "Window"
msgstr ""
#: src/dynalab/ui/windows.py:116
msgid "Data Input"
msgstr ""

Binary file not shown.

View File

@@ -0,0 +1,56 @@
# DynaLab English Translations.
# Copyright (C) 2026 Exergie
# This file is distributed under the same license as the DynaLab package.
# Hector van der Aa <hector@h3cx.dev>, 2026.
#
msgid ""
msgstr ""
"Project-Id-Version: DynaLab\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-07-08 23:50+0100\n"
"Last-Translator: Hector van der Aa <hector@h3cx.dev>\n"
"Language-Team: English\n"
"Language: en\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: src/dynalab/app.py:53
msgid "Fetched DynaLab config"
msgstr "Fetched DynaLab config"
#: src/dynalab/app.py:56
msgid "Starting JSON data server"
msgstr "Starting JSON data server"
#: src/dynalab/app.py:66
msgid "JSON data server started"
msgstr "JSON data server started"
#: src/dynalab/app.py:68
msgid "DynaLab Ready"
msgstr "DynaLab Ready"
#: src/dynalab/ui/windows.py:101
msgid "File"
msgstr "File"
#: src/dynalab/ui/windows.py:102
msgid "Quit"
msgstr "Quit"
#: src/dynalab/ui/windows.py:103 src/dynalab/ui/windows.py:110
msgid "Record"
msgstr "Record"
#: src/dynalab/ui/windows.py:105
msgid "Configuration"
msgstr "Configuration"
#: src/dynalab/ui/windows.py:107
msgid "Window"
msgstr "Window"
#: src/dynalab/ui/windows.py:116
msgid "Data Input"
msgstr "Data Input"

Binary file not shown.

View File

@@ -0,0 +1,56 @@
# DynaLab French Translations.
# Copyright (C) 2026 Exergie
# This file is distributed under the same license as the DynaLab package.
# Hector van der Aa <hector@h3cx.dev>, 2026.
#
msgid ""
msgstr ""
"Project-Id-Version: DynaLab\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-07-08 23:50+0100\n"
"Last-Translator: Hector van der Aa <hector@h3cx.dev>\n"
"Language-Team: French\n"
"Language: fr\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: src/dynalab/app.py:53
msgid "Fetched DynaLab config"
msgstr "Configuration DynaLab récupérée"
#: src/dynalab/app.py:56
msgid "Starting JSON data server"
msgstr "Démarrage du serveur de données JSON"
#: src/dynalab/app.py:66
msgid "JSON data server started"
msgstr "Serveur de données JSON démarré"
#: src/dynalab/app.py:68
msgid "DynaLab Ready"
msgstr "DynaLab prêt"
#: src/dynalab/ui/windows.py:101
msgid "File"
msgstr "Fichier"
#: src/dynalab/ui/windows.py:102
msgid "Quit"
msgstr "Quitter"
#: src/dynalab/ui/windows.py:103 src/dynalab/ui/windows.py:110
msgid "Record"
msgstr "Enregistrer"
#: src/dynalab/ui/windows.py:105
msgid "Configuration"
msgstr "Configuration"
#: src/dynalab/ui/windows.py:107
msgid "Window"
msgstr "Fenêtre"
#: src/dynalab/ui/windows.py:116
msgid "Data Input"
msgstr "Saisie des données"

29
scripts/i18n_compile.sh Executable file
View File

@@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
DOMAIN="dynalab"
LOCALES_DIR="locales"
found_po=false
for po_file in "$LOCALES_DIR"/*/LC_MESSAGES/"$DOMAIN.po"; do
if [[ ! -f "$po_file" ]]; then
continue
fi
found_po=true
mo_file="${po_file%.po}.mo"
echo "Checking $po_file"
msgfmt --check "$po_file"
echo "Compiling $po_file -> $mo_file"
msgfmt "$po_file" -o "$mo_file"
done
if [[ "$found_po" == false ]]; then
echo "No .po files found in $LOCALES_DIR/*/LC_MESSAGES/$DOMAIN.po"
exit 1
fi
echo "Compiled all locales"

19
scripts/i18n_extract.sh Executable file
View File

@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
DOMAIN="dynalab"
SOURCE_DIR="src/dynalab"
LOCALES_DIR="locales"
POT_FILE="$LOCALES_DIR/$DOMAIN.pot"
mkdir -p "$LOCALES_DIR"
xgettext \
--language=Python \
--keyword=_ \
--keyword=gettext_ \
--from-code=UTF-8 \
--output="$POT_FILE" \
$(find "$SOURCE_DIR" -name "*.py" | sort)
echo "Generated $POT_FILE"

View File

@@ -13,6 +13,7 @@ import sys
from dynalab.config import ConfigLoadError, load_config
from dynalab.data.json import json_thread_main
from dynalab.i18n import init_i18n, _
from dynalab.state import AppState
from dynalab.tags import WINDOW_DATA_INPUT_GAUGE_GRID
from dynalab.ui.error import build_error_modal, show_error_modal
@@ -28,6 +29,8 @@ from dynalab.ui.windows import (
import dpg_gauges as dpgg
from dpg_gauges.exceptions import GaugeNotFoundError
from dynalab.utils import resource_path
def _setup_logging(level: int = logging.INFO) -> None:
logging.basicConfig(
@@ -38,29 +41,6 @@ def _setup_logging(level: int = logging.INFO) -> None:
)
def _asset_path(relative_path: str) -> str:
path = Path(relative_path)
candidates: list[Path] = []
bundle_dir = getattr(sys, "_MEIPASS", None)
if bundle_dir is not None:
candidates.append(Path(bundle_dir) / path)
candidates.extend(
(
Path.cwd() / path,
Path(__file__).resolve().parents[2] / path,
)
)
for candidate in candidates:
if candidate.exists():
return str(candidate)
searched = ", ".join(str(candidate) for candidate in candidates)
raise FileNotFoundError(f"Missing asset {relative_path!r}. Searched: {searched}")
def _init_worker(
state: AppState, stop_event: threading.Event, message_queue: Queue[str]
) -> None:
@@ -69,10 +49,11 @@ def _init_worker(
message_queue.put("Fetching DynaLab config")
time.sleep(0.5)
state.app_config = load_config()
message_queue.put("Fetched DynaLab config")
state.language = init_i18n(state.app_config.display.language)
message_queue.put(_("Fetched DynaLab config"))
time.sleep(0.5)
message_queue.put("Starting JSON data server")
message_queue.put(_("Starting JSON data server"))
# Init and start json connector thread
state.data_json_thread = Thread(
target=json_thread_main,
@@ -82,9 +63,9 @@ def _init_worker(
)
state.data_json_thread.start()
time.sleep(0.5)
message_queue.put("JSON data server started")
message_queue.put(_("JSON data server started"))
time.sleep(0.5)
message_queue.put("DynaLab Ready")
message_queue.put(_("DynaLab Ready"))
time.sleep(1.0)
stop_event.set()
@@ -130,16 +111,16 @@ def run() -> None:
# Load fonts at 2x size for HiDPI scaling
with dpg.font_registry():
app_font = dpg.add_font(
_asset_path("assets/fonts/Inter-Regular.ttf"), 18 * 2
resource_path("assets/fonts/Inter-Regular.ttf"), 18 * 2
)
mono_font = dpg.add_font(
_asset_path("assets/fonts/JetBrainsMono-Regular.ttf"),
resource_path("assets/fonts/JetBrainsMono-Regular.ttf"),
size=36,
label="mono_font",
)
dpg.bind_font(app_font)
_load_image(_asset_path("assets/images/logo@300.png"), "logo")
_load_image(resource_path("assets/images/logo@300.png"), "logo")
build_loading()
build_error_modal()

View File

@@ -13,7 +13,12 @@ class JsonConnectorConfig(BaseModel):
port: int = Field(default=8765, ge=1, le=65535)
class DisplayConfig(BaseModel):
language: str = ""
class AppConfig(BaseModel):
display: DisplayConfig = DisplayConfig()
json_connector: JsonConnectorConfig = JsonConnectorConfig()

View File

@@ -82,6 +82,7 @@ async def _handle_connector(
for signal in connector_hello.signals:
state.new_signal_queue.put(signal)
state.available_signals.append(signal)
heartbeat_task = asyncio.create_task(_heartbeat_loop(writer))
timeout_event = asyncio.Event()

57
src/dynalab/i18n.py Normal file
View File

@@ -0,0 +1,57 @@
import gettext
import locale
from dynalab.utils import resource_path
DOMAIN = "dynalab"
SUPPORTED_LANGUAGES = {"en", "fr"}
_translation: gettext.NullTranslations = gettext.NullTranslations()
def _normalize_language(language: str | None) -> str:
if not language:
return "en"
language = language.split(".")[0]
language = language.replace("-", "_")
language = language.split("_")[0]
if language in SUPPORTED_LANGUAGES:
return language
return "en"
def detect_system_language() -> str:
language, _encoding = locale.getlocale()
if not language:
language, _encoding = locale.getdefaultlocale()
return _normalize_language(language)
def init_i18n(config_language: str | None) -> str:
global _translation
if config_language:
language = _normalize_language(config_language)
else:
language = detect_system_language()
locale_dir = resource_path("locales")
_translation = gettext.translation(
DOMAIN, localedir=locale_dir, languages=[language], fallback=True
)
return language
def gettext_(message: str) -> str:
return _translation.gettext(message)
_ = gettext_

View File

@@ -7,6 +7,7 @@ import threading
from uuid import UUID
from dynalab_protocol.models import SignalDescriptor
from dynalab_protocol.dlconf import DLConf
from dynalab.config import AppConfig
@@ -19,6 +20,7 @@ class AppState:
new_signal_queue: Queue[SignalDescriptor] = Queue()
removed_signal_queue: Queue[SignalDescriptor] = Queue()
available_signals: list[SignalDescriptor] = field(default_factory=list)
live_data: dict[UUID, float] = field(default_factory=dict)
new_data_id: Queue[UUID] = Queue()
@@ -28,3 +30,8 @@ class AppState:
init_error: str | None = None
window_map: dict[str, str] = field(default_factory=dict)
language: str = ""
loaded_dlconf: DLConf | None = None
building_dlconf: DLConf | None = None

View File

@@ -2,6 +2,9 @@
# Copyright (C) 2026 Association Exergie <association.exergie@gmail.com>
# SPDX-License-Identifier: GPL-3.0-or-later
WINDOW_RECORD_CONFIGURATION: str = "window_record_configuration"
WINDOW_RECORD_CONFIGURATION_ADD_SIGNAL: str = "window_record_configuration_add_signal"
# Window groups
WINDOW_GROUP_DATA_INPUT: str = "window_group_data_input"
WINDOW_GROUP_RECORD: str = "window_group_record"
@@ -13,3 +16,8 @@ MENU_FILE_QUIT: str = "menu_file_quit"
MENU_RECORD_CONFIGURATION: str = "menu_record_configuration"
MENU_WINDOW_RECORD: str = "menu_window_record"
MENU_WINDOW_DATA_INPUT: str = "menu_window_data_input"
LISTBOX_RECORD_CONFIGURATION: str = "listbox_record_configuration"
LISTBOX_RECORD_CONFIGURATION_ADD_SINGAL: str = "listbox_record_configuration_add_singal"
TEXT_INPUT_RECORD_CONFIGURATION: str = "text_input_record_configuration"

30
src/dynalab/ui/render.py Normal file
View File

@@ -0,0 +1,30 @@
import dearpygui.dearpygui as dpg
from dynalab.state import AppState
from dynalab.tags import (
LISTBOX_RECORD_CONFIGURATION,
LISTBOX_RECORD_CONFIGURATION_ADD_SINGAL,
TEXT_INPUT_RECORD_CONFIGURATION,
)
def render_window_record_configuration(state: AppState) -> None:
dpg.set_value(
item=TEXT_INPUT_RECORD_CONFIGURATION, value=state.building_dlconf.name
)
items: list[str] = []
for signal in state.building_dlconf.signals:
name: str = f"{signal.name} | {signal.id}"
items.append(name)
dpg.configure_item(LISTBOX_RECORD_CONFIGURATION, items=items)
def render_listbox_record_configuration_add_item(state: AppState) -> None:
items: list[str] = []
for item in state.available_signals:
name: str = f"{item.name} | {item.id}"
items.append(name)
dpg.configure_item(LISTBOX_RECORD_CONFIGURATION_ADD_SINGAL, items=items)

View File

@@ -1,7 +1,22 @@
import uuid
from dynalab_protocol.dlconf import DLConf
from dynalab.state import AppState
import dearpygui.dearpygui as dpg
from dynalab.tags import (
LISTBOX_RECORD_CONFIGURATION,
LISTBOX_RECORD_CONFIGURATION_ADD_SINGAL,
TEXT_INPUT_RECORD_CONFIGURATION,
WINDOW_RECORD_CONFIGURATION,
WINDOW_RECORD_CONFIGURATION_ADD_SIGNAL,
)
from dynalab.ui.render import (
render_window_record_configuration,
render_listbox_record_configuration_add_item,
)
def switch_window(sender, app_data, user_data: AppState) -> None:
for button_tag, window_group_tag in user_data.window_map.items():
@@ -9,3 +24,54 @@ def switch_window(sender, app_data, user_data: AppState) -> None:
dpg.show_item(window_group_tag)
else:
dpg.hide_item(window_group_tag)
def listbox_record_configuration_remove_selected_item(
sender, app_data, user_data: AppState
) -> None:
selected = dpg.get_value(LISTBOX_RECORD_CONFIGURATION)
if selected is None:
return
try:
split = selected.split(" | ")[1]
except Exception:
return
signal_id = uuid.UUID(split)
render_window_record_configuration(user_data)
def listbox_record_configuration_add_item(
sender, app_data, user_data: AppState
) -> None:
render_listbox_record_configuration_add_item(user_data)
dpg.show_item(WINDOW_RECORD_CONFIGURATION_ADD_SIGNAL)
def listbox_record_configuration_add_item_select(
sender, app_data, user_data: AppState
) -> None:
value: str = dpg.get_value(LISTBOX_RECORD_CONFIGURATION_ADD_SINGAL)
try:
split = value.split(" | ")[1]
except Exception:
return
signal_id = uuid.UUID(split)
if user_data.building_dlconf is None:
user_data.building_dlconf = DLConf()
signal = next(
(signal for signal in user_data.available_signals if signal.id == signal_id),
None,
)
if signal is not None:
user_data.building_dlconf.signals.append(signal)
render_window_record_configuration(user_data)
dpg.hide_item(WINDOW_RECORD_CONFIGURATION_ADD_SIGNAL)
def show_window_record_configuration(sender, app_data, user_data: AppState) -> None:
if user_data.building_dlconf is None:
user_data.building_dlconf = DLConf()
render_window_record_configuration(user_data)
dpg.show_item(WINDOW_RECORD_CONFIGURATION)

View File

@@ -7,15 +7,27 @@ import dpg_gauges as dpgg
from dynalab.state import AppState
from dynalab.tags import (
LISTBOX_RECORD_CONFIGURATION,
LISTBOX_RECORD_CONFIGURATION_ADD_SINGAL,
MENU_FILE_QUIT,
MENU_RECORD_CONFIGURATION,
MENU_WINDOW_DATA_INPUT,
MENU_WINDOW_RECORD,
TEXT_INPUT_RECORD_CONFIGURATION,
WINDOW_DATA_INPUT_GAUGE_GRID,
WINDOW_GROUP_DATA_INPUT,
WINDOW_GROUP_RECORD,
WINDOW_RECORD_CONFIGURATION,
WINDOW_RECORD_CONFIGURATION_ADD_SIGNAL,
)
from dynalab.ui.routines import switch_window
from dynalab.ui.routines import (
listbox_record_configuration_add_item,
listbox_record_configuration_add_item_select,
listbox_record_configuration_remove_selected_item,
show_window_record_configuration,
switch_window,
)
from dynalab.i18n import _
LOADING_WINDOW_WIDTH = 600
@@ -94,23 +106,85 @@ def center_loading_content() -> None:
dpg.set_item_width("loading_text_left_spacer", text_spacer_width)
def build_record_configuration_add_signal_window(state: AppState) -> None:
with dpg.window(
label="Add Signal",
tag=WINDOW_RECORD_CONFIGURATION_ADD_SIGNAL,
no_collapse=True,
show=False,
min_size=(500, 300),
modal=True,
):
dpg.add_listbox(
tag=LISTBOX_RECORD_CONFIGURATION_ADD_SINGAL,
num_items=10,
width=480,
)
with dpg.group(horizontal=True):
dpg.add_button(
label="Select",
callback=listbox_record_configuration_add_item_select,
user_data=state,
)
dpg.add_button(
label="Refresh",
callback=listbox_record_configuration_add_item,
user_data=state,
)
def build_record_configuration_window(state: AppState) -> None:
with dpg.window(
label="Recording Configuration",
tag=WINDOW_RECORD_CONFIGURATION,
no_collapse=True,
show=False,
min_size=(500, 300),
):
with dpg.group():
dpg.add_text("Configuration Name")
dpg.add_input_text(tag=TEXT_INPUT_RECORD_CONFIGURATION)
dpg.add_text("Selected Signals")
dpg.add_listbox(
tag=LISTBOX_RECORD_CONFIGURATION,
num_items=10,
width=480,
)
with dpg.group(horizontal=True):
dpg.add_button(
label="Add",
callback=listbox_record_configuration_add_item,
user_data=state,
)
dpg.add_button(
label="Remove",
callback=listbox_record_configuration_remove_selected_item,
user_data=state,
)
def build_windows(state: AppState) -> None:
with dpg.window(label="DynaLab", tag="main_window", no_collapse=True):
with dpg.menu_bar():
with dpg.menu(label="File"):
dpg.add_menu_item(tag=MENU_FILE_QUIT, label="Quit", enabled=True)
with dpg.menu(label="Record"):
dpg.add_menu_item(tag=MENU_RECORD_CONFIGURATION, label="Configuration")
with dpg.menu(label="Window"):
with dpg.menu(label=_("File")):
dpg.add_menu_item(tag=MENU_FILE_QUIT, label=_("Quit"), enabled=True)
with dpg.menu(label=_("Record")):
dpg.add_menu_item(
tag=MENU_RECORD_CONFIGURATION,
label=_("Configuration"),
callback=show_window_record_configuration,
user_data=state,
)
with dpg.menu(label=_("Window")):
dpg.add_menu_item(
tag=MENU_WINDOW_RECORD,
label="Record",
label=_("Record"),
callback=switch_window,
user_data=state,
)
dpg.add_menu_item(
tag=MENU_WINDOW_DATA_INPUT,
label="Data Input",
label=_("Data Input"),
callback=switch_window,
user_data=state,
)
@@ -121,9 +195,7 @@ def build_windows(state: AppState) -> None:
# with dpg.group(tag=MENU_WINDOW_DATA_INPUT):
with dpg.group(tag=WINDOW_GROUP_DATA_INPUT):
state.window_map[MENU_WINDOW_DATA_INPUT] = WINDOW_GROUP_DATA_INPUT
with dpgg.gauge_panel(
label="Autobreak narrow panel", autosize_x=True, autosize_y=True
):
with dpgg.gauge_panel(autosize_x=True, autosize_y=True):
with dpgg.gauge_grid(
columns="auto",
min_column_width=180,
@@ -134,3 +206,6 @@ def build_windows(state: AppState) -> None:
with dpg.group(tag=WINDOW_GROUP_RECORD, show=False):
state.window_map[MENU_WINDOW_RECORD] = WINDOW_GROUP_RECORD
dpg.add_text("Some text")
build_record_configuration_window(state)
build_record_configuration_add_signal_window(state)

28
src/dynalab/utils.py Normal file
View File

@@ -0,0 +1,28 @@
# Copyright (C) 2026 Hector van der Aa <hector@h3cx.dev>
# Copyright (C) 2026 Association Exergie <association.exergie@gmail.com>
# SPDX-License-Identifier: GPL-3.0-or-later
from pathlib import Path
import sys
def resource_path(relative_path: str) -> str:
path = Path(relative_path)
candidates: list[Path] = []
bundle_dir = getattr(sys, "_MEIPASS", None)
if bundle_dir is not None:
candidates.append(Path(bundle_dir) / path)
candidates.extend(
(
Path.cwd() / path,
Path(__file__).resolve().parents[2] / path,
)
)
for candidate in candidates:
if candidate.exists():
return str(candidate)
searched = ", ".join(str(candidate) for candidate in candidates)
raise FileNotFoundError(f"Missing asset {relative_path!r}. Searched: {searched}")