Source code for manim_databases.m_table.m_table

"""MTable: animated database table mobject."""

from __future__ import annotations

from copy import deepcopy
from typing import Any

from manim import (
    DOWN,
    Animation,
    ApplyMethod,
    FadeOut,
    Indicate,
    Rectangle,
    Succession,
    Text,
    VGroup,
    Write,
    override_animate,
)

from manim_databases.constants import MTableStyle
from manim_databases.m_table.m_row import MRow
from manim_databases.utils.utils import Labelable


[docs] class MTable(VGroup, Labelable): """An animated database table. Renders columns and rows as a grid of cells with optional primary-key highlighting and animated CRUD operations. Designed so that other mobjects in this library (``MIndex``, ``MWal``, ``MLock``) can hold stable references to individual :class:`MRow` instances and animate against them. Parameters ---------- columns : list[str] Column names, displayed as the header row. rows : list[list], optional Initial row values. Each inner list must have ``len(columns)`` items. primary_key : str or None, optional Name of the primary key column. When set, the column header text is colored ``style.primary_key_color`` to mark it visually. style : MTableStyle._DefaultStyle, optional Style configuration. Default ``MTableStyle.DEFAULT``. Examples -------- >>> table = MTable( ... columns=["id", "name", "status"], ... rows=[[1, "alice", "active"], [2, "bob", "pending"]], ... primary_key="id", ... style=MTableStyle.BLUE, ... ) >>> self.play(Create(table)) >>> self.play(table.animate.insert_row([3, "carol", "active"])) >>> self.play(table.animate.update_cell(1, "status", "active")) >>> self.play(table.animate.delete_row(0)) """ def __init__( self, columns: list[str], rows: list[list[Any]] | None = None, primary_key: str | None = None, style: MTableStyle._DefaultStyle = MTableStyle.DEFAULT, ): super().__init__() if rows is None: rows = [] self.columns = list(columns) self.style = deepcopy(style) self.primary_key = primary_key self.primary_key_index = ( columns.index(primary_key) if primary_key in columns else None ) self._column_widths = self._compute_column_widths(columns, rows) self.header: VGroup = self._build_header() self += self.header self.rows: list[MRow] = [] for row_values in rows: self._append_row_internal(row_values) if self.primary_key_index is not None: self._tint_primary_key_column() self.move_to([0, 0, 0]) # ── construction helpers ────────────────────────────────────────── def _compute_column_widths( self, columns: list[str], rows: list[list[Any]] ) -> list[float]: """Use a single uniform width for now. Future versions can autosize per column based on the longest value. """ return [self.style.cell["width"]] * len(columns) def _build_header(self) -> VGroup: header_group = VGroup() previous_cell: Rectangle | None = None self.header_cells: list[Rectangle] = [] self.header_texts: list[Text] = [] for i, name in enumerate(self.columns): cell_kwargs = dict(self.style.header_cell) cell_kwargs["width"] = self._column_widths[i] cell = Rectangle(**cell_kwargs) if previous_cell is None: cell.move_to([0, 0, 0]) else: cell.next_to(previous_cell, direction=[1, 0, 0], buff=0) text = Text(str(name), **self.style.header).move_to(cell) self.header_cells.append(cell) self.header_texts.append(text) header_group += cell header_group += text previous_cell = cell return header_group def _append_row_internal(self, values: list[Any]) -> MRow: """Add a row to the geometry without animations. New rows are created at the original (unscaled) style size and then scaled to match the existing geometry. This is necessary because the table may have been scaled after construction; ``self.style.cell`` no longer reflects the on-screen size. """ if len(values) != len(self.columns): raise ValueError( f"Row has {len(values)} values but table has " f"{len(self.columns)} columns" ) row = MRow(values, style=self.style, column_widths=self._column_widths) # Scale the new row to match the current geometry of existing rows # (or the header if this is the first data row). reference_cell = ( self.rows[0].cells[0] if self.rows else self.header_cells[0] ) new_cell = row.cells[0] if new_cell.width > 0 and reference_cell.width != new_cell.width: row.scale(reference_cell.width / new_cell.width) self._position_row_below_last(row) self.rows.append(row) self += row return row def _position_row_below_last(self, row: MRow) -> None: """Snap a new row directly under the last existing row (or header).""" anchor = self.rows[-1] if self.rows else self.header row.next_to(anchor, DOWN, buff=0) def _tint_primary_key_column(self) -> None: """Color the primary key header text to mark the column visually. Only the text is colored — not the cell stroke. Modifying the stroke of one cell in a tightly-packed grid causes color bleeding onto adjacent cells at shared borders, regardless of stroke width. """ if self.primary_key_index is None: return pk_color = self.style.primary_key_color self.header_texts[self.primary_key_index].set_color(pk_color) # ── public API ────────────────────────────────────────────────────
[docs] def insert_row(self, values: list[Any]) -> MTable: """Append a row to the bottom of the table. Parameters ---------- values : list Cell values matching ``self.columns`` length. """ self._append_row_internal(values) return self
@override_animate(insert_row) def _insert_row_animation( self, values: list[Any], anim_args: dict | None = None ) -> Animation: if anim_args is None: anim_args = {} self.insert_row(values) return Write(self.rows[-1], **anim_args)
[docs] def delete_row(self, row_index: int) -> MTable: """Remove a row by position and shift everything below upward.""" if not 0 <= row_index < len(self.rows): raise IndexError(f"Row index {row_index} out of range") # Capture the removed row's actual height *before* removing it so the # shift amount stays correct under any post-construction transforms. shift_amount = self.rows[row_index].height removed = self.rows.pop(row_index) self -= removed below = VGroup(*self.rows[row_index:]) if len(below) > 0: below.shift([0, shift_amount, 0]) return self
@override_animate(delete_row) def _delete_row_animation( self, row_index: int, anim_args: dict | None = None ) -> Animation: if anim_args is None: anim_args = {} if not 0 <= row_index < len(self.rows): raise IndexError(f"Row index {row_index} out of range") shift_amount = self.rows[row_index].height removed = self.rows.pop(row_index) self -= removed below = VGroup(*self.rows[row_index:]) anims = [FadeOut(removed)] if len(below) > 0: anims.append(ApplyMethod(below.shift, [0, shift_amount, 0])) return Succession(*anims, **anim_args, group=VGroup(self, removed))
[docs] def update_cell( self, row_index: int, column: int | str, new_value: Any ) -> MTable: """Replace a cell value in place. Parameters ---------- row_index : int Row position. column : int or str Column position or column name. new_value : Any New value (coerced to string for display). """ col_index = self._resolve_column(column) self.rows[row_index].set_cell_value(col_index, new_value) return self
@override_animate(update_cell) def _update_cell_animation( self, row_index: int, column: int | str, new_value: Any, anim_args: dict | None = None, ) -> Indicate: if anim_args is None: anim_args = {} self.update_cell(row_index, column, new_value) col_index = self._resolve_column(column) return Indicate(self.rows[row_index].value_texts[col_index], **anim_args)
[docs] def highlight_row(self, row_index: int) -> MTable: """Apply a highlight stroke to a row.""" self.rows[row_index].highlight() return self
@override_animate(highlight_row) def _highlight_row_animation( self, row_index: int, anim_args: dict | None = None ) -> Animation: if anim_args is None: anim_args = {} return self.rows[row_index]._highlight_animation(anim_args=anim_args)
[docs] def unhighlight_row(self, row_index: int) -> MTable: """Remove the highlight from a row.""" self.rows[row_index].unhighlight() return self
@override_animate(unhighlight_row) def _unhighlight_row_animation( self, row_index: int, anim_args: dict | None = None ) -> Animation: if anim_args is None: anim_args = {} return self.rows[row_index]._unhighlight_animation(anim_args=anim_args)
[docs] def get_row(self, row_index: int) -> MRow: """Return the :class:`MRow` at a given position. The returned reference is stable across subsequent inserts and deletes on the table — other mobjects can hold it without worrying about row index invalidation. """ return self.rows[row_index]
[docs] def get_cell_value(self, row_index: int, column: int | str) -> Any: """Return the current value of a cell.""" col_index = self._resolve_column(column) return self.rows[row_index].values[col_index]
def __getitem__(self, row_index: int) -> MRow: return self.rows[row_index] def __len__(self) -> int: return len(self.rows) # ── internals ───────────────────────────────────────────────────── def _resolve_column(self, column: int | str) -> int: if isinstance(column, int): return column try: return self.columns.index(column) except ValueError as exc: raise KeyError(f"Unknown column: {column!r}") from exc