← XeFM crftwr/xefm on GitHub · craftware

Task Framework Implementation

Overview

The task framework (xefm/task.py) runs long operations — file copy/move/delete, archive create/extract, directory-diff content comparison — on a background thread while the UI stays responsive, without letting worker code touch the UI directly.

A task is an ordinary run(task) function handed to TaskManager.submit(). The manager shows a modal ProgressDialog, spawns one worker thread, and services the task’s UI bridge on each animation tick. From the worker the job body can:

The worker never calls into the panel or any widget itself — every UI interaction is marshalled to the main thread. This replaced the pre-PuiKit-port BaseTask / FileOperationTask state-machine design; there is no per-operation state machine any more, just a linear worker plus a small status enum.

Architecture

flowchart TB
    App["XeFMApp<br/>TaskManager.submit(task, panel, run=fn, on_done=…)"]

    subgraph MAINT["Main thread"]
        direction TB
        Mgr["TaskManager<br/>registry · one modal task at a time<br/>active_tasks() · has_active()"]
        Dlg["ProgressDialog (Widget)<br/>show · pump · draw · close"]
        Tick["animation tick()<br/>pump UI bridge · repaint<br/>on finish → close + on_done"]
    end

    subgraph WORKT["Worker thread (daemon)"]
        direction TB
        Run["run(task) — the job body"]
        TaskO["Task<br/>ask() · checkpoint() · cancelled()<br/>progress · status"]
    end

    App --> Mgr
    Mgr -->|show + register| Dlg
    Mgr -->|spawn worker| Run
    Mgr --> Tick
    Run -->|drives| TaskO
    TaskO -.->|"ask(): enqueue _UiRequest, block until answer"| Tick
    Tick -.->|"pump → show modal → deliver(answer)"| TaskO
    TaskO -->|"request_cancel() sets Event → Cancelled"| Run

    Status["TaskStatus: PENDING → RUNNING → DONE / CANCELLED / FAILED"]
    Tick --> Status

    classDef app fill:#1a5490,stroke:#7fb3d5,color:#fff;
    classDef main fill:#1e7e34,stroke:#7fd39b,color:#fff;
    classDef work fill:#8b2e24,stroke:#e0897f,color:#fff;
    classDef state fill:#9a6308,stroke:#e0b45f,color:#fff;
    class App app;
    class Mgr,Dlg,Tick main;
    class Run,TaskO work;
    class Status state;

Task

A Task is the handle shared between the worker thread and the main thread.

Member Called on Purpose
progress both A ProgressManager the worker drives and the dialog reads
status main Current TaskStatus (see below)
counted both Items seen so far during the pre-total counting phase (display only)
result / error both The worker’s return value / the exception it raised, if any
ask(show_fn, *, headless) worker Show a modal via show_fn(panel, deliver) on the main thread and block until it delivers an answer; raises Cancelled if cancelled while waiting. Returns headless without prompting in synchronous mode.
checkpoint() worker Raise Cancelled if cancellation was requested. Call between units of work (per file / per chunk).
cancelled() worker Non-raising check of the cancel flag
request_cancel() main Set the cooperative cancel flag; a blocked ask() wakes within _WAIT_TICK (50 ms) and unwinds

Internally the cancel flag is a threading.Event and pending UI requests sit on a queue.Queue of _UiRequest. See Cancellation below for the full cancellation model.

TaskStatus

PENDING → RUNNING → DONE | CANCELLED | FAILED

PENDING (submitted, worker not started) → RUNNING (worker active) → one terminal state: DONE (finished normally), CANCELLED (cancelled before or during the run), or FAILED (the worker raised an unexpected exception).

TaskManager

One instance per app (XeFMApp.tasks). It is the registry of live tasks and the main-thread pump.

On completion TaskStatus is set from the outcome — FAILED if the worker raised, CANCELLED if it was cancelled or returned a cancelled result, otherwise DONE — and the task is removed from the registry.

Today the manager runs one modal task at a time; the shape (a registry plus a generic dialog) is deliberately left open for background / queued execution and a task-management UI later.

ProgressDialog

A generic modal progress surface (a PuiKit Widget) that renders purely from task.title and task.progress, so every task type reuses it. It shows three phases:

