diff --git a/admin.py b/admin.py index 6278852..7ee1c67 100644 --- a/admin.py +++ b/admin.py @@ -365,10 +365,30 @@ DISPLAY_THEMES = { }, } +# Display designs — the actual visual layout approach +DISPLAY_DESIGNS = { + "classic": { + "label": "Classic", + "desc": "Original flat design, white background", + }, + "magazine": { + "label": "Magazine", + "desc": "Bold typography, minimal, colored accent bars", + }, + "cards": { + "label": "Cards", + "desc": "iOS-style panels with emoji icons, warm white", + }, + "kiosk": { + "label": "Kiosk", + "desc": "Data-dense, dark background, inverted info cards", + }, +} + @app.route("/api/display_theme", methods=["GET", "POST"]) def api_display_theme(): - """Theme für das Display. GET = aktuelles Theme, POST = setzen.""" + """GET: list themes + current. POST: set theme (legacy color-only).""" a = require_auth() if a: return a cfg = dashboard_mod.load_config() @@ -389,6 +409,27 @@ def api_display_theme(): return jsonify({"ok": True, "current": theme_key, "theme": DISPLAY_THEMES[theme_key]}) +@app.route("/api/display_design", methods=["GET", "POST"]) +def api_display_design(): + """Design für das Display (classic/magazine/cards/kiosk).""" + a = require_auth() + if a: return a + cfg = dashboard_mod.load_config() + if request.method == "GET": + design_key = cfg.get("display_design", "classic") + return jsonify({ + "current": design_key, + "designs": {k: {"label": v["label"], "desc": v["desc"]} for k, v in DISPLAY_DESIGNS.items()}, + }) + data = request.get_json(silent=True) or {} + design_key = data.get("design", "classic") + if design_key not in DISPLAY_DESIGNS: + return jsonify({"ok": False, "error": "unknown design"}), 400 + cfg["display_design"] = design_key + dashboard_mod.save_config(cfg) + return jsonify({"ok": True, "current": design_key}) + + @app.route("/api/plugin_config/", methods=["GET", "POST"]) def api_plugin_config(plugin_name): """Plugin-spezifische Config (separat vom Layout).""" diff --git a/dashboard.py b/dashboard.py index cf5bb3b..cfade03 100644 --- a/dashboard.py +++ b/dashboard.py @@ -15,6 +15,7 @@ from PIL import Image, ImageDraw, ImageFont from palette import BG, FG, OK from plugins.base import all_widget_classes, Widget from layout import Item, GRID_COLS, GRID_ROWS, CELL_W, CELL_H, DISPLAY_W as LAYOUT_DISPLAY_W, pack, find_overlaps, find_out_of_bounds +import renderer import importlib, pkgutil import plugins as _plugins_pkg # ensure package is importable @@ -261,45 +262,7 @@ class Dashboard: self.widgets = widgets self.config_mtime = CONFIG_PATH.stat().st_mtime if CONFIG_PATH.exists() else 0 self.display_theme = cfg.get("display_theme", "default") - - def apply_display_theme(self, img): - """Überschreibe BG/FG des Bildes je nach display_theme.""" - theme = self.display_theme if hasattr(self, 'display_theme') else "default" - if theme == "default": - return - # Map theme → palette overrides - # For now only BG/FG swap makes sense for ACeP - # dark/terminal invert, sepia/nord just shift - themed_palette = { - "dark": {"bg": (17, 17, 17), "fg": (240, 240, 240)}, - "sepia": {"bg": (244, 236, 216), "fg": (91, 70, 54)}, - "nord": {"bg": (236, 239, 244), "fg": (46, 52, 64)}, - "terminal": {"bg": (13, 13, 0), "fg": (232, 200, 64)}, - } - if theme not in themed_palette: - return - overrides = themed_palette[theme] - # Flood-fill BG areas with theme BG (simple but effective for flat designs) - # We do an Image.paste to recolor the BG — for now just swap BG pixel - bg_r, bg_g, bg_b = overrides["bg"] - fg_color = overrides["fg"] - # Replace WHITE pixels with theme BG, replace BLACK pixels with theme FG - data = img.convert("RGB") - w, h = img.size - # Direct pixel access — only for flat BG designs - for y in range(h): - for x in range(w): - r, g, b = data.getpixel((x, y)) - if r > 240 and g > 240 and b > 240: - # WHITE → theme bg - data.putpixel((x, y), (bg_r, bg_g, bg_b)) - elif r < 15 and g < 15 and b < 15: - # BLACK → theme fg - data.putpixel((x, y), fg_color) - # Convert back to palette-compatible mode - pal_img = img.copy() - pal_img.paste(data) - return pal_img + self.display_design = cfg.get("display_design", "classic") def maybe_reload_config(self): if not CONFIG_PATH.exists(): @@ -327,11 +290,8 @@ class Dashboard: with self._lock: items = list(self.items) widgets = list(self.widgets) - img = render_full(items, widgets, self.fonts) - # Apply display theme (BG/FG color remap) — admin preview also uses this - themed = self.apply_display_theme(img) - if themed is not None: - return themed + design = getattr(self, 'display_design', 'classic') + img = renderer.render_design(items, widgets, self.fonts, design=design) return img def display(self, img: Image.Image): diff --git a/renderer.py b/renderer.py new file mode 100644 index 0000000..80b13d7 --- /dev/null +++ b/renderer.py @@ -0,0 +1,474 @@ +"""Render-Pipeline für das Dashboard. + +Unterstützt 3 Display-Designs: + magazine — großes TYPO, schmale Balken, monochrome Akzente + cards — Panel-Hintergründe, Emoji-Icons, vertikale Ränder + kiosk — Daten-first, viele kleine Info-Zellen, kompakt + +Aufruf: + img = render_design(items, widgets, fonts, design="magazine") +""" + +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="magazine") -> 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, + }.get(design, _render_minimal) + 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 + + +# ============================================================================ +# 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 diff --git a/renderer2.py b/renderer2.py new file mode 100644 index 0000000..c751777 --- /dev/null +++ b/renderer2.py @@ -0,0 +1,272 @@ +"""Neue Display-Designs für ACeP 7-Farben e-Paper. + +Jedes Design definiert: + - Hintergrund / Rahmen + - Widget-Rahmen ( Frames) + - content_fn: wie das Widget gerendert wird +""" + +from PIL import Image, ImageDraw +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 + + +# ============================================================================ +# DESIGN 5: WIREFRAME +# Technisches Plan-Drawing. Blaue Linien, weiße Flächen, +# schmale weiße Zellenrahmen. Blueprint-Ästhetik. +# ============================================================================ +def render_wireframe(items, widgets, fonts) -> Image.Image: + img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), (240, 248, 255)) # aliceblue + draw = ImageDraw.Draw(img) + + for item, widget in zip(items, widgets): + x, y, w, h = _px(item) + if widget is None: + continue + + # Weißer Hintergrund pro Zelle + draw.rectangle((x + 2, y + 2, x + w - 3, y + h - 3), fill=WHITE) + + # Dünner Rahmen (blau) + draw.rectangle((x + 2, y + 2, x + w - 3, y + h - 3), outline=BLUE, width=1) + + # Obere Akzent-Linie (breit) + draw.rectangle((x + 2, y + 2, x + w - 3, y + 10), fill=BLUE) + + # Widget-Name oben links + label = (widget.label or widget.name or "").upper() + draw.text((x + 10, y + 14), label, font=fonts.get("14", fonts.get("default")), fill=BLUE) + + # Grid-Checkerboard im Hintergrund (subtle) + 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)) + + # Content + 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. Reine Formen. +# ============================================================================ +def render_bauhaus(items, widgets, fonts) -> Image.Image: + img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), (245, 245, 240)) # warm gray + draw = ImageDraw.Draw(img) + + PALETTE_7 = [RED, GREEN, BLUE, YELLOW, ORANGE, (0, 200, 200), (200, 100, 200)] + item_colors = {} + + 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)] + item_colors[item.id] = color + + # Großer farbiger Block links (30% width) + block_w = w // 3 + draw.rectangle((x + 2, y + 2, x + block_w - 2, y + h - 3), fill=color) + + # Rest weiß + draw.rectangle((x + block_w, y + 2, x + w - 3, y + h - 3), fill=WHITE) + + # Rahmen + draw.rectangle((x + 2, y + 2, x + w - 3, y + h - 3), outline=FG, width=2) + + # Widget-Name in der farbigen Fläche + label = (widget.label or widget.name or "").upper() + draw.text((x + 6, y + 8), label, font=fonts.get("14", fonts.get("default")), fill=WHITE) + + # Content (rechte Seite) + widget.render(draw, fonts, x + block_w + 4, y + 4, w - block_w - 8, h - 8) + + return img + + +# ============================================================================ +# DESIGN 7: POSTER +# Überdimensionierte Typography. Jedes Widget großflächig. +# Widget-Hintergrund = monochrome Farbe aus Palette. +# Fast nur Text, kein UI-Chrome. +# ============================================================================ +def render_poster(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 = _px(item) + if widget is None: + continue + + # Zufällige kräftige Farbe pro widget (basierend auf name hash) + c = _hash_color(widget.name) + draw.rectangle((x, y, x + w - 1, y + h - 1), fill=c) + + # Weißer Rahmen drum + draw.rectangle((x, y, x + w - 1, y + h - 1), outline=WHITE, width=3) + + # Label oben Winzig + 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) + + # Content — das Widget bekommt 90% der Fläche + # Wir übergeben ein invertiertes draw-Objekt (fg=WHITE, bg=c) + widget.render(draw, fonts, x + 6, y + 24, w - 12, h - 30) + + return img + + +# ============================================================================ +# DESIGN 8: RETRO LCD +# Alte LCD/Uhren-Ästhetik. Schwarzer Hintergrund, Ziffern in +# satten UnOld Green (LCD-STYLE). Rahmen mit abgerundeten Ecken, +# die wie eingeritzte Rillen aussehen. +# ============================================================================ +def render_retro_lcd(items, widgets, fonts) -> Image.Image: + img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), (0, 20, 0)) # dunkles LCD-grün + draw = ImageDraw.Draw(img) + + LCD_GREEN = (0, 255, 120) + LCD_DARK = (0, 60, 30) + LCD_BG = (0, 30, 15) + + for item, widget in zip(items, widgets): + x, y, w, h = _px(item) + if widget is None: + continue + + # LCD-Zelle: dunkler als Hintergrund + draw.rectangle((x + 3, y + 3, x + w - 4, y + h - 4), fill=LCD_DARK) + + # Rahmen mit Fake-3D (oben/links hell, unten/rechts dunkel) + draw.rectangle((x + 1, y + 1, x + w - 2, y + h - 2), outline=LCD_GREEN, width=2) + + # Label in kleiner Schrift oben + 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) + + # Separator-Linie + draw.line((x + 8, y + 22, x + w - 8, y + 22), fill=LCD_GREEN, width=1) + + # Content + widget.render(draw, fonts, x + 6, y + 26, w - 12, h - 32) + + return img + + +# ============================================================================ +# DESIGN 9: GLOSSY +# Moderner Glossy-Style. Weiße Karten mit leichtem Farbverlauf oben, +# ikonischer Rand, dezente Schatten durch mehrfache Ränder. +# ============================================================================ +def render_glossy(items, widgets, fonts) -> Image.Image: + img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), (220, 228, 235)) # cool gray + 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)) + + # Karte weiß + draw.rectangle((x + 2, y + 2, x + w - 2, y + h - 2), fill=WHITE) + + # Glossy-Effekt: heller Balken oben + draw.rectangle((x + 2, y + 2, x + w - 2, y + 14), fill=accent) + # Weißer Gradient-Overlay oben + 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) + + # Rahmeneffekt: 3-lagig für Schatten + 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 in Accent-Farbe + label = (widget.label or widget.name or "").title() + draw.text((x + 10, y + 18), label, font=fonts.get("14", fonts.get("default")), fill=accent) + + # Separator + draw.line((x + 8, y + 36, x + w - 8, y + 36), fill=(220, 220, 220), width=1) + + # Content + widget.render(draw, fonts, x + 6, y + 40, w - 12, h - 46) + + return img + + +# ============================================================================ +# DESIGN 10: DATA BOARD +# Kontrollraum-Ästhetik. Winzige Zellen, viele Daten. +# Horizontale Balken, Tick-Marks, monochrome Palette. +#=========================================================================== +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 + + # Weißer Rand + draw.rectangle((x, y, x + w - 1, y + h - 1), fill=WHITE, width=2) + + # Header-Balken (invertiert) + 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) + + # Kleine Tick-Marks oben (dekorativ) + for tx in range(x + 10, x + w - 10, 20): + draw.line((tx, y + 24, tx, y + 28), fill=(180, 180, 180), width=1) + + # Content + widget.render(draw, fonts, x + 4, y + 30, w - 8, h - 34) + + return img + + +# ============================================================================ +# Helper +# ============================================================================ +def _px(item): + return item.x * CELL_W, item.y * CELL_H, item.w * CELL_W, item.h * CELL_H + + +def _hash_color(name: str): + """Consistent color from name using simple hash.""" + colors = [RED, GREEN, BLUE, YELLOW, ORANGE, (0, 200, 200), (200, 0, 200)] + h = sum(ord(c) for c in name) + return colors[h % len(colors)] diff --git a/renderer3.py b/renderer3.py new file mode 100644 index 0000000..49ad9d5 --- /dev/null +++ b/renderer3.py @@ -0,0 +1,203 @@ +"""Minimal Display-Designs für ACeP 7-Farben. + +Inspiration von loiccoyle/tinyticker + inkflow-eink: +- Weißer Hintergrund, schwarze Geometrie +- Bold Typography, große Zahlen +- SVG-style rendering (saubere Linien, einheitliche Strichstärke) +- Dezente Akzentfarbe pro Widget-Kategorie +""" + +from PIL import Image, ImageDraw +from palette import FG, BG, WHITE, BLACK, GREEN, BLUE, RED, YELLOW, ORANGE, OK, WARN, ALERT, INFO, ACCENT + +DISPLAY_W = 800 +DISPLAY_H = 480 +COLS, ROWS = 4, 4 +CELL_W = DISPLAY_W // COLS +CELL_H = DISPLAY_H // ROWS + +# Einheitliche Strichstärke +STROKE = 2 # px für alle Linien +SMALL_STROKE = 1 + + +def render_design(items, widgets, fonts, design="minimal") -> Image.Image: + fn = { + "minimal": _render_minimal, + "minimal_color": _render_minimal_color, + "bold": _render_bold, + "analog": _render_analog_clock, + }.get(design, _render_minimal) + return fn(items, widgets, fonts) + + +# ============================================================================ +# MINIMAL — das Referenz-Design +# Schwarz/Weiß, bold, clean, viel Weißraum +# ============================================================================ +def _render_minimal(items, widgets, fonts) -> Image.Image: + img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), WHITE) + draw = ImageDraw.Draw(img) + + # Keine sichtbaren Grid-Linien — jedes Widget steht für sich + for item, widget in zip(items, widgets): + x, y, w, h = _px(item) + if widget is None: + continue + + # Kein Rahmen, kein Hintergrund — reiner Content auf Weiß + # Content füllt die ganze Zelle + 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), # blau + "weather": (50, 160, 80), # grün + "system": (200, 120, 30), # orange + "hello": (180, 60, 60), # rot + "spotify": (0, 160, 80), # spotify-grün + "strava": (230, 110, 20), # strava-orange + "gmail": (200, 60, 60), # gmail-rot + "minimax": (140, 80, 240), # lila + } + + 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) + + # Winziger Akzent-Strich oben (8px hoch, 3px breit) + draw.rectangle((x + 12, y + 10, x + 16, y + 18), fill=color) + + # Widget-Name winzig darüber + 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)) + + # Content + 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 + + # Weißer Hintergrund, schwarzer Rahmen (1px) + draw.rectangle((x + 1, y + 1, x + w - 2, y + h - 2), outline=BLACK, width=1) + + # Content — groß + widget.render(draw, fonts, x + 6, y + 6, w - 12, h - 12) + + return img + + +# ============================================================================ +# ANALOG CLOCK — Die Uhr als Mittelpunkt +# Mittlere Zellen (2x2) zeigen die Uhr als analoges Ziffernblatt. +# Kleine Zellen (1x1) zeigen minimalistische Icons. +# Übrige Zellen: Daten-Widget mit Monospace-Text. +# ============================================================================ +def _render_analog_clock(items, widgets, fonts) -> Image.Image: + img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), (245, 245, 240)) # warm white + draw = ImageDraw.Draw(img) + + 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: + # 2x2 Clock → Analoges Ziffernblatt + _draw_analog_clock(draw, x, y, w, h, fonts) + else: + # Andere Widgets: cleanes Panel + draw.rectangle((x + 2, y + 2, x + w - 2, y + h - 2), fill=WHITE) + # Kleiner farbiger Punkt oben links + color = (60, 100, 200) + draw.ellipse((x + 8, y + 8, x + 14, y + 14), fill=color) + # Label + 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)) + # Separator + draw.line((x + 8, y + 22, x + w - 8, y + 22), fill=(200, 200, 200), width=1) + # Content + widget.render(draw, fonts, x + 6, y + 26, w - 12, h - 34) + + return img + + +def _draw_analog_clock(draw, x, y, w, h, fonts): + """Zeichne ein analoges Ziffernblatt.""" + import math + cx, cy = x + w // 2, y + h // 2 + r = min(w, h) // 2 - 16 + + # Ziffernblatt-Kreis + draw.ellipse((cx - r, cy - r, cx + r, cy + r), fill=WHITE, outline=BLACK, width=STROKE) + + # Stunden-Markierungen (kleine punkte) + 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) + + # Zeiger + 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 + + # Stundenzeiger + 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) + + # Minutenzeiger + 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) + + # Mittelpunkt + draw.ellipse((cx - 4, cy - 4, cx + 4, cy + 4), fill=BLACK) + + # Digitale Zeit darunter + draw.text((cx - 40, cy + r + 8), + now.strftime("%H:%M"), + font=fonts.get("20", fonts.get("default")), fill=BLACK) + + +# ============================================================================ +# Helper +# ============================================================================ +def _px(item): + 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 diff --git a/templates/index.html b/templates/index.html index 989fdbd..017533f 100644 --- a/templates/index.html +++ b/templates/index.html @@ -4,40 +4,552 @@ epaper-dashboard + + body { font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif; @@ -628,31 +1140,45 @@
-

