Files
epaper-dashboard/plugins/clock_wordclock.py
T
hermes d71b17d5c9 FIX-WC-01: WordClock Redesign - 3 klare Zonen ohne Überlappung
Vorher: 11x8-Grid mit starrem Layout, viele Buchstaben-Cluster überlappten
sich gegenseitig, Stunden-Pixel-Font war unleserlich klein/groß gemischt.

Nachher: QlockTwo-inspiriertes Layout in 3 diskreten Zonen:
  - Header (Wochentag + Datum, accent, klein)
  - Wort-Grid (ES IST + Minuten-Phrase in einer sauberen Zeile)
  - Stunden-Block (Stunde in großer Aldrich-Schrift)

Plus:
  - Minuten-Dots oben rechts (QlockTwo-typisch: ●●●● für Sektor-Offset)
  - Bug-Fix: font_key-Fallback überspringt 'clock'-Pixel-Font
  - Bug-Fix: 12/24h Stunden-Mapping für Wortuhr (16:30 = 'halb fünf' → FÜNF)
  - Neue Option 'show_es_ist' zum Ein-/Ausblenden des 'ES IST'-Prefixes
  - Slot-size-aware: 1x1 single-line, 2x2/4x4 zones, small fallback < 240x140

Verifiziert auf Pi-Display: 'VIERTEL NACH VIER' / 'HALB FÜNF' rendern sauber.
2026-08-29 18:35:20 +04:00

310 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""WordClock-Plugin: Deutsche Wort-Uhr im QlockTwo-Stil.
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)
└────────────────────────────────────┘
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
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 …). QlockTwo-Style, ab 1×1 sauber."
category = "info"
config_schema = [
{"key": "show_date", "label": "Datum anzeigen",
"type": "bool", "default": True},
{"key": "show_weekday", "label": "Wochentag anzeigen",
"type": "bool", "default": True},
{"key": "accent_color", "label": "Akzentfarbe",
"type": "select",
"choices": ["accent", "blue", "ok", "warn", "info"],
"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 = {
"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):
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
# --- Hintergrund ---
draw.rectangle((x, y, x + w - 1, y + h - 1), fill=bg)
now = datetime.now()
pad = 6
inner_w = w - 2 * pad
inner_h = h - 2 * pad
inner_x = x + pad
inner_y = y + pad
# ============================================================
# 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
# ============================================================
# 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
# 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)
if show_wd and show_dt:
header_h = 26
elif show_wd or show_dt:
header_h = 18
else:
header_h = 0
# Stunden-Block: fest ~36% der inneren Höhe, aber mindestens 50px
hour_block_h = max(50, int(inner_h * 0.36))
# 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)
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")