Source code for simvx.core.ui.grid_slots

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