Skip to content

Dock

The dock tree and its dockview layout, the persisted UI state it restores from, and the refresh loop each panel runs.

femforge_workbench.dock

The dock tree: panels as tabs in splits and tab groups, its dockview layout JSON, and the rule for restoring a saved arrangement.

Type Aliases:

  • Builder –

    Makes a panel's component from the shell, the panel id and the title.

  • Layout –

    A dockview layout document as JSON.

  • Node –

    A node of the dock tree: a panel, a tab group or a split.

Classes:

  • Orientation –

    The axis a dockview branch lays its children along.

  • PlacementDirection –

    Where add_panel puts a panel relative to its reference.

  • Key –

    The keys of a dockview layout JSON.

  • NodeType –

    The two node kinds of a dockview grid.

  • Panel –

    One tab: its title, the builder that makes its component once the shell exists, and its share of the axis.

  • Tabs –

    Panels sharing one tab group, the first shown.

  • Split –

    Panels, tab groups or splits sharing one axis by weight; the axis alternates with nesting, horizontal at the root.

  • Placement –

    Where add_panel puts one panel: beside, below or within the group of a reference panel.

Functions:

  • template_name –

    The trame template a panel renders through.

  • panels –

    Every panel under a node, in authored order.

  • panel_ids –

    The ids of every panel under a node, in authored order.

  • placements –

    The add_panel sequence that rebuilds the tree group by group: each sibling beside or below the previous one, tabs within the first.

  • layout_json –

    The dockview serialization of the authored tree: splits alternate orientation from a horizontal root, weights become sizes, each leaf one tab group.

  • restorable –

    The saved layout when it names exactly the authored panels, with popout and floating groups folded into the first grid group; else None.

Attributes:

  • AXIS –

    The nominal extent of each axis the weights are shared over; dockview rescales proportionally.

  • CONTENT_COMPONENT –

    The Dock component that hosts a server template inside a panel, as Dock.tsx names it.

AXIS module-attribute

AXIS = 1000

The nominal extent of each axis the weights are shared over; dockview rescales proportionally.

CONTENT_COMPONENT module-attribute

CONTENT_COMPONENT = 'DockPanel'

The Dock component that hosts a server template inside a panel, as Dock.tsx names it.

Builder

Makes a panel's component from the shell, the panel id and the title.

Layout

Layout = dict[str, object]

A dockview layout document as JSON.

Node

Node = Panel | Tabs | Split

A node of the dock tree: a panel, a tab group or a split.

Orientation

Bases: StrEnum

The axis a dockview branch lays its children along.

Methods:

  • flipped –

    The other orientation.

flipped

flipped() -> Orientation

The other orientation.

Source code in packages/femforge-workbench/src/femforge_workbench/dock.py
def flipped(self) -> "Orientation":
    """The other orientation."""
    return Orientation.VERTICAL if self is Orientation.HORIZONTAL else Orientation.HORIZONTAL

PlacementDirection

Bases: StrEnum

Where add_panel puts a panel relative to its reference.

Key

Bases: StrEnum

The keys of a dockview layout JSON.

NodeType

Bases: StrEnum

The two node kinds of a dockview grid.

Panel dataclass

Panel(name: str, build: Builder, weight: float = 1, id: str | None = None)

One tab: its title, the builder that makes its component once the shell exists, and its share of the axis.

Attributes:

  • name (str) –

    The tab title, from which the panel id is derived when id is None.

  • build (Builder) –

    Makes the panel's component from the shell, the panel id and the title, once the shell exists.

  • weight (float) –

    [-]: The panel's share of its parent's axis, relative to its siblings' weights.

  • id (str | None) –

    The panel id; None derives it from name.

  • panel_id (str) –

    The id the dock knows the panel by: id when given, else name lowercased with dashes.

name instance-attribute

name: str

The tab title, from which the panel id is derived when id is None.

build instance-attribute

build: Builder

Makes the panel's component from the shell, the panel id and the title, once the shell exists.

weight class-attribute instance-attribute

weight: float = 1

[-]: The panel's share of its parent's axis, relative to its siblings' weights.

id class-attribute instance-attribute

id: str | None = None

The panel id; None derives it from name.

panel_id property

panel_id: str

The id the dock knows the panel by: id when given, else name lowercased with dashes.

Tabs dataclass

Tabs(panels: Sequence[Panel], weight: float = 1)

Panels sharing one tab group, the first shown.