The box is a fixed 8 rows and the byte bar’s two rows are reserved whether or not the current file reports bytes — an operation alternates between files that do and files that don’t, and a box that resized under each one would jitter for its whole run. Those two rows are measured up from box_h rather than written as absolute offsets, the way ConflictDialog places its button row: a centered box can land on a half cell (an odd screen height, a GUI backend’s line metrics) and absolute offsets then round a row further down — which is how the byte label came to be drawn on the bottom border. test_progress_dialog_draws_the_byte_bar_inside_its_frame renders the real grid at several screen heights on both profiles to hold that.

The current item name is fitted with abbreviate_path (xefm/str_format.py) against ctx.measure_text, the same pairing the pane header uses. Measuring through the draw context is what makes the budget a drawn-width budget: a wide CJK glyph counts as the two columns it takes and a proportional GUI font by its real width, where a character count let a long name run out through the dialog’s own border. Names arrive as bare Path.names, so the fallback middle cut applies and the extension survives; a full path handed to it would instead lose whole components.

It is modal: handle_event swallows all input, so while a task runs the rest of the app is inert. Esc opens a confirm box; confirming calls task.request_cancel().

The UI bridge (ask / _UiRequest / pump)

The bridge is how a worker safely drives a modal:

  1. The worker calls task.ask(show_fn, headless=…), which enqueues a _UiRequest and blocks on its event.
  2. On the next main-thread tick, ProgressDialog.pump() pops one request and calls show_fn(panel, deliver), which pushes the modal.
  3. When the user answers, the widget calls deliver(answer), which unblocks the worker’s ask() with that value.

Only one request is serviced at a time — the worker blocks on the answer before it can post the next — so prompts (e.g. per-file copy conflicts) appear sequentially.

Cancellation

Cancellation is cooperative: the main thread sets a flag, and the worker unwinds itself at the next safe point, leaving a clean partial result. Nothing is force-killed.

The cooperative model

Each Task owns a threading.Event cancel flag (_cancel). Two calls on the worker thread observe it and raise Cancelled when it is set:

task.cancelled() is the non-raising form for spots that want to branch rather than unwind.

Requesting cancellation

sequenceDiagram
    autonumber
    participant U as User
    participant Dlg as ProgressDialog (main thread)
    participant T as Task (flag)
    participant W as worker thread

    U->>Dlg: press Esc
    Dlg->>U: confirm box — "Cancel <title>?" (default: Keep running)
    Note over W: worker keeps running while the user decides
    U-->>Dlg: choose "Cancel operation"
    Dlg->>T: request_cancel() — set _cancel Event
    W->>T: next checkpoint() / ask() sees the flag
    T-->>W: raise Cancelled
    W-->>Dlg: run() returns a partial result {cancelled: True}

Esc on the progress dialog does not cancel immediately — it opens a confirm box (Cancel operation / Keep running, defaulting to keep). The worker continues while the user decides; cancellation only takes effect if confirmed, at which point task.request_cancel() sets the flag.

A third path reaches the same place: choosing Cancel in a per-file conflict dialog raises Cancelled directly inside the operation’s _resolve step.

Unwinding into a partial result

Cancelled propagates up through the operation body, which catches it and returns a summary dict with cancelled = True (the counts reflect whatever completed before the cancel). TaskManager then maps the outcome to TaskStatus.CANCELLED when finalising the task, closes the dialog, and calls the caller’s on_done with the partial result — so a cancelled copy still reports what it managed to copy.

Blocking other actions

The ProgressDialog is pushed as a modal layer; its handle_event returns True for everything, so no key or mouse event reaches the panes or menus while a task runs. Blocking is therefore a property of the modal layer, not a special case in the key handler. For callers that need to know programmatically whether work is in flight, TaskManager.has_active() / active_tasks() report the PENDING / RUNNING tasks in the registry.

Example: a file operation as a task

FileOperationService (see File Operations System) builds and submits a task like this:

task = Task("Copy…", config=self.config, kind="copy")
task.progress.start_operation("copy", 0, description="")

def run(task: Task) -> dict:
    return self._run(task, "copy", targets, dest_dir, panel, log, z)

self.tasks.submit(task, panel, run=run, on_done=on_complete, z=z, background=background)

The run body resolves conflicts (via task.ask), counts work (updating task.progress), then executes each target, calling task.checkpoint() between them. A Cancelled exception unwinds run into a clean partial-result dict.

Implementation files