teksilo-core 0.17.0

Core of the Teksilo GUI framework — widget trait, arena, layout engine, event dispatch, focus, signals and theming.
Documentation
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: 2026 FernTech

//! Generic, runtime-agnostic delivery of a one-shot main-thread callback that
//! runs with a *fresh* [`EventContext`] bound to a window's tree.
//!
//! This is the plumbing the optional `teksilo-async` crate uses to implement
//! `spawn_local_with`: a future runs on the main-thread executor, and when it
//! completes its result is handed to a callback that needs ambient context
//! operations (`open_window`, `send_intent`, …). Those operations require an
//! [`EventContext`], which only exists *during* event dispatch — so the
//! callback is *registered* here (keyed by an id and the originating window)
//! and *delivered* later by `teksilo-app`, which routes an
//! [`AsyncCompletionPayload`] to the window's tree and calls
//! [`AsyncCompletionHandle::deliver`] inside a freshly-minted context.
//!
//! It mirrors the file-dialog result-delivery pattern, but uses only
//! teksilo-core types so a crate layered *above* `teksilo-app` (like
//! `teksilo-async`) can register callbacks without forcing `teksilo-app` to
//! depend on it (which would be a dependency cycle). There is no async,
//! future, or runtime type here — just a callback registry and a `Send`
//! payload.

use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;

use crate::widget::EventContext;
use crate::widget_tree::WidgetTree;
use crate::window::{NoopWindowOps, TeksiloWindowId};

type CompletionCallback = Box<dyn FnOnce(&mut EventContext)>;

struct Pending {
    window_id: TeksiloWindowId,
    callback: CompletionCallback,
}

struct CompletionState {
    next_id: u64,
    pending: HashMap<u64, Pending>,
}

/// Main-thread registry of pending async completions. `Clone` shares the same
/// inner state (`Rc`), so the executor and the app event loop both hold a
/// handle to one registry. `!Send` by construction — completions only ever run
/// on the UI thread.
#[derive(Clone)]
pub struct AsyncCompletionHandle {
    inner: Rc<RefCell<CompletionState>>,
}

impl Default for AsyncCompletionHandle {
    fn default() -> Self {
        Self::new()
    }
}

impl AsyncCompletionHandle {
    pub fn new() -> Self {
        Self {
            inner: Rc::new(RefCell::new(CompletionState {
                next_id: 0,
                pending: HashMap::new(),
            })),
        }
    }

    /// Register a callback to run later with a fresh [`EventContext`] on the
    /// tree of `window_id`. Returns the id to place in an
    /// [`AsyncCompletionPayload`].
    pub fn register(&self, window_id: TeksiloWindowId, callback: CompletionCallback) -> u64 {
        let mut state = self.inner.borrow_mut();
        let id = state.next_id;
        state.next_id = state.next_id.wrapping_add(1);
        state.pending.insert(
            id,
            Pending {
                window_id,
                callback,
            },
        );
        id
    }

    /// Invoke and remove the completion registered under `id`, if its target
    /// window still matches `window_id`. Called by `teksilo-app` from inside
    /// [`WidgetTree::run_with_event_context`](crate::WidgetTree::run_with_event_context).
    /// A no-op if the entry was already purged (e.g. the window closed) — the
    /// use-after-free guard.
    pub fn deliver(&self, id: u64, window_id: TeksiloWindowId, ctx: &mut EventContext) {
        let entry = self.inner.borrow_mut().pending.remove(&id);
        if let Some(pending) = entry
            && pending.window_id == window_id
        {
            (pending.callback)(ctx);
        }
    }

    /// Drop every pending completion targeting `window_id`. Called when a
    /// window closes so a late-arriving completion never touches a torn-down
    /// tree.
    pub fn purge_window(&self, window_id: TeksiloWindowId) {
        self.inner
            .borrow_mut()
            .pending
            .retain(|_, p| p.window_id != window_id);
    }

