Manage Layouts and Playback

Recipes for desktop management, animation requests and non-blocking execution. Functions below are reusable building blocks; defining them does not change the desktop.

Save and Restore a Layout

Save before changing a layout. Restore only after reviewing the saved positions against the current monitor arrangement. IDs are opaque, not portable identities; renamed/deleted icons can disappear between saving and restoring.

from pathlib import Path
import json
import rusty_desktop_icons as rdi

def save_layout(controller: rdi.DesktopController, path: Path) -> None:
    layout = {icon.id: list(icon.position) for icon in controller.list_icons()}
    path.write_text(json.dumps(layout, indent=2), encoding="utf-8")

def restore_layout(controller: rdi.DesktopController, path: Path) -> list[str]:
    saved = json.loads(path.read_text(encoding="utf-8"))
    current = {icon.id for icon in controller.list_icons()}
    positions = [(identity, (int(point[0]), int(point[1])))
                 for identity, point in saved.items() if identity in current]
    absent = [identity for identity in saved if identity not in current]
    return absent + controller.set_positions(positions)

This format is for your own trusted snapshots, not arbitrary uploaded JSON. save_layout(controller, Path("layout.json")) only reads the desktop; restore_layout(...) writes it. Inspect its returned missing IDs. Explorer’s Auto arrange can prevent a requested layout from sticking.

Change Only the Relevant Flags

This context manager temporarily clears Auto arrange and Snap to grid, then restores only those bits. Use it around direct positioning, not around an active animation reservation; stop and wait before leaving the block.

from contextlib import contextmanager
from typing import Iterator
import rusty_desktop_icons as rdi

@contextmanager
def free_positioning(controller: rdi.DesktopController) -> Iterator[None]:
    mask = rdi.FolderFlag.FWF_AUTOARRANGE | rdi.FolderFlag.FWF_SNAPTOGRID
    saved = controller.get_flags() & int(mask)
    try:
        controller.unset_flags(mask)
        yield
    finally:
        controller.apply_flags(mask, saved)

For animation-scoped changes, supply FolderFlagOp values in AnimationOptions.before_flags and after_flags instead. See the flag-operation contract.

Start a Planned Batch

Pass reviewed icon IDs and targets; this function starts a live desktop animation and returns immediately after startup/preparation. Grid snapping can adjust destinations, so inspect the prepared context when exact planning matters.

from typing import Mapping
import rusty_desktop_icons as rdi

def start_layout(
    controller: rdi.DesktopController,
    targets: Mapping[str, tuple[int, int]],
) -> rdi.AnimationHandle:
    if not targets:
        raise ValueError("Select at least one icon")
    specs = [rdi.IconAnimationSpec(
        identity, point, rdi.Duration.distance_clamped(500, 0.25, 1.5),
        rdi.Curve.ease_in_out(),
    ) for identity, point in targets.items()]
    return controller.animate(specs, rdi.AnimationOptions(snap_to_grid=True))

In a GUI, poll handle.progress() and handle.is_running() from its timer. After completion, inspect handle.finish_reason() and handle.final_commit(). To cancel, call handle.stop() followed by handle.wait() before restoring positions or starting another write. The execution contract explains stop policies and why a timeout is not cancellation.

Wait Without Blocking an Async Event Loop

Preparation can block too, so move both startup and waiting to executor threads. The controller still performs native work on its own STA worker. Cancellation waits for in-flight startup before requesting cleanup, avoiding an orphaned animation. Call this helper from your existing asyncio application; it starts real playback with the supplied specs.

import asyncio
import rusty_desktop_icons as rdi

async def play(
    controller: rdi.DesktopController, specs: list[rdi.IconAnimationSpec],
) -> rdi.FinishReason:
    startup = asyncio.create_task(asyncio.to_thread(controller.animate, specs))
    handle = None
    try:
        handle = await asyncio.shield(startup)
        return await asyncio.to_thread(handle.wait)
    finally:
        if handle is None:
            handle = await startup
        handle.stop()
        await asyncio.to_thread(handle.wait)

Keep the cleanup task alive during application shutdown. For reversible UI scrubbing, use the timeline recipe. For failures and diagnostic routing, see errors and logging.