diff --git a/app/app.py b/app/app.py index 0cf0bcf..34c6abc 100644 --- a/app/app.py +++ b/app/app.py @@ -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 diff --git a/app/gui/color_picker.py b/app/gui/color_picker.py index ff4f6c9..f7f945e 100644 --- a/app/gui/color_picker.py +++ b/app/gui/color_picker.py @@ -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) diff --git a/app/gui/theme.py b/app/gui/theme.py index 58b622e..24d23b0 100644 --- a/app/gui/theme.py +++ b/app/gui/theme.py @@ -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: diff --git a/app/gui/ui.py b/app/gui/ui.py index 87561f9..168d37d 100644 --- a/app/gui/ui.py +++ b/app/gui/ui.py @@ -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,10 +39,10 @@ class UIBuilderMixin: status_container.pack(side=tk.RIGHT, expand=True, fill=tk.X) self.status = ttk.Label( status_container, - text="Keine Datei geladen.", - anchor="e", - foreground="#efefef", - ) + text=self._t("status.no_file"), + anchor="e", + foreground="#efefef", + ) self.status.pack(fill=tk.X) self._attach_copy_menu(self.status) self.status_default_text = self.status.cget("text") @@ -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(): @@ -75,14 +75,14 @@ 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), - ] + sliders = [ + (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) sliders_frame.grid_columnconfigure(index, weight=1) @@ -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("", self.on_canvas_click) self.canvas_orig.bind("", 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", @@ -177,20 +187,20 @@ class UIBuilderMixin: pass self._update_job = self.root.after(self.update_delay_ms, self.update_preview) - 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"), - ] + def _preset_colours(self): + return [ + (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: swatch = tk.Canvas( @@ -401,14 +411,14 @@ class UIBuilderMixin: logo_label.image = logo # keep reference logo_label.pack(side=tk.LEFT, padx=(10, 6), pady=4) - title_label = tk.Label( - title_bar, - text="Interactive Color Range Analyzer", - bg=bar_bg, - fg="#f5f5f5", - font=("Segoe UI", 11, "bold"), - anchor="w", - ) + title_label = tk.Label( + title_bar, + text=self._t("app.title"), + bg=bar_bg, + fg="#f5f5f5", + font=("Segoe UI", 11, "bold"), + anchor="w", + ) title_label.pack(side=tk.LEFT, padx=6) close_btn = tk.Button( @@ -499,18 +509,31 @@ class UIBuilderMixin: for text_id in text_ids: canvas.itemconfigure(text_id, fill=palette["text"]) - def _refresh_navigation_buttons_theme(self) -> None: - if not getattr(self, "_nav_buttons", None): - return - palette = self._navigation_palette() - for btn in self._nav_buttons: + def _refresh_navigation_buttons_theme(self) -> None: + if not getattr(self, "_nav_buttons", None): + return + palette = self._navigation_palette() + for btn in self._nav_buttons: btn.configure( background=palette["bg"], activebackground=palette["bg"], fg=palette["fg"], - activeforeground=palette["fg"], - ) - + 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 @@ -542,10 +565,11 @@ class UIBuilderMixin: r, g, b = colorsys.hsv_to_rgb(hue / 360.0, saturation / 100.0, value / 100.0) return f"#{int(r * 255):02x}{int(g * 255):02x}{int(b * 255):02x}" - 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) + def _init_copy_menu(self): + self._copy_target = None + self.copy_menu = tk.Menu(self.root, tearoff=0) + 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("", lambda event, w=widget: self._show_copy_menu(event, w)) diff --git a/app/i18n.py b/app/i18n.py new file mode 100644 index 0000000..56b877b --- /dev/null +++ b/app/i18n.py @@ -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"] diff --git a/app/lang/de.toml b/app/lang/de.toml new file mode 100644 index 0000000..e646464 --- /dev/null +++ b/app/lang/de.toml @@ -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}" diff --git a/app/lang/en.toml b/app/lang/en.toml new file mode 100644 index 0000000..f8efc1d --- /dev/null +++ b/app/lang/en.toml @@ -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}" diff --git a/app/logic/__init__.py b/app/logic/__init__.py index 55be9fd..37b5aec 100644 --- a/app/logic/__init__.py +++ b/app/logic/__init__.py @@ -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", diff --git a/app/logic/constants.py b/app/logic/constants.py index eec0ff2..7e4e659 100644 --- a/app/logic/constants.py +++ b/app/logic/constants.py @@ -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) diff --git a/app/logic/image_processing.py b/app/logic/image_processing.py index 64cdc09..4ee0f07 100644 --- a/app/logic/image_processing.py +++ b/app/logic/image_processing.py @@ -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: diff --git a/app/logic/reset.py b/app/logic/reset.py index 6b6275b..b5c5038 100644 --- a/app/logic/reset.py +++ b/app/logic/reset.py @@ -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) diff --git a/config.toml b/config.toml index 3fe8ce4..9930654 100644 --- a/config.toml +++ b/config.toml @@ -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. diff --git a/pyproject.toml b/pyproject.toml index c21f837..adedbd6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,4 +20,4 @@ package = true include = ["app"] [tool.setuptools.package-data] -"app" = ["assets/logo.png"] +"app" = ["assets/logo.png", "lang/*.toml"]