"""GridSlots: non-widget layout helper for fixed-size cell grids.
Used by ports that draw N×M slots of identical cells (You're the OS dashboards:
CPUs, idle queues, ragequit, RAM, disk; Solitaire foundation/tableau rows;
Balatro hand+jokers+consumables; tactics movement-range overlays). The shape is
always "compute a slot index → position" and never a hierarchical container.
``GridSlots`` is a pure function-of-its-fields layout calculator: it owns no
nodes, queues no redraws, and never participates in input. Drop one into any
``on_draw`` and call :meth:`idx_to_xy` per cell.
Example::
from simvx.core.ui import GridSlots
slots = GridSlots(rows=4, cols=8, size=(48, 48), gap=4, origin=(20, 100))
for i, item in enumerate(items):
x, y = slots.idx_to_xy(i)
renderer.draw_rect((x, y), slots.size, colour=...)
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Iterator
from ..math.types import Vec2
__all__ = ["GridSlots"]
[docs]
@dataclass(frozen=True)
class GridSlots:
"""Fixed-size cell grid laid out row-major from ``origin``.
All coordinates are returned as :class:`Vec2`. Layout is row-major
(``idx = row * cols + col``); pass ``row_major=False`` to flip to
column-major.
Args:
rows: Number of rows. Must be ``>= 1``.
cols: Number of columns. Must be ``>= 1``.
size: ``(cell_w, cell_h)`` cell dimensions in pixels.
gap: Pixel gutter between adjacent cells (same on both axes).
origin: ``(x, y)`` top-left corner of the cell at index ``0``.
row_major: When True (default), ``idx`` increases left-to-right then
top-to-bottom; when False, top-to-bottom then left-to-right.
"""
rows: int
cols: int
size: tuple[float, float] = (48.0, 48.0)
gap: float = 4.0
origin: tuple[float, float] = (0.0, 0.0)
row_major: bool = True
[docs]
def __post_init__(self):
if self.rows < 1 or self.cols < 1:
raise ValueError(f"GridSlots needs rows>=1 and cols>=1, got {self.rows}x{self.cols}")
[docs]
@property
def count(self) -> int:
"""Total number of slots."""
return self.rows * self.cols
[docs]
@property
def cell_w(self) -> float:
return float(self.size[0])
[docs]
@property
def cell_h(self) -> float:
return float(self.size[1])
[docs]
@property
def total_size(self) -> Vec2:
"""Outer width and height of the grid (no trailing gap)."""
w = self.cols * self.cell_w + max(0, self.cols - 1) * self.gap
h = self.rows * self.cell_h + max(0, self.rows - 1) * self.gap
return Vec2(w, h)
[docs]
def idx_to_rc(self, idx: int) -> tuple[int, int]:
"""Translate a linear index into ``(row, col)`` honouring ``row_major``."""
if idx < 0 or idx >= self.count:
raise IndexError(f"GridSlots index {idx} out of range 0..{self.count - 1}")
if self.row_major:
return divmod(idx, self.cols)
col, row = divmod(idx, self.rows)
return row, col
[docs]
def idx_to_xy(self, idx: int) -> Vec2:
"""Top-left corner of slot ``idx`` in absolute coordinates."""
row, col = self.idx_to_rc(idx)
x = self.origin[0] + col * (self.cell_w + self.gap)
y = self.origin[1] + row * (self.cell_h + self.gap)
return Vec2(x, y)
[docs]
def rc_to_idx(self, row: int, col: int) -> int:
"""Linear index for ``(row, col)``."""
if row < 0 or row >= self.rows or col < 0 or col >= self.cols:
raise IndexError(f"GridSlots cell ({row},{col}) out of range")
return row * self.cols + col if self.row_major else col * self.rows + row
[docs]
def xy_to_idx(self, x: float, y: float) -> int | None:
"""Hit-test ``(x, y)``; returns the slot index or ``None`` if outside.
Points inside the gap gutter return ``None``.
"""
rel_x = x - self.origin[0]
rel_y = y - self.origin[1]
if rel_x < 0 or rel_y < 0:
return None
stride_x = self.cell_w + self.gap
stride_y = self.cell_h + self.gap
col = int(rel_x // stride_x)
row = int(rel_y // stride_y)
if row >= self.rows or col >= self.cols:
return None
# Reject gutter coordinates.
if rel_x - col * stride_x >= self.cell_w:
return None
if rel_y - row * stride_y >= self.cell_h:
return None
return self.rc_to_idx(row, col)
[docs]
def __iter__(self) -> Iterator[Vec2]:
"""Iterate ``idx_to_xy`` for every slot in order."""
for i in range(self.count):
yield self.idx_to_xy(i)