# -*- coding: utf-8 -*- """Farben nach dem Windows-Theme (hell/dunkel) - inklusive Titelleiste. Native tk-Menues und -Popups lassen sich unter Windows nicht einfaerben, darum baut das Programm Menueleiste und Dropdowns selbst; hier stehen nur die Farben. """ import sys import tkinter as tk from tkinter import font as tkfont from tkinter import ttk import config # Schrift: Roboto, so wie in den Web-Oberflaechen. Sie wird NICHT auf dem # Rechner installiert, sondern nur fuer diesen einen Prozess geladen (die # Windows-Funktion AddFontResourceEx mit FR_PRIVATE). Damit braucht niemand # Adminrechte, und nach dem Schliessen ist der Rechner wieder wie vorher. # Findet sich die Datei nicht, bleibt es bei Segoe UI - das Programm muss auch # ohne mitgelieferte Schrift laufen. SCHRIFT_NAME = "Segoe UI" SCHRIFT = (SCHRIFT_NAME, 10) KLEIN = (SCHRIFT_NAME, 9) _schrift_geladen = False def schrift_vorbereiten(): """Roboto laden und zur Schrift des Programms machen. Gibt den Namen zurueck.""" global SCHRIFT_NAME, SCHRIFT, KLEIN, _schrift_geladen if _schrift_geladen: return SCHRIFT_NAME _schrift_geladen = True if not sys.platform.startswith("win"): return SCHRIFT_NAME import ctypes import os FR_PRIVATE = 0x10 geladen = 0 for basis in (config.BASE_DIR, config.RESOURCE_DIR, os.path.dirname(os.path.abspath(__file__))): ordner = os.path.join(basis, "assets", "fonts") if not os.path.isdir(ordner): continue for datei in sorted(os.listdir(ordner)): if datei.lower().endswith((".ttf", ".otf")): geladen += ctypes.windll.gdi32.AddFontResourceExW( os.path.join(ordner, datei), FR_PRIVATE, 0) if geladen: break if not geladen: print("[schrift] Roboto nicht gefunden - bleibe bei Segoe UI") return SCHRIFT_NAME # Ob Windows sie wirklich angenommen hat, verraet erst die Schriftenliste. if "Roboto" not in tkfont.families(): print("[schrift] Roboto liess sich nicht laden - bleibe bei Segoe UI") return SCHRIFT_NAME SCHRIFT_NAME = "Roboto" SCHRIFT = (SCHRIFT_NAME, 10) KLEIN = (SCHRIFT_NAME, 9) for name in ("TkDefaultFont", "TkTextFont", "TkMenuFont", "TkHeadingFont", "TkIconFont", "TkTooltipFont"): try: tkfont.nametofont(name).configure(family=SCHRIFT_NAME, size=10) except tk.TclError: pass print("[schrift] Roboto geladen") return SCHRIFT_NAME def mischen(vorn, hinten, anteil): """Wie 'rgba(vorn, anteil)' auf 'hinten' - nur ausgerechnet statt gemalt. Tk kann keine halbdurchsichtigen Flaechen (kein backdrop-filter, keine Alpha-Kanaele auf Widgets). Seine Web-Oberflaechen bauen den Glas-Effekt aus genau zwei Zutaten: eine getoente Flaeche und ein feiner Rand in Akzent-Transparenz. Beides laesst sich vorher ausrechnen - das Ergebnis sieht identisch aus, solange der Grund einfarbig ist. Und der ist es hier. """ v = vorn.lstrip("#") h = hinten.lstrip("#") teile = [] for i in (0, 2, 4): a = int(v[i:i + 2], 16) b = int(h[i:i + 2], 16) teile.append(round(a * anteil + b * (1 - anteil))) return "#%02x%02x%02x" % tuple(teile) def windows_dark_mode(): """True, wenn Windows auf das dunkle App-Theme eingestellt ist.""" if not sys.platform.startswith("win"): return False try: import winreg with winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize") as k: return winreg.QueryValueEx(k, "AppsUseLightTheme")[0] == 0 except OSError: return False class ThemeMixin: """Farben setzen. Erwartet ein tk.Tk als Basis.""" # ----------------------------------------------------------------- Theme def _apply_theme(self): """Farben passend zum Windows-Theme (hell/dunkel) setzen.""" schrift_vorbereiten() # Roboto, sonst Segoe UI dark = windows_dark_mode() self._dark_mode = dark # Die Farben sind die aus seinen anderen Sachen (FiveM-Oberflaechen, # Adminpage, Webseiten): Tuerkis #06C6A4 auf sehr dunklen, gestuften # Flaechen. Das PROGRAMM traegt diese Handschrift - die RECHNUNG traegt # die Farbe der jeweiligen Firma (Einstellungen -> Farbe der Rechnung). # Beides zu vermischen waere falsch: das Blatt gehoert dem Kunden. if dark: self.col_bg = "#0d0e10" # Fenster-Hintergrund (tiefstes Grau) self.col_card = "#15171b" # Kachel / Abschnitt self.col_fg = "#e8e8ea" # Schrift self.col_field = "#1d2025" # Eingabefelder self.col_sel = "#1f3d38" # aktiv/hover (Akzent, stark abgedunkelt) self.col_accent = "#06C6A4" # Akzent (Pcore-Tuerkis) self.col_accent_hell = "#08dbb5" self.col_auf_accent = "#08221d" # Schrift AUF der Akzentflaeche self.col_gefahr = "#e85a5a" self.col_line = "#25282e" self.col_scroll = "#2f333a" self.col_muted = "#8a8e96" self.col_zebra = "#1b1e24" # jede zweite Zeile der Liste else: self.col_bg = "#eef0f3" self.col_card = "#ffffff" self.col_fg = "#14161a" self.col_field = "#ffffff" self.col_sel = "#d7f5ee" self.col_accent = "#04886f" # dunkler, damit es auf Weiss lesbar ist self.col_accent_hell = "#06C6A4" self.col_auf_accent = "#ffffff" self.col_gefahr = "#c0392b" self.col_line = "#d8dce1" self.col_scroll = "#c2c6cc" self.col_muted = "#5c626b" self.col_zebra = "#e9ecf0" self.configure(bg=self.col_bg) style = ttk.Style(self) try: style.theme_use("clam") except tk.TclError: pass bg, fg, field, sel, accent = (self.col_bg, self.col_fg, self.col_field, self.col_sel, self.col_accent) style.configure(".", background=bg, foreground=fg, fieldbackground=field, bordercolor=self.col_line, lightcolor=bg, darkcolor=bg) style.configure("TFrame", background=bg) style.configure("TLabel", background=bg, foreground=fg, font=SCHRIFT) style.configure("TLabelframe", background=bg, bordercolor=self.col_line) style.configure("TLabelframe.Label", background=bg, foreground=accent) # --- Kacheln: ein Abschnitt ist eine Flaeche mit Rand, keine Linie --- style.configure("Karte.TFrame", background=self.col_card, bordercolor=self.col_line, relief="solid", borderwidth=1) # Ein Abschnitt ist eine Kachel: eigene Flaeche, feiner Rand, die # Ueberschrift in Akzentfarbe - genau wie die Kacheln in den # Web-Oberflaechen. style.configure("Karte.TLabelframe", background=self.col_card, bordercolor=self.col_line, relief="solid", borderwidth=1, lightcolor=self.col_card, darkcolor=self.col_card) style.configure("Karte.TLabelframe.Label", background=self.col_card, foreground=accent, font=(SCHRIFT_NAME, 9, "bold")) style.configure("Karte.TEntry", fieldbackground=field, foreground=fg) style.configure("Karte.TLabel", background=self.col_card, foreground=fg, font=SCHRIFT) style.configure("KarteMuted.TLabel", background=self.col_card, foreground=self.col_muted, font=KLEIN) # Ueberschrift eines Abschnitts: klein, gross geschrieben, in Akzentfarbe - # so wie die Kachelkoepfe in den Web-Oberflaechen. style.configure("Abschnitt.TLabel", background=self.col_card, foreground=accent, font=(SCHRIFT_NAME, 9, "bold")) style.configure("Wert.TLabel", background=self.col_card, foreground=fg, font=(SCHRIFT_NAME, 10)) style.configure("Summe.TLabel", background=bg, foreground=accent, font=(SCHRIFT_NAME, 15, "bold")) style.configure("Zebra.TLabel", background=self.col_zebra, foreground=fg, font=SCHRIFT) # --- Knoepfe im Glas-Stil (wie in seinen Oberflaechen) --- # Getoente Flaeche, feiner Rand in Akzent-Transparenz, Schrift in der # Akzentfarbe. Kein Vollton: der wirkt in dieser Palette wie ein # Fremdkoerper. glas = mischen(accent, bg, 0.10) # ruhige Flaeche glas_hover = mischen(accent, bg, 0.20) rand = mischen(accent, bg, 0.35) rand_hell = mischen(accent, bg, 0.55) style.configure("TButton", background=glas, foreground=fg, bordercolor=rand, padding=(14, 7), font=SCHRIFT, focuscolor=rand, relief="solid", borderwidth=1, lightcolor=rand, darkcolor=rand) style.map("TButton", background=[("pressed", mischen(accent, bg, 0.28)), ("active", glas_hover)], bordercolor=[("active", rand_hell)], lightcolor=[("active", rand_hell)], darkcolor=[("active", rand_hell)], foreground=[("disabled", self.col_muted)]) # Der Knopf, um den es geht: dieselbe Glasflaeche, aber kraeftiger # getoent, mit Akzentrand und Akzentschrift. haupt = mischen(accent, bg, 0.22) style.configure("Haupt.TButton", background=haupt, foreground=self.col_accent_hell, bordercolor=self.col_accent, lightcolor=self.col_accent, darkcolor=self.col_accent, relief="solid", borderwidth=1, padding=(18, 8), font=(SCHRIFT_NAME, 10, "bold")) style.map("Haupt.TButton", background=[("pressed", mischen(accent, bg, 0.45)), ("active", mischen(accent, bg, 0.34)), ("disabled", bg)], foreground=[("disabled", self.col_muted)]) # Storno greift in eine ausgestellte Rechnung ein - gleicher Stil, rot. g_rot = mischen(self.col_gefahr, bg, 0.10) style.configure("Gefahr.TButton", background=g_rot, foreground=self.col_gefahr, bordercolor=mischen(self.col_gefahr, bg, 0.35), lightcolor=mischen(self.col_gefahr, bg, 0.35), darkcolor=mischen(self.col_gefahr, bg, 0.35), relief="solid", borderwidth=1, padding=(14, 7), font=SCHRIFT) style.map("Gefahr.TButton", background=[("pressed", mischen(self.col_gefahr, bg, 0.30)), ("active", mischen(self.col_gefahr, bg, 0.20))], bordercolor=[("active", mischen(self.col_gefahr, bg, 0.55))], lightcolor=[("active", mischen(self.col_gefahr, bg, 0.55))], darkcolor=[("active", mischen(self.col_gefahr, bg, 0.55))]) style.configure("Menu.TMenubutton", background=bg, foreground=fg, relief="flat", arrowcolor=bg, padding=(10, 3)) style.map("Menu.TMenubutton", background=[("active", sel)], foreground=[("active", fg)]) style.configure("TCheckbutton", background=bg, foreground=fg) style.map("TCheckbutton", background=[("active", bg)], foreground=[("active", fg)]) style.configure("TEntry", fieldbackground=field, foreground=fg, insertcolor=fg, bordercolor=self.col_line, padding=4) style.map("TEntry", bordercolor=[("focus", accent)]) # Bei state="readonly" zeichnet die Combobox ihren Text als MARKIERUNG, # sobald sie den Fokus hat - ohne diese beiden Farben steht der Wert # unsichtbar im Feld. style.configure("TCombobox", fieldbackground=field, foreground=fg, background=field, arrowcolor=fg, selectbackground=field, selectforeground=fg) style.map("TCombobox", fieldbackground=[("readonly", field)], foreground=[("readonly", fg)], background=[("readonly", field)], selectbackground=[("readonly", field)], selectforeground=[("readonly", fg)]) # Die aufgeklappte Liste ist ein klassisches tk-Listbox-Widget self.option_add("*TCombobox*Listbox.background", field) self.option_add("*TCombobox*Listbox.foreground", fg) self.option_add("*TCombobox*Listbox.selectBackground", sel) self.option_add("*TCombobox*Listbox.selectForeground", fg) for sbs in ("Vertical.TScrollbar", "Horizontal.TScrollbar", "TScrollbar"): style.configure(sbs, troughcolor=bg, background=self.col_scroll, bordercolor=bg, lightcolor=bg, darkcolor=bg, arrowcolor=fg, relief="flat", gripcount=0) style.map(sbs, background=[("active", accent)], troughcolor=[("disabled", bg)]) style.configure("TSeparator", background=self.col_line) # Reiter (Campingrechnung / Montage): ohne eigene Farben zeichnet clam den # AKTIVEN Reiter dunkler als die ruhenden - man sieht dann nicht, wo man # ist. Der offene Reiter bekommt darum den Hintergrund des Blattes und die # Akzentfarbe, die ruhenden bleiben zurueckgenommen. style.configure("TNotebook", background=bg, bordercolor=self.col_line, tabmargins=(2, 4, 2, 0), borderwidth=0) style.configure("TNotebook.Tab", background=self.col_field, foreground=self.col_muted, bordercolor=self.col_line, padding=(20, 9), font=(SCHRIFT_NAME, 10), borderwidth=0) style.map("TNotebook.Tab", background=[("selected", self.col_card), ("active", sel)], foreground=[("selected", accent), ("active", fg)], font=[("selected", (SCHRIFT_NAME, 10, "bold"))]) # Kein expand beim ausgewaehlten Reiter: er wuchs dadurch um ein paar # Pixel, die anderen rueckten nach - beim Umschalten sprang die ganze # Leiste. Erkannt wird der offene Reiter an Farbe und Fettschrift. # Klassische tk-Widgets (Canvas, Toplevel, Text) erben diese Defaults self.option_add("*background", bg) self.option_add("*foreground", fg) def _style_dialog(self, d): """Toplevel-Dialog an das Theme angleichen. Im Pruefstand wird derselbe Dialog gebaut und bedient, aber nicht gezeigt - und sein grab_set faellt weg. Sonst haelt jeder Dialog Maus und Tastatur fest, waehrend nebenan jemand schreibt. """ d.configure(bg=self.col_bg) if config.TESTLAUF: # Wie das Hauptfenster: neben den Bildschirm, und ohne grab_set - # ein Dialog, der Maus und Tastatur festhaelt, blockiert den # Menschen, der nebenher schreibt. d.geometry(f"+{d.winfo_screenwidth() + 20}+40") d.grab_set = lambda *a, **k: None # erst nach dem Anzeigen setzen, sonst greift DWM noch nicht d.after(20, lambda: self._dark_titlebar(d)) def _dark_titlebar(self, win): """Windows-Titelleiste des Fensters dunkel machen (nur im Dark-Mode).""" if not (sys.platform.startswith("win") and getattr(self, "_dark_mode", False)): return try: import ctypes win.update_idletasks() hwnd = ctypes.windll.user32.GetParent(win.winfo_id()) val = ctypes.c_int(1) for attr in (20, 19): # DWMWA_USE_IMMERSIVE_DARK_MODE (Win11=20, frühes Win10=19) ctypes.windll.dwmapi.DwmSetWindowAttribute( hwnd, attr, ctypes.byref(val), ctypes.sizeof(val)) # Frame neu zeichnen lassen (ohne Flackern/Fokusverlust) SWP = 0x0001 | 0x0002 | 0x0004 | 0x0020 # NOSIZE|NOMOVE|NOZORDER|FRAMECHANGED ctypes.windll.user32.SetWindowPos(hwnd, 0, 0, 0, 0, 0, SWP) except Exception as e: # noqa: BLE001 print("[theme] Titelleiste:", e)