"""DesignBase — Plugin Design Template / Schablone. Dieses Modul ist KEIN eigenständiges Widget, sondern eine SAMMLUNG von Design-Konzepten, Layout-Helfern und Theme-Definitionen, die als Vorlage für alle anderen Plugin-Renderer dienen. ANATOMIE EINES DESIGN-TEMPLATES ================================= Jedes Plugin-render() folgt diesem Schema: def render(self, draw, fonts, x, y, w, h): theme = self._theme() # Theme-Objekt mit Farben + Schriften data = self.fetch() # Daten holen if "_error" in data: render_error_banner(...) return layout = self._pick_layout(w, h) # Layout-Strategie wählen self._render_header(draw, theme, x, y, w, header_h) self._render_body(draw, theme, data, x, body_y, w, body_h, layout) if self._show_footer(w, h): self._render_footer(draw, theme, data, x, y+h-footer_h, w, footer_h) Die 4 LAYOUT-STRATEGIEN ======================== is_small (1x1): Eine einzige große Zahl / ein Icon is_wide (4x1/2x1): Horizontale Teilung in 2-3 Spalten is_tall (1x4/1x2): Vertikale Teilung in Zeilen standard (2x2+): Header + Content + Footer (oder Karten) Die 4 THEMES ============= THEME_LIGHT — Weiß, schwarz, eine Akzentfarbe (z.B. Netatmo: blau) THEME_DARK — Fast schwarz, helle Farben auf dunklem Grund THEME_RETRO — Gelb/Orange-Schwarz (80er-Terminal-Feeling) THEME_MAG — Minimal, typografisch, viel Weißraum Farben werden IMMER über theme.xxx bezogen, NIEMALS direkt als RGB-Tuple. Das erlaubt komplettes Umstyling ohne den Renderer-Code anzufassen. Farben-Schema pro Theme (Palette: FG, BG, ACCENT, OK, WARN, ALERT, INFO): THEME_LIGHT: BG=WHITE, FG=BLACK, ACCENT=BLUE, OK=GREEN, WARN=YELLOW, ALERT=RED THEME_DARK: BG=BLACK, FG=WHITE, ACCENT=CYAN, OK=GREEN, WARN=ORANGE,ALERT=RED THEME_RETRO: BG=BLACK, FG=YELLOW,ACCENT=ORANGE, OK=GREEN, WARN=YELLOW, ALERT=RED THEME_MAG: BG=WHITE, FG=BLACK, ACCENT=ORANGE, OK=GREEN, WARN=ORANGE, ALERT=BLUE Beispiel-Implementierung: widgets/netatmo.py (theme='light', accent='blue') Beispiel-Implementierung: widgets/weather.py (theme='light', accent='orange') Beispiel-Implementierung: widgets/system.py (theme='light', accent='green') """ from __future__ import annotations from dataclasses import dataclass, field from typing import Literal # Palette-Aliase (für direkten Zugriff in Templates) # noqa: E402 — diese Imports funktionieren weil plugins/ im Python-Path liegt from palette import ( FG, BG, WHITE, BLACK, GREEN, BLUE, RED, YELLOW, ORANGE, INFO, OK, WARN, ALERT, ACCENT, measure, fit_font, centered_text, hbar, parse_thresholds, is_small, is_wide, is_tall, ) # noqa: E402 from plugins.base import Widget, render_error_banner # ============================================================================ # THEME DEFINITIONS # ============================================================================ ThemeName = Literal["light", "dark", "retro", "mag"] LayoutName = Literal["small", "wide", "tall", "standard"] @dataclass class Theme: """Alle visuellen Eigenschaften eines Designs. Ein Theme-Objekt wird in render() erzeugt und an alle _render_*-Methoden weitergegeben. Nie direkt Farbwerte hardcoden. """ name: ThemeName bg: tuple[int, int, int] # Hintergrund fg: tuple[int, int, int] # Primärtext accent: tuple[int, int, int] # Akzentfarbe (Platzierung je nach Theme) ok: tuple[int, int, int] warn: tuple[int, int, int] alert: tuple[int, int, int] info: tuple[int, int, int] header_font_key: str = "24" # Font-Schlüssel für Headlines label_font_key: str = "20" # Font-Schlüssel für Modul-Labels body_font_key: str = "32" # Font-Schlüssel für Hauptwerte mono_font_key: str = "20" # Font-Schlüssel für Metadaten pad: int = 8 # Innenabstand border_w: int = 2 # Rahmendicke corner_r: int = 0 # Eckenradius (0 = scharf) # ---- Farb-Helfer ---- def temp_color(self, t: float | None) -> tuple[int, int, int]: """Temperaturanzeige: kalt→info, warm→accent, heiß→alert.""" if t is None: return self.fg if t >= 30: return self.alert if t >= 22: return self.accent if t <= 5: return self.info if t <= 12: return self.info return self.fg def value_color(self, value: float, warn_at: float, alert_at: float) -> tuple[int, int, int]: """Ampel-Helfer: value + Schwellen → passende Farbe.""" if value >= alert_at: return self.alert if value >= warn_at: return self.warn return self.ok def text(self, draw, text: str, x: int, y: int, font_key: str | None = None, color=None, max_w: int | None = None): """Short-hand: Text zeichnen mit Theme-Farbe.""" font = draw.font if hasattr(draw, 'font') else None # Actual implementation uses fonts dict from render scope pass # see render helpers below # Vordefinierte Themes THEMES: dict[ThemeName, Theme] = { "light": Theme( name="light", bg=WHITE, fg=BLACK, accent=BLUE, ok=GREEN, warn=YELLOW, alert=RED, info=BLUE, header_font_key="24", label_font_key="20", body_font_key="32", mono_font_key="20", pad=8, border_w=2, corner_r=0, ), "dark": Theme( name="dark", bg=BLACK, fg=WHITE, accent=(0, 200, 220), ok=GREEN, warn=ORANGE, alert=RED, info=(0, 180, 255), header_font_key="24", label_font_key="20", body_font_key="32", mono_font_key="20", pad=8, border_w=2, corner_r=0, ), "retro": Theme( name="retro", bg=BLACK, fg=YELLOW, accent=ORANGE, ok=GREEN, warn=YELLOW, alert=RED, info=ORANGE, header_font_key="24", label_font_key="20", body_font_key="32", mono_font_key="20", pad=8, border_w=2, corner_r=0, ), "mag": Theme( name="mag", bg=WHITE, fg=BLACK, accent=ORANGE, ok=GREEN, warn=ORANGE, alert=RED, info=BLUE, header_font_key="24", label_font_key="20", body_font_key="32", mono_font_key="20", pad=16, border_w=1, corner_r=0, ), } # ============================================================================ # LAYOUT HELPERS # ============================================================================ def pick_layout(w: int, h: int) -> LayoutName: """Wähle Layout-Strategie basierend auf Slot-Größe. Reihenfolge ist wichtig: is_wide/is_tall VOR is_small prüfen, weil is_small zu eager matcht (w<280 or h<180). """ if is_wide(w, h): return "wide" if is_tall(w, h): return "tall" if is_small(w, h): return "small" return "standard" def header_height(layout: LayoutName) -> int: """Höhe des Header-Bereichs (Label + Titel).""" return 32 if layout == "standard" else 28 def footer_height(layout: LayoutName) -> int: """Höhe des Footer-Bereichs (Metadaten, Timestamp).""" if layout == "small": return 0 if layout in ("wide", "tall"): return 20 return 18 # ============================================================================ # RENDER HELPERS (Theme-bewusst) # ============================================================================ def th_text(draw, fonts, theme: Theme, text: str, x: int, y: int, font_key: str | None = None, color=None, max_w: int | None = None): """Text mit Theme-Default zeichnen.""" key = font_key or theme.header_font_key font = fonts.get(key) or fonts.get("default") c = color if color is not None else theme.fg draw.text((x, y), text, font=font, fill=c) def th_centered(draw, fonts, theme: Theme, text: str, x: int, y: int, w: int, h: int, font_key: str | None = None, color=None): """Text in Box zentrieren mit Theme-Defaults.""" key = font_key or theme.body_font_key font = fonts.get(key) or fonts.get("default") c = color if color is not None else theme.fg centered_text(draw, text, x, y, w, h, font, c) def th_rect(draw, theme: Theme, x: int, y: int, w: int, h: int, fill=None, outline=None, width: int = 1): """Rechteck mit Theme-Defaults.""" f = fill if fill is not None else None o = outline if outline is not None else theme.fg draw.rectangle((x, y, x + w - 1, y + h - 1), fill=f, outline=o, width=width) def th_header_bar(draw, fonts, theme: Theme, x: int, y: int, w: int, h: int, label: str, value: str = "", value_color=None): """Header-Leiste: links Label, rechts optionaler Wert. Layout: [LABEL] [VALUE] Farbe: accent-Bg fg """ pad = theme.pad # Hintergrund links: Label-Bereich label_w = min(measure(draw, label, fonts.get(theme.label_font_key) or fonts.get("default"))[0] + pad * 2, w // 2) draw.rectangle((x, y, x + label_w, y + h - 1), fill=theme.accent) draw.text((x + pad, y + (h - 20) // 2), label, font=fonts.get(theme.label_font_key) or fonts.get("default"), fill=theme.bg) # Wert rechts if value: vc = value_color if value_color is not None else theme.fg font_v = fonts.get(theme.label_font_key) or fonts.get("default") vw, _ = measure(draw, value, font_v) draw.text((x + w - vw - pad, y + (h - 20) // 2), value, font=font_v, fill=vc) def th_big_value(draw, fonts, theme: Theme, x: int, y: int, w: int, h: int, value: str, unit: str = "", color=None, align: str = "left"): """Die große zentrale Kennzahl (z.B. "22°", "45%", "12.4 km"). Der Wert wird so groß wie möglich dargestellt, das Unit darunter oder daneben in kleinerer Schrift. """ c = color if color is not None else theme.fg candidates = [theme.body_font_key, "80", "60", "48", "36", "28", "24"] font_v = fit_font(draw, value, fonts, w - 2 * theme.pad, h - 2 * theme.pad, candidates=candidates) if align == "center": th_centered(draw, fonts, theme, value, x, y, w, h, font_key=None, color=c) else: # Linksbündig, groß tw, th_f = measure(draw, value, font_v) draw.text((x + theme.pad, y + max(0, (h - th_f) // 2)), value, font=font_v, fill=c) # Unit darunter oder daneben if unit: unit_font = fonts.get(theme.mono_font_key) or fonts.get("default") if align == "center": # Unter dem Wert uw, uh = measure(draw, unit, unit_font) draw.text((x + max(0, (w - uw) // 2), y + h // 2 + 4), unit, font=unit_font, fill=theme.fg) else: uw, uh = measure(draw, unit, unit_font) draw.text((x + theme.pad + tw + 6, y + max(0, (h - uh) // 2)), unit, font=unit_font, fill=theme.fg) def th_mini_bar(draw, fonts, theme: Theme, x: int, y: int, w: int, h: int, pct: float, thresholds: list | None = None, gradient: bool = True): """Kompakter Fortschrittsbalken mit Theme-Styling. thresholds: [(pct, color), ...] — z.B. [(50, ok), (80, warn), (100, alert)] gradient: True = zeigt alle Farbstufen gleichzeitig (e-Paper-nativ) """ if thresholds is None: thresholds = [(50, theme.ok), (80, theme.warn), (100, theme.alert)] hbar(draw, x, y, w, h, pct, thresholds=thresholds, gradient=gradient) def th_divider(draw, theme: Theme, x: int, y: int, w: int, style: str = "solid"): """Horizontale Trennlinie.""" if style == "solid": draw.line((x, y, x + w, y), fill=theme.fg, width=1) elif style == "dashed": # Dashed: 4px dash, 4px gap for dx in range(0, w, 8): draw.line((x + dx, y, min(x + dx + 4, x + w), y), fill=theme.fg, width=1) elif style == "accent": draw.line((x, y, x + w, y), fill=theme.accent, width=2) def th_corner_marker(draw, theme: Theme, x: int, y: int, size: int = 8): """Kleiner Eck-Marker (L-Form) oben-links — markiert den Slot-Ursprung. Optionaler visueller Anker der zeigt: "hier beginnt dieses Widget". """ draw.line((x, y, x + size, y), fill=theme.accent, width=2) draw.line((x, y, x, y + size), fill=theme.accent, width=2) # ============================================================================ # LAYOUT PATTERN TEMPLATES # ============================================================================ def layout_1x1(draw, fonts, theme: Theme, value: str, unit: str = "", label: str = "", color=None): """ONE BIG NUMBER — für 1x1 Slots. Große zentrierte Zahl, darunter kleines Label. Beispiel: Gmail ungelesen, Strava Jahr-km, System CPU% """ pad = theme.pad # Rahmen th_rect(draw, theme, x=0, y=0, w=200, h=120, outline=theme.fg, width=theme.border_w) c = color if color is not None else theme.fg font_val = fit_font(draw, value, fonts, 200 - 2 * pad, 80, candidates=[theme.body_font_key, "80", "60", "48"]) th_centered(draw, fonts, theme, value, x=0, y=10, w=200, h=90, color=c) if label: font_l = fonts.get(theme.mono_font_key) or fonts.get("default") lw = measure(draw, label, font_l)[0] draw.text(((200 - lw) // 2, 96), label, font=font_l, fill=theme.fg) def layout_wide_strip(draw, fonts, theme: Theme, cols: list[dict], header_h: int = 30): """WIDE STRIP — für 4x1 / 2x1 Slots. Horizontale Teilung in gleichbreite Spalten. Jede Spalte: [LABEL] über [VALUE] über [UNIT] cols = [ {"label": "TEMP", "value": "22°", "unit": "", "color": theme.temp_color(22)}, {"label": "HUM", "value": "65%", "unit": "Feuchte", "color": theme.ok}, {"label": "CO₂", "value": "820", "unit": "ppm", "color": theme.warn}, ] """ n = len(cols) col_w = 200 // n if n > 0 else 200 pad = theme.pad for i, col in enumerate(cols): cx = i * col_w c = col.get("color", theme.fg) # Label lbl = col.get("label", "") if lbl: draw.text((cx + pad, 4), lbl, font=fonts.get(theme.label_font_key) or fonts.get("default"), fill=theme.accent) # Value (groß) val = col.get("value", "—") font_v = fit_font(draw, val, fonts, col_w - 2 * pad, header_h + 30, candidates=[theme.body_font_key, "48", "36", "28"]) draw.text((cx + pad, header_h), val, font=font_v, fill=c) # Unit (klein darunter) unit = col.get("unit", "") if unit: font_u = fonts.get(theme.mono_font_key) or fonts.get("default") draw.text((cx + pad, header_h + font_v.size + 2), unit, font=font_u, fill=theme.fg) def layout_card_grid(draw, fonts, theme: Theme, cards: list[dict], x: int, y: int, w: int, h: int, cols: int = 2): """CARD GRID — für 2x2+ Slots. Teil den verfügbaren Raum in gleichmäßige Karten auf. Jede Karte hat: Border + Header-Akzent + Label + Wert + optional Bar. cards = [ { "label": "🏠 Indoor", "value": "22.5°", "unit": "", "color": theme.temp_color(22.5), "bar": {"pct": 45, "thresholds": [...], "gradient": True}, "meta": "min 18° / max 26°", }, ... ] """ pad = theme.pad rows = (len(cards) + cols - 1) // cols card_w = (w - (cols + 1) * pad) // cols card_h = (h - (rows + 1) * pad) // rows for i, card in enumerate(cards): row = i // cols col = i % cols cx = x + pad + col * (card_w + pad) cy = y + pad + row * (card_h + pad) # Border th_rect(draw, theme, cx, cy, card_w, card_h, outline=theme.fg, width=theme.border_w) # Label-Balken oben lbl = card.get("label", "") if lbl: lbl_h = 24 draw.rectangle((cx, cy, cx + card_w - 1, cy + lbl_h), fill=theme.accent) font_l = fonts.get(theme.label_font_key) or fonts.get("default") draw.text((cx + pad, cy + 4), lbl, font=font_l, fill=theme.bg) # Value inner_y = cy + 28 inner_h = card_h - 30 val = card.get("value", "—") c = card.get("color", theme.fg) font_v = fit_font(draw, val, fonts, card_w - 2 * pad, inner_h // 2, candidates=[theme.body_font_key, "48", "36", "28"]) draw.text((cx + pad, inner_y), val, font=font_v, fill=c) # Bar (optional) bar = card.get("bar") if bar and inner_h > 60: bar_pct = bar.get("pct", 0) bar_thresholds = bar.get("thresholds") or [ (50, theme.ok), (80, theme.warn), (100, theme.alert)] bar_y = cy + card_h - 28 th_mini_bar(draw, fonts, theme, cx + pad, bar_y, card_w - 2 * pad, 12, bar_pct, bar_thresholds, bar.get("gradient", True)) # Meta (optional) meta = card.get("meta", "") if meta: font_m = fonts.get(theme.mono_font_key) or fonts.get("default") draw.text((cx + pad, cy + card_h - 18), meta, font=font_m, fill=theme.fg) def layout_poster(draw, fonts, theme: Theme, label: str, value: str, unit: str = "", sub: str = "", color=None): """POSTER — eine einzelne Aussage, maximal typografisch. Für 2x2+ Slots die WIRKLICH nur eine Zahl zeigen wollen. Beispiel: Eine gigantische Uhrzeit, eine große Temperatur. """ pad = theme.pad c = color if color is not None else theme.fg # Dünne Rahmenlinie th_rect(draw, theme, x=0, y=0, w=400, h=240, outline=theme.accent, width=1) # Label oben links if label: font_l = fonts.get(theme.label_font_key) or fonts.get("default") draw.text((pad, pad), label, font=font_l, fill=theme.accent) # Value zentriert, RIESIG font_v = fit_font(draw, value, fonts, 400 - 2 * pad, 180, candidates=["80", "60", "48", theme.body_font_key]) tw, th_f = measure(draw, value, font_v) draw.text(((400 - tw) // 2, 40 + max(0, (180 - th_f) // 2)), value, font=font_v, fill=c) # Unit darunter if unit: font_u = fonts.get(theme.mono_font_key) or fonts.get("default") uw, _ = measure(draw, unit, font_u) draw.text(((400 - uw) // 2, 40 + 180 - th_f // 2 + 4), unit, font=font_u, fill=theme.fg) # Sub / Metatext unten if sub: font_s = fonts.get(theme.mono_font_key) or fonts.get("default") sw, _ = measure(draw, sub, font_s) draw.text(((400 - sw) // 2, 220), sub, font=font_s, fill=theme.fg) # ============================================================================ # EXAMPLE: So wird ein Plugin-Design daraus gebaut # ============================================================================ # # class Widget(Widget): # name = "myplugin" # default_config = { # "theme": "light", # light | dark | retro | mag # "accent_color": "blue", # blue | green | orange | red # } # # def render(self, draw, fonts, x, y, w, h): # theme = THEMES[self.cfg("theme", "light")] # data = self.fetch() # if "_error" in data: # render_error_banner(draw, fonts, x, y, w, h, self.label, data["_error"]) # return # # layout = pick_layout(w, h) # hdr_h = header_height(layout) # # if layout == "small": # th_corner_marker(draw, theme, x, y) # layout_1x1(draw, fonts, theme, data["value"], data.get("unit", "")) # elif layout == "wide": # layout_wide_strip(draw, fonts, theme, data["cols"]) # else: # # standard: Card-Grid # cards = [ # {"label": k, "value": v, "color": theme.fg} # for k, v in data["cards"].items() # ] # layout_card_grid(draw, fonts, theme, cards, x, y, w, h) # # ============================================================================