diff --git a/.gitignore b/.gitignore index cb62507..9a6c7ba 100644 --- a/.gitignore +++ b/.gitignore @@ -138,6 +138,15 @@ dmypy.json # Cython debug symbols cython_debug/ -# Pycharm +# JetBrains / PyCharm .idea/ +*.iml +*.ipr +*.iws +# Git worktree cruft +*.orig +*.rej +*.merge_file_* +.git/modules/ +.git/worktrees/ diff --git a/colorcalc/__init__.py b/colorcalc/__init__.py new file mode 100644 index 0000000..5d393c5 --- /dev/null +++ b/colorcalc/__init__.py @@ -0,0 +1,5 @@ +"""ColorCalc application package.""" + +from .app import PurpleTunerApp, start_app + +__all__ = ["PurpleTunerApp", "start_app"] diff --git a/colorcalc/app.py b/colorcalc/app.py new file mode 100644 index 0000000..ef341cf --- /dev/null +++ b/colorcalc/app.py @@ -0,0 +1,78 @@ +"""Application composition root.""" + +from __future__ import annotations + +import tkinter as tk + +from .color_picker import ColorPickerMixin +from .constants import DEFAULTS +from .exclusions import ExclusionMixin +from .image_processing import ImageProcessingMixin +from .reset import ResetMixin +from .theme import ThemeMixin +from .ui import UIBuilderMixin + + +class PurpleTunerApp( + ThemeMixin, + UIBuilderMixin, + ImageProcessingMixin, + ExclusionMixin, + ColorPickerMixin, + ResetMixin, +): + """Tkinter based application for highlighting purple hues in images.""" + + def __init__(self, root: tk.Tk): + self.root = root + self.root.title("Purple Tuner — Bild + Overlay") + try: + self.root.state("zoomed") + except Exception: + pass + self.root.configure(bg="#f2f2f7") + + # Theme and styling + self.init_theme() + + # Tkinter state variables + self.DEFAULTS = DEFAULTS.copy() + self.hue_min = tk.DoubleVar(value=self.DEFAULTS["hue_min"]) + self.hue_max = tk.DoubleVar(value=self.DEFAULTS["hue_max"]) + self.sat_min = tk.DoubleVar(value=self.DEFAULTS["sat_min"]) + self.val_min = tk.DoubleVar(value=self.DEFAULTS["val_min"]) + self.val_max = tk.DoubleVar(value=self.DEFAULTS["val_max"]) + self.alpha = tk.IntVar(value=self.DEFAULTS["alpha"]) + self.ref_hue = None + + # Debounce for heavy preview updates + self.update_delay_ms = 400 + self._update_job = None + + # Exclusion rectangles (preview coordinates) + self.exclude_rects: list[tuple[int, int, int, int]] = [] + self._rubber_start = None + self._rubber_id = None + self.pick_mode = False + + # Image references + self.image_path = None + self.orig_img = None + self.preview_img = None + self.preview_tk = None + self.overlay_tk = None + + # Build UI + self.setup_ui() + self._init_copy_menu() + self.bring_to_front() + + +def start_app() -> None: + """Entry point used by the CLI script.""" + root = tk.Tk() + app = PurpleTunerApp(root) + root.mainloop() + + +__all__ = ["PurpleTunerApp", "start_app"] diff --git a/colorcalc/color_picker.py b/colorcalc/color_picker.py new file mode 100644 index 0000000..72135db --- /dev/null +++ b/colorcalc/color_picker.py @@ -0,0 +1,60 @@ +"""Color selection utilities.""" + +from __future__ import annotations + +import colorsys + +from tkinter import colorchooser, messagebox + + +class ColorPickerMixin: + """Handles colour selection from dialogs and mouse clicks.""" + + ref_hue: float | None + + def choose_color(self): + rgb, hex_colour = colorchooser.askcolor(title="Farbe wählen") + if rgb is None: + return + r, g, b = [int(channel) for channel in rgb] + h, s, v = colorsys.rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0) + self.ref_hue = h * 360.0 + span = 30 + self.hue_min.set((self.ref_hue - span) % 360) + self.hue_max.set((self.ref_hue + span) % 360) + self.update_preview() + self.status.config(text=f"Farbe gewählt: {hex_colour} (Hue {self.ref_hue:.1f}°)") + + def enable_pick_mode(self): + if self.preview_img is None: + messagebox.showinfo("Info", "Bitte zuerst ein Bild laden.") + return + self.pick_mode = True + self.status.config(text="Pick-Modus: Klicke links ins Bild, um Farbe zu wählen (Esc beendet)") + + def disable_pick_mode(self, event=None): + if self.pick_mode: + self.pick_mode = False + self.status.config(text="Pick-Modus beendet.") + + def on_canvas_click(self, event): + if not self.pick_mode or self.preview_img is None: + return + x = int(event.x) + y = int(event.y) + if x < 0 or y < 0 or x >= self.preview_img.width or y >= self.preview_img.height: + return + r, g, b, a = self.preview_img.getpixel((x, y)) + if a == 0: + return + h, s, v = colorsys.rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0) + self.ref_hue = h * 360.0 + span = 30 + self.hue_min.set((self.ref_hue - span) % 360) + self.hue_max.set((self.ref_hue + span) % 360) + self.disable_pick_mode() + self.update_preview() + self.status.config(text=f"Farbe vom Bild gewählt: Hue {self.ref_hue:.1f}°") + + +__all__ = ["ColorPickerMixin"] diff --git a/colorcalc/constants.py b/colorcalc/constants.py new file mode 100644 index 0000000..5177984 --- /dev/null +++ b/colorcalc/constants.py @@ -0,0 +1,19 @@ +"""Shared configuration constants for ColorCalc.""" + +from __future__ import annotations + +from pathlib import Path + +PREVIEW_MAX_SIZE = (1200, 800) + +DEFAULTS = { + "hue_min": 250.0, + "hue_max": 310.0, + "sat_min": 15.0, + "val_min": 15.0, + "val_max": 100.0, + "alpha": 120, +} + +BASE_DIR = Path(__file__).resolve().parent.parent +IMAGES_DIR = BASE_DIR / "images" diff --git a/colorcalc/exclusions.py b/colorcalc/exclusions.py new file mode 100644 index 0000000..fb51cb4 --- /dev/null +++ b/colorcalc/exclusions.py @@ -0,0 +1,57 @@ +"""Mouse handlers for exclusion rectangles.""" + +from __future__ import annotations + + +class ExclusionMixin: + """Manage exclusion rectangles drawn on the preview canvas.""" + + def _exclude_start(self, event): + if self.preview_img is None: + return + x = max(0, min(self.preview_img.width - 1, int(event.x))) + y = max(0, min(self.preview_img.height - 1, int(event.y))) + self._rubber_start = (x, y) + if self._rubber_id: + try: + self.canvas_orig.delete(self._rubber_id) + except Exception: + pass + self._rubber_id = self.canvas_orig.create_rectangle(x, y, x, y, outline="yellow", width=2) + + def _exclude_drag(self, event): + if not self._rubber_start: + return + x0, y0 = self._rubber_start + x1 = max(0, min(self.preview_img.width - 1, int(event.x))) + y1 = max(0, min(self.preview_img.height - 1, int(event.y))) + self.canvas_orig.coords(self._rubber_id, x0, y0, x1, y1) + + def _exclude_end(self, event): + if not self._rubber_start: + return + x0, y0 = self._rubber_start + x1 = max(0, min(self.preview_img.width - 1, int(event.x))) + y1 = max(0, min(self.preview_img.height - 1, int(event.y))) + rx0, rx1 = sorted((x0, x1)) + ry0, ry1 = sorted((y0, y1)) + if (rx1 - rx0) > 0 and (ry1 - ry0) > 0: + self.exclude_rects.append((rx0, ry0, rx1, ry1)) + self._rubber_start = None + self._rubber_id = None + self.update_preview() + + def clear_excludes(self): + self.exclude_rects = [] + self.canvas_orig.delete("all") + if self.preview_tk: + self.canvas_orig.create_image(0, 0, anchor="nw", image=self.preview_tk) + self.update_preview() + + def undo_exclude(self): + if self.exclude_rects: + self.exclude_rects.pop() + self.update_preview() + + +__all__ = ["ExclusionMixin"] diff --git a/colorcalc/image_processing.py b/colorcalc/image_processing.py new file mode 100644 index 0000000..ed257e9 --- /dev/null +++ b/colorcalc/image_processing.py @@ -0,0 +1,231 @@ +"""Image loading, processing, and statistics logic.""" + +from __future__ import annotations + +import colorsys +from pathlib import Path +from typing import Iterable, Tuple + +from tkinter import filedialog, messagebox + +from PIL import Image, ImageDraw, ImageTk + +from .constants import IMAGES_DIR, PREVIEW_MAX_SIZE + + +class ImageProcessingMixin: + """Handles all image related operations.""" + + image_path: Path | None + orig_img: Image.Image | None + preview_img: Image.Image | None + preview_tk: ImageTk.PhotoImage | None + overlay_tk: ImageTk.PhotoImage | None + + 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")], + initialdir=str(default_dir), + ) + if not path: + return + self.image_path = Path(path) + try: + image = Image.open(path).convert("RGBA") + except Exception as exc: + messagebox.showerror("Fehler", f"Bild konnte nicht geladen werden: {exc}") + return + self.orig_img = image + self.prepare_preview() + self.update_preview() + self.status.config( + text=f"Geladen: {self.image_path.name} — {self.orig_img.width}x{self.orig_img.height}" + ) + + def save_overlay(self) -> None: + if self.orig_img is None: + messagebox.showinfo("Info", "Kein Bild geladen.") + return + if self.preview_img is None: + messagebox.showerror("Fehler", "Keine Preview vorhanden.") + return + + overlay = self._build_overlay_image( + self.orig_img, + self.exclude_rects, + alpha=int(self.alpha.get()), + scale_from_preview=self.preview_img.size, + is_purple_fn=self.is_purple_pixel, + ) + merged = Image.alpha_composite(self.orig_img.convert("RGBA"), overlay) + + out_path = filedialog.asksaveasfilename( + defaultextension=".png", filetypes=[("PNG", "*.png")], title="Overlay speichern als" + ) + if not out_path: + return + merged.save(out_path) + messagebox.showinfo("Gespeichert", f"Overlay gespeichert: {out_path}") + + def prepare_preview(self) -> None: + if self.orig_img is None: + return + width, height = self.orig_img.size + max_w, max_h = PREVIEW_MAX_SIZE + scale = min(max_w / width, max_h / height, 1.0) + size = (max(1, int(width * scale)), max(1, int(height * scale))) + self.preview_img = self.orig_img.resize(size, Image.LANCZOS) + self.preview_tk = ImageTk.PhotoImage(self.preview_img) + self.canvas_orig.delete("all") + self.canvas_orig.config(width=size[0], height=size[1]) + self.canvas_overlay.config(width=size[0], height=size[1]) + self.canvas_orig.create_image(0, 0, anchor="nw", image=self.preview_tk) + + def update_preview(self) -> None: + if self.preview_img is None: + return + merged = self.create_overlay_preview() + if merged is None: + return + self.overlay_tk = ImageTk.PhotoImage(merged) + self.canvas_overlay.delete("all") + self.canvas_overlay.create_image(0, 0, anchor="nw", image=self.overlay_tk) + + self.canvas_orig.delete("all") + self.canvas_orig.create_image(0, 0, anchor="nw", image=self.preview_tk) + for (x0, y0, x1, y1) in self.exclude_rects: + self.canvas_orig.create_rectangle(x0, y0, x1, y1, outline="yellow", width=3) + + stats = self.compute_stats_preview() + if stats: + p_all, t_all = stats["all"] + p_keep, t_keep = stats["keep"] + p_ex, t_ex = stats["excl"] + r_with = (p_keep / t_keep * 100) if t_keep else 0.0 + r_no = (p_all / t_all * 100) if t_all else 0.0 + excl_share = (t_ex / t_all * 100) if t_all else 0.0 + excl_purp = (p_ex / t_ex * 100) if t_ex else 0.0 + self.ratio_label.config( + text=( + f"Purple (mit Excludes): {r_with:.2f}% | " + f"Purple (ohne Excludes): {r_no:.2f}% | " + f"Excluded: {excl_share:.2f}% vom Bild, davon {excl_purp:.2f}% purple" + ) + ) + + bg = "#0f0f10" if self.theme == "dark" else "#1e1e1e" + 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: + return None + base = self.preview_img.convert("RGBA") + overlay = Image.new("RGBA", base.size, (0, 0, 0, 0)) + draw = ImageDraw.Draw(overlay) + pixels = base.load() + width, height = base.size + alpha = int(self.alpha.get()) + for y in range(height): + for x in range(width): + if self._is_excluded(x, y): + continue + r, g, b, a = pixels[x, y] + if a == 0: + continue + if self.is_purple_pixel(r, g, b): + draw.point((x, y), fill=(255, 0, 0, alpha)) + merged = Image.alpha_composite(base, overlay) + for (x0, y0, x1, y1) in self.exclude_rects: + ImageDraw.Draw(merged).rectangle([x0, y0, x1, y1], outline=(255, 215, 0, 200), width=3) + return merged + + def compute_stats_preview(self): + if self.preview_img is None: + return None + px = self.preview_img.convert("RGBA").load() + width, height = self.preview_img.size + purple_all = total_all = 0 + purple_keep = total_keep = 0 + purple_excl = total_excl = 0 + for y in range(height): + for x in range(width): + r, g, b, a = px[x, y] + if a == 0: + continue + is_excluded = self._is_excluded(x, y) + total_all += 1 + if self.is_purple_pixel(r, g, b): + purple_all += 1 + if not is_excluded: + total_keep += 1 + if self.is_purple_pixel(r, g, b): + purple_keep += 1 + else: + total_excl += 1 + if self.is_purple_pixel(r, g, b): + purple_excl += 1 + return {"all": (purple_all, total_all), "keep": (purple_keep, total_keep), "excl": (purple_excl, total_excl)} + + def is_purple_pixel(self, r, g, b) -> bool: + h, s, v = colorsys.rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0) + hue = h * 360.0 + hmin = float(self.hue_min.get()) + hmax = float(self.hue_max.get()) + smin = float(self.sat_min.get()) / 100.0 + vmin = float(self.val_min.get()) / 100.0 + vmax = float(self.val_max.get()) / 100.0 + if hmin <= hmax: + hue_ok = hmin <= hue <= hmax + else: + hue_ok = (hue >= hmin) or (hue <= hmax) + return hue_ok and (s >= smin) and (v >= vmin) and (v <= vmax) + + def _is_excluded(self, x: int, y: int) -> bool: + return any(x0 <= x <= x1 and y0 <= y <= y1 for (x0, y0, x1, y1) in self.exclude_rects) + + @staticmethod + def _map_preview_excludes( + excludes: Iterable[Tuple[int, int, int, int]], + orig_size: Tuple[int, int], + preview_size: Tuple[int, int], + ) -> list[Tuple[int, int, int, int]]: + scale_x = orig_size[0] / preview_size[0] + scale_y = orig_size[1] / preview_size[1] + mapped = [] + for x0, y0, x1, y1 in excludes: + mapped.append( + (int(x0 * scale_x), int(y0 * scale_y), int(x1 * scale_x), int(y1 * scale_y)) + ) + return mapped + + @classmethod + def _build_overlay_image( + cls, + image: Image.Image, + excludes_preview: Iterable[Tuple[int, int, int, int]], + *, + alpha: int, + scale_from_preview: Tuple[int, int], + is_purple_fn, + ) -> Image.Image: + overlay = Image.new("RGBA", image.size, (0, 0, 0, 0)) + draw = ImageDraw.Draw(overlay) + pixels = image.load() + width, height = image.size + excludes = cls._map_preview_excludes(excludes_preview, image.size, scale_from_preview) + for y in range(height): + for x in range(width): + if any(x0 <= x <= x1 and y0 <= y <= y1 for (x0, y0, x1, y1) in excludes): + continue + r, g, b, a = pixels[x, y] + if a == 0: + continue + if is_purple_fn(r, g, b): + draw.point((x, y), fill=(255, 0, 0, alpha)) + return overlay + + +__all__ = ["ImageProcessingMixin"] diff --git a/colorcalc/reset.py b/colorcalc/reset.py new file mode 100644 index 0000000..45e849e --- /dev/null +++ b/colorcalc/reset.py @@ -0,0 +1,17 @@ +"""Utility mixin for restoring default slider values.""" + +from __future__ import annotations + + +class ResetMixin: + def reset_sliders(self): + self.hue_min.set(self.DEFAULTS["hue_min"]) + self.hue_max.set(self.DEFAULTS["hue_max"]) + self.sat_min.set(self.DEFAULTS["sat_min"]) + self.val_min.set(self.DEFAULTS["val_min"]) + self.val_max.set(self.DEFAULTS["val_max"]) + self.alpha.set(self.DEFAULTS["alpha"]) + self.update_preview() + + +__all__ = ["ResetMixin"] diff --git a/colorcalc/theme.py b/colorcalc/theme.py new file mode 100644 index 0000000..3242120 --- /dev/null +++ b/colorcalc/theme.py @@ -0,0 +1,104 @@ +"""Theme and window helpers.""" + +from __future__ import annotations + +import platform +from tkinter import ttk + +try: + import ttkbootstrap as tb # type: ignore + + HAS_TTKBOOTSTRAP = True +except Exception: # pragma: no cover - optional dependency + tb = None + HAS_TTKBOOTSTRAP = False + +try: + import winreg +except Exception: # pragma: no cover - platform-specific + winreg = None # type: ignore + + +class ThemeMixin: + """Provides theme handling utilities for the main application.""" + + theme: str + style: ttk.Style + using_tb: bool + scale_style: str + + def init_theme(self) -> None: + """Initialise ttk style handling and apply the detected theme.""" + if HAS_TTKBOOTSTRAP: + self.style = tb.Style() + self.using_tb = True + else: + self.style = ttk.Style() + self.style.theme_use("clam") + self.using_tb = False + + self.theme = "light" + self.apply_theme(self.detect_system_theme()) + + def apply_theme(self, mode: str) -> None: + """Apply light/dark theme including widget palette.""" + mode = (mode or "light").lower() + self.theme = "dark" if mode == "dark" else "light" + + if HAS_TTKBOOTSTRAP: + try: + theme_name = "darkly" if self.theme == "dark" else "flatly" + self.style.theme_use(theme_name) + except Exception: + pass + self.scale_style = ( + "info.Horizontal.TScale" if self.theme == "dark" else "primary.Horizontal.TScale" + ) + else: + self.scale_style = "Horizontal.TScale" + + bg, fg = ("#0f0f10", "#f1f1f1") if self.theme == "dark" else ("#f2f2f7", "#202020") + self.root.configure(bg=bg) # type: ignore[attr-defined] + + s = self.style + s.configure("TFrame", background=bg) + s.configure("TLabel", background=bg, foreground=fg, font=("Segoe UI", 10)) + if not HAS_TTKBOOTSTRAP: + s.configure( + "TButton", padding=8, relief="flat", background="#e0e0e0", foreground=fg, font=("Segoe UI", 10) + ) + s.map("TButton", background=[("active", "#d0d0d0")]) + + def detect_system_theme(self) -> str: + """Best-effort detection of the OS theme preference.""" + try: + if platform.system() == "Windows" and winreg is not None: + key = winreg.OpenKey( + winreg.HKEY_CURRENT_USER, + r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize", + ) + value, _ = winreg.QueryValueEx(key, "AppsUseLightTheme") + return "light" if int(value) == 1 else "dark" + except Exception: + pass + return "light" + + def bring_to_front(self) -> None: + """Try to focus the window and raise it to the foreground.""" + try: + self.root.lift() + self.root.focus_force() + self.root.attributes("-topmost", True) + self.root.update() + self.root.attributes("-topmost", False) + except Exception: + pass + + def toggle_theme(self) -> None: + """Toggle between light and dark themes.""" + next_mode = "dark" if self.theme == "light" else "light" + self.apply_theme(next_mode) + self.update_preview() # type: ignore[attr-defined] + + +__all__ = ["ThemeMixin", "HAS_TTKBOOTSTRAP"] diff --git a/colorcalc/ui.py b/colorcalc/ui.py new file mode 100644 index 0000000..879ebb6 --- /dev/null +++ b/colorcalc/ui.py @@ -0,0 +1,151 @@ +"""UI helpers and reusable Tk callbacks.""" + +from __future__ import annotations + +import tkinter as tk +from tkinter import ttk + +from .theme import HAS_TTKBOOTSTRAP + + +class UIBuilderMixin: + """Constructs the Tkinter UI and common widgets.""" + + def setup_ui(self) -> None: + toolbar = ttk.Frame(self.root) + toolbar.pack(fill=tk.X, padx=12, pady=8) + buttons = [ + ("📂 Bild laden", self.load_image), + ("🎨 Farbe wählen", self.choose_color), + ("🖱️Farbe aus Bild klicken", self.enable_pick_mode), + ("💾 Overlay speichern", self.save_overlay), + ("🧹 Excludes löschen", self.clear_excludes), + ("↩️ Letztes Exclude entfernen", self.undo_exclude), + ("🔄 Slider zurücksetzen", self.reset_sliders), + ("🌓 Theme umschalten", self.toggle_theme), + ] + for text, command in buttons: + if HAS_TTKBOOTSTRAP: + from ttkbootstrap import Button # type: ignore + + Button(toolbar, text=text, command=command, bootstyle="secondary").pack(side=tk.LEFT, padx=6) + else: + ttk.Button(toolbar, text=text, command=command).pack(side=tk.LEFT, padx=6) + + 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), + ] + 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) + + main = ttk.Frame(self.root) + main.pack(fill=tk.BOTH, expand=True, padx=12, pady=12) + + self.canvas_orig = tk.Canvas(main, bg="#1e1e1e", highlightthickness=0, relief="flat") + self.canvas_orig.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 6)) + self.canvas_orig.bind("", self.on_canvas_click) + self.canvas_orig.bind("", self._exclude_start) + self.canvas_orig.bind("", self._exclude_drag) + self.canvas_orig.bind("", self._exclude_end) + + self.canvas_overlay = tk.Canvas(main, bg="#1e1e1e", highlightthickness=0, relief="flat") + self.canvas_overlay.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True, padx=(6, 0)) + + status_frame = ttk.Frame(self.root) + status_frame.pack(fill=tk.X, padx=12, pady=6) + self.status = ttk.Label(status_frame, text="Keine Datei geladen.") + self.status.pack(anchor="w") + self._attach_copy_menu(self.status) + self.ratio_label = ttk.Label(status_frame, text="Purple (mit Excludes): —") + self.ratio_label.pack(anchor="w") + self._attach_copy_menu(self.ratio_label) + self.hint = ttk.Label( + status_frame, + text="Tipp: Rechtsklick + Ziehen auf dem linken Bild, um Bereiche auszuschließen. Esc beendet den Pick-Modus.", + ) + self.hint.pack(anchor="w", pady=(2, 0)) + self._attach_copy_menu(self.hint) + + self.root.bind("", self.disable_pick_mode) + + def add_slider_with_value(self, parent, text, var, minimum, maximum, column=0): + cell = ttk.Frame(parent) + cell.grid(row=0, column=column, sticky="we", padx=6) + header = ttk.Frame(cell) + header.pack(fill="x") + name_lbl = ttk.Label(header, text=text) + name_lbl.pack(side="left") + self._attach_copy_menu(name_lbl) + val_lbl = ttk.Label(header, text=f"{float(var.get()):.0f}") + val_lbl.pack(side="right") + self._attach_copy_menu(val_lbl) + style_name = getattr(self, "scale_style", "Horizontal.TScale") + ttk.Scale( + cell, + from_=minimum, + to=maximum, + orient="horizontal", + variable=var, + style=style_name, + command=self.on_slider_change, + ).pack(fill="x", pady=(2, 8)) + + def on_var_change(*_): + val_lbl.config(text=f"{float(var.get()):.0f}") + + try: + var.trace_add("write", on_var_change) + except Exception: + var.trace("w", lambda *_: on_var_change()) # type: ignore[attr-defined] + + def on_slider_change(self, *_): + if self._update_job is not None: + try: + self.root.after_cancel(self._update_job) + except Exception: + pass + self._update_job = self.root.after(self.update_delay_ms, self.update_preview) + + 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 _attach_copy_menu(self, widget): + widget.bind("", lambda event, w=widget: self._show_copy_menu(event, w)) + widget.bind("", lambda event, w=widget: self._copy_widget_text(w)) + + def _show_copy_menu(self, event, widget): + self._copy_target = widget + try: + self.copy_menu.tk_popup(event.x_root, event.y_root) + finally: + self.copy_menu.grab_release() + + def _copy_current_label(self): + if self._copy_target is not None: + self._copy_widget_text(self._copy_target) + + def _copy_widget_text(self, widget): + try: + text = widget.cget("text") + except Exception: + text = "" + if not text: + return + try: + self.root.clipboard_clear() + self.root.clipboard_append(text) + except Exception: + pass + + +__all__ = ["UIBuilderMixin"] diff --git a/images/271.webp b/images/271.webp new file mode 100644 index 0000000..9c1e883 Binary files /dev/null and b/images/271.webp differ diff --git a/images/296.webp b/images/296.webp new file mode 100644 index 0000000..cc04aa4 Binary files /dev/null and b/images/296.webp differ diff --git a/images/328.webp b/images/328.webp new file mode 100644 index 0000000..5d7aff5 Binary files /dev/null and b/images/328.webp differ diff --git a/images/460.webp b/images/460.webp new file mode 100644 index 0000000..7ce8b26 Binary files /dev/null and b/images/460.webp differ diff --git a/images/487.webp b/images/487.webp new file mode 100644 index 0000000..3360c03 Binary files /dev/null and b/images/487.webp differ diff --git a/images/552.webp b/images/552.webp new file mode 100644 index 0000000..669bf8e Binary files /dev/null and b/images/552.webp differ diff --git a/images/572.webp b/images/572.webp new file mode 100644 index 0000000..e3b1956 Binary files /dev/null and b/images/572.webp differ diff --git a/images/583.webp b/images/583.webp new file mode 100644 index 0000000..cf6b95b Binary files /dev/null and b/images/583.webp differ diff --git a/images/654.webp b/images/654.webp new file mode 100644 index 0000000..285c8e5 Binary files /dev/null and b/images/654.webp differ diff --git a/images/696.webp b/images/696.webp new file mode 100644 index 0000000..752ec05 Binary files /dev/null and b/images/696.webp differ diff --git a/images/70.webp b/images/70.webp new file mode 100644 index 0000000..82f96d7 Binary files /dev/null and b/images/70.webp differ diff --git a/images/705.webp b/images/705.webp new file mode 100644 index 0000000..7a34dbb Binary files /dev/null and b/images/705.webp differ diff --git a/images/83.webp b/images/83.webp new file mode 100644 index 0000000..f5b573e Binary files /dev/null and b/images/83.webp differ diff --git a/images/858.webp b/images/858.webp new file mode 100644 index 0000000..f65e6da Binary files /dev/null and b/images/858.webp differ diff --git a/images/86.webp b/images/86.webp new file mode 100644 index 0000000..f4255c0 Binary files /dev/null and b/images/86.webp differ diff --git a/images/862.webp b/images/862.webp new file mode 100644 index 0000000..5a4ff61 Binary files /dev/null and b/images/862.webp differ diff --git a/images/911.webp b/images/911.webp new file mode 100644 index 0000000..dea0b09 Binary files /dev/null and b/images/911.webp differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..f9b65af --- /dev/null +++ b/main.py @@ -0,0 +1,7 @@ +"""CLI entry point for the ColorCalc application.""" + +from colorcalc import start_app + + +if __name__ == "__main__": + start_app()