<jupyter_start><jupyter_text>Grid MenuThe current notebook develop a grid menu widget that allows clickable widgets to be displayed as grid. The next cell will design the `Grid` class that contain the settings for the `GridMenu` component.<jupyter_code>#exporti
@attr.define(slots=False)
class Grid:
    width: int
    height: int
    n_rows: Optional[int] = 3
    n_cols: Optional[int] = 3
    disp_number: int = 9
    display_label: bool = False

    @property
    def num_items(self) -> int:
        row, col = self.area_adjusted(self.disp_number)
        return row * col

    def area_adjusted(self, n_total: int) -> Tuple[int, int]:
        """Returns the row and col automatic arranged"""
        if self.n_cols is None:
            if self.n_rows is None:  # automatic arrange
                label_cols = 3
                label_rows = ceil(n_total / label_cols)
            else:  # calc cols to show all labels
                label_rows = self.n_rows
                label_cols = ceil(n_total / label_rows)
        else:
            if self.n_rows is None:  # calc rows to show all labels
                label_cols = self.n_cols
                label_rows = ceil(n_total / label_cols)
            else:  # user defined
                label_cols = self.n_cols
                label_rows = self.n_rows

        return label_rows, label_cols
@pytest.fixture
def grid_fixture() -> Grid:
    return Grid(width=300, height=300)
%%ipytest
def test_it_return_num_items(grid_fixture):
    assert grid_fixture.num_items == 9
%%ipytest
def test_it_adjusts_area_missing_args(grid_fixture):
    grid_fixture.n_rows = None
    assert grid_fixture.area_adjusted(12) == (4, 3)<jupyter_output><empty_output><jupyter_text>The `GridMenu` doesn't have a `on_click` event listener, but grid elements itself should implement `on_click(ev)`, `reset_callbacks()` and `update(other: SameWidgetType)` methods to register/reset onclick callback function and update its internal values, respectively. Also grid element shoudl have a field name in order user can destinguish between grid children.<jupyter_code>#export
class GridMenu(GridBox):
    debug_output = Output(layout={'border': '1px solid black'})

    def __init__(
        self,
        grid: Grid,
        widgets: Optional[Iterable] = None,
    ):
        self.callback = None
        self.gap = 40 if grid.display_label else 15
        self.grid = grid

        n_row, n_col = grid.area_adjusted(grid.disp_number)
        column = grid.width + self.gap
        row = grid.height + self.gap
        centered_settings = {
            'grid_template_columns': " ".join([f'{(column)}px' for _
                                               in range(n_col)]),
            'grid_template_rows': " ".join([f'{row}px' for _
                                            in range(n_row)]),
            'justify_content': 'center',
            'align_content': 'space-around'
        }

        super().__init__(
            layout=Layout(**centered_settings)
        )

        if widgets:
            self.load(widgets)
        self.widgets = widgets

    def _fill_widgets(self, widgets: Iterable):
        if self.widgets is None:
            self.widgets = widgets

            self.children = self.widgets

            if self.callback:
                self.register_on_click()
        else:
            iter_state = iter(widgets)

            for widget in self.widgets:
                i_widget = next(iter_state, None)
                if i_widget:
                    widget.update(i_widget)
                else:
                    widget.clear()

    def _filter_widgets(self, widgets: Iterable) -> Iterable:
        """Limit the number of widgets to be rendered
        according to the grid's area"""
        widgets_list = list(widgets)  # Iterable don't have len()
        num_widgets = len(widgets_list)
        row, col = self.grid.area_adjusted(num_widgets)
        num_items = row * col

        if num_widgets > num_items:
            warnings.warn("!! Not all labels shown. Check n_cols, n_rows args !!")
            return widgets_list[:num_items]

        return widgets

    @debug_output.capture(clear_output=False)
    def load(self, widgets: Iterable, callback: Optional[Callable] = None):
        widgets_filtered = self._filter_widgets(widgets)
        self._fill_widgets(widgets_filtered)

        if callback:
            self.on_click(callback)

    @debug_output.capture(clear_output=False)
    def on_click(self, callback: Callable):
        setattr(self, 'callback', callback)
        self.register_on_click()

    @debug_output.capture(clear_output=False)
    def register_on_click(self):
        if self.widgets:
            for widget in self.widgets:
                widget.reset_callbacks()

                widget.on_click(
                    partial(
                        self.callback,
                        value=widget.value
                    )
                )

    def clear(self):
        self.widgets = None
        self.children = tuple()<jupyter_output><empty_output><jupyter_text>We now can instantiate the grid menu and load widgets on it. For this example we're using the custom widget `ImageButton` to be displayed using the load function.<jupyter_code>from ipyannotator.custom_input.buttons import ImageButton, ImageButtonSetting
from ipywidgets import HTML
from IPython.display import display
grid = Grid(width=50, height=75, n_cols=2, n_rows=2)
grid_menu = GridMenu(grid)
widgets = []
setting = ImageButtonSetting(im_path='../data/projects/capture1/pics/pink25x25.png')
for i in range(4):
    widgets.append(ImageButton(setting))
grid_menu.load(widgets)
grid_menu
widgets = []
setting = ImageButtonSetting(im_path='../data/projects/capture1/pics/teal50x50_5.png')
for i in range(2):
    widgets.append(ImageButton(setting))
grid_menu.load(widgets)<jupyter_output><empty_output><jupyter_text>While ipyevents implementation lacks `sender` or `source` in callback args, `functools.partial` used to back element `name` into return value. You can see example of on_click event handler `test_handler` below. name of the button is printed out on click.<jupyter_code># hide
h = HTML('Event info')
display(h)


def test_handler(event, value=None):
    event.update({'label_name': value})
    h.value = event['label_name']


grid_menu.on_click(test_handler)
#hide
from ipyannotator.custom_input.buttons import ActionButton
%%ipytest
def test_it_doesnt_load_more_widgets_than_the_grid_area():
    with warnings.catch_warnings(record=True) as w:
        grid = Grid(width=50, height=75, n_cols=1, n_rows=1)
        grid_menu = GridMenu(grid)
        widgets = [ActionButton() for _ in range(2)]
        grid_menu.load(widgets)
        assert len(grid_menu.widgets) == 1
    assert bool(w) is True
%%ipytest
def test_it_doesnt_throw_warning_if_number_of_widgets_is_small():
    with warnings.catch_warnings(record=True) as w:
        grid = Grid(width=100, height=100, n_rows=2, n_cols=2)
        grid_menu = GridMenu(grid)
        grid_menu._filter_widgets([1])
    assert bool(w) is False
#hide
from nbdev.export import notebook2script
notebook2script()<jupyter_output><empty_output>
{'fraction_non_alphanumeric': 0.09180830314850934, 'fraction_numerical': 0.008080245193647256, 'mean_word_length': 2.6571574121242993, 'pattern_counts': {'":': 0, '<': 17, '<?xml version=': 0, '>': 22, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 4, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '4', 'dir': 'jupyter-structured-clean-dedup', 'id': '634490', 'license': 'Apache-2.0', 'n_tokens_mistral': 2230, 'n_tokens_neox': 2099, 'n_words': 546, 'path': 'nbs/02b_grid_menu.ipynb', 'repo_name': 'itepifanio/ipyannotator', 'size': '7178'}