Files
epaper-dashboard/layout.py
T
ki c6919c5104 BUG-04: Add-Item ohne Re-Pack (first-fit)
Beim Hinzufügen eines neuen Widgets via /api/layout/add rief der Server
layout_mod.pack() auf alle Items auf — pack() sortiert nach Fläche
absteigend und platziert scan-line greedy. Ein 1x1 hello konnte dabei
einen 2x1 spotify aus seiner Position drängen, weil die Sort-Reihenfolge
sich ändert sobald ein neues Item im Mix ist.

Reproduktion vor dem Fix:
  Bestehende config: c1@(0,0) w1@(2,0) st1@(0,2) sp1@(2,2) sv1@(2,3)
  Add hello (1x1) → w1 wurde nach (0,2) verschoben, hello landete bei (2,0).
  Siehe RED-Test in tests/test_add_route_no_repack.py.

Fix:
- layout.py: neue Funktion first_fit(item, others) — platziert ein Item in
  der ersten freien scan-line-Zelle OHNE andere Items zu verändern.
- admin.py /api/layout/add nutzt first_fit. Wenn kein Platz: HTTP 409 mit
  {ok:false, error:'kein Platz für WxH-Item', hint:'use_auto_pack'}.
- templates/index.html addItem(): 409 als 'warn'-Toast mit Auto-Pack-Hinweis.

Akzeptanzkriterien (BUG-04):
- bestehende Items bleiben bei Add unverändert an (x,y)
- neues Item landet in erster freier Zelle
- voller Grid → 409, kein bestehendes Item verschoben
- Auto-Pack bleibt als expliziter User-Wunsch erhalten (BUG-06)

Tests:
- tests/test_layout_firstfit.py: 3 unit tests (empty, gappy, full grid)
- tests/test_add_route_no_repack.py: 2 integration tests gegen /api/layout/add
  mit gemocktem dashboard-Modul + Flask test_client

Closes #6
2026-08-29 17:20:22 +04:00

191 lines
5.9 KiB
Python

"""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),
"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}