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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
144
app/gui/ui.py
144
app/gui/ui.py
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
"""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",
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue