teksilo-core 0.24.1

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

//! What a tree that belongs to no window can rely on.
//!
//! `teksilo-app` hosts application code that must run with no window open
//! (a tray's menu, a single-instance handler) in a tree of its own, which is
//! never painted and has no `WindowState`. That tree is an ordinary
//! `WidgetTree`; these tests pin the parts of its contract the app layer
//! builds on, so a change here that would break it fails here first:
//!
//! - an intent sent from an application callback reaches an action the root
//!   registered globally, and the action reaches the window operations;
//! - a root rebuilt by a `Rebuild` binding during `layout_with_ops`, the tick
//!   such a tree gets, registers its action again, once;
//! - a mount action runs with the window operations;
//! - a tree with no root drops the context's intents, which is why that tree
//!   always has a root;
//! - a tree that was laid out but never painted still reports
//!   `needs_render`, which is why its tick must not be gated on it;
//! - its own wake-up time, `next_unpainted_deadline`, holds what layout
//!   moves forward and nothing that only render or a window would.

use std::cell::{Cell, RefCell};
use std::rc::Rc;

use teksilo_canvas::{Size, SizeProposal};

use crate::binding::BindingLevel;
use crate::signal::Signal;
use crate::window::{TeksiloWindowId, WindowConfig, WindowOps, WindowState};
use crate::{Action, BuildContext, Intent, LayoutContext, LayoutResponse, Widget, WidgetId};

use super::WidgetTree;

/// Records the window operations a tree asks for.
#[derive(Default)]
struct RecordingOps {
    opened: Vec<Option<String>>,
    finished_compositions: usize,
    /// What the input method commits as a composition is finished.
    committed: Option<String>,
}

impl WindowOps for RecordingOps {
    fn open_window(&mut self, config: WindowConfig) -> TeksiloWindowId {
        self.opened.push(config.string_id.clone());
        TeksiloWindowId::new(self.opened.len() as u64)
    }

    fn find_window(&self, _string_id: &str) -> Option<TeksiloWindowId> {
        None
    }

    fn window_state(&self, _id: TeksiloWindowId) -> Option<WindowState> {
        None
    }

    fn windows(&self) -> Vec<WindowState> {
        Vec::new()
    }

    fn focus_window(&mut self, _id: TeksiloWindowId) {}

    fn close_window_by_id(&mut self, _id: TeksiloWindowId) {}

    fn finish_ime_composition(&mut self) -> Option<String> {
        self.finished_compositions += 1;
        self.committed.clone()
    }
}

/// A zero-size root that registers one global action, `app.open`, which
/// opens a window and counts its invocations under the build generation
/// that registered it. Rebuilt whenever `generation` changes.
#[derive(Debug)]
struct AppRoot {
    generation: Signal<u32>,
    invoked_by: Rc<RefCell<Vec<u32>>>,
    mounted: Rc<Cell<bool>>,
}

impl Widget for AppRoot {
    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
        self.generation
            .bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
        let generation = self.generation.get();
        let invoked_by = self.invoked_by.clone();
        ctx.register_action_global(Action::new("app.open").on_invoke(move |_, ctx| {
            invoked_by.borrow_mut().push(generation);
            ctx.open_window(WindowConfig::new().id("opened"));
        }));
        let mounted = self.mounted.clone();
        ctx.run_after_mount(move |ctx| {
            mounted.set(true);
            ctx.open_window(WindowConfig::new().id("from-mount"));
        });
        Vec::new()
    }

    fn layout_response(&self, _: SizeProposal, _: &LayoutContext) -> LayoutResponse {
        Size::new(0.0, 0.0).into()
    }
}

fn app_tree() -> (
    WidgetTree,
    Signal<u32>,
    Rc<RefCell<Vec<u32>>>,
    Rc<Cell<bool>>,
) {
    let generation = Signal::new(0);
    let invoked_by = Rc::new(RefCell::new(Vec::new()));
    let mounted = Rc::new(Cell::new(false));
    let mut tree = WidgetTree::new();
    tree.add(AppRoot {
        generation: generation.clone(),
        invoked_by: invoked_by.clone(),
        mounted: mounted.clone(),
    });
    (tree, generation, invoked_by, mounted)
}

