Source code for modelly.viz.solara.layout

"""Paged, draggable grid layout of SolaraViz components."""

from __future__ import annotations

import collections
from collections.abc import Callable
from typing import TYPE_CHECKING

import reacton.core
import solara

from modelly.viz.utils import update_counter

if TYPE_CHECKING:
    from modelly.core.model import Model

_GRID_COLUMNS = 2
_ITEM_WIDTH = 6
_ITEM_HEIGHT = 10
_ROW_SPACING = 16


def _wrap_component(
    component: reacton.core.Component | Callable[[Model], reacton.core.Component],
) -> reacton.core.Component:
    """Wrap a component in an auto-updated Solara component if needed."""
    if isinstance(component, reacton.core.Component):
        return component

    @solara.component
    def WrappedComponent(model):
        update_counter.get()
        return component(model)

    return WrappedComponent


def _build_pages(components: list) -> tuple[dict, list]:
    """Group the components by page and return ``(pages, sorted page indices)``."""
    # Backward's compatibility, page = 0 if not passed.
    for i, comp in enumerate(components):
        if not isinstance(comp, tuple):
            components[i] = (comp, 0)

    # Build pages mapping
    pages = collections.defaultdict(list)
    for component, page_index in components:
        pages[page_index].append(_wrap_component(component))

    # Fill in missing page indices for sequential tab order
    all_indices = sorted(pages.keys())
    if len(all_indices) > 1:
        min_page, max_page = all_indices[0], all_indices[-1]
        all_indices = list(range(min_page, max_page + 1))
        for idx in all_indices:
            pages.setdefault(idx, [])

    return pages, all_indices


def _sync_layouts(components, pages, layouts, set_layouts) -> None:
    """Keep the per-page grid layouts in sync with the pages."""
    if not components:  # Handle empty case inside the effect
        return

    current_keys = set(pages.keys())
    layout_keys = set(layouts.keys())

    # Add layouts for new pages
    new_layouts = {
        index: make_initial_grid_layout(len(pages[index]))
        for index in current_keys - layout_keys
    }

    # Remove layouts for deleted pages
    cleaned_layouts = {k: v for k, v in layouts.items() if k in current_keys}

    if new_layouts or len(cleaned_layouts) != len(layouts):
        set_layouts({**cleaned_layouts, **new_layouts})


def _layout_change_handler(set_layouts, page_id):
    """Return a callback that stores a new grid layout for ``page_id``."""

    def on_layout_change(new_layout, current_page_id=page_id):
        set_layouts(lambda old: {**old, current_page_id: new_layout})

    return on_layout_change


def _render_page(model, page_components, page_layout, on_layout_change) -> None:
    """Render one page's components in a draggable, resizable grid."""
    if not page_layout:
        return
    solara.GridDraggable(
        items=[c(model) for c in page_components],
        grid_layout=page_layout,
        resizable=True,
        draggable=True,
        on_grid_layout=on_layout_change,
    )


def _render_tabs(page_indices, current_tab_index, set_current_tab_index) -> None:
    """Render the tab navigation bar, one tab per page."""
    with solara.v.Tabs(v_model=current_tab_index, on_v_model=set_current_tab_index):
        for index in page_indices:
            solara.v.Tab(children=[f"Page {index}"])


@solara.component
def ComponentsView(
    components: list[tuple[reacton.core.Component], int]
    | list[tuple[Callable[[Model], reacton.core.Component], int]],
    model: Model,
):
    """Display a list of components.

    Args:
        components: List of (components, page) to display
        model: Model instance to pass to each component
    """
    current_tab_index, set_current_tab_index = solara.use_state(0)
    layouts, set_layouts = solara.use_state({})

    pages, sorted_page_indices = _build_pages(components)
    solara.use_effect(
        lambda: _sync_layouts(components, pages, layouts, set_layouts),
        list(pages.keys()),
    )

    # Allow early return (which is now safe because all hooks have been called)
    if not components:
        return

    _render_tabs(sorted_page_indices, current_tab_index, set_current_tab_index)

    with solara.v.Window(v_model=current_tab_index):
        for i, page_id in enumerate(sorted_page_indices):
            with solara.v.WindowItem():
                # Use tab index 'i' instead of 'page_id' because solara.v.Window
                # Tracks active tabs using 0-based indexing, which may differ from page IDs.
                if i == current_tab_index:
                    handler = _layout_change_handler(set_layouts, page_id)
                    _render_page(model, pages[page_id], layouts.get(page_id), handler)


[docs] def make_initial_grid_layout(num_components): """Create an initial grid layout for visualization components. Args: num_components: Number of components to display Returns: list: Initial grid layout configuration """ return [ { "i": i, "w": _ITEM_WIDTH, "h": _ITEM_HEIGHT, "moved": False, "x": _ITEM_WIDTH * (i % _GRID_COLUMNS), "y": _ROW_SPACING * (i - i % _GRID_COLUMNS), } for i in range(num_components) ]