    /// Deliver every completion that is ready, each with its own fresh
    /// [`EventContext`] over `tree` and a [`NoopWindowOps`] sink. Returns how
    /// many ran.
    ///
    /// A completion is registered only once its future has resolved, so every
    /// pending entry is ready. This is the test door: a headless
    /// [`WidgetTree`] has no event loop to route an
    /// [`AsyncCompletionPayload`], so a test ticks the executor and then calls
    /// this instead. Completions registered by a callback that runs here wait
    /// for the next call. Delivery order is registration order.
    ///
    /// Window ops a callback performs (`open_window`, …) go to the no-op sink.
    /// In a running app the event loop delivers completions itself; calling
    /// this there is harmless (the loop's later delivery finds nothing).
    pub fn drain_into(&self, tree: &mut WidgetTree) -> usize {
        let mut ready: Vec<(u64, TeksiloWindowId)> = self
            .inner
            .borrow()
            .pending
            .iter()
            .map(|(id, p)| (*id, p.window_id))
            .collect();
        ready.sort_by_key(|(id, _)| *id);
        for (id, window_id) in &ready {
            tree.run_with_event_context(&mut NoopWindowOps, |ctx| {
                self.deliver(*id, *window_id, ctx)
            });
        }
        ready.len()
    }

    /// Number of pending completions (diagnostics / tests).
    pub fn pending_len(&self) -> usize {
        self.inner.borrow().pending.len()
    }
}

/// `Send` payload posted through [`AppEventPoster`](crate::AppEventPoster) when
/// an async task completes. `teksilo-app` downcasts it, routes to the target
/// window's tree, and calls [`AsyncCompletionHandle::deliver`] with a fresh
/// context. Carries only ids — the (`!Send`) callback stays in the registry.
#[derive(Debug, Clone, Copy)]
pub struct AsyncCompletionPayload {
    pub id: u64,
    pub window_id: TeksiloWindowId,
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::widget_tree::WidgetTree;
    use crate::window::NoopWindowOps;
    use std::cell::Cell;
    use std::rc::Rc;

    #[test]
    fn deliver_runs_callback_with_fresh_context() {
        let handle = AsyncCompletionHandle::new();
        let win = TeksiloWindowId::new(1);
        let ran = Rc::new(Cell::new(false));
        let flag = ran.clone();
        let id = handle.register(win, Box::new(move |_ctx| flag.set(true)));
        assert_eq!(handle.pending_len(), 1);

        let mut tree = WidgetTree::new();
        tree.run_with_event_context(&mut NoopWindowOps, |ctx| handle.deliver(id, win, ctx));

        assert!(ran.get(), "callback must run with the fresh context");
        assert_eq!(
            handle.pending_len(),
            0,
            "delivered completion must be removed"
        );
    }

    #[test]
    fn drain_into_delivers_every_pending_completion_in_order() {
        let handle = AsyncCompletionHandle::new();
        let order = Rc::new(RefCell::new(Vec::new()));
        for n in 0..3 {
            let log = order.clone();
            handle.register(
                TeksiloWindowId::new(n),
                Box::new(move |_ctx| log.borrow_mut().push(n)),
            );
        }
        let mut tree = WidgetTree::new();
        assert_eq!(handle.drain_into(&mut tree), 3);
        assert_eq!(*order.borrow(), vec![0, 1, 2]);
        assert_eq!(handle.pending_len(), 0);
        assert_eq!(handle.drain_into(&mut tree), 0, "nothing left to deliver");
    }

    #[test]
    fn purge_window_drops_only_that_windows_completions() {
        let handle = AsyncCompletionHandle::new();
        let win = TeksiloWindowId::new(7);
        handle.register(win, Box::new(|_ctx| {}));
        handle.register(win, Box::new(|_ctx| {}));
        handle.register(TeksiloWindowId::new(8), Box::new(|_ctx| {}));
        assert_eq!(handle.pending_len(), 3);

        handle.purge_window(win);
        assert_eq!(
            handle.pending_len(),
            1,
            "only the other window's completion survives"
        );
    }

    #[test]
    fn deliver_to_mismatched_window_does_not_run() {
        let handle = AsyncCompletionHandle::new();
        let win = TeksiloWindowId::new(3);
        let ran = Rc::new(Cell::new(false));
        let flag = ran.clone();
        let id = handle.register(win, Box::new(move |_ctx| flag.set(true)));

        let mut tree = WidgetTree::new();
        tree.run_with_event_context(&mut NoopWindowOps, |ctx| {
            handle.deliver(id, TeksiloWindowId::new(999), ctx)
        });
        assert!(
            !ran.get(),
            "a window-mismatched delivery must not run the callback"
        );
    }
}