/// The proposal a windowless tree is laid out at: it has no window size.
fn no_window() -> SizeProposal {
    SizeProposal::exact(0.0, 0.0)
}

#[test]
fn an_application_callback_reaches_a_root_action_and_the_window_operations() {
    let (mut tree, _, invoked_by, _) = app_tree();
    let mut ops = RecordingOps::default();
    tree.run_with_event_context(&mut ops, |ctx| {
        assert!(ctx.window().is_none(), "the tree belongs to no window");
        ctx.send_intent(Intent::new("app.open"));
    });
    assert_eq!(*invoked_by.borrow(), [0]);
    assert_eq!(ops.opened, [Some("opened".to_string())]);
}

#[test]
fn finish_ime_composition_reaches_the_window_operations() {
    let mut tree = WidgetTree::new();
    let mut ops = RecordingOps::default();
    let mut kept = Some(String::new());
    tree.run_with_event_context(&mut ops, |ctx| kept = ctx.finish_ime_composition());
    assert_eq!(ops.finished_compositions, 1);
    assert_eq!(
        kept, None,
        "nothing committed: the field keeps what it showed"
    );

    ops.committed = Some("nihao".to_owned());
    tree.run_with_event_context(&mut ops, |ctx| kept = ctx.finish_ime_composition());
    assert_eq!(ops.finished_compositions, 2);
    assert_eq!(kept.as_deref(), Some("nihao"), "the input method's commit");
}

#[test]
fn a_rebuilt_root_registers_its_action_again_once() {
    let (mut tree, generation, invoked_by, _) = app_tree();
    let mut ops = RecordingOps::default();
    tree.layout_with_ops(no_window(), &mut ops);
    generation.set(1);
    tree.layout_with_ops(no_window(), &mut ops);
    tree.run_with_event_context(&mut ops, |ctx| ctx.send_intent(Intent::new("app.open")));
    assert_eq!(
        *invoked_by.borrow(),
        [1],
        "the action registered by the rebuild answers, and the old one is gone"
    );
}

#[test]
fn a_mount_action_runs_with_the_window_operations() {
    let (mut tree, _, _, mounted) = app_tree();
    let mut ops = RecordingOps::default();
    assert!(tree.has_pending_mount_actions());
    tree.run_mount_actions(&mut ops);
    assert!(mounted.get());
    assert_eq!(ops.opened, [Some("from-mount".to_string())]);
    assert!(!tree.has_pending_mount_actions());
}

#[test]
fn a_tree_with_no_root_drops_the_contexts_intents() {
    let mut tree = WidgetTree::new();
    let mut ops = RecordingOps::default();
    let ran = Rc::new(Cell::new(false));
    let flag = ran.clone();
    tree.run_with_event_context(&mut ops, move |ctx| {
        flag.set(true);
        ctx.send_intent(Intent::new("app.open"));
    });
    assert!(ran.get(), "the callback itself runs");
    assert!(
        tree.pending_intents.is_empty(),
        "but its intent has nowhere to start and is gone"
    );
    let invoked_by = Rc::new(RefCell::new(Vec::new()));
    tree.add(AppRoot {
        generation: Signal::new(0),
        invoked_by: invoked_by.clone(),
        mounted: Rc::new(Cell::new(false)),
    });
    tree.run_with_event_context(&mut ops, |_| {});
    assert!(
        invoked_by.borrow().is_empty(),
        "a root added afterwards never sees the dropped intent"
    );
}

#[test]
fn a_tree_laid_out_but_never_painted_still_needs_render() {
    let (mut tree, _, _, _) = app_tree();
    let mut ops = RecordingOps::default();
    tree.layout_with_ops(no_window(), &mut ops);
    tree.layout_with_ops(no_window(), &mut ops);
    assert!(
        tree.needs_render(),
        "paint never clears it here, so a tick gated on it would run on every turn"
    );
}

