"""Render-Pipeline für das Dashboard. Unterstützte Display-Designs: classic — Original flach, weiß, kein UI-Chrome (DEFAULT) minimal — Schwarz/Weiß, bold, clean, viel Weißraum minimal_color — Minimal + dezenter Akzent-Strich pro Kategorie bold — Riesen-Typography, 1px-Rahmen, vollflächig analog — 2×2-Uhr als Ziffernblatt, Rest clean Panels magazine — Bold Typography, farbige Akzentstreifen links cards — iOS-Widget-Style, Emoji-Icons, warme Paneele kiosk — Dunkel, Daten-first, kompakte Info-Zellen wireframe — Blueprint-Style, gestrichelte Rahmen, keine Füllungen bauhaus — Geometrische Primärfarben, blockweise poster — Farbige Flächen, bold, fast nur Content retro_lcd — Alte LCD-Uhren-Ästhetik, grüner Hintergrund glossy — Moderner iOS-Style, Emoji-Icons, Panels data_board — Kontrollraum-Dashboard, dunkel, invertierte Karten Aufruf: img = render_design(items, widgets, fonts, design="classic") """ from PIL import Image, ImageDraw, ImageFont from palette import FG, BG, WHITE, BLACK, GREEN, BLUE, RED, YELLOW, ORANGE, OK, WARN, ALERT, INFO, ACCENT import math DISPLAY_W = 800 DISPLAY_H = 480 COLS, ROWS = 4, 4 CELL_W = DISPLAY_W // COLS CELL_H = DISPLAY_H // ROWS def render_design(items, widgets, fonts, design="classic") -> Image.Image: fn = { "magazine": _render_magazine, "cards": _render_cards, "kiosk": _render_kiosk, "wireframe": _render_wireframe, "bauhaus": _render_bauhaus, "poster": _render_poster, "retro_lcd": _render_retro_lcd, "glossy": _render_glossy, "data_board": _render_data_board, "minimal": _render_minimal, "minimal_color": _render_minimal_color, "bold": _render_bold, "analog": _render_analog_clock, "classic": _render_classic, }.get(design, _render_classic) return fn(items, widgets, fonts) # ============================================================================ # MAGAZINE — Bold Typography, Minimalist # Idee: Wanduhr-Magazin / Bloomberg-Terminal-Ästhetik # Schwarz/Weiß + eine Akzentfarbe pro Widget # ============================================================================ def _render_magazine(items, widgets, fonts) -> Image.Image: img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), WHITE) draw = ImageDraw.Draw(img) for item, widget in zip(items, widgets): x, y, w, h = _pixels(item) if widget is None: continue # Jedes Widget bekommt einen vertikalen Akzent-Streifen links accent = _widget_accent(widget.name) draw.rectangle((x, y, x + 6, y + h - 1), fill=accent) # Widget-Name oben links, klein label = (widget.label or widget.name or "").upper() draw.text((x + 14, y + 6), label, font=fonts.get("16", fonts.get("default")), fill=(160, 160, 160)) # Horizontale Linie unter Label draw.line((x + 14, y + 26, x + w - 10, y + 26), fill=(220, 220, 220), width=1) # Content-Bereich (ab y+32) cy = y + 36 ch = h - 42 widget.render(draw, fonts, x + 14, cy, w - 20, ch) return img # ============================================================================ # CARDS — Background Panels + Emoji Icons + Vertical Accent Bars # Idee: iOS Widgets / Google Smart Display # Leicht grauer Hintergrund pro Karte, Emoji für Kategorie # ============================================================================ def _render_cards(items, widgets, fonts) -> Image.Image: img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), (245, 244, 240)) # warm white draw = ImageDraw.Draw(img) for item, widget in zip(items, widgets): x, y, w, h = _pixels(item) if widget is None: continue # Card background draw.rectangle((x + 2, y + 2, x + w - 3, y + h - 3), fill=WHITE) # Top accent bar accent = _widget_accent(widget.name) draw.rectangle((x + 2, y + 2, x + w - 3, y + 10), fill=accent) # Emoji icon (use emoji renderer via fallback) icon = _widget_icon(widget.name) _draw_emoji(draw, icon, x + 10, y + 18, 28) # Label label = widget.label or widget.name or "" draw.text((x + 44, y + 18), label, font=fonts.get("16", fonts.get("default")), fill=(80, 80, 80)) # Separator line draw.line((x + 8, y + 52, x + w - 8, y + 52), fill=(230, 230, 230), width=1) # Content widget.render(draw, fonts, x + 8, y + 58, w - 16, h - 64) return img # ============================================================================ # KIOSK — Data-Dense, Compact, Multi-Zone # Idee: Control-Room / LoRa-Tracker / Science-Dashboard # Viele kleine Info-Zellen, horizontale Teilung, monochrome Palette # ============================================================================ def _render_kiosk(items, widgets, fonts) -> Image.Image: img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), BLACK) draw = ImageDraw.Draw(img) for item, widget in zip(items, widgets): x, y, w, h = _pixels(item) if widget is None: continue # Invertierte Box mit Akzent-Rand draw.rectangle((x, y, x + w - 1, y + h - 1), fill=WHITE) accent = _widget_accent(widget.name) draw.rectangle((x, y, x + w - 1, y + 3), fill=accent) # Label in Akzentfarbe oben label = (widget.label or widget.name or "").upper() draw.text((x + 8, y + 8), label, font=fonts.get("14", fonts.get("default")), fill=accent) # Content widget.render(draw, fonts, x + 6, y + 28, w - 12, h - 34) return img # ============================================================================ # Helper # ============================================================================ def _pixels(item): """Item (x,y,w,h) in Pixel (800x480, 4x4 grid)""" COLS, ROWS = 4, 4 CELL_W = DISPLAY_W // COLS CELL_H = DISPLAY_H // ROWS return item.x * CELL_W, item.y * CELL_H, item.w * CELL_W, item.h * CELL_H # Alias für neue Designs def _px(item): return _pixels(item) def _widget_accent(name): accents = { "clock": BLUE, "weather": GREEN, "system": ORANGE, "hello": RED, "spotify": (0, 200, 0), # spotify green "strava": ORANGE, "gmail": RED, "minimax": (180, 100, 255), # purple } return accents.get(name, ORANGE) def _widget_icon(name): icons = { "clock": "⏰", "weather": "🌤️", "system": "💻", "hello": "👋", "spotify": "🎵", "strava": "🚴", "gmail": "📧", "minimax": "🤖", } return icons.get(name, "📟") def _draw_emoji(draw, emoji, x, y, size): """Draw emoji by using a fallback ImageFont trick with the emoji char.""" # On systems without an emoji font, this just draws a rectangle placeholder # The real implementation would need a font with emoji support # For now draw a colored square as placeholder colors = { "⏰": (80, 160, 255), "🌤️": (255, 200, 80), "💻": (100, 100, 100), "👋": (255, 160, 120), "🎵": (0, 200, 100), "🚴": (255, 140, 0), "📧": (220, 80, 80), "🤖": (180, 100, 255), } c = colors.get(emoji, (200, 200, 200)) draw.ellipse((x, y, x + size, y + size), fill=c) # ============================================================================ # DESIGN 5: WIREFRAME # Technisches Plan-Drawing. Blaue Linien, weiße Flächen. # Blueprint-Ästhetik. # ============================================================================ def _render_wireframe(items, widgets, fonts) -> Image.Image: img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), (240, 248, 255)) draw = ImageDraw.Draw(img) for item, widget in zip(items, widgets): x, y, w, h = _px(item) if widget is None: continue draw.rectangle((x + 2, y + 2, x + w - 3, y + h - 3), fill=WHITE) draw.rectangle((x + 2, y + 2, x + w - 3, y + h - 3), outline=BLUE, width=1) draw.rectangle((x + 2, y + 2, x + w - 3, y + 10), fill=BLUE) label = (widget.label or widget.name or "").upper() draw.text((x + 10, y + 14), label, font=fonts.get("14", fonts.get("default")), fill=BLUE) for row in range(item.y, item.y + item.h): for col in range(item.x, item.x + item.w): if (row + col) % 2 == 0: cx = col * CELL_W + 4 cy = row * CELL_H + 4 cw = CELL_W - 6 ch = CELL_H - 6 draw.rectangle((cx, cy, cx + cw, cy + ch), fill=(240, 248, 255)) widget.render(draw, fonts, x + 6, y + 32, w - 12, h - 38) return img # ============================================================================ # DESIGN 6: BAUHAUS # Geometrische Primärfarben. Jedes Widget bekommt eine der # 7 ACeP-Farben als Hintergrund-Balken links. # ============================================================================ def _render_bauhaus(items, widgets, fonts) -> Image.Image: img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), (245, 245, 240)) draw = ImageDraw.Draw(img) PALETTE_7 = [RED, GREEN, BLUE, YELLOW, ORANGE, (0, 200, 200), (200, 100, 200)] for i, (item, widget) in enumerate(zip(items, widgets)): x, y, w, h = _px(item) if widget is None: continue color = PALETTE_7[i % len(PALETTE_7)] block_w = w // 3 draw.rectangle((x + 2, y + 2, x + block_w - 2, y + h - 3), fill=color) draw.rectangle((x + block_w, y + 2, x + w - 3, y + h - 3), fill=WHITE) draw.rectangle((x + 2, y + 2, x + w - 3, y + h - 3), outline=FG, width=2) label = (widget.label or widget.name or "").upper() draw.text((x + 6, y + 8), label, font=fonts.get("14", fonts.get("default")), fill=WHITE) widget.render(draw, fonts, x + block_w + 4, y + 4, w - block_w - 8, h - 8) return img # ============================================================================ # DESIGN 7: POSTER # Farbige Flächen, bold, fast nur Content. # Jedes Widget in einer kräftigen Farbe. # ============================================================================ def _render_poster(items, widgets, fonts) -> Image.Image: img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), BLACK) draw = ImageDraw.Draw(img) colors = [RED, GREEN, BLUE, YELLOW, ORANGE, (0, 200, 200), (200, 100, 200)] for i, (item, widget) in enumerate(zip(items, widgets)): x, y, w, h = _px(item) if widget is None: continue c = colors[i % len(colors)] draw.rectangle((x, y, x + w - 1, y + h - 1), fill=c) draw.rectangle((x, y, x + w - 1, y + h - 1), outline=WHITE, width=3) label = (widget.label or widget.name or "").upper() draw.text((x + 8, y + 6), label[:18], font=fonts.get("12", fonts.get("default")), fill=WHITE) widget.render(draw, fonts, x + 6, y + 24, w - 12, h - 30) return img # ============================================================================ # DESIGN 8: RETRO LCD # Alte LCD-Uhren-Ästhetik. Dunkelgrüner Hintergrund, # leuchtende grüne Ziffern, Fake-3D-Rahmen. # ============================================================================ def _render_retro_lcd(items, widgets, fonts) -> Image.Image: img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), (0, 20, 0)) draw = ImageDraw.Draw(img) LCD_GREEN = (0, 255, 120) LCD_DARK = (0, 60, 30) for item, widget in zip(items, widgets): x, y, w, h = _px(item) if widget is None: continue draw.rectangle((x + 3, y + 3, x + w - 4, y + h - 4), fill=LCD_DARK) draw.rectangle((x + 1, y + 1, x + w - 2, y + h - 2), outline=LCD_GREEN, width=2) label = (widget.label or widget.name or "").upper() draw.text((x + 8, y + 6), label[:16], font=fonts.get("11", fonts.get("default")), fill=LCD_GREEN) draw.line((x + 8, y + 22, x + w - 8, y + 22), fill=LCD_GREEN, width=1) widget.render(draw, fonts, x + 6, y + 26, w - 12, h - 32) return img # ============================================================================ # DESIGN 9: GLOSSY # Moderne iOS-Widget-Ästhetik. Weiße Karten, Glossy-Header # in Accent-Farbe, dezente Schatten-Rahmen. # ============================================================================ def _render_glossy(items, widgets, fonts) -> Image.Image: img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), (220, 228, 235)) draw = ImageDraw.Draw(img) WIDGET_ACCENTS = { "clock": (80, 160, 255), "weather": (60, 200, 100), "system": (255, 160, 40), "hello": (200, 80, 80), "spotify": (0, 180, 80), "strava": (255, 120, 0), "gmail": (200, 60, 60), "minimax": (160, 80, 255), } for item, widget in zip(items, widgets): x, y, w, h = _px(item) if widget is None: continue accent = WIDGET_ACCENTS.get(widget.name, (100, 100, 100)) draw.rectangle((x + 2, y + 2, x + w - 2, y + h - 2), fill=WHITE) draw.rectangle((x + 2, y + 2, x + w - 2, y + 14), fill=accent) # Glossy highlight for i in range(6): alpha = int(80 - i * 12) c = tuple(min(255, accent[j] + alpha) for j in range(3)) draw.line((x + 2, y + 2 + i, x + w - 2, y + 2 + i), fill=c, width=1) draw.rectangle((x + 2, y + 2, x + w - 2, y + h - 2), outline=(200, 210, 220), width=1) draw.rectangle((x + 3, y + 3, x + w - 3, y + h - 3), outline=(220, 228, 235), width=1) draw.rectangle((x + 4, y + 4, x + w - 4, y + h - 4), outline=(180, 190, 200), width=1) label = (widget.label or widget.name or "").title() draw.text((x + 10, y + 18), label, font=fonts.get("14", fonts.get("default")), fill=accent) draw.line((x + 8, y + 36, x + w - 8, y + 36), fill=(220, 220, 220), width=1) widget.render(draw, fonts, x + 6, y + 40, w - 12, h - 46) return img # ============================================================================ # DESIGN 10: DATA BOARD # Kontrollraum-Ästhetik. Winzige Zellen, viele Daten. # Monochrome, Tick-Marks, Data-first. # ============================================================================ def _render_data_board(items, widgets, fonts) -> Image.Image: img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), (10, 10, 15)) draw = ImageDraw.Draw(img) for item, widget in zip(items, widgets): x, y, w, h = _px(item) if widget is None: continue draw.rectangle((x, y, x + w - 1, y + h - 1), fill=WHITE, width=2) draw.rectangle((x, y, x + w - 1, y + 22), fill=WHITE) draw.text((x + 6, y + 4), (widget.label or widget.name or "").upper(), font=fonts.get("12", fonts.get("default")), fill=BLACK) for tx in range(x + 10, x + w - 10, 20): draw.line((tx, y + 24, tx, y + 28), fill=(180, 180, 180), width=1) widget.render(draw, fonts, x + 4, y + 30, w - 8, h - 34) return img # ============================================================================ # CLASSIC — Original flaches Design # Weißer Hintergrund, kein UI-Chrome, widget füllt Zelle aus # Das was auf deinem Referenz-Bild zu sehen ist # ============================================================================ def _render_classic(items, widgets, fonts) -> Image.Image: img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), WHITE) draw = ImageDraw.Draw(img) for item, widget in zip(items, widgets): x, y, w, h = _px(item) if widget is None: continue widget.render(draw, fonts, x + 8, y + 8, w - 16, h - 16) return img # ============================================================================ # MINIMAL — das Referenz-Design # Schwarz/Weiß, bold, clean, viel Weißraum. Kein UI-Chrome. # ============================================================================ def _render_minimal(items, widgets, fonts) -> Image.Image: img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), WHITE) draw = ImageDraw.Draw(img) for item, widget in zip(items, widgets): x, y, w, h = _px(item) if widget is None: continue widget.render(draw, fonts, x + 12, y + 12, w - 24, h - 24) return img # ============================================================================ # MINIMAL COLOR — Minimal mit dezenter Akzentfarbe pro Kategorie # Kleiner Farbbalken oben links, sonst weiß + schwarz # ============================================================================ def _render_minimal_color(items, widgets, fonts) -> Image.Image: img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), WHITE) draw = ImageDraw.Draw(img) CATEGORY_COLORS = { "clock": (60, 80, 200), "weather": (50, 160, 80), "system": (200, 120, 30), "hello": (180, 60, 60), "spotify": (0, 160, 80), "strava": (230, 110, 20), "gmail": (200, 60, 60), "minimax": (140, 80, 240), } for item, widget in zip(items, widgets): x, y, w, h = _px(item) if widget is None: continue color = CATEGORY_COLORS.get(widget.name, FG) draw.rectangle((x + 12, y + 10, x + 16, y + 18), fill=color) label = (widget.label or widget.name or "").upper() draw.text((x + 22, y + 8), label, font=fonts.get("11", fonts.get("default")), fill=(160, 160, 160)) widget.render(draw, fonts, x + 12, y + 24, w - 24, h - 36) return img # ============================================================================ # BOLD — Riesen-Typography # Zahlen und Text füllen die Zelle komplett. # Kaum Weißraum, maximaler Informationsdichte. # ============================================================================ def _render_bold(items, widgets, fonts) -> Image.Image: img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), WHITE) draw = ImageDraw.Draw(img) for item, widget in zip(items, widgets): x, y, w, h = _px(item) if widget is None: continue draw.rectangle((x + 1, y + 1, x + w - 2, y + h - 2), outline=BLACK, width=1) widget.render(draw, fonts, x + 6, y + 6, w - 12, h - 12) return img # ============================================================================ # ANALOG CLOCK — Die Uhr als Mittelpunkt # 2x2-Clock-Zellen zeigen ein analoges Ziffernblatt. # Übrige Zellen: cleanes Panel mit Monospace. # ============================================================================ def _render_analog_clock(items, widgets, fonts) -> Image.Image: img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), (245, 245, 240)) draw = ImageDraw.Draw(img) import math for item, widget in zip(items, widgets): x, y, w, h = _px(item) if widget is None: continue is_2x2 = item.w >= 2 and item.h >= 2 is_clock = widget.name == "clock" and is_2x2 if is_clock: cx, cy = x + w // 2, y + h // 2 r = min(w, h) // 2 - 16 draw.ellipse((cx - r, cy - r, cx + r, cy + r), fill=WHITE, outline=BLACK, width=2) for i in range(12): angle = (i / 12) * 2 * math.pi - math.pi / 2 mx = cx + int((r - 12) * math.cos(angle)) my = cy + int((r - 12) * math.sin(angle)) draw.ellipse((mx - 3, my - 3, mx + 3, my + 3), fill=BLACK) from datetime import datetime now = datetime.now() hour_angle = ((now.hour % 12) / 12) * 2 * math.pi - math.pi / 2 min_angle = (now.minute / 60) * 2 * math.pi - math.pi / 2 hx = cx + int(r * 0.5 * math.cos(hour_angle)) hy = cy + int(r * 0.5 * math.sin(hour_angle)) draw.line((cx, cy, hx, hy), fill=BLACK, width=4) mx = cx + int(r * 0.75 * math.cos(min_angle)) my = cy + int(r * 0.75 * math.sin(min_angle)) draw.line((cx, cy, mx, my), fill=BLACK, width=2) draw.ellipse((cx - 4, cy - 4, cx + 4, cy + 4), fill=BLACK) draw.text((cx - 40, cy + r + 8), now.strftime("%H:%M"), font=fonts.get("20", fonts.get("default")), fill=BLACK) else: draw.rectangle((x + 2, y + 2, x + w - 2, y + h - 2), fill=WHITE) draw.ellipse((x + 8, y + 8, x + 14, y + 14), fill=(60, 100, 200)) label = (widget.label or widget.name or "").upper()[:12] draw.text((x + 20, y + 6), label, font=fonts.get("12", fonts.get("default")), fill=(100, 100, 100)) draw.line((x + 8, y + 22, x + w - 8, y + 22), fill=(200, 200, 200), width=1) widget.render(draw, fonts, x + 6, y + 26, w - 12, h - 34) return img