Source code for annie.pages.pending
"""The pending slot: a box that shows a spinner until its content arrives (UI helper).
Every pane whose content Annie is still producing in the background — a decoding frame,
a rendering clip, a video being opened — shows a spinner in its box, so the page never
looks idle while work is under way. This module owns that look and its life cycle:
- :func:`pending` puts a centred spinner in a box. With ``keep=True`` the current
content stays visible, dimmed under the spinner (e.g. frames being redrawn for another
track).
- :func:`ready` swaps in the finished content; :func:`failed` shows an error icon whose
tooltip carries the reason.
- :func:`pending_badge` / :func:`settle_badge` do the same for a small tag such as
``#frames``.
Each call is a no-op once the box (or its browser tab) is gone, so a background task that
finishes after a refresh or reload never has to guard the UI itself.
"""
from __future__ import annotations
import contextlib
from typing import TYPE_CHECKING
from nicegui import run, ui
from annie.core import theme
from annie.pages.utils import _alive
if TYPE_CHECKING:
from collections.abc import Callable
MEDIA_BOX = (
"position:relative;border-radius:8px;background:#e5e7eb;display:flex;"
"align-items:center;justify-content:center;overflow:hidden"
)
"""Style of the grey, centred media placeholder box (frames, ORIGINAL, render slots)."""
CSS = (
".annie-pending{position:absolute;inset:0;display:flex;align-items:center;"
"justify-content:center;border-radius:inherit;z-index:2;pointer-events:none}"
".annie-pending.annie-dim{background:rgba(229,231,235,0.65)}"
)
"""Global CSS for the spinner overlay; registered once by the app at start-up."""
_MARK = "data-annie-pending"
"""Prop that marks an element as a pending overlay, so it can be found and removed."""
def _overlays(box: ui.element) -> list[ui.element]:
"""The pending overlays currently inside ``box``."""
return [child for child in box.default_slot.children if child.props.get(_MARK)]
[docs]
def is_pending(box: ui.element) -> bool:
"""Whether ``box`` currently shows a pending spinner (``False`` once it is gone)."""
return _alive(box) and bool(_overlays(box))
[docs]
def pending(box: ui.element, *, keep: bool = False, size: str = "md") -> None:
"""Show a centred spinner in ``box`` until :func:`ready` or :func:`failed`.
Calling it again on a pending box changes nothing (no second spinner).
Args:
box: The box whose content is being produced.
keep: Keep the current content visible, dimmed under the spinner, instead of
emptying the box first.
size: Quasar spinner size (``"xs"`` … ``"xl"`` or a CSS length).
"""
if not _alive(box):
return
with contextlib.suppress(RuntimeError): # the box was refreshed away meanwhile
if _overlays(box):
return
if not keep:
box.clear()
with box:
overlay = ui.element("div").props(f"{_MARK}=1").classes("annie-pending")
if keep:
overlay.classes("annie-dim")
with overlay:
ui.spinner(size=size, color=theme.PRIMARY)
[docs]
def ready(box: ui.element, build: Callable[[], object]) -> None:
"""Replace the spinner (and any old content) with the finished content.
Args:
box: The pending box.
build: Builds the new content; called inside ``box``.
"""
if not _alive(box):
return
with contextlib.suppress(RuntimeError):
box.clear()
with box:
build()
[docs]
def failed(box: ui.element, reason: str) -> None:
"""Replace the spinner with an error icon whose tooltip explains why.
Args:
box: The pending box.
reason: A short, user-readable cause (shown on hover).
"""
ready(box, lambda: ui.icon("broken_image", color=theme.DANGER).tooltip(reason))
[docs]
def pending_badge(text: str) -> ui.element:
"""Create a badge slot showing ``text`` with a small spinner, for a value not known yet.
Args:
text: The badge text while pending, e.g. ``"#frames"``.
Returns:
The slot to pass to :func:`settle_badge` once the value is known.
"""
slot = ui.element("span")
with slot, ui.badge(text, color=theme.NEUTRAL).classes("items-center gap-1"):
ui.spinner(size="0.9em", color="white")
return slot
[docs]
def settle_badge(slot: ui.element, text: str, color: str = theme.NEUTRAL) -> None:
"""Replace a pending badge with its final ``text`` (no spinner).
Args:
slot: The slot returned by :func:`pending_badge`.
text: The final text, e.g. ``"#frames: 120"`` or ``"audio: ?"``.
color: The badge colour.
"""
ready(slot, lambda: ui.badge(text, color=color))
[docs]
async def while_busy[T](message: str, work: Callable[[], T]) -> T | None:
"""Run blocking ``work`` off the event loop behind a modal spinner showing ``message``.
The dialog is persistent (it cannot be dismissed), so the user cannot act in this tab
while the work runs, e.g. while a database is being copied.
Args:
message: What is happening, e.g. ``"Reading visits.xlsx…"``.
work: The blocking call, run on a worker thread.
Returns:
What ``work`` returned; ``None`` if the app is shutting down.
"""
with ui.dialog().props("persistent") as dialog, ui.card().classes("items-center gap-3"):
ui.spinner(size="lg", color=theme.PRIMARY)
ui.label(message).classes("text-sm")
dialog.open()
try:
return await run.io_bound(work)
finally:
dialog.close()
dialog.delete()