#[test]
fn the_unpainted_deadline_holds_a_requested_wake() {
    let (tree, _, _, _) = app_tree();
    assert_eq!(tree.next_unpainted_deadline(), None, "nothing to wake for");
    let at = std::time::Instant::now() + std::time::Duration::from_secs(3);
    tree.request_wake_at(at);
    assert_eq!(tree.next_unpainted_deadline(), Some(at));
}

#[test]
fn the_unpainted_deadline_paces_a_frame_request_from_the_last_layout() {
    let (mut tree, _, _, _) = app_tree();
    let mut ops = RecordingOps::default();
    tree.layout_with_ops(no_window(), &mut ops);
    tree.request_frame();
    let deadline = tree
        .next_unpainted_deadline()
        .expect("a frame was asked for");
    tree.layout_with_ops(no_window(), &mut ops);
    assert_eq!(
        tree.next_unpainted_deadline(),
        None,
        "the layout ran the frame and nothing asked again"
    );
    assert!(deadline > std::time::Instant::now() - std::time::Duration::from_secs(1));
}

/// An animated quad advances only when the tree renders. Counted, it would
/// keep a tree that never renders due forever and spin the loop.
#[test]
fn the_unpainted_deadline_leaves_out_animated_quads() {
    let (mut tree, _, _, _) = app_tree();
    let root = tree.roots()[0];
    let _quad = tree.register_animated_quad(
        root,
        crate::animated_quad::AnimatedQuadKind::SpinnerArc {
            period: std::time::Duration::from_secs(1),
            arc_fraction: 0.25,
            stroke_fraction: 0.12,
            color: teksilo_tokens::Color::RED.into(),
        },
    );
    assert!(
        tree.next_timer_deadline().is_some(),
        "a window would wake for it"
    );
    assert_eq!(tree.next_unpainted_deadline(), None);
}

/// The tick of a tree that is never painted is gated on this, not on
/// `needs_render`: it says yes for a rebuild, an idle callback or a due
/// timer, and no once a frame has run.
#[test]
fn needs_logic_frame_turns_false_once_a_frame_ran() {
    let (mut tree, generation, _, _) = app_tree();
    let mut ops = RecordingOps::default();
    let now = std::time::Instant::now;
    assert!(tree.needs_logic_frame(now()), "never laid out");
    tree.layout_with_ops(no_window(), &mut ops);
    assert!(
        !tree.needs_logic_frame(now()),
        "nothing to do after a frame"
    );
    generation.set(5);
    assert!(tree.needs_logic_frame(now()), "a bound signal changed");
    tree.layout_with_ops(no_window(), &mut ops);
    assert!(!tree.needs_logic_frame(now()));
    tree.request_wake_at(now());
    assert!(tree.needs_logic_frame(now()), "a wake is due");
    tree.layout_with_ops(no_window(), &mut ops);
    tree.run_with_event_context(&mut ops, |ctx| ctx.request_idle_callback(|_| {}));
    assert!(tree.needs_logic_frame(now()), "an idle callback waits");
}

#[test]
fn has_pending_app_requests_sees_each_request() {
    let (mut tree, _, _, _) = app_tree();
    let mut ops = RecordingOps::default();
    tree.run_mount_actions(&mut ops);
    assert!(!tree.has_pending_app_requests());
    let asks: [fn(&mut crate::widget::EventContext); 6] = [
        |ctx| ctx.set_locale("fr-FR"),
        |ctx| ctx.set_theme(crate::presets::intui::dark()),
        |ctx| ctx.follow_system_theme(),
        |ctx| ctx.set_text_scale(1.5),
        |ctx| ctx.close_window(),
        |ctx| ctx.close_window_forced(),
    ];
    for ask in asks {
        let (mut tree, _, _, _) = app_tree();
        tree.run_mount_actions(&mut ops);
        tree.run_with_event_context(&mut ops, ask);
        assert!(tree.has_pending_app_requests());
    }
    let (tree, _, _, _) = app_tree();
    assert!(tree.has_pending_app_requests(), "a mount action waits");
}