Attributes:

  • panels (Sequence[Panel]) –

    The panels sharing the tab group, the first one shown.

  • weight (float) –

    [-]: The group's share of its parent's axis, relative to its siblings' weights.

panels instance-attribute

panels: Sequence[Panel]

The panels sharing the tab group, the first one shown.

weight class-attribute instance-attribute

weight: float = 1

[-]: The group's share of its parent's axis, relative to its siblings' weights.

Split dataclass

Split(children: Sequence[Node], weight: float = 1)

Panels, tab groups or splits sharing one axis by weight; the axis alternates with nesting, horizontal at the root.

Attributes:

  • children (Sequence[Node]) –

    The panels, tab groups and splits along the axis, in order.

  • weight (float) –

    [-]: The split's share of its parent's axis, relative to its siblings' weights; ignored at the root.

children instance-attribute

children: Sequence[Node]

The panels, tab groups and splits along the axis, in order.

weight class-attribute instance-attribute

weight: float = 1

[-]: The split's share of its parent's axis, relative to its siblings' weights; ignored at the root.

Placement dataclass

Placement(panel: Panel, reference: str | None, direction: PlacementDirection)

Where add_panel puts one panel: beside, below or within the group of a reference panel.

Methods:

  • add_on –

    The position add-on for add_panel, empty for the first panel.

add_on

add_on() -> Layout

The position add-on for add_panel, empty for the first panel.

Source code in packages/femforge-workbench/src/femforge_workbench/dock.py
def add_on(self) -> Layout:
    """The position add-on for add_panel, empty for the first panel."""
    if self.reference is None:
        return {}

    return {Key.POSITION: {Key.REFERENCE_PANEL: self.reference, Key.DIRECTION: str(self.direction)}}

template_name

template_name(panel_id: str) -> str

The trame template a panel renders through.

Source code in packages/femforge-workbench/src/femforge_workbench/dock.py
def template_name(panel_id: str) -> str:
    """The trame template a panel renders through."""
    return f"{TEMPLATE_PREFIX}{panel_id}"

panels

panels(node: Node) -> list[Panel]

Every panel under a node, in authored order.

Source code in packages/femforge-workbench/src/femforge_workbench/dock.py
def panels(node: Node) -> list[Panel]:
    """Every panel under a node, in authored order."""
    if isinstance(node, Panel):
        return [node]

    children = node.panels if isinstance(node, Tabs) else node.children

    return [panel for child in children for panel in panels(child)]

panel_ids

panel_ids(node: Node) -> list[str]

The ids of every panel under a node, in authored order.

Source code in packages/femforge-workbench/src/femforge_workbench/dock.py
def panel_ids(node: Node) -> list[str]:
    """The ids of every panel under a node, in authored order."""
    return [panel.panel_id for panel in panels(node)]

placements

placements(root: Split) -> list[Placement]

The add_panel sequence that rebuilds the tree group by group: each sibling beside or below the previous one, tabs within the first.

Source code in packages/femforge-workbench/src/femforge_workbench/dock.py
def placements(root: Split) -> list[Placement]:
    """The add_panel sequence that rebuilds the tree group by group: each sibling beside or below the previous one, tabs within the first."""
    out: list[Placement] = []

    def visit(node: Node, orientation: Orientation, reference: str | None, direction: PlacementDirection) -> str:
        if isinstance(node, Panel):
            out.append(Placement(node, reference, direction))

            return node.panel_id

        if isinstance(node, Tabs):
            first = visit(node.panels[0], orientation, reference, direction)
            for panel in node.panels[1:]:
                out.append(Placement(panel, first, PlacementDirection.WITHIN))

            return first

        beside = PlacementDirection.RIGHT if orientation is Orientation.HORIZONTAL else PlacementDirection.BELOW
        first = visit(node.children[0], orientation.flipped(), reference, direction)
        previous = first
        for child in node.children[1:]:
            previous = visit(child, orientation.flipped(), previous, beside)

        return first

    _ = visit(root, Orientation.HORIZONTAL, None, PlacementDirection.WITHIN)

    return out

layout_json

layout_json(root: Split) -> Layout

The dockview serialization of the authored tree: splits alternate orientation from a horizontal root, weights become sizes, each leaf one tab group.

Source code in packages/femforge-workbench/src/femforge_workbench/dock.py
def layout_json(root: Split) -> Layout:
    """The dockview serialization of the authored tree: splits alternate orientation from a horizontal root, weights become sizes, each leaf one tab group."""
    groups = 0

    def leaf(members: Sequence[Panel], size: int) -> Layout:
        nonlocal groups
        groups += 1
        ids = [panel.panel_id for panel in members]
        data: Layout = {Key.ID: f"group-{groups}", Key.VIEWS: ids, Key.ACTIVE_VIEW: ids[0]}

        return {Key.TYPE: str(NodeType.LEAF), Key.SIZE: size, Key.DATA: data}

    def branch(split: Split, size: int) -> Layout:
        total = sum(child.weight for child in split.children)
        data = [node(child, round(AXIS * child.weight / total)) for child in split.children]

        return {Key.TYPE: str(NodeType.BRANCH), Key.SIZE: size, Key.DATA: data}

    def node(child: Node, size: int) -> Layout:
        if isinstance(child, Panel):
            return leaf([child], size)

        if isinstance(child, Tabs):
            return leaf(child.panels, size)

        return branch(child, size)

    grid: Layout = {Key.ROOT: branch(root, AXIS), Key.WIDTH: AXIS, Key.HEIGHT: AXIS, Key.ORIENTATION: str(Orientation.HORIZONTAL)}
    entries: dict[str, Layout] = {
        panel.panel_id: {
            Key.ID: panel.panel_id,
            Key.TITLE: panel.name,
            Key.CONTENT_COMPONENT: CONTENT_COMPONENT,
            Key.PARAMS: {Key.TEMPLATE_NAME: template_name(panel.panel_id)},
        }
        for panel in panels(root)
    }

    return {Key.GRID: grid, Key.PANELS: entries, Key.ACTIVE_GROUP: "group-1"}

restorable

restorable(saved: Mapping[str, object] | None, ids: Sequence[str]) -> Layout | None

The saved layout when it names exactly the authored panels, with popout and floating groups folded into the first grid group; else None.

Source code in packages/femforge-workbench/src/femforge_workbench/dock.py
def restorable(saved: Mapping[str, object] | None, ids: Sequence[str]) -> Layout | None:
    """The saved layout when it names exactly the authored panels, with popout and floating groups folded into the first grid group; else None."""
    if saved is None:
        return None

    def mapping(value: object) -> Layout | None:
        if not isinstance(value, Mapping):
            return None

        return cast("Layout", value)

    def first_leaf(node: Layout) -> Layout | None:
        data = node.get(Key.DATA)
        if node.get(Key.TYPE) == NodeType.LEAF:
            return mapping(data)

        children = cast("list[object]", data) if isinstance(data, list) else []
        for child in children:
            branch = mapping(child)
            found = first_leaf(branch) if branch is not None else None
            if found is not None:
                return found

        return None

    layout = copy.deepcopy(dict(saved))
    entries = mapping(layout.get(Key.PANELS))
    if entries is None or sorted(entries) != sorted(ids):
        log.bind(saved=sorted(entries or {}), authored=sorted(ids)).warning("Saved layout ignored; panel ids differ.")
        return None

    grid = mapping(layout.get(Key.GRID))
    root = mapping(grid.get(Key.ROOT)) if grid is not None else None
    home = first_leaf(root) if root is not None else None
    if home is None:
        log.warning("Saved layout ignored; no group left in the grid.")
        return None

    views = cast("list[str]", home.setdefault(Key.VIEWS, []))
    for key in (Key.POPOUT_GROUPS, Key.FLOATING_GROUPS):
        detached = layout.pop(key, None)
        groups = cast("list[object]", detached) if isinstance(detached, list) else []
        for group in groups:
            entry = mapping(group)
            data = mapping(entry.get(Key.DATA)) if entry is not None else None
            folded = data.get(Key.VIEWS) if data is not None else None
            if isinstance(folded, list):
                views.extend(view for view in cast("list[str]", folded) if view not in views)

    home[Key.ACTIVE_VIEW] = home.get(Key.ACTIVE_VIEW) or views[0]
    layout[Key.ACTIVE_GROUP] = home.get(Key.ID)

    return layout

femforge_workbench.persist

UI state persistence: the UiStateStore protocol and a debounced per-user JSON implementation under the user config directory.

Type Aliases:

  • Data –

    A persisted document: JSON-ready values by key.

  • Scope –

    The (user_id, workbench_name) a saved document belongs to.

Classes:

  • StateKey –

    The documents the shell itself persists per workbench.

  • UiStateStore –

    Where a workbench keeps its UI state per user: layouts, dropdowns, brushes.

  • LocalJsonStore –

    One JSON file per (user, workbench, key), written at most once per debounce window; a load sees unflushed saves.

