diff --git a/plugins/clock_wordclock.py b/plugins/clock_wordclock.py index 262f1ed..b2c71ea 100644 --- a/plugins/clock_wordclock.py +++ b/plugins/clock_wordclock.py @@ -1,47 +1,84 @@ -"""WordClock-Plugin:Deutsche Wort-Uhr (QWATCHLayout). +"""WordClock-Plugin: Deutsche Wort-Uhr im QlockTwo-Stil. -Reines Wort-Uhr-Display im deutschen Stil: - ES IST +Layout in 3 klar getrennten Zonen: + ┌────────────────────────────────────┐ + │ Header: Wochentag · Datum │ (accent-farbe, klein) + ├────────────────────────────────────┤ + │ │ + │ Wort-Grid: ES IST │ + │ ──── ZEHN NACH ──── │ (eine zentrale Zeile, + │ DREI │ je nach Minute) + │ │ + ├────────────────────────────────────┤ + │ Stunden gross: DREI │ (Aldrich, riesig) + └────────────────────────────────────┘ -Unterstützt auch ENGLISCH (US-Layout). - -Layout: fester 4×4-Zeichen-Grid im Quadrat (4×4 Zellen = 800×480 Display). -Jede Zelle = 200×120px → Grid = 800×480px. -Wir nutzen 11×8 "字符-Zellen" pro Grid (72×60px pro Zeichen). - -Minuten-Zeilen (oberer Block): - Zeile 0: [E][S][ ][I][S][T] - Zeile 1: [F][Ü][N][F][Z][E][H][N][Z][W][A][N] - Zeile 2: [V][I][E][R][T][E][L][Z][W][A][N][Z] - Zeile 3: [N][U][L][L] - Zeile 4: [N][A][C][H][ ][V][O][R][ ][H][A][L] - Zeile 5: [B][ ] - Zeile 6: [S][P][R][A][C][H][E] - -Stunden (unterer Block, je 2×2 Zellen für die Ziffern): - DieZiffern werden als gefüllte Rechtecke in der unteren Reihe gerendert. - -Das Layout wird intern gecacht bis sich die Minute ändert. +Die Minuten-Wörter sind in einer DYNAMISCHEN Zeile, nicht in einem starren +11×8-Grid. Das vermeidet Überlappungen und sieht auf jedem Slot gleich aus. """ from __future__ import annotations import os, sys from datetime import datetime -from functools import lru_cache sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from plugins.base import Widget from palette import FG, BG, OK, BLUE, YELLOW, ORANGE, fill_for, measure +# ---- Deutsche Minuten-Phrasen (QlockTwo-konform) ---- +# Schlüssel: Sektor-Untergrenze (5, 10, ..., 55). Wert: (minute_words, hour_offset) +# hour_offset = +1 bedeutet wir gehen zur nächsten Stunde ("VOR") +DE_PHRASES = { + 0: ("", 0), # ES IST genau + 5: ("FÜNF NACH", 0), # 5 nach + 10: ("ZEHN NACH", 0), # 10 nach + 15: ("VIERTEL NACH", 0), # viertel nach + 20: ("ZWANZIG NACH", 0), # 20 nach + 25: ("FÜNF VOR HALB", 1), # 5 vor halb → 0:30 + 30: ("HALB", 1), # halb → 0:30 (zählt zur nächsten Stunde) + 35: ("FÜNF NACH HALB",1), # 5 nach halb + 40: ("ZWANZIG VOR", 1), # 20 vor + 45: ("VIERTEL VOR", 1), # viertel vor + 50: ("ZEHN VOR", 1), # 10 vor + 55: ("FÜNF VOR", 1), # 5 vor +} + +# Minuten → Sektor (0, 5, 10, ... 55) +def _minute_sector(m: int) -> int: + return (m // 5) * 5 + + +# Stunden-Namen (QlockTwo). Index 0..11 = Wortuhr-Stunden (1..12 Uhr). +# 4 PM wird "VIER" (deutsch), 5 PM = "FÜNF", etc. +# Daher: hour 16..23 → DE_HOURS[16%12]=DE_HOURS[4]="VIER". Korrekt. +DE_HOURS = [ + "ZWÖLF", "EINS", "ZWEI", "DREI", "VIER", "FÜNF", + "SECHS", "SIEBEN", "ACHT", "NEUN", "ZEHN", "ELF", +] + + +def _hour_name(h24: int, offset: int) -> str: + """Liefert Stunden-Name. offset=+1 → nächste volle Stunde. + + Beispiel: h24=16, offset=0 → DE_HOURS[16%12=4]="VIER" (4 PM) + h24=16, offset=1 → DE_HOURS[(16+1)%12=5]="FÜNF" (halb 5 = 5 PM) + """ + h = (h24 + offset) % 24 + return DE_HOURS[h % 12] + + +def _minute_phrase(m: int): + """Returns (phrase_text, hour_offset) für die aktuelle Minute.""" + sector = _minute_sector(m) + return DE_PHRASES[sector] + + class Widget(Widget): name = "clock_wordclock" label = "WordClock" - description = "Deutsche Wort-Uhr (ES IST …). Nur für 2×2 oder größer." + description = "Deutsche Wort-Uhr (ES IST …). QlockTwo-Style, ab 1×1 sauber." category = "info" config_schema = [ - {"key": "lang", "label": "Sprache", - "type": "select", "choices": ["de", "en"], "default": "de", - "help": "de = Deutsch (Standard), en = Englisch"}, {"key": "show_date", "label": "Datum anzeigen", "type": "bool", "default": True}, {"key": "show_weekday", "label": "Wochentag anzeigen", @@ -49,176 +86,225 @@ class Widget(Widget): {"key": "accent_color", "label": "Akzentfarbe", "type": "select", "choices": ["accent", "blue", "ok", "warn", "info"], - "default": "accent"}, + "default": "accent", + "help": "Farbe für Wochentag + Datum"}, {"key": "invert", "label": "Invertiert (dunkel)", "type": "bool", "default": False}, + {"key": "show_es_ist", "label": "'ES IST' anzeigen", + "type": "bool", "default": True, + "help": "QlockTwo-Klassiker. Aus = nur Minuten-Text + Stunde."}, ] default_config = { - "lang": "de", "show_date": True, "show_weekday": True, + "show_date": True, "show_weekday": True, "accent_color": "accent", "invert": False, + "show_es_ist": True, } def fetch(self): return {} def render(self, draw, fonts, x: int, y: int, w: int, h: int): - from palette import fill_for - now = datetime.now() - m = now.minute - h_ = now.hour - invert = self.cfg("invert", False) accent = fill_for(self.cfg("accent_color", "accent")) fg = BG if invert else FG bg = FG if invert else BG - pad = 8 - - # Mindestgröße: 300px-breit, 240px-hoch für WordClock - if w < 300 or h < 240: - draw.rectangle((x, y, x + w - 1, y + h - 1), fill=bg) - draw.text((x + pad, y + pad), - "WordClock\n(min 2×2)", - font=fonts.get("20", fonts.get("default")), fill=fg) - return - - # ---- Wort-Uhr Grid ---- - # 4×4 Zellen → 800×480px - # Wir rendern in ein 11×8 Zeichen-Grid - chars_x, chars_y = 11, 8 - char_w = w // chars_x - char_h = h // chars_y - font_size = min(char_w, char_h) * 2 // 3 - font_key = str(font_size) - if font_key not in fonts: - font_key = str(max(16, min(fonts.keys(), key=lambda k: abs(int(k) - font_size) if k.isdigit() else 9999)) if fonts else "20") - fnt = fonts.get(font_key, fonts.get("20", fonts.get("default"))) - - def _draw_char(cx, cy, char, color): - """Zeichnet ein Zeichen an Gitterposition (cx, cy).""" - px = x + cx * char_w - py = y + cy * char_h - tw, th = measure(draw, char, fnt) - draw.text((px + (char_w - tw) // 2, py + (char_h - th) // 2), - char, font=fnt, fill=color) - - def _fill_char(cx, cy, color): - """Füllt eine Gitterzelle mit einer Farbe (z.B. für Stunden-Dots).""" - px = x + cx * char_w - py = y + cy * char_h - draw.rectangle((px + 2, py + 2, px + char_w - 3, py + char_h - 3), fill=color) - - def _lit(cx, cy): - _draw_char(cx, cy, LAYOUT_DE[cy][cx], fg) - - def _dim(cx, cy): - _draw_char(cx, cy, LAYOUT_DE[cy][cx], (150, 150, 150)) - - # ---- Minuten-Logik (Deutsch) ---- - def lit_minute(m): - """Sektor der Minuten: 0-4, 5-9, 10-14, 15-19, 20-24, 25-29, 30-34, 35-39, 40-44, 45-49, 50-54, 55-59.""" - if m < 5: - return [] - elif m < 10: - return [(0, 1), (1, 1), (2, 1), (3, 1)] # FÜNF - elif m < 15: - return [(5, 1), (6, 1), (7, 1), (8, 1)] # ZEHN - elif m < 20: - return [(0, 2), (1, 2), (2, 2), (3, 2), (4, 2), (5, 2), (6, 2)] # VIERTEL - elif m < 25: - return [(7, 2), (8, 2), (9, 2), (10, 2), (7, 3), (8, 3), (9, 3), (10, 3)] # ZWANZIG - elif m < 30: - return [(0, 4), (1, 4), (2, 4), (3, 4)] # NACH - elif m < 35: - return [(0, 1), (1, 1), (2, 1), (3, 1), # FÜNF - (4, 4), (5, 4), (6, 4)] # + HALB - elif m < 40: - return [(4, 4), (5, 4), (6, 4)] # HALB - elif m < 45: - return [(7, 3), (8, 3), (9, 3), (10, 3)] # ZWANZIG - elif m < 50: - return [(0, 2), (1, 2), (2, 2), (3, 2), (4, 2), (5, 2), (6, 2), # VIERTEL - (7, 4), (8, 4), (9, 4)] # + VOR - elif m < 55: - return [(5, 1), (6, 1), (7, 1), (8, 1)] # ZEHN + VOR - else: - return [(0, 1), (1, 1), (2, 1), (3, 1), # FÜNF + VOR - (7, 4), (8, 4), (9, 4)] - - def hour_name(h, past_half): - """Gibt die Stunde zurück für die Wortuhr.""" - DE_HOURS = [ - "ZWÖLF", "EINS", "ZWEI", "DREI", "VIER", - "FÜNF", "SECHS", "SIEBEN", "ACHT", "NEUN", - "ZEHN", "ELF", "ZWÖLF" - ] - if past_half: - h = (h + 1) % 24 - if h == 0: - return "ZWÖLF" - return DE_HOURS[h % 12] - - past_half = m >= 20 - h_display = hour_name(h_, past_half) - lit_cells = lit_minute(m) - - # ---- Render ---- - # Hintergrund + # --- Hintergrund --- draw.rectangle((x, y, x + w - 1, y + h - 1), fill=bg) - # "ES IST" immer lit in Spalte 0 - _lit(0, 0); _lit(1, 0); _lit(3, 0); _lit(4, 0) + now = datetime.now() + pad = 6 + inner_w = w - 2 * pad + inner_h = h - 2 * pad + inner_x = x + pad + inner_y = y + pad - # Minuten-Wörter - for (cx, cy) in lit_cells: - _lit(cx, cy) + # ============================================================ + # Sehr kleine Slots (1×1 = 200×120): kompakter Single-Line Modus + # ============================================================ + if w < 240 or h < 140: + # "ES IST DREI" oder "VIERTEL NACH DREI" als eine Zeile + phrase, offset = _minute_phrase(now.minute) + hour_str = _hour_name(now.hour, offset) + if phrase: + text = f"{phrase} {hour_str}" if self.cfg("show_es_ist") else f"{phrase} {hour_str}" + else: + text = f"ES IST {hour_str}" if self.cfg("show_es_ist") else hour_str + font = _fit_font(draw, fonts, text, inner_w, inner_h, prefer=["20","16","default"]) + from palette import centered_text + centered_text(draw, text, inner_x, inner_y, inner_w, inner_h, font, fg) + return - # "VOR" und "NACH" (Zeile 4) - if 5 <= m < 30: - _lit(0, 4); _lit(1, 4); _lit(2, 4); _lit(3, 4) # NACH - elif m >= 35 and m < 55: - _lit(7, 4); _lit(8, 4); _lit(9, 4) # VOR + # ============================================================ + # Standard-Layout: 3 Zonen + # ============================================================ + # Höhenverteilung: + # Header: bis zu 28px (oder weniger wenn beides aus) + # Grid: Rest minus Stunden + # Stunden: grosse Schrift, ~30% der Höhe - # Rest dim - for row in range(chars_y): - for col in range(chars_x): - if (col, row) not in lit_cells and not (row == 0 and col in (0, 1, 3, 4)): - _dim(col, row) + # Header-Höhe dynamisch: nur soviel wie gebraucht + show_wd = self.cfg("show_weekday", True) + show_dt = self.cfg("show_date", True) + show_es = self.cfg("show_es_ist", True) - # ---- Stunden-Balken unten ---- - # Zeile 6+7: Stunden-Name in großen Buchstaben unten zentriert - hour_str = h_display - hour_font_size = min(w // len(hour_str), h // 4) * 3 // 4 - hf = fonts.get(str(hour_font_size), fonts.get("60", fonts.get("default"))) - tw, th = measure(draw, hour_str, hf) - hour_y = y + h - th - pad - draw.text((x + (w - tw) // 2, hour_y), hour_str, font=hf, fill=fg) + if show_wd and show_dt: + header_h = 26 + elif show_wd or show_dt: + header_h = 18 + else: + header_h = 0 - # ---- Datum + Wochentag ---- - if self.cfg("show_date", True): - date_str = now.strftime("%d. %b %Y") - df = fonts.get("16", fonts.get("default")) - dw, dh = measure(draw, date_str, df) - draw.text((x + (w - dw) // 2, y + pad), date_str, font=df, fill=accent) + # Stunden-Block: fest ~36% der inneren Höhe, aber mindestens 50px + hour_block_h = max(50, int(inner_h * 0.36)) - if self.cfg("show_weekday", True): - day_str = now.strftime("%A").upper() - df = fonts.get("14", fonts.get("default")) - dw, dh = measure(draw, day_str, df) - draw.text((x + (w - dw) // 2, y + pad + (18 if self.cfg("show_date", True) else 0)), - day_str, font=df, fill=accent) + # Wort-Block = Rest + grid_h = inner_h - header_h - hour_block_h - 6 # 6px gap + if grid_h < 40: + # Slot zu klein für alles — Stunden-Anteil reduzieren + hour_block_h = max(36, int(inner_h * 0.28)) + grid_h = inner_h - header_h - hour_block_h - 6 + + grid_y = inner_y + header_h + hour_y = grid_y + grid_h + 6 + + # --- Header (Wochentag / Datum) --- + cy = inner_y + header_font = _fit_font(draw, fonts, "Mittwoch · 31. Aug 2026", inner_w, header_h or 18, + prefer=["16", "20", "default"]) + if header_h: + if show_wd and show_dt: + day = now.strftime("%A") + date = now.strftime("%e. %b %Y").strip() + text = f"{day} · {date}" + tw, th = measure(draw, text, header_font) + draw.text((inner_x + (inner_w - tw) // 2, cy), + text, font=header_font, fill=accent) + elif show_wd: + text = now.strftime("%A") + tw, _ = measure(draw, text, header_font) + draw.text((inner_x + (inner_w - tw) // 2, cy), + text, font=header_font, fill=accent) + elif show_dt: + text = now.strftime("%e. %b %Y").strip() + tw, _ = measure(draw, text, header_font) + draw.text((inner_x + (inner_w - tw) // 2, cy), + text, font=header_font, fill=accent) + + # --- Wort-Grid: zwei Zeilen --- + # Zeile 1 (oben): "ES IST" (klein, accent) — nur wenn show_es_ist + # Zeile 2 (mittig, gross): Minuten-Phrase + phrase, offset = _minute_phrase(now.minute) + + # Wie viele Zeilen brauchen wir im Grid? + # Wenn ES IST → ES IST (Zeile 1 klein) + Phrase (Zeile 2 gross) + # Wenn nicht → nur Phrase (eine Zeile) + # Bei sehr grossen Slots könnte man auch stacked machen, aber single-line + # ist klarer. + + if show_es and phrase: + # Zwei-Zeilen-Layout im Grid + line1_h = int(grid_h * 0.30) + line2_h = grid_h - line1_h - 2 + es_font = _fit_font(draw, fonts, "ES IST", inner_w, line1_h, + prefer=["20", "16", "default"]) + draw.text((inner_x + (inner_w - measure(draw, "ES IST", es_font)[0]) // 2, + grid_y), + "ES IST", font=es_font, fill=accent) + # Minuten-Phrase in der unteren, größeren Zeile + phrase_font = _fit_font(draw, fonts, phrase, inner_w, line2_h, + prefer=["48", "36", "32", "28", "24", "20", "default"]) + tw, th = measure(draw, phrase, phrase_font) + draw.text((inner_x + (inner_w - tw) // 2, + grid_y + line1_h + (line2_h - th) // 2), + phrase, font=phrase_font, fill=fg) + else: + # Eine-Zeilen-Layout: nur die Phrase (oder "ES IST DREI" wenn keine Phrase) + if not phrase: + phrase = "ES IST" if show_es else "" + phrase_font = _fit_font(draw, fonts, phrase, inner_w, grid_h, + prefer=["60", "48", "36", "32", "28", "24", "20", "default"]) + tw, th = measure(draw, phrase, phrase_font) + draw.text((inner_x + (inner_w - tw) // 2, + grid_y + (grid_h - th) // 2), + phrase, font=phrase_font, fill=fg) + + # --- Stunden-Block (gross, unten) --- + hour_str = _hour_name(now.hour, offset) + hour_font = _fit_font(draw, fonts, hour_str, inner_w, hour_block_h, + prefer=["100", "80", "60", "48", "36", "32", "default"]) + # Robuste Font-Wahl: lieber kleiner als abgeschnitten + tw, th = measure(draw, hour_str, hour_font) + if tw > inner_w: + # noch kleiner probieren + for k in ["80", "60", "48", "36", "32", "28", "24", "20"]: + if k not in fonts: + continue + cand = fonts[k] + cw, ch = measure(draw, hour_str, cand) + if cw <= inner_w and ch <= hour_block_h: + hour_font = cand + tw, th = cw, ch + break + draw.text((inner_x + (inner_w - tw) // 2, + hour_y + (hour_block_h - th) // 2), + hour_str, font=hour_font, fill=fg) + + # --- Minuten-Dots (4 Dots oben rechts, wie eine echte QlockTwo) --- + # Zeigen die genauen Minuten-Module: ● ● ● ● ● — einer pro 1-2 Min + # Wenn minute < 5: leer; bei 25: ●; bei 26: ●●; etc. + if inner_w >= 200: + self._draw_minute_dots(draw, inner_x, inner_y, inner_w, header_h, fg, accent) -# ---- Deutsches WordClock-Layout (11×8) ---- -# Jede Position ist ein Zeichen das gerendert wird. -# ' ' = Leerzeichen, rest = Buchstabe. -LAYOUT_DE = [ - ["E", "S", " ", "I", "S", "T", " ", " ", " ", " ", " "], - ["F", "Ü", "N", "F", " ", "Z", "E", "H", "N", " ", " "], - ["V", "I", "E", "R", "T", "E", "L", " ", "Z", "W", "A"], - ["N", "U", "L", "L", " ", "Z", "W", "A", "N", "Z", " "], - ["N", "A", "C", "H", " ", "V", "O", "R", " ", "H", "A"], - [" ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " "], - [" ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " "], - [" ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " "], -] + def _draw_minute_dots(self, draw, x, y, w, header_h, fg, accent): + """Vier Minuten-Dots (●) in der Ecke, analog zu echten QlockTwo-Uhren. + + Sie zeigen: ●=1, ●●=2, ●●●=3, ●●●●=4 Minuten innerhalb des 5-Min-Sektors. + """ + m = datetime.now().minute + sector_m = m % 5 # 0..4 + if sector_m == 0: + return # exakt auf 5-Min-Marke → keine Dots + + # Position: oben rechts, in der Header-Zone (oder oben falls kein Header) + dot_size = 3 + spacing = 4 + total_w = sector_m * (dot_size + spacing) - spacing + start_x = x + w - total_w + # Position: am unteren Rand des Header-Bereichs + if header_h >= 26: + dot_y = y + header_h - dot_size - 2 + elif header_h > 0: + dot_y = y + header_h - dot_size + else: + # kein Header → dots ganz oben, klein + dot_y = y + 2 + + for i in range(sector_m): + cx = start_x + i * (dot_size + spacing) + # Eckige Punkte (Mini-Quadrate) statt runder, weil runde auf dem + # ePaper oft Matsch produzieren + draw.rectangle((cx, dot_y, cx + dot_size - 1, dot_y + dot_size - 1), + fill=fg) + + +def _fit_font(draw, fonts, text, max_w, max_h, prefer=None): + """Wählt den größten Font aus `prefer`, dessen Text in max_w × max_h passt. + + Robuster als die alte Logik — überspringt 'clock' (Pixel-Font) automatisch. + """ + if prefer is None: + prefer = ["60", "48", "36", "32", "28", "24", "20", "16", "default"] + for key in prefer: + f = fonts.get(key) + if f is None: + continue + tw, th = measure(draw, text, f) + if tw <= max_w and th <= max_h: + return f + # Fallback: das Kleinste + for key in reversed(prefer): + if key in fonts: + return fonts[key] + return fonts.get("default") or fonts.get("20") \ No newline at end of file