feat: add translation files and i18n loader

Create TOML-based localisation resources under app/lang and introduce a Translator/I18nMixin that reads them. Update config handling to recognise available languages, switch UI strings to translation lookups, and bundle language files with the package.
This commit is contained in:
lm 2025-10-17 16:43:25 +02:00
parent 76073ab0b5
commit 91fad62808
13 changed files with 442 additions and 100 deletions

View File

@ -5,10 +5,12 @@ from __future__ import annotations
import tkinter as tk
from .gui import ColorPickerMixin, ExclusionMixin, ThemeMixin, UIBuilderMixin
from .logic import DEFAULTS, ImageProcessingMixin, ResetMixin
from .i18n import I18nMixin
from .logic import DEFAULTS, LANGUAGE, ImageProcessingMixin, ResetMixin
class ICRAApp(
I18nMixin,
ThemeMixin,
UIBuilderMixin,
ImageProcessingMixin,
@ -20,7 +22,8 @@ class ICRAApp(
def __init__(self, root: tk.Tk):
self.root = root
self.root.title("Interactive Color Range Analyzer")
self.init_i18n(LANGUAGE)
self.root.title(self._t("app.title"))
self._setup_window()
# Theme and styling

View File

@ -15,15 +15,21 @@ class ColorPickerMixin:
selected_colour: tuple[int, int, int] | None = None
def choose_color(self):
rgb, hex_colour = colorchooser.askcolor(title="Farbe wählen")
title = self._t("dialog.choose_colour_title") if hasattr(self, "_t") else "Choose colour"
rgb, hex_colour = colorchooser.askcolor(title=title)
if rgb is None:
return
r, g, b = (int(round(channel)) for channel in rgb)
hue_deg, sat_pct, val_pct = self._apply_rgb_selection(r, g, b)
label = hex_colour or f"RGB({r}, {g}, {b})"
self.status.config(
text=f"Farbe gewählt: {label} — Hue {hue_deg:.1f}°, S {sat_pct:.0f}%, V {val_pct:.0f}%"
message = self._t(
"status.color_selected",
label=label,
hue=hue_deg,
saturation=sat_pct,
value=val_pct,
)
self.status.config(text=message)
self._update_selected_colour(r, g, b)
def apply_sample_colour(self, hex_colour: str, name: str | None = None) -> None:
@ -35,25 +41,31 @@ class ColorPickerMixin:
if self.pick_mode:
self.pick_mode = False
label = name or hex_colour.upper()
self.status.config(
text=(
f"Beispielfarbe gewählt: {label} ({hex_colour}) — "
f"Hue {hue_deg:.1f}°, S {sat_pct:.0f}%, V {val_pct:.0f}%"
)
message = self._t(
"status.sample_colour",
label=label,
hex_code=hex_colour,
hue=hue_deg,
saturation=sat_pct,
value=val_pct,
)
self.status.config(text=message)
self._update_selected_colour(*rgb)
def enable_pick_mode(self):
if self.preview_img is None:
messagebox.showinfo("Info", "Bitte zuerst ein Bild laden.")
messagebox.showinfo(
self._t("dialog.info_title"),
self._t("dialog.load_image_first"),
)
return
self.pick_mode = True
self.status.config(text="Pick-Modus: Klicke links ins Bild, um Farbe zu wählen (Esc beendet)")
self.status.config(text=self._t("status.pick_mode_ready"))
def disable_pick_mode(self, event=None):
if self.pick_mode:
self.pick_mode = False
self.status.config(text="Pick-Modus beendet.")
self.status.config(text=self._t("status.pick_mode_ended"))
def on_canvas_click(self, event):
if not self.pick_mode or self.preview_img is None:
@ -68,7 +80,12 @@ class ColorPickerMixin:
hue_deg, sat_pct, val_pct = self._apply_rgb_selection(r, g, b)
self.disable_pick_mode()
self.status.config(
text=f"Farbe vom Bild gewählt: Hue {hue_deg:.1f}°, S {sat_pct:.0f}%, V {val_pct:.0f}%"
text=self._t(
"status.pick_mode_from_image",
hue=hue_deg,
saturation=sat_pct,
value=val_pct,
)
)
self._update_selected_colour(r, g, b)

View File

@ -67,6 +67,10 @@ class ThemeMixin:
if callable(accent_refresher) and hasattr(self, "filename_label"):
accent_refresher(highlight_fg)
canvas_refresher = getattr(self, "_refresh_canvas_backgrounds", None)
if callable(canvas_refresher):
canvas_refresher()
def detect_system_theme(self) -> str:
"""Best-effort detection of the OS theme preference."""
try:

View File

@ -17,15 +17,15 @@ class UIBuilderMixin:
toolbar = ttk.Frame(self.root)
toolbar.pack(fill=tk.X, padx=12, pady=(4, 2))
buttons = [
("📂", "Bild laden", self.load_image),
("📁", "Ordner laden", self.load_folder),
("🎨", "Farbe wählen", self.choose_color),
("🖱", "Farbe aus Bild klicken", self.enable_pick_mode),
("💾", "Overlay speichern", self.save_overlay),
("🧹", "Ausschlüsse löschen", self.clear_excludes),
("", "Letzten Ausschluss entfernen", self.undo_exclude),
("🔄", "Slider zurücksetzen", self.reset_sliders),
("🌓", "Theme umschalten", self.toggle_theme),
("📂", self._t("toolbar.open_image"), self.load_image),
("📁", self._t("toolbar.open_folder"), self.load_folder),
("🎨", self._t("toolbar.choose_color"), self.choose_color),
("🖱", self._t("toolbar.pick_from_image"), self.enable_pick_mode),
("💾", self._t("toolbar.save_overlay"), self.save_overlay),
("🧹", self._t("toolbar.clear_excludes"), self.clear_excludes),
("", self._t("toolbar.undo_exclude"), self.undo_exclude),
("🔄", self._t("toolbar.reset_sliders"), self.reset_sliders),
("🌓", self._t("toolbar.toggle_theme"), self.toggle_theme),
]
self._toolbar_buttons: list[dict[str, object]] = []
self._nav_buttons: list[tk.Button] = []
@ -39,7 +39,7 @@ class UIBuilderMixin:
status_container.pack(side=tk.RIGHT, expand=True, fill=tk.X)
self.status = ttk.Label(
status_container,
text="Keine Datei geladen.",
text=self._t("status.no_file"),
anchor="e",
foreground="#efefef",
)
@ -54,7 +54,7 @@ class UIBuilderMixin:
current_frame = ttk.Frame(palette_frame)
current_frame.pack(side=tk.LEFT, padx=(0, 16))
ttk.Label(current_frame, text="Farbe:").pack(side=tk.LEFT, padx=(0, 6))
ttk.Label(current_frame, text=self._t("palette.current")).pack(side=tk.LEFT, padx=(0, 6))
self.current_colour_sw = tk.Canvas(
current_frame,
width=24,
@ -67,7 +67,7 @@ class UIBuilderMixin:
self.current_colour_label = ttk.Label(current_frame, text=f"({default_colour})")
self.current_colour_label.pack(side=tk.LEFT, padx=(6, 0))
ttk.Label(palette_frame, text="Weitere Farben:").pack(side=tk.LEFT, padx=(0, 8))
ttk.Label(palette_frame, text=self._t("palette.more")).pack(side=tk.LEFT, padx=(0, 8))
swatch_container = ttk.Frame(palette_frame)
swatch_container.pack(side=tk.LEFT)
for name, hex_code in self._preset_colours():
@ -76,12 +76,12 @@ class UIBuilderMixin:
sliders_frame = ttk.Frame(self.root)
sliders_frame.pack(fill=tk.X, padx=12, pady=4)
sliders = [
("Hue Min (°)", self.hue_min, 0, 360),
("Hue Max (°)", self.hue_max, 0, 360),
("Sättigung Min (%)", self.sat_min, 0, 100),
("Helligkeit Min (%)", self.val_min, 0, 100),
("Helligkeit Max (%)", self.val_max, 0, 100),
("Overlay Alpha", self.alpha, 0, 255),
(self._t("sliders.hue_min"), self.hue_min, 0, 360),
(self._t("sliders.hue_max"), self.hue_max, 0, 360),
(self._t("sliders.sat_min"), self.sat_min, 0, 100),
(self._t("sliders.val_min"), self.val_min, 0, 100),
(self._t("sliders.val_max"), self.val_max, 0, 100),
(self._t("sliders.alpha"), self.alpha, 0, 255),
]
for index, (label, variable, minimum, maximum) in enumerate(sliders):
self.add_slider_with_value(sliders_frame, label, variable, minimum, maximum, column=index)
@ -97,7 +97,12 @@ class UIBuilderMixin:
self._create_navigation_button(left_column, "", self.show_previous_image, column=0)
self.canvas_orig = tk.Canvas(left_column, bg="#1e1e1e", highlightthickness=0, relief="flat")
self.canvas_orig = tk.Canvas(
left_column,
bg=self._canvas_background_colour(),
highlightthickness=0,
relief="flat",
)
self.canvas_orig.grid(row=0, column=1, sticky="nsew")
self.canvas_orig.bind("<Button-1>", self.on_canvas_click)
self.canvas_orig.bind("<ButtonPress-3>", self._exclude_start)
@ -109,7 +114,12 @@ class UIBuilderMixin:
right_column.grid_columnconfigure(0, weight=1)
right_column.grid_rowconfigure(0, weight=1)
self.canvas_overlay = tk.Canvas(right_column, bg="#1e1e1e", highlightthickness=0, relief="flat")
self.canvas_overlay = tk.Canvas(
right_column,
bg=self._canvas_background_colour(),
highlightthickness=0,
relief="flat",
)
self.canvas_overlay.grid(row=0, column=0, sticky="nsew")
self._create_navigation_button(right_column, "", self.show_next_image, column=1)
@ -128,7 +138,7 @@ class UIBuilderMixin:
self.ratio_label = ttk.Label(
info_frame,
text="Markierungen (mit Ausschlüssen): —",
text=self._t("stats.placeholder"),
font=("Segoe UI", 10, "bold"),
anchor="center",
justify="center",
@ -179,17 +189,17 @@ class UIBuilderMixin:
def _preset_colours(self):
return [
("Rot", "#ff3b30"),
("Orange", "#ff9500"),
("Gelb", "#ffd60a"),
("Grün", "#34c759"),
("Türkis", "#5ac8fa"),
("Blau", "#0a84ff"),
("Violett", "#af52de"),
("Magenta", "#ff2d55"),
("Weiß", "#ffffff"),
("Grau", "#8e8e93"),
("Schwarz", "#000000"),
(self._t("palette.swatch.red"), "#ff3b30"),
(self._t("palette.swatch.orange"), "#ff9500"),
(self._t("palette.swatch.yellow"), "#ffd60a"),
(self._t("palette.swatch.green"), "#34c759"),
(self._t("palette.swatch.teal"), "#5ac8fa"),
(self._t("palette.swatch.blue"), "#0a84ff"),
(self._t("palette.swatch.violet"), "#af52de"),
(self._t("palette.swatch.magenta"), "#ff2d55"),
(self._t("palette.swatch.white"), "#ffffff"),
(self._t("palette.swatch.grey"), "#8e8e93"),
(self._t("palette.swatch.black"), "#000000"),
]
def _add_palette_swatch(self, parent, name: str, hex_code: str) -> None:
@ -403,7 +413,7 @@ class UIBuilderMixin:
title_label = tk.Label(
title_bar,
text="Interactive Color Range Analyzer",
text=self._t("app.title"),
bg=bar_bg,
fg="#f5f5f5",
font=("Segoe UI", 11, "bold"),
@ -511,6 +521,19 @@ class UIBuilderMixin:
activeforeground=palette["fg"],
)
def _canvas_background_colour(self) -> str:
return "#0f0f10" if getattr(self, "theme", "light") == "dark" else "#ffffff"
def _refresh_canvas_backgrounds(self) -> None:
bg = self._canvas_background_colour()
for attr in ("canvas_orig", "canvas_overlay"):
canvas = getattr(self, attr, None)
if canvas is not None:
try:
canvas.configure(bg=bg)
except Exception:
pass
def _refresh_status_palette(self, fg: str) -> None:
self.status.configure(foreground=fg)
self._status_palette["fg"] = fg
@ -545,7 +568,8 @@ class UIBuilderMixin:
def _init_copy_menu(self):
self._copy_target = None
self.copy_menu = tk.Menu(self.root, tearoff=0)
self.copy_menu.add_command(label="Kopieren", command=self._copy_current_label)
label = self._t("menu.copy") if hasattr(self, "_t") else "Copy"
self.copy_menu.add_command(label=label, command=self._copy_current_label)
def _attach_copy_menu(self, widget):
widget.bind("<Button-3>", lambda event, w=widget: self._show_copy_menu(event, w))

106
app/i18n.py Normal file
View File

@ -0,0 +1,106 @@
"""Translation helpers and language-aware mixins."""
from __future__ import annotations
import contextlib
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict
try: # Python 3.11+
import tomllib # type: ignore[attr-defined]
except ModuleNotFoundError: # pragma: no cover - fallback
with contextlib.suppress(ModuleNotFoundError):
import tomli as tomllib # type: ignore[assignment]
if "tomllib" not in globals():
tomllib = None # type: ignore[assignment]
LANG_DIR = Path(__file__).resolve().parent / "lang"
FALLBACK_LANGUAGE = "en"
def _available_language_files() -> Dict[str, Path]:
files: Dict[str, Path] = {}
if LANG_DIR.exists():
for path in LANG_DIR.glob("*.toml"):
files[path.stem.lower()] = path
return files
def _load_translations(lang: str) -> Dict[str, str]:
if tomllib is None:
return {}
lang_files = _available_language_files()
path = lang_files.get(lang.lower())
if path is None:
return {}
try:
with path.open("rb") as handle:
data = tomllib.load(handle)
except (OSError, AttributeError, ValueError, TypeError): # type: ignore[arg-type]
return {}
translations = data.get("translations")
if not isinstance(translations, dict):
return {}
out: Dict[str, str] = {}
for key, value in translations.items():
if isinstance(key, str) and isinstance(value, str):
out[key] = value
return out
@dataclass
class Translator:
"""Simple lookup-based translator with file-backed dictionaries."""
language: str = FALLBACK_LANGUAGE
_translations: Dict[str, str] = field(default_factory=dict, init=False)
_fallback: Dict[str, str] = field(default_factory=dict, init=False)
def __post_init__(self) -> None:
self._fallback = _load_translations(FALLBACK_LANGUAGE)
self.set_language(self.language)
def set_language(self, language: str) -> None:
chosen = language.lower()
data = _load_translations(chosen)
if not data:
chosen = FALLBACK_LANGUAGE
data = _load_translations(FALLBACK_LANGUAGE)
self.language = chosen
self._translations = data or {}
def translate(self, key: str, **values: Any) -> str:
template = self._translations.get(key) or self._fallback.get(key) or key
if values:
try:
return template.format(**values)
except (KeyError, ValueError):
return template
return template
class I18nMixin:
"""Mixin providing translated text helpers."""
language: str
translator: Translator
def init_i18n(self, language: str | None = None) -> None:
self.translator = Translator()
self.set_language(language or FALLBACK_LANGUAGE)
def set_language(self, language: str) -> None:
self.translator.set_language(language)
self.language = self.translator.language
def _t(self, key: str, **values: Any) -> str:
return self.translator.translate(key, **values)
@property
def available_languages(self) -> tuple[str, ...]:
return tuple(sorted(_available_language_files().keys()))
__all__ = ["I18nMixin", "Translator", "LANG_DIR", "FALLBACK_LANGUAGE"]

58
app/lang/de.toml Normal file
View File

@ -0,0 +1,58 @@
[translations]
"app.title" = "Interactive Color Range Analyzer"
"toolbar.open_image" = "Bild laden"
"toolbar.open_folder" = "Ordner laden"
"toolbar.choose_color" = "Farbe wählen"
"toolbar.pick_from_image" = "Farbe aus Bild klicken"
"toolbar.save_overlay" = "Overlay speichern"
"toolbar.clear_excludes" = "Ausschlüsse löschen"
"toolbar.undo_exclude" = "Letzten Ausschluss entfernen"
"toolbar.reset_sliders" = "Slider zurücksetzen"
"toolbar.toggle_theme" = "Theme umschalten"
"status.no_file" = "Keine Datei geladen."
"status.defaults_restored" = "Standardwerte aktiv."
"status.loaded" = "Geladen: {name} — {dimensions}{position}"
"status.filename_label" = "{name} — {dimensions}{position}"
"status.color_selected" = "Farbe gewählt: {label} — Hue {hue:.1f}°, S {saturation:.0f}%, V {value:.0f}%"
"status.sample_colour" = "Beispielfarbe gewählt: {label} ({hex_code}) — Hue {hue:.1f}°, S {saturation:.0f}%, V {value:.0f}%"
"status.pick_mode_ready" = "Pick-Modus: Klicke links ins Bild, um Farbe zu wählen (Esc beendet)"
"status.pick_mode_ended" = "Pick-Modus beendet."
"status.pick_mode_from_image" = "Farbe vom Bild gewählt: Hue {hue:.1f}°, S {saturation:.0f}%, V {value:.0f}%"
"palette.current" = "Farbe:"
"palette.more" = "Weitere Farben:"
"palette.swatch.red" = "Rot"
"palette.swatch.orange" = "Orange"
"palette.swatch.yellow" = "Gelb"
"palette.swatch.green" = "Grün"
"palette.swatch.teal" = "Türkis"
"palette.swatch.blue" = "Blau"
"palette.swatch.violet" = "Violett"
"palette.swatch.magenta" = "Magenta"
"palette.swatch.white" = "Weiß"
"palette.swatch.grey" = "Grau"
"palette.swatch.black" = "Schwarz"
"sliders.hue_min" = "Hue Min (°)"
"sliders.hue_max" = "Hue Max (°)"
"sliders.sat_min" = "Sättigung Min (%)"
"sliders.val_min" = "Helligkeit Min (%)"
"sliders.val_max" = "Helligkeit Max (%)"
"sliders.alpha" = "Overlay Alpha"
"stats.placeholder" = "Markierungen (mit Ausschlüssen): —"
"stats.summary" = "Markierungen (mit Ausschlüssen): {with_pct:.2f}% | Markierungen (ohne Ausschlüsse): {without_pct:.2f}% | Ausgeschlossen: {excluded_pct:.2f}% der Pixel, davon {excluded_match_pct:.2f}% markiert"
"menu.copy" = "Kopieren"
"dialog.info_title" = "Info"
"dialog.error_title" = "Fehler"
"dialog.saved_title" = "Gespeichert"
"dialog.open_image_title" = "Bild wählen"
"dialog.open_folder_title" = "Ordner mit Bildern wählen"
"dialog.save_overlay_title" = "Overlay speichern als"
"dialog.choose_colour_title" = "Farbe wählen"
"dialog.images_filter" = "Bilder"
"dialog.folder_not_found" = "Der Ordner wurde nicht gefunden."
"dialog.folder_empty" = "Keine unterstützten Bilder im Ordner gefunden."
"dialog.file_missing" = "Datei nicht gefunden: {path}"
"dialog.image_open_failed" = "Bild konnte nicht geladen werden: {error}"
"dialog.load_image_first" = "Bitte zuerst ein Bild laden."
"dialog.no_image_loaded" = "Kein Bild geladen."
"dialog.no_preview_available" = "Keine Preview vorhanden."
"dialog.overlay_saved" = "Overlay gespeichert: {path}"

58
app/lang/en.toml Normal file
View File

@ -0,0 +1,58 @@
[translations]
"app.title" = "Interactive Color Range Analyzer"
"toolbar.open_image" = "Open image"
"toolbar.open_folder" = "Open folder"
"toolbar.choose_color" = "Choose colour"
"toolbar.pick_from_image" = "Pick from image"
"toolbar.save_overlay" = "Save overlay"
"toolbar.clear_excludes" = "Clear exclusions"
"toolbar.undo_exclude" = "Undo last exclusion"
"toolbar.reset_sliders" = "Reset sliders"
"toolbar.toggle_theme" = "Toggle theme"
"status.no_file" = "No file loaded."
"status.defaults_restored" = "Defaults restored."
"status.loaded" = "Loaded: {name} — {dimensions}{position}"
"status.filename_label" = "{name} — {dimensions}{position}"
"status.color_selected" = "Colour chosen: {label} — Hue {hue:.1f}°, S {saturation:.0f}%, V {value:.0f}%"
"status.sample_colour" = "Sample colour applied: {label} ({hex_code}) — Hue {hue:.1f}°, S {saturation:.0f}%, V {value:.0f}%"
"status.pick_mode_ready" = "Pick mode: Click the left image to choose a colour (Esc exits)"
"status.pick_mode_ended" = "Pick mode ended."
"status.pick_mode_from_image" = "Colour picked from image: Hue {hue:.1f}°, S {saturation:.0f}%, V {value:.0f}%"
"palette.current" = "Colour:"
"palette.more" = "More colours:"
"palette.swatch.red" = "Red"
"palette.swatch.orange" = "Orange"
"palette.swatch.yellow" = "Yellow"
"palette.swatch.green" = "Green"
"palette.swatch.teal" = "Teal"
"palette.swatch.blue" = "Blue"
"palette.swatch.violet" = "Violet"
"palette.swatch.magenta" = "Magenta"
"palette.swatch.white" = "White"
"palette.swatch.grey" = "Grey"
"palette.swatch.black" = "Black"
"sliders.hue_min" = "Hue min (°)"
"sliders.hue_max" = "Hue max (°)"
"sliders.sat_min" = "Saturation min (%)"
"sliders.val_min" = "Value min (%)"
"sliders.val_max" = "Value max (%)"
"sliders.alpha" = "Overlay alpha"
"stats.placeholder" = "Matches (with exclusions): —"
"stats.summary" = "Matches (with exclusions): {with_pct:.2f}% | Matches (without exclusions): {without_pct:.2f}% | Excluded: {excluded_pct:.2f}% of pixels, {excluded_match_pct:.2f}% marked"
"menu.copy" = "Copy"
"dialog.info_title" = "Info"
"dialog.error_title" = "Error"
"dialog.saved_title" = "Saved"
"dialog.open_image_title" = "Select image"
"dialog.open_folder_title" = "Select folder"
"dialog.save_overlay_title" = "Save overlay as"
"dialog.choose_colour_title" = "Choose colour"
"dialog.images_filter" = "Images"
"dialog.folder_not_found" = "The folder could not be found."
"dialog.folder_empty" = "No supported images were found in the folder."
"dialog.file_missing" = "File not found: {path}"
"dialog.image_open_failed" = "Image could not be loaded: {error}"
"dialog.load_image_first" = "Please load an image first."
"dialog.no_image_loaded" = "No image loaded."
"dialog.no_preview_available" = "No preview available."
"dialog.overlay_saved" = "Overlay saved: {path}"

View File

@ -1,6 +1,6 @@
"""Logic utilities and mixins for processing and configuration."""
from .constants import BASE_DIR, DEFAULTS, IMAGES_DIR, PREVIEW_MAX_SIZE, SUPPORTED_IMAGE_EXTENSIONS
from .constants import BASE_DIR, DEFAULTS, IMAGES_DIR, LANGUAGE, PREVIEW_MAX_SIZE, SUPPORTED_IMAGE_EXTENSIONS
from .image_processing import ImageProcessingMixin
from .reset import ResetMixin
@ -8,6 +8,7 @@ __all__ = [
"BASE_DIR",
"DEFAULTS",
"IMAGES_DIR",
"LANGUAGE",
"PREVIEW_MAX_SIZE",
"SUPPORTED_IMAGE_EXTENSIONS",
"ImageProcessingMixin",

View File

@ -19,6 +19,7 @@ PREVIEW_MAX_SIZE = (900, 660)
BASE_DIR = Path(__file__).resolve().parents[2]
IMAGES_DIR = BASE_DIR / "images"
CONFIG_FILE = BASE_DIR / "config.toml"
LANG_DIR = BASE_DIR / "app" / "lang"
_DEFAULTS_BASE = {
"hue_min": 250.0,
@ -30,6 +31,7 @@ _DEFAULTS_BASE = {
}
SUPPORTED_IMAGE_EXTENSIONS = (".webp", ".png", ".jpg", ".jpeg", ".bmp")
LANGUAGE_DEFAULT = "en"
_DEFAULT_TYPES: dict[str, Callable[[Any], Any]] = {
"hue_min": float,
@ -41,8 +43,8 @@ _DEFAULT_TYPES: dict[str, Callable[[Any], Any]] = {
}
def _load_default_overrides() -> dict[str, Any]:
"""Load default slider overrides from config.toml if available."""
def _load_config_data() -> dict[str, Any]:
"""Read the optional config file once and return its parsed data."""
if tomllib is None or not CONFIG_FILE.exists():
return {}
decode_error = getattr(tomllib, "TOMLDecodeError", ValueError) # type: ignore[attr-defined]
@ -51,6 +53,12 @@ def _load_default_overrides() -> dict[str, Any]:
data = tomllib.load(handle)
except (OSError, AttributeError, decode_error, TypeError): # type: ignore[arg-type]
return {}
if not isinstance(data, dict):
return {}
return data
def _extract_default_overrides(data: dict[str, Any]) -> dict[str, Any]:
settings = data.get("defaults")
if not isinstance(settings, dict):
return {}
@ -65,4 +73,26 @@ def _load_default_overrides() -> dict[str, Any]:
return overrides
DEFAULTS = {**_DEFAULTS_BASE, **_load_default_overrides()}
def _available_languages() -> set[str]:
languages = {path.stem.lower() for path in LANG_DIR.glob("*.toml")}
if not languages:
languages.add(LANGUAGE_DEFAULT)
return languages
def _extract_language(data: dict[str, Any]) -> str:
value = data.get("language")
supported = _available_languages()
if isinstance(value, str):
normalised = value.strip().lower()
if normalised in supported:
return normalised
if LANGUAGE_DEFAULT in supported:
return LANGUAGE_DEFAULT
return sorted(supported)[0]
_CONFIG_DATA = _load_config_data()
DEFAULTS = {**_DEFAULTS_BASE, **_extract_default_overrides(_CONFIG_DATA)}
LANGUAGE = _extract_language(_CONFIG_DATA)

View File

@ -28,8 +28,8 @@ class ImageProcessingMixin:
def load_image(self) -> None:
default_dir = IMAGES_DIR if IMAGES_DIR.exists() else Path.cwd()
path = filedialog.askopenfilename(
title="Bild wählen",
filetypes=[("Images", "*.webp *.png *.jpg *.jpeg *.bmp")],
title=self._t("dialog.open_image_title"),
filetypes=[(self._t("dialog.images_filter"), "*.webp *.png *.jpg *.jpeg *.bmp")],
initialdir=str(default_dir),
)
if not path:
@ -39,14 +39,17 @@ class ImageProcessingMixin:
def load_folder(self) -> None:
default_dir = IMAGES_DIR if IMAGES_DIR.exists() else Path.cwd()
directory = filedialog.askdirectory(
title="Ordner mit Bildern wählen",
title=self._t("dialog.open_folder_title"),
initialdir=str(default_dir),
)
if not directory:
return
folder = Path(directory)
if not folder.exists():
messagebox.showerror("Fehler", "Der Ordner wurde nicht gefunden.")
messagebox.showerror(
self._t("dialog.error_title"),
self._t("dialog.folder_not_found"),
)
return
image_files = sorted(
(
@ -57,7 +60,10 @@ class ImageProcessingMixin:
key=lambda item: item.name.lower(),
)
if not image_files:
messagebox.showinfo("Info", "Keine unterstützten Bilder im Ordner gefunden.")
messagebox.showinfo(
self._t("dialog.info_title"),
self._t("dialog.folder_empty"),
)
return
self._set_image_collection(image_files, 0)
@ -93,12 +99,18 @@ class ImageProcessingMixin:
return
path = self.image_paths[index]
if not path.exists():
messagebox.showerror("Fehler", f"Datei nicht gefunden: {path}")
messagebox.showerror(
self._t("dialog.error_title"),
self._t("dialog.file_missing", path=path),
)
return
try:
image = Image.open(path).convert("RGBA")
except Exception as exc:
messagebox.showerror("Fehler", f"Bild konnte nicht geladen werden: {exc}")
messagebox.showerror(
self._t("dialog.error_title"),
self._t("dialog.image_open_failed", error=exc),
)
return
self.image_path = path
@ -113,20 +125,32 @@ class ImageProcessingMixin:
dimensions = f"{self.orig_img.width}x{self.orig_img.height}"
suffix = f" [{index + 1}/{len(self.image_paths)}]" if len(self.image_paths) > 1 else ""
status_text = f"Geladen: {path.name}{dimensions}{suffix}"
status_text = self._t("status.loaded", name=path.name, dimensions=dimensions, position=suffix)
self.status.config(text=status_text)
self.status_default_text = status_text
if hasattr(self, "filename_label"):
self.filename_label.config(text=f"{path.name}{dimensions}{suffix}")
filename_text = self._t(
"status.filename_label",
name=path.name,
dimensions=dimensions,
position=suffix,
)
self.filename_label.config(text=filename_text)
self.current_image_index = index
def save_overlay(self) -> None:
if self.orig_img is None:
messagebox.showinfo("Info", "Kein Bild geladen.")
messagebox.showinfo(
self._t("dialog.info_title"),
self._t("dialog.no_image_loaded"),
)
return
if self.preview_img is None:
messagebox.showerror("Fehler", "Keine Preview vorhanden.")
messagebox.showerror(
self._t("dialog.error_title"),
self._t("dialog.no_preview_available"),
)
return
overlay = self._build_overlay_image(
@ -139,12 +163,17 @@ class ImageProcessingMixin:
merged = Image.alpha_composite(self.orig_img.convert("RGBA"), overlay)
out_path = filedialog.asksaveasfilename(
defaultextension=".png", filetypes=[("PNG", "*.png")], title="Overlay speichern als"
defaultextension=".png",
filetypes=[("PNG", "*.png")],
title=self._t("dialog.save_overlay_title"),
)
if not out_path:
return
merged.save(out_path)
messagebox.showinfo("Gespeichert", f"Overlay gespeichert: {out_path}")
messagebox.showinfo(
self._t("dialog.saved_title"),
self._t("dialog.overlay_saved", path=out_path),
)
def prepare_preview(self) -> None:
if self.orig_img is None:
@ -185,16 +214,22 @@ class ImageProcessingMixin:
excl_share = (total_ex / total_all * 100) if total_all else 0.0
excl_match = (matches_ex / total_ex * 100) if total_ex else 0.0
self.ratio_label.config(
text=(
f"Markierungen (mit Ausschlüssen): {r_with:.2f}% | "
f"Markierungen (ohne Ausschlüsse): {r_no:.2f}% | "
f"Ausgeschlossen: {excl_share:.2f}% der Pixel, davon {excl_match:.2f}% markiert"
text=self._t(
"stats.summary",
with_pct=r_with,
without_pct=r_no,
excluded_pct=excl_share,
excluded_match_pct=excl_match,
)
)
bg = "#0f0f10" if self.theme == "dark" else "#1e1e1e"
self.canvas_orig.configure(bg=bg)
self.canvas_overlay.configure(bg=bg)
refresher = getattr(self, "_refresh_canvas_backgrounds", None)
if callable(refresher):
refresher()
else:
bg = "#0f0f10" if self.theme == "dark" else "#ffffff"
self.canvas_orig.configure(bg=bg)
self.canvas_overlay.configure(bg=bg)
def create_overlay_preview(self) -> Image.Image | None:
if self.preview_img is None:

View File

@ -12,7 +12,9 @@ class ResetMixin:
self.val_max.set(self.DEFAULTS["val_max"])
self.alpha.set(self.DEFAULTS["alpha"])
self.update_preview()
default_text = getattr(self, "status_default_text", "Standardwerte aktiv.")
default_text = getattr(self, "status_default_text", None)
if default_text is None:
default_text = self._t("status.defaults_restored") if hasattr(self, "_t") else "Defaults restored."
if hasattr(self, "status"):
self.status.config(text=default_text)

View File

@ -1,3 +1,7 @@
# Optional global settings
# language must correspond to a file name in app/lang (e.g. "en", "de").
language = "en"
[defaults]
# Override any of the following keys to tweak the initial slider values:
# hue_min, hue_max, sat_min, val_min, val_max accept floating point numbers.

View File

@ -20,4 +20,4 @@ package = true
include = ["app"]
[tool.setuptools.package-data]
"app" = ["assets/logo.png"]
"app" = ["assets/logo.png", "lang/*.toml"]