"""Grid-Layout: 4x4-Grid mit Pack-Algorithmus und Konflikt-Detection. Display: 800x480 Pixel. Grid: 4x4 Zellen, jede Zelle 200x120 Pixel. Item-Geometrie: (x, y, w, h) in Zelleneinheiten, x/y ∈ [0..GRID_COLS-1], w/h ∈ [1..GRID_COLS] (x+w) ≤ GRID_COLS, (y+h) ≤ GRID_ROWS Regeln: - Items dürfen sich nicht überlappen - Wenn mehrere Items die gleiche Zelle belegen wollen → Konflikt - Pack-Algorithmus: greedy, scan-line, größte zuerst """ from __future__ import annotations from dataclasses import dataclass, field, asdict from typing import Optional GRID_COLS = 4 GRID_ROWS = 4 CELL_W = 200 # px CELL_H = 120 # px DISPLAY_W = GRID_COLS * CELL_W # 800 DISPLAY_H = GRID_ROWS * CELL_H # 480 # Standard-Größen die im UI als Buttons angeboten werden SIZE_PRESETS = [ (1, 1), (2, 1), (1, 2), (2, 2), (4, 1), (1, 4), (2, 4), (4, 2), (3, 1), (1, 3), (3, 2), (2, 3), (3, 3), (4, 4), ] @dataclass class Item: id: str plugin: str x: int = 0 y: int = 0 w: int = 1 h: int = 1 def to_dict(self): return asdict(self) @classmethod def from_dict(cls, d: dict) -> "Item": return cls( id=str(d.get("id", _new_id())), plugin=str(d.get("plugin", "")), x=int(d.get("x", 0)), y=int(d.get("y", 0)), w=int(d.get("w", 1)), h=int(d.get("h", 1)), ) def bounds(self) -> tuple[int, int, int, int]: """Returns (x, y, w, h).""" return self.x, self.y, self.w, self.h def pixels(self) -> tuple[int, int, int, int]: """Returns (x, y, width, height) in pixels.""" return self.x * CELL_W, self.y * CELL_H, self.w * CELL_W, self.h * CELL_H def _new_id() -> str: import secrets return secrets.token_hex(4) def cells_occupied(item: Item) -> set[tuple[int, int]]: """Set of (col, row) cells this item covers.""" return {(item.x + dx, item.y + dy) for dx in range(item.w) for dy in range(item.h)} def find_overlaps(items: list[Item]) -> list[tuple[str, str]]: """Returns list of (id_a, id_b) pairs that overlap.""" overlaps = [] for i, a in enumerate(items): cells_a = cells_occupied(a) for b in items[i+1:]: if cells_a & cells_occupied(b): overlaps.append((a.id, b.id)) return overlaps def find_out_of_bounds(items: list[Item]) -> list[str]: """Returns list of item IDs that are outside the grid.""" bad = [] for it in items: if it.x < 0 or it.y < 0 or it.x + it.w > GRID_COLS or it.y + it.h > GRID_ROWS: bad.append(it.id) return bad def pack(items: list[Item], order: Optional[list[str]] = None) -> list[Item]: """Auto-pack items into a 4x4 grid. Strategy: greedy first-fit, sort by area descending (largest first). Items that don't fit are placed in a 'trash row' below the grid (y=GRID_ROWS, h=1) so they are visible to the user as overflowing. `order` (optional): explicit ordering by item id. """ # Sort: largest area first; tie-break by id for determinism indexed = list(items) if order: priority = {id_: i for i, id_ in enumerate(order)} indexed.sort(key=lambda it: (priority.get(it.id, 9999), -it.w * it.h, it.id)) else: indexed.sort(key=lambda it: (-it.w * it.h, it.id)) taken: set[tuple[int, int]] = set() result: list[Item] = [] overflow_row: list[Item] = [] for it in indexed: # clamp size to grid it.w = max(1, min(GRID_COLS, it.w)) it.h = max(1, min(GRID_ROWS, it.h)) # try to place placed = False for y in range(GRID_ROWS - it.h + 1): for x in range(GRID_COLS - it.w + 1): cells = {(x + dx, y + dy) for dx in range(it.w) for dy in range(it.h)} if not (cells & taken): it.x, it.y = x, y taken |= cells result.append(it) placed = True break if placed: break if not placed: # couldn't fit → overflow marker it.x = 0 it.y = GRID_ROWS # off-screen overflow_row.append(it) result.append(it) return result def first_fit(item: Item, others: list[Item]) -> Item | None: """Place the item at the first free scan-line position without moving others. Returns the item with x/y set, or None if no placement fits. Used by /api/layout/add so a new widget does not reshuffle the grid. """ w = max(1, min(GRID_COLS, item.w)) h = max(1, min(GRID_ROWS, item.h)) for y in range(GRID_ROWS - h + 1): for x in range(GRID_COLS - w + 1): candidate = Item(item.id, item.plugin, x, y, w, h) cells = cells_occupied(candidate) if all(not (cells & cells_occupied(o)) for o in others): return candidate return None def auto_size_for_plugin(plugin_name: str) -> tuple[int, int]: """Default size when user adds a new item.""" presets = { "clock": (2, 2), "clock_wordclock": (2, 2), "weather": (2, 2), "netatmo": (4, 4), # zeigt 4 Sub-Cards; ab 2x2 sinnvoll, 4x4 ideal "system": (2, 2), "minimax": (2, 2), "spotify": (2, 1), "strava": (2, 1), "gmail": (1, 1), "hello": (1, 1), } return presets.get(plugin_name, (2, 2)) def render_grid_layout(items: list[Item]) -> dict: """Render each item to its (x, y, w, h) pixel-box on the 800x480 display.""" boxes = [] for it in items: px, py, pw, ph = it.pixels() # Overflow items still get rendered as small placeholder, not visible boxes.append({ "id": it.id, "plugin": it.plugin, "x": px, "y": py, "w": pw, "h": ph, "out_of_bounds": it.y >= GRID_ROWS, }) return {"display_w": DISPLAY_W, "display_h": DISPLAY_H, "grid_cols": GRID_COLS, "grid_rows": GRID_ROWS, "cell_w": CELL_W, "cell_h": CELL_H, "items": boxes}