Functions:

  • default_user –

    The OS user, the local stand-in for an authenticated user id.

  • default_root –

    The per-user config directory the local store writes under.

Scope

Scope = tuple[str, str]

The (user_id, workbench_name) a saved document belongs to.

Data

Data = dict[str, object]

A persisted document: JSON-ready values by key.

StateKey

Bases: StrEnum

The documents the shell itself persists per workbench.

UiStateStore

Bases: Protocol

Where a workbench keeps its UI state per user: layouts, dropdowns, brushes.

Methods:

  • load –

    The document saved under key in scope, None when there is none.

  • save –

    Saves data as the document under key in scope.

  • flush –

    Writes every pending save now.

load

load(scope: Scope, key: str) -> Data | None

The document saved under key in scope, None when there is none.

Source code in packages/femforge-workbench/src/femforge_workbench/persist.py
def load(self, scope: Scope, key: str) -> Data | None:
    """The document saved under `key` in `scope`, None when there is none."""
    ...

save

save(scope: Scope, key: str, data: Mapping[str, object]) -> None

Saves data as the document under key in scope.

Parameters:

  • scope (Scope) –

    The user and workbench the document belongs to.

  • key (str) –

    The document's name within the scope.

  • data (Mapping[str, object]) –

    The JSON-ready document.

Source code in packages/femforge-workbench/src/femforge_workbench/persist.py
def save(self, scope: Scope, key: str, data: Mapping[str, object]) -> None:
    """Saves `data` as the document under `key` in `scope`.

    Args:
        scope: The user and workbench the document belongs to.
        key: The document's name within the scope.
        data: The JSON-ready document.
    """
    ...

flush

flush() -> None

Writes every pending save now.

Source code in packages/femforge-workbench/src/femforge_workbench/persist.py
def flush(self) -> None:
    """Writes every pending save now."""
    ...

LocalJsonStore

LocalJsonStore(root: Path | None = None, *, debounce: float = DEBOUNCE_SECONDS)

One JSON file per (user, workbench, key), written at most once per debounce window; a load sees unflushed saves.

Methods:

  • path –

    The JSON file of one document.

  • load –

    The document: a pending save first, else the file's, None when neither exists.

  • save –

    Queues the document and restarts the debounce timer that writes it.

  • flush –

    Writes every pending document now.

Source code in packages/femforge-workbench/src/femforge_workbench/persist.py
def __init__(self, root: Path | None = None, *, debounce: float = DEBOUNCE_SECONDS) -> None:
    """Stores under `root`, the per-user config directory by default, writing at most once per `debounce` seconds."""
    self.root = root or default_root()
    self.debounce = debounce
    self.pending: dict[tuple[Scope, str], Data] = {}
    self.timer: threading.Timer | None = None
    self.lock = threading.Lock()

path

path(scope: Scope, key: str) -> Path

The JSON file of one document.

Source code in packages/femforge-workbench/src/femforge_workbench/persist.py
def path(self, scope: Scope, key: str) -> Path:
    """The JSON file of one document."""
    user, workbench = scope

    return self.root / user / workbench / f"{key}.json"

load

load(scope: Scope, key: str) -> Data | None

The document: a pending save first, else the file's, None when neither exists.

Source code in packages/femforge-workbench/src/femforge_workbench/persist.py
def load(self, scope: Scope, key: str) -> Data | None:
    """The document: a pending save first, else the file's, None when neither exists."""
    with self.lock:
        pending = self.pending.get((scope, key))

    if pending is not None:
        return dict(pending)

    path = self.path(scope, key)
    if not path.exists():
        return None

    loaded = cast("object", json.loads(path.read_text(encoding="utf-8")))
    if not isinstance(loaded, dict):
        return None

    return cast("Data", loaded)

save

save(scope: Scope, key: str, data: Mapping[str, object]) -> None

Queues the document and restarts the debounce timer that writes it.

Parameters:

  • scope (Scope) –

    The user and workbench the document belongs to.

  • key (str) –

    The document's name within the scope.

  • data (Mapping[str, object]) –

    The JSON-ready document.

