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.
This commit is contained in:
hermes
2026-08-29 18:35:20 +04:00
parent 94509e6836
commit d71b17d5c9
+253 -167
View File
@@ -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 <Fünf/Zehn/Viertel/Zwanzig> <Minuten> <nach/vor> <Stunde>
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:
# --- Hintergrund ---
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)
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
# ---- 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")))
# ============================================================
# 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
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)
# 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)
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
if show_wd and show_dt:
header_h = 26
elif show_wd or show_dt:
header_h = 18
else:
return [(0, 1), (1, 1), (2, 1), (3, 1), # FÜNF + VOR
(7, 4), (8, 4), (9, 4)]
header_h = 0
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]
# Stunden-Block: fest ~36% der inneren Höhe, aber mindestens 50px
hour_block_h = max(50, int(inner_h * 0.36))
past_half = m >= 20
h_display = hour_name(h_, past_half)
lit_cells = lit_minute(m)
# 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
# ---- Render ----
# Hintergrund
draw.rectangle((x, y, x + w - 1, y + h - 1), fill=bg)
grid_y = inner_y + header_h
hour_y = grid_y + grid_h + 6
# "ES IST" immer lit in Spalte 0
_lit(0, 0); _lit(1, 0); _lit(3, 0); _lit(4, 0)
# --- 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)
# Minuten-Wörter
for (cx, cy) in lit_cells:
_lit(cx, cy)
# --- 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)
# "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
# 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.
# 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)
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-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)
# --- 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)
# ---- 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)
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)
# --- 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")