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 import tkinter as tk
from .gui import ColorPickerMixin, ExclusionMixin, ThemeMixin, UIBuilderMixin 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( class ICRAApp(
I18nMixin,
ThemeMixin, ThemeMixin,
UIBuilderMixin, UIBuilderMixin,
ImageProcessingMixin, ImageProcessingMixin,
@ -20,7 +22,8 @@ class ICRAApp(
def __init__(self, root: tk.Tk): def __init__(self, root: tk.Tk):
self.root = root self.root = root
self.root.title("Interactive Color Range Analyzer") self.init_i18n(LANGUAGE)
self.root.title(self._t("app.title"))
self._setup_window() self._setup_window()
# Theme and styling # Theme and styling

View File

@ -15,15 +15,21 @@ class ColorPickerMixin:
selected_colour: tuple[int, int, int] | None = None selected_colour: tuple[int, int, int] | None = None
def choose_color(self): 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: if rgb is None:
return return
r, g, b = (int(round(channel)) for channel in rgb) r, g, b = (int(round(channel)) for channel in rgb)
hue_deg, sat_pct, val_pct = self._apply_rgb_selection(r, g, b) hue_deg, sat_pct, val_pct = self._apply_rgb_selection(r, g, b)
label = hex_colour or f"RGB({r}, {g}, {b})" label = hex_colour or f"RGB({r}, {g}, {b})"
self.status.config( message = self._t(
text=f"Farbe gewählt: {label} — Hue {hue_deg:.1f}°, S {sat_pct:.0f}%, V {val_pct:.0f}%" "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) self._update_selected_colour(r, g, b)
def apply_sample_colour(self, hex_colour: str, name: str | None = None) -> None: def apply_sample_colour(self, hex_colour: str, name: str | None = None) -> None:
@ -35,25 +41,31 @@ class ColorPickerMixin:
if self.pick_mode: if self.pick_mode:
self.pick_mode = False self.pick_mode = False
label = name or hex_colour.upper() label = name or hex_colour.upper()
self.status.config( message = self._t(
text=( "status.sample_colour",
f"Beispielfarbe gewählt: {label} ({hex_colour}) — " label=label,
f"Hue {hue_deg:.1f}°, S {sat_pct:.0f}%, V {val_pct:.0f}%" hex_code=hex_colour,
) hue=hue_deg,
saturation=sat_pct,
value=val_pct,
) )
self.status.config(text=message)
self._update_selected_colour(*rgb) self._update_selected_colour(*rgb)
def enable_pick_mode(self): def enable_pick_mode(self):
if self.preview_img is None: 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 return
self.pick_mode = True 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): def disable_pick_mode(self, event=None):
if self.pick_mode: if self.pick_mode:
self.pick_mode = False 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): def on_canvas_click(self, event):
if not self.pick_mode or self.preview_img is None: 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) hue_deg, sat_pct, val_pct = self._apply_rgb_selection(r, g, b)
self.disable_pick_mode() self.disable_pick_mode()
self.status.config( 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) self._update_selected_colour(r, g, b)

View File

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

View File

@ -17,15 +17,15 @@ class UIBuilderMixin:
toolbar = ttk.Frame(self.root) toolbar = ttk.Frame(self.root)
toolbar.pack(fill=tk.X, padx=12, pady=(4, 2)) toolbar.pack(fill=tk.X, padx=12, pady=(4, 2))
buttons = [ buttons = [
("📂", "Bild laden", self.load_image), ("📂", self._t("toolbar.open_image"), self.load_image),
("📁", "Ordner laden", self.load_folder), ("📁", self._t("toolbar.open_folder"), self.load_folder),
("🎨", "Farbe wählen", self.choose_color), ("🎨", self._t("toolbar.choose_color"), self.choose_color),
("🖱", "Farbe aus Bild klicken", self.enable_pick_mode), ("🖱", self._t("toolbar.pick_from_image"), self.enable_pick_mode),
("💾", "Overlay speichern", self.save_overlay), ("💾", self._t("toolbar.save_overlay"), self.save_overlay),
("🧹", "Ausschlüsse löschen", self.clear_excludes), ("🧹", self._t("toolbar.clear_excludes"), self.clear_excludes),
("", "Letzten Ausschluss entfernen", self.undo_exclude), ("", self._t("toolbar.undo_exclude"), self.undo_exclude),
("🔄", "Slider zurücksetzen", self.reset_sliders), ("🔄", self._t("toolbar.reset_sliders"), self.reset_sliders),
("🌓", "Theme umschalten", self.toggle_theme), ("🌓", self._t("toolbar.toggle_theme"), self.toggle_theme),
] ]
self._toolbar_buttons: list[dict[str, object]] = [] self._toolbar_buttons: list[dict[str, object]] = []
self._nav_buttons: list[tk.Button] = [] self._nav_buttons: list[tk.Button] = []
@ -39,10 +39,10 @@ class UIBuilderMixin:
status_container.pack(side=tk.RIGHT, expand=True, fill=tk.X) status_container.pack(side=tk.RIGHT, expand=True, fill=tk.X)
self.status = ttk.Label( self.status = ttk.Label(
status_container, status_container,
text="Keine Datei geladen.", text=self._t("status.no_file"),
anchor="e", anchor="e",
foreground="#efefef", foreground="#efefef",
) )
self.status.pack(fill=tk.X) self.status.pack(fill=tk.X)
self._attach_copy_menu(self.status) self._attach_copy_menu(self.status)
self.status_default_text = self.status.cget("text") self.status_default_text = self.status.cget("text")
@ -54,7 +54,7 @@ class UIBuilderMixin:
current_frame = ttk.Frame(palette_frame) current_frame = ttk.Frame(palette_frame)
current_frame.pack(side=tk.LEFT, padx=(0, 16)) 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( self.current_colour_sw = tk.Canvas(
current_frame, current_frame,
width=24, width=24,
@ -67,7 +67,7 @@ class UIBuilderMixin:
self.current_colour_label = ttk.Label(current_frame, text=f"({default_colour})") self.current_colour_label = ttk.Label(current_frame, text=f"({default_colour})")
self.current_colour_label.pack(side=tk.LEFT, padx=(6, 0)) 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 = ttk.Frame(palette_frame)
swatch_container.pack(side=tk.LEFT) swatch_container.pack(side=tk.LEFT)
for name, hex_code in self._preset_colours(): for name, hex_code in self._preset_colours():
@ -75,14 +75,14 @@ class UIBuilderMixin:
sliders_frame = ttk.Frame(self.root) sliders_frame = ttk.Frame(self.root)
sliders_frame.pack(fill=tk.X, padx=12, pady=4) sliders_frame.pack(fill=tk.X, padx=12, pady=4)
sliders = [ sliders = [
("Hue Min (°)", self.hue_min, 0, 360), (self._t("sliders.hue_min"), self.hue_min, 0, 360),
("Hue Max (°)", self.hue_max, 0, 360), (self._t("sliders.hue_max"), self.hue_max, 0, 360),
("Sättigung Min (%)", self.sat_min, 0, 100), (self._t("sliders.sat_min"), self.sat_min, 0, 100),
("Helligkeit Min (%)", self.val_min, 0, 100), (self._t("sliders.val_min"), self.val_min, 0, 100),
("Helligkeit Max (%)", self.val_max, 0, 100), (self._t("sliders.val_max"), self.val_max, 0, 100),
("Overlay Alpha", self.alpha, 0, 255), (self._t("sliders.alpha"), self.alpha, 0, 255),
] ]
for index, (label, variable, minimum, maximum) in enumerate(sliders): for index, (label, variable, minimum, maximum) in enumerate(sliders):
self.add_slider_with_value(sliders_frame, label, variable, minimum, maximum, column=index) self.add_slider_with_value(sliders_frame, label, variable, minimum, maximum, column=index)
sliders_frame.grid_columnconfigure(index, weight=1) 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._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.grid(row=0, column=1, sticky="nsew")
self.canvas_orig.bind("<Button-1>", self.on_canvas_click) self.canvas_orig.bind("<Button-1>", self.on_canvas_click)
self.canvas_orig.bind("<ButtonPress-3>", self._exclude_start) 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_columnconfigure(0, weight=1)
right_column.grid_rowconfigure(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.canvas_overlay.grid(row=0, column=0, sticky="nsew")
self._create_navigation_button(right_column, "", self.show_next_image, column=1) self._create_navigation_button(right_column, "", self.show_next_image, column=1)
@ -128,7 +138,7 @@ class UIBuilderMixin:
self.ratio_label = ttk.Label( self.ratio_label = ttk.Label(
info_frame, info_frame,
text="Markierungen (mit Ausschlüssen): —", text=self._t("stats.placeholder"),
font=("Segoe UI", 10, "bold"), font=("Segoe UI", 10, "bold"),
anchor="center", anchor="center",
justify="center", justify="center",
@ -177,20 +187,20 @@ class UIBuilderMixin:
pass pass
self._update_job = self.root.after(self.update_delay_ms, self.update_preview) self._update_job = self.root.after(self.update_delay_ms, self.update_preview)
def _preset_colours(self): def _preset_colours(self):
return [ return [
("Rot", "#ff3b30"), (self._t("palette.swatch.red"), "#ff3b30"),
("Orange", "#ff9500"), (self._t("palette.swatch.orange"), "#ff9500"),
("Gelb", "#ffd60a"), (self._t("palette.swatch.yellow"), "#ffd60a"),
("Grün", "#34c759"), (self._t("palette.swatch.green"), "#34c759"),
("Türkis", "#5ac8fa"), (self._t("palette.swatch.teal"), "#5ac8fa"),
("Blau", "#0a84ff"), (self._t("palette.swatch.blue"), "#0a84ff"),
("Violett", "#af52de"), (self._t("palette.swatch.violet"), "#af52de"),
("Magenta", "#ff2d55"), (self._t("palette.swatch.magenta"), "#ff2d55"),
("Weiß", "#ffffff"), (self._t("palette.swatch.white"), "#ffffff"),
("Grau", "#8e8e93"), (self._t("palette.swatch.grey"), "#8e8e93"),
("Schwarz", "#000000"), (self._t("palette.swatch.black"), "#000000"),
] ]
def _add_palette_swatch(self, parent, name: str, hex_code: str) -> None: def _add_palette_swatch(self, parent, name: str, hex_code: str) -> None:
swatch = tk.Canvas( swatch = tk.Canvas(
@ -401,14 +411,14 @@ class UIBuilderMixin:
logo_label.image = logo # keep reference logo_label.image = logo # keep reference
logo_label.pack(side=tk.LEFT, padx=(10, 6), pady=4) logo_label.pack(side=tk.LEFT, padx=(10, 6), pady=4)
title_label = tk.Label( title_label = tk.Label(
title_bar, title_bar,
text="Interactive Color Range Analyzer", text=self._t("app.title"),
bg=bar_bg, bg=bar_bg,
fg="#f5f5f5", fg="#f5f5f5",
font=("Segoe UI", 11, "bold"), font=("Segoe UI", 11, "bold"),
anchor="w", anchor="w",
) )
title_label.pack(side=tk.LEFT, padx=6) title_label.pack(side=tk.LEFT, padx=6)
close_btn = tk.Button( close_btn = tk.Button(
@ -499,18 +509,31 @@ class UIBuilderMixin:
for text_id in text_ids: for text_id in text_ids:
canvas.itemconfigure(text_id, fill=palette["text"]) canvas.itemconfigure(text_id, fill=palette["text"])
def _refresh_navigation_buttons_theme(self) -> None: def _refresh_navigation_buttons_theme(self) -> None:
if not getattr(self, "_nav_buttons", None): if not getattr(self, "_nav_buttons", None):
return return
palette = self._navigation_palette() palette = self._navigation_palette()
for btn in self._nav_buttons: for btn in self._nav_buttons:
btn.configure( btn.configure(
background=palette["bg"], background=palette["bg"],
activebackground=palette["bg"], activebackground=palette["bg"],
fg=palette["fg"], 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: def _refresh_status_palette(self, fg: str) -> None:
self.status.configure(foreground=fg) self.status.configure(foreground=fg)
self._status_palette["fg"] = 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) 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}" return f"#{int(r * 255):02x}{int(g * 255):02x}{int(b * 255):02x}"
def _init_copy_menu(self): def _init_copy_menu(self):
self._copy_target = None self._copy_target = None
self.copy_menu = tk.Menu(self.root, tearoff=0) 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): def _attach_copy_menu(self, widget):
widget.bind("<Button-3>", lambda event, w=widget: self._show_copy_menu(event, w)) 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.""" """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 .image_processing import ImageProcessingMixin
from .reset import ResetMixin from .reset import ResetMixin
@ -8,6 +8,7 @@ __all__ = [
"BASE_DIR", "BASE_DIR",
"DEFAULTS", "DEFAULTS",
"IMAGES_DIR", "IMAGES_DIR",
"LANGUAGE",
"PREVIEW_MAX_SIZE", "PREVIEW_MAX_SIZE",
"SUPPORTED_IMAGE_EXTENSIONS", "SUPPORTED_IMAGE_EXTENSIONS",
"ImageProcessingMixin", "ImageProcessingMixin",

View File

@ -19,6 +19,7 @@ PREVIEW_MAX_SIZE = (900, 660)
BASE_DIR = Path(__file__).resolve().parents[2] BASE_DIR = Path(__file__).resolve().parents[2]
IMAGES_DIR = BASE_DIR / "images" IMAGES_DIR = BASE_DIR / "images"
CONFIG_FILE = BASE_DIR / "config.toml" CONFIG_FILE = BASE_DIR / "config.toml"
LANG_DIR = BASE_DIR / "app" / "lang"
_DEFAULTS_BASE = { _DEFAULTS_BASE = {
"hue_min": 250.0, "hue_min": 250.0,
@ -30,6 +31,7 @@ _DEFAULTS_BASE = {
} }
SUPPORTED_IMAGE_EXTENSIONS = (".webp", ".png", ".jpg", ".jpeg", ".bmp") SUPPORTED_IMAGE_EXTENSIONS = (".webp", ".png", ".jpg", ".jpeg", ".bmp")
LANGUAGE_DEFAULT = "en"
_DEFAULT_TYPES: dict[str, Callable[[Any], Any]] = { _DEFAULT_TYPES: dict[str, Callable[[Any], Any]] = {
"hue_min": float, "hue_min": float,
@ -41,8 +43,8 @@ _DEFAULT_TYPES: dict[str, Callable[[Any], Any]] = {
} }
def _load_default_overrides() -> dict[str, Any]: def _load_config_data() -> dict[str, Any]:
"""Load default slider overrides from config.toml if available.""" """Read the optional config file once and return its parsed data."""
if tomllib is None or not CONFIG_FILE.exists(): if tomllib is None or not CONFIG_FILE.exists():
return {} return {}
decode_error = getattr(tomllib, "TOMLDecodeError", ValueError) # type: ignore[attr-defined] 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) data = tomllib.load(handle)
except (OSError, AttributeError, decode_error, TypeError): # type: ignore[arg-type] except (OSError, AttributeError, decode_error, TypeError): # type: ignore[arg-type]
return {} return {}
if not isinstance(data, dict):
return {}
return data
def _extract_default_overrides(data: dict[str, Any]) -> dict[str, Any]:
settings = data.get("defaults") settings = data.get("defaults")
if not isinstance(settings, dict): if not isinstance(settings, dict):
return {} return {}
@ -65,4 +73,26 @@ def _load_default_overrides() -> dict[str, Any]:
return overrides 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: def load_image(self) -> None:
default_dir = IMAGES_DIR if IMAGES_DIR.exists() else Path.cwd() default_dir = IMAGES_DIR if IMAGES_DIR.exists() else Path.cwd()
path = filedialog.askopenfilename( path = filedialog.askopenfilename(
title="Bild wählen", title=self._t("dialog.open_image_title"),
filetypes=[("Images", "*.webp *.png *.jpg *.jpeg *.bmp")], filetypes=[(self._t("dialog.images_filter"), "*.webp *.png *.jpg *.jpeg *.bmp")],
initialdir=str(default_dir), initialdir=str(default_dir),
) )
if not path: if not path:
@ -39,14 +39,17 @@ class ImageProcessingMixin:
def load_folder(self) -> None: def load_folder(self) -> None:
default_dir = IMAGES_DIR if IMAGES_DIR.exists() else Path.cwd() default_dir = IMAGES_DIR if IMAGES_DIR.exists() else Path.cwd()
directory = filedialog.askdirectory( directory = filedialog.askdirectory(
title="Ordner mit Bildern wählen", title=self._t("dialog.open_folder_title"),
initialdir=str(default_dir), initialdir=str(default_dir),
) )
if not directory: if not directory:
return return
folder = Path(directory) folder = Path(directory)
if not folder.exists(): 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 return
image_files = sorted( image_files = sorted(
( (
@ -57,7 +60,10 @@ class ImageProcessingMixin:
key=lambda item: item.name.lower(), key=lambda item: item.name.lower(),
) )
if not image_files: 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 return
self._set_image_collection(image_files, 0) self._set_image_collection(image_files, 0)
@ -93,12 +99,18 @@ class ImageProcessingMixin:
return return
path = self.image_paths[index] path = self.image_paths[index]
if not path.exists(): 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 return
try: try:
image = Image.open(path).convert("RGBA") image = Image.open(path).convert("RGBA")
except Exception as exc: 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 return
self.image_path = path self.image_path = path
@ -113,20 +125,32 @@ class ImageProcessingMixin:
dimensions = f"{self.orig_img.width}x{self.orig_img.height}" 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 "" 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.config(text=status_text)
self.status_default_text = status_text self.status_default_text = status_text
if hasattr(self, "filename_label"): 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 self.current_image_index = index
def save_overlay(self) -> None: def save_overlay(self) -> None:
if self.orig_img is 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 return
if self.preview_img is None: 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 return
overlay = self._build_overlay_image( overlay = self._build_overlay_image(
@ -139,12 +163,17 @@ class ImageProcessingMixin:
merged = Image.alpha_composite(self.orig_img.convert("RGBA"), overlay) merged = Image.alpha_composite(self.orig_img.convert("RGBA"), overlay)
out_path = filedialog.asksaveasfilename( 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: if not out_path:
return return
merged.save(out_path) 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: def prepare_preview(self) -> None:
if self.orig_img is 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_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 excl_match = (matches_ex / total_ex * 100) if total_ex else 0.0
self.ratio_label.config( self.ratio_label.config(
text=( text=self._t(
f"Markierungen (mit Ausschlüssen): {r_with:.2f}% | " "stats.summary",
f"Markierungen (ohne Ausschlüsse): {r_no:.2f}% | " with_pct=r_with,
f"Ausgeschlossen: {excl_share:.2f}% der Pixel, davon {excl_match:.2f}% markiert" without_pct=r_no,
excluded_pct=excl_share,
excluded_match_pct=excl_match,
) )
) )
bg = "#0f0f10" if self.theme == "dark" else "#1e1e1e" refresher = getattr(self, "_refresh_canvas_backgrounds", None)
self.canvas_orig.configure(bg=bg) if callable(refresher):
self.canvas_overlay.configure(bg=bg) 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: def create_overlay_preview(self) -> Image.Image | None:
if self.preview_img is None: if self.preview_img is None:

View File

@ -12,7 +12,9 @@ class ResetMixin:
self.val_max.set(self.DEFAULTS["val_max"]) self.val_max.set(self.DEFAULTS["val_max"])
self.alpha.set(self.DEFAULTS["alpha"]) self.alpha.set(self.DEFAULTS["alpha"])
self.update_preview() 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"): if hasattr(self, "status"):
self.status.config(text=default_text) 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] [defaults]
# Override any of the following keys to tweak the initial slider values: # 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. # hue_min, hue_max, sat_min, val_min, val_max accept floating point numbers.

View File

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