Source code in packages/femforge-workbench/src/femforge_workbench/persist.py
def save(self, scope: Scope, key: str, data: Mapping[str, object]) -> None:
    """Queues the document and restarts the debounce timer that writes it.

    Args:
        scope: The user and workbench the document belongs to.
        key: The document's name within the scope.
        data: The JSON-ready document.
    """
    with self.lock:
        self.pending[(scope, key)] = dict(data)
        if self.timer is not None:
            self.timer.cancel()

        self.timer = threading.Timer(self.debounce, self.flush)
        self.timer.daemon = True
        self.timer.start()

flush

flush() -> None

Writes every pending document now.

Source code in packages/femforge-workbench/src/femforge_workbench/persist.py
def flush(self) -> None:
    """Writes every pending document now."""
    with self.lock:
        if self.timer is not None:
            self.timer.cancel()
            self.timer = None

        pending = self.pending
        self.pending = {}

    for (scope, key), data in pending.items():
        path = self.path(scope, key)
        path.parent.mkdir(parents=True, exist_ok=True)
        write_atomic(path, json.dumps(data, indent=1))

default_user

default_user() -> str

The OS user, the local stand-in for an authenticated user id.

Source code in packages/femforge-workbench/src/femforge_workbench/persist.py
def default_user() -> str:
    """The OS user, the local stand-in for an authenticated user id."""
    return getpass.getuser()

default_root

default_root() -> Path

The per-user config directory the local store writes under.

Source code in packages/femforge-workbench/src/femforge_workbench/persist.py
def default_root() -> Path:
    """The per-user config directory the local store writes under."""
    return Path(platformdirs.user_config_dir(APP_NAME, appauthor=False))

femforge_workbench.feeds

A panel's refresh loop: one task per panel, polling off the server loop and publishing on it.

Classes:

  • Feed –

    Runs poll in a worker thread every period seconds and hands what it returns to publish inside a state context.

  • QueueFeed –

    Drains an asyncio queue another party feeds on the server loop, handing each burst of items to publish inside a state context.

Feed

Feed(panel: PanelComponent, period: float, poll: Callable[[], T], publish: Callable[[T], None])

Runs poll in a worker thread every period seconds and hands what it returns to publish inside a state context.

Parameters:

  • panel (PanelComponent) –

    The panel whose state the publish runs in.

  • period (float) –

    Seconds between polls.

  • poll (Callable[[], T]) –

    Reads what changed, off the server loop.

  • publish (Callable[[T], None]) –

    Writes a poll's result into the panel, on the server loop.

Methods:

  • start –

    Starts the loop unless it already runs; the first poll happens immediately.

  • stop –

    Cancels the loop, so a hidden panel costs nothing.

  • cycle –

    Poll, publish, sleep — until the task is cancelled; a failed poll is logged and the loop carries on.

Attributes:

  • polls –

    How many polls have completed; a panel shows it as its refresh count and the tests wait on it.

  • running (bool) –

    Whether the loop runs.

Source code in packages/femforge-workbench/src/femforge_workbench/feeds.py
def __init__(self, panel: "PanelComponent", period: float, poll: Callable[[], T], publish: Callable[[T], None]) -> None:
    """Sets the loop up stopped; `start` runs it.

    Args:
        panel: The panel whose state the publish runs in.
        period: Seconds between polls.
        poll: Reads what changed, off the server loop.
        publish: Writes a poll's result into the panel, on the server loop.
    """
    self.panel = panel
    self.period = period
    self.poll = poll
    self.publish = publish
    self.task: asyncio.Task[None] | None = None
    self.polls = 0
    """How many polls have completed; a panel shows it as its refresh count and the tests wait on it."""

polls instance-attribute

polls = 0

How many polls have completed; a panel shows it as its refresh count and the tests wait on it.

running property

running: bool

Whether the loop runs.

start

start() -> None

Starts the loop unless it already runs; the first poll happens immediately.

Source code in packages/femforge-workbench/src/femforge_workbench/feeds.py
def start(self) -> None:
    """Starts the loop unless it already runs; the first poll happens immediately."""
    if self.running:
        return

    self.task = create_task(self.cycle())

    log.bind(panel=self.panel.id, period=self.period).info("Feed started.")

stop

stop() -> None

Cancels the loop, so a hidden panel costs nothing.

Source code in packages/femforge-workbench/src/femforge_workbench/feeds.py
def stop(self) -> None:
    """Cancels the loop, so a hidden panel costs nothing."""
    if self.task is None:
        return

    _ = self.task.cancel()
    self.task = None

    log.bind(panel=self.panel.id, polls=self.polls).info("Feed stopped.")

cycle async

