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:
parent
76073ab0b5
commit
91fad62808
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
144
app/gui/ui.py
144
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("<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",
|
||||
|
|
@ -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("<Button-3>", lambda event, w=widget: self._show_copy_menu(event, w))
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -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}"
|
||||
|
|
@ -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}"
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -20,4 +20,4 @@ package = true
|
|||
include = ["app"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
"app" = ["assets/logo.png"]
|
||||
"app" = ["assets/logo.png", "lang/*.toml"]
|
||||
|
|
|
|||
Loading…
Reference in New Issue