use std::cell::RefCell;
use std::rc::Rc;
use teksilo_canvas::{Point, Rect, Size, SizeProposal};
use teksilo_core::accessibility::AccessNodeBuilder;
use teksilo_core::binding::BindingLevel;
use teksilo_core::signal::Signal;
use teksilo_core::widget::{LayoutContext, Widget, WidgetPlacement};
use teksilo_core::widget_id::WidgetId;
enum Slot {
Pending(Box<dyn Widget>),
PreMounted(WidgetId),
Mounted(WidgetId),
}
pub struct Switcher {
selected: Signal<usize>,
slots: Vec<Slot>,
child_ids_out: Option<Rc<RefCell<Vec<WidgetId>>>>,
}
impl Switcher {
pub fn new(selected: Signal<usize>) -> Self {
Self {
selected,
slots: Vec::new(),
child_ids_out: None,
}
}
pub fn capture_child_ids_into(mut self, out: Rc<RefCell<Vec<WidgetId>>>) -> Self {
self.child_ids_out = Some(out);
self
}
pub fn child(mut self, widget: impl Widget + 'static) -> Self {
self.slots.push(Slot::Pending(Box::new(widget)));
self
}
pub fn child_boxed(mut self, widget: Box<dyn Widget>) -> Self {
self.slots.push(Slot::Pending(widget));
self
}
pub fn child_id(mut self, id: WidgetId) -> Self {
self.slots.push(Slot::PreMounted(id));
self
}
pub fn children(mut self, iter: impl IntoIterator<Item = impl Widget + 'static>) -> Self {
for widget in iter {
self.slots.push(Slot::Pending(Box::new(widget)));
}
self
}
}
impl std::fmt::Debug for Switcher {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Switcher")
.field("num_children", &self.slots.len())
.finish()
}
}
impl Widget for Switcher {
fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
let self_id = ctx.self_id();
self.selected
.bind_to(self_id, ctx.binding_registry(), BindingLevel::Rebuild);
let current = self.selected.get();
for slot in self.slots.iter() {
if let Slot::Pending(widget) = slot {
let declared = widget.declare_shortcuts();
if !declared.is_empty() {
ctx.register_pending_shortcuts(declared);
}
}
}
for (i, slot) in self.slots.iter_mut().enumerate() {
match slot {
Slot::PreMounted(id) => {
*slot = Slot::Mounted(*id);
}
Slot::Pending(_) if i == current => {
let widget = match std::mem::replace(slot, Slot::Mounted(WidgetId::default())) {
Slot::Pending(w) => w,
_ => unreachable!(),
};
let id = ctx.add_boxed(widget);
*slot = Slot::Mounted(id);
}
_ => {}
}
}
for (i, slot) in self.slots.iter().enumerate() {
if let Slot::Mounted(id) = slot {
let idx = i;
let vis = self.selected.map(move |s| *s == idx);
ctx.visible_when(*id, vis);
}
}
if let Some(ref out) = self.child_ids_out {
let mut buf = out.borrow_mut();
buf.clear();
for slot in &self.slots {
if let Slot::Mounted(id) = slot {
buf.push(*id);
}
}
}
self.slots
.iter()
.filter_map(|s| match s {
Slot::Mounted(id) => Some(*id),
_ => None,
})
.collect()
}
fn preserves_children_on_rebuild(&self) -> bool {
true
}
fn layout_response(
&self,
proposal: SizeProposal,
ctx: &LayoutContext,
) -> teksilo_core::widget::LayoutResponse {
let mut max_w: f32 = 0.0;
let mut max_h: f32 = 0.0;
let mut any = false;
for slot in &self.slots {
if let Slot::Mounted(id) = slot
&& let Some(child_size) = ctx.child_size(*id, proposal)
{
max_w = max_w.max(child_size.width);
max_h = max_h.max(child_size.height);
any = true;
}
}
if any {
Size::new(max_w, max_h)
} else {
proposal.resolve(0.0, 0.0)
}
.into()
}
fn place_children(
&self,
bounds: Rect,
_proposal: SizeProposal,
children: &mut [WidgetPlacement],
ctx: &LayoutContext,
) {
let rtl = ctx.is_rtl();
let exact_proposal = SizeProposal::exact(bounds.width, bounds.height);
for child in children.iter_mut() {
let child_size = ctx
.child_size(child.id, exact_proposal)
.unwrap_or_else(|| bounds.size());
let dx = if rtl {
bounds.width - child_size.width
} else {
0.0
};
child.origin = Point::new(bounds.x + dx, bounds.y);
child.size = child_size;
}
}
fn accessibility(&self, builder: &mut AccessNodeBuilder) {
builder.set_hidden();
}
fn children(&self) -> Vec<WidgetId> {
self.slots
.iter()
.filter_map(|s| match s {
Slot::Mounted(id) => Some(*id),
_ => None,
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use teksilo_canvas::Size;
use teksilo_core::widget_tree::WidgetTree;
#[derive(Debug)]
struct FixedLeaf(f32, f32);
impl Widget for FixedLeaf {
fn layout_response(
&self,
_proposal: SizeProposal,
_ctx: &LayoutContext,
) -> teksilo_core::widget::LayoutResponse {
Size::new(self.0, self.1).into()
}
}
#[derive(Debug)]
struct CountingLeaf {
build_calls: Rc<std::cell::Cell<u32>>,
size: (f32, f32),
}
impl CountingLeaf {
fn new(w: f32, h: f32) -> (Self, Rc<std::cell::Cell<u32>>) {
let counter = Rc::new(std::cell::Cell::new(0));
(
Self {
build_calls: counter.clone(),
size: (w, h),
},
counter,
)
}
}
impl Widget for CountingLeaf {
fn build(&mut self, _ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
self.build_calls.set(self.build_calls.get() + 1);
Vec::new()
}
fn layout_response(
&self,
_proposal: SizeProposal,
_ctx: &LayoutContext,
) -> teksilo_core::widget::LayoutResponse {
Size::new(self.size.0, self.size.1).into()
}
}
#[test]
fn switcher_builds_and_lays_out() {
let selected = Signal::new(1_usize);
let mut tree = WidgetTree::new();
let switcher_id = tree.add(
Switcher::new(selected.clone())
.child(FixedLeaf(100.0, 40.0))
.child(FixedLeaf(80.0, 30.0))
.child(FixedLeaf(60.0, 20.0)),
);
tree.layout(SizeProposal::exact(200.0, 200.0));
assert!(tree.is_visible(switcher_id));
let bounds = tree.bounds(switcher_id);
assert!(bounds.width > 0.0);
assert!(bounds.height > 0.0);
}
#[test]
fn unvisited_pages_never_build() {
let selected = Signal::new(0_usize);
let (page0, c0) = CountingLeaf::new(50.0, 50.0);
let (page1, c1) = CountingLeaf::new(60.0, 60.0);
let (page2, c2) = CountingLeaf::new(70.0, 70.0);
let mut tree = WidgetTree::new();
let _id = tree.add(
Switcher::new(selected.clone())
.child(page0)
.child(page1)
.child(page2),
);
tree.layout(SizeProposal::exact(200.0, 200.0));
assert_eq!(c0.get(), 1, "selected page must be built");
assert_eq!(c1.get(), 0, "unvisited page must not build");
assert_eq!(c2.get(), 0, "unvisited page must not build");
}
#[test]
fn switching_mounts_lazily_and_preserves_prior_pages() {
let selected = Signal::new(0_usize);
let (page0, c0) = CountingLeaf::new(50.0, 50.0);
let (page1, c1) = CountingLeaf::new(60.0, 60.0);
let (page2, c2) = CountingLeaf::new(70.0, 70.0);
let mut tree = WidgetTree::new();
let _id = tree.add(
Switcher::new(selected.clone())
.child(page0)
.child(page1)
.child(page2),
);
tree.layout(SizeProposal::exact(200.0, 200.0));
assert_eq!((c0.get(), c1.get(), c2.get()), (1, 0, 0));
selected.set(1);
tree.layout(SizeProposal::exact(200.0, 200.0));
assert_eq!(
(c0.get(), c1.get(), c2.get()),
(1, 1, 0),
"page 1 mounts on first visit; page 0 is preserved (not rebuilt)"
);
selected.set(0);
tree.layout(SizeProposal::exact(200.0, 200.0));
assert_eq!(
(c0.get(), c1.get(), c2.get()),
(1, 1, 0),
"returning to page 0 must reuse the existing subtree"
);
selected.set(2);
tree.layout(SizeProposal::exact(200.0, 200.0));
assert_eq!(
(c0.get(), c1.get(), c2.get()),
(1, 1, 1),
"page 2 mounts on first visit"
);
}
#[test]
fn hidden_page_is_dormant_and_excluded_from_at() {
let selected = Signal::new(0_usize);
let ids = Rc::new(RefCell::new(Vec::new()));
let mut tree = WidgetTree::new();
let _switcher = tree.add(
Switcher::new(selected.clone())
.capture_child_ids_into(ids.clone())
.child(FixedLeaf(50.0, 50.0))
.child(FixedLeaf(60.0, 60.0)),
);
tree.layout(SizeProposal::exact(200.0, 200.0));
selected.set(1);
tree.layout(SizeProposal::exact(200.0, 200.0));
let (page0, page1) = {
let ids = ids.borrow();
assert_eq!(ids.len(), 2, "both pages mounted after each is visited");
(ids[0], ids[1])
};
assert!(tree.is_active(page1), "selected page must be active");
assert!(tree.is_visible(page1), "selected page must be visible");
assert!(
!tree.is_active(page0),
"hidden page must be dormant — excluded from AT / focus / hit-test"
);
assert!(!tree.is_visible(page0), "hidden page must be invisible");
selected.set(0);
tree.layout(SizeProposal::exact(200.0, 200.0));
assert!(tree.is_active(page0) && tree.is_visible(page0));
assert!(
!tree.is_active(page1),
"previously-shown page must now be dormant"
);
}
#[test]
fn premounted_child_id_pages_build_eagerly_unlike_pending() {
let selected = Signal::new(0_usize);
let (page1, c1) = CountingLeaf::new(60.0, 60.0);
let mut tree = WidgetTree::new();
let p0 = tree.add(FixedLeaf(50.0, 50.0));
let p1 = tree.add(page1); let _switcher = tree.add(Switcher::new(selected.clone()).child_id(p0).child_id(p1));
tree.layout(SizeProposal::exact(200.0, 200.0));
assert_eq!(
c1.get(),
1,
"PreMounted page builds eagerly even when not selected"
);
assert!(tree.is_visible(p0), "selected page visible");
assert!(!tree.is_visible(p1), "non-selected page hidden");
selected.set(1);
tree.layout(SizeProposal::exact(200.0, 200.0));
assert!(tree.is_visible(p1), "switched-to page visible");
assert!(!tree.is_visible(p0), "switched-from page hidden");
assert_eq!(c1.get(), 1, "switching must not rebuild the page");
}
#[test]
fn switcher_pending_pages_declare_shortcuts_eagerly() {
use teksilo_core::event::Key;
use teksilo_core::shortcut::{KeyStroke, Shortcut};
#[derive(Debug)]
struct LazyWithShortcuts(Rc<std::cell::Cell<u32>>);
impl Widget for LazyWithShortcuts {
fn declare_shortcuts(&self) -> Vec<Shortcut> {
vec![
Shortcut::new("__test.lazy.action")
.name("Lazy Action")
.primary(KeyStroke::ctrl(Key::L))
.build(),
]
}
fn build(
&mut self,
_ctx: &mut teksilo_core::build_context::BuildContext,
) -> Vec<WidgetId> {
self.0.set(self.0.get() + 1);
Vec::new()
}
fn layout_response(
&self,
_proposal: SizeProposal,
_ctx: &LayoutContext,
) -> teksilo_core::widget::LayoutResponse {
Size::new(10.0, 10.0).into()
}
}
let selected = Signal::new(0_usize);
let build_count = Rc::new(std::cell::Cell::new(0));
let mut tree = WidgetTree::new();
let _id = tree.add(
Switcher::new(selected.clone())
.child(FixedLeaf(50.0, 50.0))
.child(LazyWithShortcuts(build_count.clone())),
);
tree.layout(SizeProposal::exact(200.0, 200.0));
assert_eq!(
build_count.get(),
0,
"lazy page must not have built — index 1 was never selected"
);
assert!(
tree.shortcut_registry()
.get_default("__test.lazy.action")
.is_some(),
"Switcher must pre-register Pending pages' declared shortcuts"
);
}
}