cycle() -> None

Poll, publish, sleep — until the task is cancelled; a failed poll is logged and the loop carries on.

Source code in packages/femforge-workbench/src/femforge_workbench/feeds.py
async def cycle(self) -> None:
    """Poll, publish, sleep — until the task is cancelled; a failed poll is logged and the loop carries on."""
    loop = asyncio.get_running_loop()
    while True:
        try:
            value: T = await loop.run_in_executor(None, self.poll)
        except Exception:
            log.opt(exception=True).warning("Feed poll failed.")
            await asyncio.sleep(self.period)
            continue

        self.polls += 1
        with self.panel.state:
            self.publish(value)

        await asyncio.sleep(self.period)

QueueFeed

QueueFeed(panel: PanelComponent, subscribe: Callable[[AbstractEventLoop], Queue[T]], unsubscribe: Callable[[Queue[T]], None], publish: Callable[[list[T]], None])

Drains an asyncio queue another party feeds on the server loop, handing each burst of items to publish inside a state context.

Parameters:

  • panel (PanelComponent) –

    The panel whose state the publish runs in.

  • subscribe (Callable[[AbstractEventLoop], Queue[T]]) –

    Hands out a queue fed on the given loop.

  • unsubscribe (Callable[[Queue[T]], None]) –

    Stops feeding a queue subscribe handed out.

  • publish (Callable[[list[T]], None]) –

    Writes a burst of items into the panel, on the server loop.

Methods:

  • start –

    Subscribes and starts draining unless it already runs.

  • stop –

    Cancels the drain and unsubscribes, so a hidden panel is no longer fed.

  • cycle –

    Wait for an item, take every other one already queued, publish them together — until the task is cancelled.

Attributes:

  • bursts –

    How many bursts have been published; the tests wait on it.

  • running (bool) –

    Whether the drain runs.

Source code in packages/femforge-workbench/src/femforge_workbench/feeds.py
def __init__(
    self,
    panel: "PanelComponent",
    subscribe: Callable[[asyncio.AbstractEventLoop], asyncio.Queue[T]],
    unsubscribe: Callable[[asyncio.Queue[T]], None],
    publish: Callable[[list[T]], None],
) -> None:
    """Sets the drain up stopped; `start` subscribes and runs it.

    Args:
        panel: The panel whose state the publish runs in.
        subscribe: Hands out a queue fed on the given loop.
        unsubscribe: Stops feeding a queue `subscribe` handed out.
        publish: Writes a burst of items into the panel, on the server loop.
    """
    self.panel = panel
    self.subscribe = subscribe
    self.unsubscribe = unsubscribe
    self.publish = publish
    self.task: asyncio.Task[None] | None = None
    self.queue: asyncio.Queue[T] | None = None
    self.bursts = 0
    """How many bursts have been published; the tests wait on it."""

bursts instance-attribute

bursts = 0

How many bursts have been published; the tests wait on it.

running property

running: bool

Whether the drain runs.

start

start() -> None

Subscribes and starts draining unless it already runs.

Source code in packages/femforge-workbench/src/femforge_workbench/feeds.py
def start(self) -> None:
    """Subscribes and starts draining unless it already runs."""
    if self.running:
        return

    self.task = create_task(self.cycle())

    log.bind(panel=self.panel.id).info("Queue feed started.")

stop

stop() -> None

Cancels the drain and unsubscribes, so a hidden panel is no longer fed.

Source code in packages/femforge-workbench/src/femforge_workbench/feeds.py
def stop(self) -> None:
    """Cancels the drain and unsubscribes, so a hidden panel is no longer fed."""
    if self.task is None:
        return

    _ = self.task.cancel()
    self.task = None
    if self.queue is not None:
        self.unsubscribe(self.queue)
        self.queue = None

    log.bind(panel=self.panel.id, bursts=self.bursts).info("Queue feed stopped.")

cycle async

cycle() -> None

Wait for an item, take every other one already queued, publish them together — until the task is cancelled.

Source code in packages/femforge-workbench/src/femforge_workbench/feeds.py
async def cycle(self) -> None:
    """Wait for an item, take every other one already queued, publish them together — until the task is cancelled."""
    queue = self.subscribe(asyncio.get_running_loop())
    self.queue = queue
    while True:
        batch: list[T] = [await queue.get()]
        while not queue.empty():
            batch.append(queue.get_nowait())

        self.bursts += 1
        with self.panel.state:
            self.publish(batch)