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.
310 lines
13 KiB
Python
310 lines
13 KiB
Python
"""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") |