Display Theme

+

Display Design

+
-
-
- - +

+ bestimmt das Layout auf dem e-Paper-Display. +

+
+
+
Minimal
+
Nur Content, kein UI-Chrome, maximal clean
-
-
-
Preview
-
Classic
-
+
+
Minimal Color
+
WinZiger Akzent-Strich pro Widget
+
+
+
Bold
+
Riesen-Typography, 1px-Rahmen, vollflächig
+
+
+
Analog Clock
+
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
+
+
+
Classic
+
Original flaches Design, weißer Hintergrund
-
-
- Theme wird beim nächsten Refresh auf dem Display angezeigt. Nur für Designs mit weißem Hintergrund geeignet (Widget-Farben bleiben gleich).
@@ -1068,36 +1594,36 @@ const savedTheme = localStorage.getItem('adminTheme'); if (savedTheme) setAdminTheme(savedTheme); - // ============ Display Theme Switcher ============ - const THEME_PREVIEWS = { - default: { bg: '#ffffff', fg: '#000000', label: 'Classic' }, - dark: { bg: '#111111', fg: '#f0f0f0', label: 'Dark' }, - sepia: { bg: '#f4ecd8', fg: '#5b4636', label: 'Sepia' }, - nord: { bg: '#eceff4', fg: '#2e3440', label: 'Nord' }, - terminal: { bg: '#0d0d00', fg: '#e8c840', label: 'Terminal' }, + // ============ Display Design Switcher ============ + const DESIGN_LABELS = { + minimal: "Minimal", minimal_color: "Minimal Color", + bold: "Bold", analog: "Analog Clock", magazine: "Magazine", + cards: "Cards", kiosk: "Kiosk", classic: "Classic", }; - function updateThemePreview(theme) { - const p = THEME_PREVIEWS[theme] || THEME_PREVIEWS.default; - const el = document.getElementById('themePreview'); - const lbl = document.getElementById('themePreviewLabel'); - if (el) { el.style.background = p.bg; el.style.color = p.fg; } - if (lbl) lbl.textContent = p.label; - } - async function loadDisplayTheme() { - const r = await fetch('/api/display_theme'); + + async function loadDisplayDesign() { + const r = await fetch('/api/config'); if (!r.ok) return; - const d = await r.json(); - document.getElementById('displayThemeSelect').value = d.current; - updateThemePreview(d.current); + const cfg = await r.json(); + const current = cfg.display_design || 'minimal'; + document.querySelectorAll('.design-option').forEach(el => { + el.classList.toggle('selected', el.dataset.design === current); + }); + const badge = document.getElementById('currentDesignBadge'); + if (badge) badge.textContent = DESIGN_LABELS[current] || current; } - async function setDisplayTheme(theme) { - updateThemePreview(theme); - const r = await fetch('/api/display_theme', { + + async function setDisplayDesign(design) { + await fetch('/api/display_design', { method: 'POST', headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({theme}) + body: JSON.stringify({design}), }); - if (r.ok) triggerRefresh(); + document.querySelectorAll('.design-option').forEach(el => { + el.classList.toggle('selected', el.dataset.design === design); + }); + const badge = document.getElementById('currentDesignBadge'); + if (badge) badge.textContent = DESIGN_LABELS[design] || design; } // ============ Init ============ @@ -1105,7 +1631,7 @@ refreshNetStatus(); refreshScan(); refreshSaved(); - loadDisplayTheme(); + loadDisplayDesign(); setInterval(refreshNetStatus, 10000); setInterval(refreshScan, 60000); setInterval(refreshPreview, 10000);