Skip to content

gallery_widget

anomaly_match_ui.widgets.gallery_widget

Paginated thumbnail gallery with sort controls.

GalleryWidget(on_magnify=None, on_star=None, cell_factory=None, setup_mode=False)

Paginated thumbnail grid with sort controls.

Parameters:

Name Type Description Default
on_magnify Callable[[str], None] | None

Callback invoked with filename when a thumbnail's magnify button is clicked.

None
on_star Callable[[str], None] | None

Callback invoked with filename when a thumbnail's star button is clicked.

None
cell_factory Callable[..., object] | None

Optional callable to create custom cell widgets. Called with on_magnify and on_star keyword arguments and must return an object with the same API as :class:ThumbnailCell.

None
setup_mode bool

When True the sort bar is hidden — the score-based sort modes have no meaning before any prediction has run, so the controls would only confuse the setup-screen flow (#427). The scale slider, pagination, and cell factory still work normally.

False
Source code in anomaly_match_ui/widgets/gallery_widget.py
def __init__(
    self,
    on_magnify: Callable[[str], None] | None = None,
    on_star: Callable[[str], None] | None = None,
    cell_factory: Callable[..., object] | None = None,
    setup_mode: bool = False,
) -> None:
    self._sort_mode = "score_desc"
    self._on_sort_change: Callable[[], None] | None = None
    self._on_page_change: Callable[[int], None] | None = None
    self._on_scale_change_callback: Callable[[], None] | None = None
    self._current_page = 0
    self._total_pages = 0
    self._setup_mode = setup_mode

    # Sort controls
    self._sort_buttons: dict[str, widgets.Button] = {}
    sort_children = []
    for label, mode in _SORT_MODES.items():
        btn = widgets.Button(
            description=label,
            layout=widgets.Layout(height="28px"),
            style={"button_color": ESA_BLUE_DEEP},
        )
        btn.on_click(lambda _b, m=mode: self._set_sort(m))
        self._sort_buttons[mode] = btn
        sort_children.append(btn)
    self._highlight_active_sort()

    sort_bar = widgets.HBox(
        sort_children,
        layout=widgets.Layout(gap="4px", padding="4px 0", justify_content="center"),
    )
    if setup_mode:
        sort_bar.layout.display = "none"

    # Column-count slider.  Sets how many thumbnails sit in each row;
    # fewer columns make every thumbnail larger (each cell is a
    # ``1fr`` share of the panel), which is how the user gets more
    # detail without the count ever depending on screen width.
    self._scale_slider = widgets.IntSlider(
        value=_COLUMNS_DEFAULT,
        min=_COLUMNS_MIN,
        max=_COLUMNS_MAX,
        step=1,
        description="Columns",
        layout=widgets.Layout(width="200px"),
        style={"description_width": "initial", "handle_color": "white"},
    )
    self._scale_slider.observe(self._on_scale_change, names="value")

    controls_bar = widgets.HBox(
        [sort_bar, self._scale_slider],
        layout=widgets.Layout(
            justify_content="space-between",
            align_items="center",
            padding="0 4px",
        ),
    )

    # Grid of thumbnail cells
    factory = cell_factory or (lambda **kw: ThumbnailCell(**kw))
    self._cells = [factory(on_magnify=on_magnify, on_star=on_star) for _ in range(PAGE_SIZE)]
    # The column count is fixed (`repeat(N, 1fr)`), set from the
    # slider — not derived from the panel width via `auto-fill`.  A
    # page holds exactly `_ROWS * N` cells, so the two-row viewport
    # (`grid_template_rows="repeat(2, auto)"` + `grid_auto_rows="0px"`
    # + `overflow="hidden"`) is always exactly full and never clips a
    # *populated* cell — only the leftover DOM cells past the current
    # page land in the clipped overflow rows.  Keeping the height
    # fixed to two rows preserves the surrounding screen geometry;
    # an earlier attempt to grow into many rows broke it (#432).
    self._grid = widgets.GridBox(
        [cell.widget for cell in self._cells],
        layout=widgets.Layout(
            grid_template_columns=f"repeat({_COLUMNS_DEFAULT}, 1fr)",
            grid_template_rows=f"repeat({_ROWS}, auto)",
            grid_auto_rows="0px",
            grid_gap="8px",
            padding="8px 0",
            width="100%",
            overflow="hidden",
        ),
    )

    # Pagination
    self._first_btn = widgets.Button(
        description="\u21e4 First",
        layout=widgets.Layout(width="100px", height="28px"),
        style={"button_color": ESA_BLUE_BRIGHT},
    )
    self._first_btn.on_click(lambda _: self._jump_to_page(0))

    self._prev_btn = widgets.Button(
        description="\u2190 Previous",
        layout=widgets.Layout(width="100px", height="28px"),
        style={"button_color": ESA_BLUE_BRIGHT},
    )
    self._prev_btn.on_click(lambda _: self._change_page(-1))

    self._page_label = widgets.HTML(
        value=self._page_text(0, 0, 0),
    )

    self._next_btn = widgets.Button(
        description="Next \u2192",
        layout=widgets.Layout(width="100px", height="28px"),
        style={"button_color": ESA_BLUE_BRIGHT},
    )
    self._next_btn.on_click(lambda _: self._change_page(1))

    pagination = widgets.HBox(
        [self._first_btn, self._prev_btn, self._page_label, self._next_btn],
        layout=widgets.Layout(
            justify_content="center",
            align_items="center",
            gap="8px",
        ),
    )

    # Assemble
    self.widget = widgets.VBox(
        [controls_bar, self._grid, pagination],
        layout=widgets.Layout(background_color=BG_COLOR),
    )

effective_page_size property

Cells shown per page at the current column count.

Equal to _ROWS * columns — the grid shows exactly this many cells, so pagination consumers (gallery_screen_base, training_setup_screen) slice their data with this rather than the static :data:PAGE_SIZE and every cell stays reachable by paging.

set_pagination(page, total_pages, total_count)

Update only the pagination label and button states.

Cheap enough to call on the UI thread — touches no cells and issues no DB query — so a background poll refresh can keep the page counter current without disturbing (or re-spinning) the visible thumbnails.

Parameters:

Name Type Description Default
page int

Current zero-based page index.

required
total_pages int

Total number of pages.

required
total_count int

Total number of results.

required
Source code in anomaly_match_ui/widgets/gallery_widget.py
def set_pagination(self, page: int, total_pages: int, total_count: int) -> None:
    """Update only the pagination label and button states.

    Cheap enough to call on the UI thread — touches no cells and issues
    no DB query — so a background poll refresh can keep the page counter
    current without disturbing (or re-spinning) the visible thumbnails.

    Args:
        page: Current zero-based page index.
        total_pages: Total number of pages.
        total_count: Total number of results.
    """
    self._current_page = page
    self._total_pages = total_pages
    self._page_label.value = self._page_text(page, total_pages, total_count)
    self._first_btn.disabled = page <= 0
    self._prev_btn.disabled = page <= 0
    self._next_btn.disabled = page >= total_pages - 1

begin_page(page, total_pages, total_count, n_loading)

Flip to page instantly, showing spinners while images load.

Called on the UI thread the moment the user navigates so the page turns without waiting on the DB query or image decode: the first n_loading cells show a loading spinner and the rest are cleared. Filenames, scores, and images are filled in afterwards from a background thread. This is what makes Next/Previous non-blocking.

Parameters:

Name Type Description Default
page int

Current zero-based page index.

required
total_pages int

Total number of pages.

required
total_count int

Total number of results.

required
n_loading int

Number of leading cells to put in the loading state.

required
Source code in anomaly_match_ui/widgets/gallery_widget.py
def begin_page(self, page: int, total_pages: int, total_count: int, n_loading: int) -> None:
    """Flip to *page* instantly, showing spinners while images load.

    Called on the UI thread the moment the user navigates so the page
    turns without waiting on the DB query or image decode: the first
    *n_loading* cells show a loading spinner and the rest are cleared.
    Filenames, scores, and images are filled in afterwards from a
    background thread. This is what makes ``Next``/``Previous``
    non-blocking.

    Args:
        page: Current zero-based page index.
        total_pages: Total number of pages.
        total_count: Total number of results.
        n_loading: Number of leading cells to put in the loading state.
    """
    self.set_pagination(page, total_pages, total_count)
    for i, cell in enumerate(self._cells):
        if i < n_loading:
            cell.mark_loading()
            cell.widget.layout.display = ""
        else:
            cell.clear()
            if self._setup_mode:
                cell.widget.layout.display = "none"

update_page(results, page, total_pages, total_count, image_loader=None)

Fill the grid cells from a page of DB results.

Parameters:

Name Type Description Default
results list[dict]

List of dicts with keys filename, score, and an optional display caption to show instead of filename (defaults to filename).

required
page int

Current zero-based page index.

required
total_pages int

Total number of pages.

required
total_count int

Total number of results.

required
image_loader Callable[[str], bytes | None] | None

Callable that returns PNG bytes for a filename, or None if unavailable.

None
Source code in anomaly_match_ui/widgets/gallery_widget.py
def update_page(
    self,
    results: list[dict],
    page: int,
    total_pages: int,
    total_count: int,
    image_loader: Callable[[str], bytes | None] | None = None,
) -> None:
    """Fill the grid cells from a page of DB results.

    Args:
        results: List of dicts with keys ``filename``, ``score``, and
            an optional ``display`` caption to show instead of
            ``filename`` (defaults to ``filename``).
        page: Current zero-based page index.
        total_pages: Total number of pages.
        total_count: Total number of results.
        image_loader: Callable that returns PNG bytes for a filename,
            or None if unavailable.
    """
    self._current_page = page
    self._total_pages = total_pages

    for i, cell in enumerate(self._cells):
        if i < len(results):
            r = results[i]
            img_bytes = b""
            if image_loader:
                loaded = image_loader(r["filename"])
                if loaded:
                    img_bytes = loaded
            cell.update(r["filename"], r["score"], img_bytes, display_name=r.get("display"))
            cell.widget.layout.display = ""
        else:
            cell.clear()
            # In setup_mode the gallery shows a finite candidate
            # list; rendering "Waiting for results…" placeholders
            # in the leftover slots is just noise (#427 follow-up).
            # Score-mode galleries leave them visible — a short
            # last page next to a populated previous one reads
            # better as placeholders than as empty space.
            if self._setup_mode:
                cell.widget.layout.display = "none"

    self._page_label.value = self._page_text(page, total_pages, total_count)
    self._first_btn.disabled = page <= 0
    self._prev_btn.disabled = page <= 0
    self._next_btn.disabled = page >= total_pages - 1

get_sort_mode()

Return the current sort key.

Returns:

Type Description
str

Sort key string (e.g. "score_desc").

Source code in anomaly_match_ui/widgets/gallery_widget.py
def get_sort_mode(self) -> str:
    """Return the current sort key.

    Returns:
        Sort key string (e.g. ``"score_desc"``).
    """
    return self._sort_mode

set_on_sort_change(callback)

Register a callback for sort mode changes.

Parameters:

Name Type Description Default
callback Callable[[], None]

Called when the user clicks a different sort button.

required
Source code in anomaly_match_ui/widgets/gallery_widget.py
def set_on_sort_change(self, callback: Callable[[], None]) -> None:
    """Register a callback for sort mode changes.

    Args:
        callback: Called when the user clicks a different sort button.
    """
    self._on_sort_change = callback

set_on_page_change(callback)

Register a callback for page changes.

Parameters:

Name Type Description Default
callback Callable[[int], None]

Called with the new page index.

required
Source code in anomaly_match_ui/widgets/gallery_widget.py
def set_on_page_change(self, callback: Callable[[int], None]) -> None:
    """Register a callback for page changes.

    Args:
        callback: Called with the new page index.
    """
    self._on_page_change = callback

set_on_scale_change(callback)

Register a callback for slider changes.

The consumer should re-render the current page using the new :attr:effective_page_size so paging tracks what's visible.

Parameters:

Name Type Description Default
callback Callable[[], None]

Zero-arg callable invoked after the slider's CSS update has been applied.

required
Source code in anomaly_match_ui/widgets/gallery_widget.py
def set_on_scale_change(self, callback: Callable[[], None]) -> None:
    """Register a callback for slider changes.

    The consumer should re-render the current page using the new
    :attr:`effective_page_size` so paging tracks what's visible.

    Args:
        callback: Zero-arg callable invoked after the slider's CSS
            update has been applied.
    """
    self._on_scale_change_callback = callback

page_size_for_columns(n_columns)

Return the page size for a grid n_columns wide.

Parameters:

Name Type Description Default
n_columns int

Number of thumbnail columns the user selected.

required

Returns:

Type Description
int

Cells per page — exactly _ROWS * n_columns — so the two

int

visible rows are always full and pagination never skips a cell.

Source code in anomaly_match_ui/widgets/gallery_widget.py
def page_size_for_columns(n_columns: int) -> int:
    """Return the page size for a grid *n_columns* wide.

    Args:
        n_columns: Number of thumbnail columns the user selected.

    Returns:
        Cells per page — exactly ``_ROWS * n_columns`` — so the two
        visible rows are always full and pagination never skips a cell.
    """
    return _ROWS * n_columns