1use std::cell::Cell;
31use std::rc::Rc;
32
33use teksilo_canvas::{Canvas, Rect, Size, SizeProposal};
34use teksilo_core::accessibility::AccessNodeBuilder;
35use teksilo_core::color_prop::ColorProp;
36use teksilo_core::signal::Prop;
37use teksilo_core::widget::{
38 EventContext, LayoutContext, PaintContext, PendingChild, Widget, WidgetPlacement,
39 WidgetTreeView,
40};
41use teksilo_core::widget_id::WidgetId;
42use teksilo_core::{HitRegions, PlatformTitleBarHost, Signal};
43use teksilo_tokens::{Color, CornerRadius};
44
45use crate::primitives::{FixedSize, HStack};
46
47mod controls;
48mod drag_region;
49mod resize_strip;
50mod window_frame;
51mod window_menu;
52
53pub use controls::{ControlAction, ControlButton, WindowControls, WindowControlsLayout};
54pub use drag_region::DragRegion;
55pub use resize_strip::ResizeStrip;
56pub use window_frame::{WINDOW_FRAME_RESIZE_THICKNESS, WindowFrame};
57
58pub type CloseAction = Rc<dyn Fn(&mut EventContext)>;
61
62pub struct TitleBar {
92 host: Rc<dyn PlatformTitleBarHost>,
93 leading: Option<PendingChild>,
94 center: Option<PendingChild>,
95 trailing: Option<PendingChild>,
96 height: f32,
97 background: ColorProp,
98 border_color: ColorProp,
99 border_width: f32,
100 close_action: Option<CloseAction>,
103 root_child_id: Option<WidgetId>,
104 drag_region_id: Cell<Option<WidgetId>>,
108 controls_layout: Rc<Cell<Option<WindowControlsLayout>>>,
112 controls_visible: Prop<bool>,
117}
118
119impl std::fmt::Debug for TitleBar {
120 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121 f.debug_struct("TitleBar")
122 .field("height", &self.height)
123 .field("has_leading", &self.leading.is_some())
124 .field("has_center", &self.center.is_some())
125 .field("has_trailing", &self.trailing.is_some())
126 .finish_non_exhaustive()
127 }
128}
129
130impl TitleBar {
131 pub fn new(host: Rc<dyn PlatformTitleBarHost>) -> Self {
137 Self {
138 host,
139 leading: None,
140 center: None,
141 trailing: None,
142 height: 40.0,
143 background: ColorProp::Static(Color::TRANSPARENT),
144 border_color: ColorProp::Static(Color::TRANSPARENT),
145 border_width: 0.0,
146 close_action: None,
147 root_child_id: None,
148 drag_region_id: Cell::new(None),
149 controls_layout: Rc::new(Cell::new(None)),
150 controls_visible: Prop::Static(true),
151 }
152 }
153
154 pub fn controls_visible(mut self, visible: impl Into<Prop<bool>>) -> Self {
177 self.controls_visible = visible.into();
178 self
179 }
180
181 pub fn height(mut self, height: f32) -> Self {
183 self.height = height;
184 self
185 }
186
187 pub fn background(mut self, color: impl Into<ColorProp>) -> Self {
195 self.background = color.into();
196 self
197 }
198
199 pub fn border(mut self, color: impl Into<ColorProp>, width: f32) -> Self {
205 self.border_color = color.into();
206 self.border_width = width;
207 self
208 }
209
210 pub fn leading(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
213 self.leading = Some(teksilo_core::IntoTeksiChild::into_pending(widget));
214 self
215 }
216
217 pub fn center(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
221 self.center = Some(teksilo_core::IntoTeksiChild::into_pending(widget));
222 self
223 }
224
225 pub fn trailing(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
228 self.trailing = Some(teksilo_core::IntoTeksiChild::into_pending(widget));
229 self
230 }
231
232 pub fn close_action(mut self, action: impl Fn(&mut EventContext) + 'static) -> Self {
238 self.close_action = Some(Rc::new(action));
239 self
240 }
241}
242
243impl Widget for TitleBar {
244 fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
245 let self_id = ctx.self_id();
249 let registry = ctx.binding_registry();
250 self.background.register_if_bound(
251 self_id,
252 registry,
253 teksilo_core::binding::BindingLevel::RepaintOnly,
254 );
255 self.border_color.register_if_bound(
256 self_id,
257 registry,
258 teksilo_core::binding::BindingLevel::RepaintOnly,
259 );
260
261 let leading_inset = self.host.reserved_leading_inset();
262 let trailing_inset = self.host.reserved_trailing_inset();
263 let renders_controls = self.host.renders_custom_controls();
264 let height = self.height;
265
266 let drag_region = match self.center.take() {
270 Some(PendingChild::Deferred(child)) => DragRegion::with_child(self.host.clone(), child),
271 Some(PendingChild::Id(id)) => DragRegion::with_child_id(self.host.clone(), id),
272 None => DragRegion::new(self.host.clone()),
273 }
274 .close_action(self.close_action.clone());
277 let drag_region_id = ctx.add(drag_region);
278 self.drag_region_id.set(Some(drag_region_id));
279
280 let show_restore_signal = ctx
289 .window()
290 .map(|w| w.placement().map(|p| p.is_maximized() || p.is_fullscreen()))
291 .unwrap_or_else(|| Signal::new(false));
292 let controls_id: Option<WidgetId> = if renders_controls {
305 let controls = WindowControls::new(
306 self.host.clone(),
307 show_restore_signal,
308 self.close_action.clone(),
309 )
310 .layout_sink(self.controls_layout.clone());
311 let id = ctx.add(controls);
312 ctx.visible_when(id, self.controls_visible.clone());
313 Some(id)
314 } else {
315 None
316 };
317
318 let mut row = HStack::new().spacing(0.0);
324
325 if leading_inset.width > 0.0 {
326 row = row.child(FixedSize::new().width(leading_inset.width).height(height));
327 }
328
329 if let Some(leading) = self.leading.take() {
330 let id = match leading {
331 PendingChild::Id(id) => id,
332 PendingChild::Deferred(w) => ctx.add_boxed(w),
333 };
334 row = row.child(id);
335 }
336
337 row = row.child(drag_region_id);
338
339 if let Some(trailing) = self.trailing.take() {
340 let id = match trailing {
341 PendingChild::Id(id) => id,
342 PendingChild::Deferred(w) => ctx.add_boxed(w),
343 };
344 row = row.child(id);
345 }
346
347 if trailing_inset.width > 0.0 {
348 row = row.child(FixedSize::new().width(trailing_inset.width).height(height));
349 }
350
351 if let Some(id) = controls_id {
352 row = row.child(id);
353 }
354
355 let root = ctx.add(row);
356 self.root_child_id = Some(root);
357 vec![root]
358 }
359
360 fn layout_response(
361 &self,
362 proposal: SizeProposal,
363 _ctx: &LayoutContext,
364 ) -> teksilo_core::widget::LayoutResponse {
365 Size::new(proposal.width.unwrap_or(0.0), self.height).into()
372 }
373
374 fn place_children(
375 &self,
376 bounds: Rect,
377 _proposal: SizeProposal,
378 children: &mut [WidgetPlacement],
379 _ctx: &LayoutContext,
380 ) {
381 for child in children.iter_mut() {
382 child.origin = bounds.origin();
383 child.size = bounds.size();
384 }
385 }
386
387 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
388 let bg = self.background.resolve(ctx.theme, ctx.effective_enabled);
389 if bg.a() > 0.0 {
390 canvas.fill_rounded_rect(bounds, CornerRadius::ZERO, bg);
391 }
392 if self.border_width > 0.0 {
393 let border = self.border_color.resolve(ctx.theme, ctx.effective_enabled);
394 if border.a() > 0.0 {
395 canvas.draw_border_bottom(bounds, border, self.border_width);
396 }
397 }
398 }
399
400 fn wants_after_paint(&self) -> bool {
401 true
406 }
407
408 fn after_paint(&self, view: &WidgetTreeView<'_>, _ctx: &PaintContext) {
409 let mut regions = HitRegions::new();
414
415 if let Some(drag_id) = self.drag_region_id.get() {
416 let drag_bounds = view.bounds(drag_id);
417 if drag_bounds.width > 0.0 && drag_bounds.height > 0.0 {
421 regions.drag.push(drag_bounds);
422 collect_dead_zones(view, drag_id, drag_bounds, &mut regions.no_drag);
431 }
432 }
433
434 if let Some(strip_id) = self.root_child_id {
447 let strip = view.bounds(strip_id);
448 for &overlay in view.overlay_rects() {
449 if let Some(hole) = intersect(overlay, strip) {
450 regions.no_drag.push(hole);
451 }
452 }
453 }
454
455 if !self.controls_visible.get() {
461 self.host.update_hit_regions(®ions);
462 return;
463 }
464
465 if let Some(layout) = self.controls_layout.take() {
466 regions.minimize = Some(view.bounds(layout.minimize_id));
467 regions.minimize_id = Some(layout.minimize_id);
468
469 regions.maximize = Some(view.bounds(layout.maximize_id));
477 regions.maximize_id = Some(layout.maximize_id);
478
479 regions.close = Some(view.bounds(layout.close_id));
480 regions.close_id = Some(layout.close_id);
481
482 self.controls_layout.set(Some(layout));
485 }
486
487 self.host.update_hit_regions(®ions);
488 }
489
490 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
491 builder.set_role(teksilo_core::accesskit::Role::Banner);
492 builder.set_name(teksilo_i18n::tr_widget!(a11y_title_bar_name()).resolve_now());
493 }
494
495 fn children(&self) -> Vec<WidgetId> {
496 self.root_child_id.into_iter().collect()
497 }
498}
499
500fn collect_dead_zones(view: &WidgetTreeView<'_>, root: WidgetId, clip: Rect, out: &mut Vec<Rect>) {
509 for &child in view.children(root) {
510 if !view.is_active(child) {
511 continue;
512 }
513 let Some(hit) = intersect(view.bounds(child), clip) else {
514 continue;
515 };
516 if view.is_gesture_dead_zone(child) {
517 out.push(hit);
518 continue;
519 }
520 collect_dead_zones(view, child, clip, out);
521 }
522}
523
524fn intersect(a: Rect, b: Rect) -> Option<Rect> {
528 let x0 = a.x.max(b.x);
529 let y0 = a.y.max(b.y);
530 let x1 = a.right().min(b.right());
531 let y1 = a.bottom().min(b.bottom());
532 (x1 > x0 && y1 > y0).then(|| Rect::new(x0, y0, x1 - x0, y1 - y0))
533}
534
535#[cfg(test)]
536mod tests {
537 use super::*;
538 use crate::primitives::{DeadZone, Expand};
539 use std::cell::{Cell, RefCell};
540 use teksilo_canvas::Point;
541 use teksilo_core::event::PointerButton;
542 use teksilo_core::widget_tree::WidgetTree;
543 use teksilo_core::{HitRegions, PlatformError, PlatformTitleBarHost, ResizeEdge};
544
545 struct TestHost {
549 minimized: Cell<u32>,
550 maximize_toggled: Cell<u32>,
551 closed: Cell<u32>,
552 drags_started: Cell<u32>,
553 is_max: Signal<bool>,
554 last_regions: RefCell<HitRegions>,
557 }
558
559 impl Default for TestHost {
560 fn default() -> Self {
561 Self {
562 minimized: Cell::new(0),
563 maximize_toggled: Cell::new(0),
564 closed: Cell::new(0),
565 drags_started: Cell::new(0),
566 is_max: Signal::new(false),
567 last_regions: RefCell::new(HitRegions::default()),
568 }
569 }
570 }
571
572 impl PlatformTitleBarHost for TestHost {
573 fn reserved_leading_inset(&self) -> Size {
574 Size::ZERO
575 }
576 fn reserved_trailing_inset(&self) -> Size {
577 Size::ZERO
578 }
579 fn renders_custom_controls(&self) -> bool {
580 true
581 }
582 fn needs_custom_resize_handles(&self) -> bool {
583 true
584 }
585 fn begin_drag(&self) -> Result<(), PlatformError> {
586 self.drags_started.set(self.drags_started.get() + 1);
587 Ok(())
588 }
589 fn begin_resize(&self, _edge: ResizeEdge) -> Result<(), PlatformError> {
590 Ok(())
591 }
592 fn show_window_menu(&self, _at: Point) -> Result<(), PlatformError> {
593 Ok(())
594 }
595 fn update_hit_regions(&self, regions: &HitRegions) {
596 *self.last_regions.borrow_mut() = regions.clone();
597 }
598 }
599
600 fn build_realistic_tree(
604 host: Rc<TestHost>,
605 bar_setup: impl FnOnce(TitleBar) -> TitleBar,
606 ) -> (WidgetTree, WidgetId) {
607 use crate::primitives::{Expand, VStack};
608
609 let bar_widget =
610 bar_setup(TitleBar::new(host as Rc<dyn PlatformTitleBarHost>).height(40.0));
611
612 let mut tree = WidgetTree::new()
616 .with_theme(teksilo_core::presets::intui::light())
617 .with_text_backend(Rc::new(std::cell::RefCell::new(
618 teksilo_canvas::MockTextBackend::new(),
619 )));
620 let bar_id = tree.add(bar_widget);
621 let body_id = tree.add(Expand::new());
622 let _root = tree.add(VStack::new().spacing(0.0).child(bar_id).child(body_id));
623 tree.layout(SizeProposal::exact(900.0, 600.0));
624 (tree, bar_id)
625 }
626
627 fn locate_control_buttons(tree: &WidgetTree, bar: WidgetId) -> [WidgetId; 3] {
640 let bar_kids = tree.children(bar);
642 assert_eq!(
643 bar_kids.len(),
644 1,
645 "TitleBar should have a single root: {bar_kids:?}"
646 );
647 let row = bar_kids[0];
648
649 let row_kids = tree.children(row);
651 assert_eq!(
652 row_kids.len(),
653 2,
654 "row should have drag_region + controls, got {row_kids:?}"
655 );
656 let controls = row_kids[1];
657
658 let controls_kids = tree.children(controls);
660 assert_eq!(controls_kids.len(), 1, "controls should wrap one HStack");
661 let inner_row = controls_kids[0];
662
663 let inner_kids = tree.children(inner_row);
665 assert_eq!(
666 inner_kids.len(),
667 3,
668 "inner controls row should contain 3 items, got {inner_kids:?}"
669 );
670 let max_buttons = tree.children(inner_kids[1]);
674 assert_eq!(
675 max_buttons.len(),
676 2,
677 "maximize Switcher should expose 2 ControlButtons (□ + ❐), got {max_buttons:?}"
678 );
679 [inner_kids[0], max_buttons[0], inner_kids[2]]
680 }
681
682 fn controls_are_live(tree: &WidgetTree, bar: WidgetId) -> bool {
688 let row = tree.children(bar)[0];
689 tree.children(row)
690 .last()
691 .is_some_and(|&id| tree.is_active(id) && tree.bounds(id).width > 0.0)
692 }
693
694 fn tree_at_placement(placement: teksilo_core::WindowPlacement) -> (WidgetTree, WidgetId) {
698 use crate::primitives::VStack;
699 use teksilo_core::window::WindowState;
700 use teksilo_core::{TeksiloWindowId, WindowStateInit};
701
702 let host = Rc::new(TestHost::default());
703 let mut tree = WidgetTree::new()
704 .with_theme(teksilo_core::presets::intui::light())
705 .with_text_backend(Rc::new(std::cell::RefCell::new(
706 teksilo_canvas::MockTextBackend::new(),
707 )));
708 tree.set_window_state(WindowState::new(WindowStateInit {
709 id: TeksiloWindowId::new(1),
710 string_id: Some("w1".to_string()),
711 placement,
712 title: "Test".to_string(),
713 size: (900, 600),
714 position: (0, 0),
715 focused: true,
716 resizable: true,
717 always_on_top: false,
718 }));
719 let bar_id = tree.add(TitleBar::new(host as Rc<dyn PlatformTitleBarHost>).height(40.0));
720 let body_id = tree.add(Expand::new());
721 let _root = tree.add(VStack::new().spacing(0.0).child(bar_id).child(body_id));
722 tree.layout(SizeProposal::exact(900.0, 600.0));
723 (tree, bar_id)
724 }
725
726 fn visible_maximize_page(tree: &WidgetTree, bar: WidgetId) -> (usize, WidgetId) {
735 let row = tree.children(bar)[0];
736 let controls = tree.children(row)[1];
737 let inner = tree.children(controls)[0];
738 let switcher = tree.children(inner)[1];
739 let pages = tree.children(switcher);
740 pages
741 .iter()
742 .enumerate()
743 .find(|&(_, &p)| tree.is_active(p))
744 .map(|(i, &p)| (i, p))
745 .expect("one maximize page must be active")
746 }
747
748 #[test]
762 fn rebuilding_keeps_the_leading_and_trailing_slots() {
763 use crate::TextWidget;
764 use crate::primitives::VStack;
765 use teksilo_i18n::lit;
766
767 let host = Rc::new(TestHost::default());
768 let visible = Signal::new(true);
769 let mut tree = WidgetTree::new()
770 .with_theme(teksilo_core::presets::intui::light())
771 .with_text_backend(Rc::new(std::cell::RefCell::new(
772 teksilo_canvas::MockTextBackend::new(),
773 )));
774 let bar = tree.add(
775 TitleBar::new(host as Rc<dyn PlatformTitleBarHost>)
776 .height(40.0)
777 .leading(TextWidget::new(lit!("MENU")))
778 .center(TextWidget::new(lit!("TITLE")))
779 .trailing(TextWidget::new(lit!("TOOLS")))
780 .controls_visible(visible.clone()),
781 );
782 let body = tree.add(Expand::new());
783 let _root = tree.add(VStack::new().spacing(0.0).child(bar).child(body));
784 tree.layout(SizeProposal::exact(900.0, 600.0));
785
786 let shape = |t: &WidgetTree| {
792 let row = t.children(bar)[0];
793 let kids = t.children(row);
794 let drag_w = kids.get(1).map(|&id| t.bounds(id).width).unwrap_or(0.0);
795 (kids.len(), drag_w > 0.0, t.bounds(row).width)
796 };
797
798 let (n, drag_fills, row_w) = shape(&tree);
799 assert_eq!(n, 4, "leading + drag + trailing + controls");
800 assert!(
801 drag_fills,
802 "the drag region is a spacer and must have width"
803 );
804 assert!((row_w - 900.0).abs() < 1.0, "row spans the bar: {row_w}");
805
806 visible.set(false);
808 tree.layout(SizeProposal::exact(900.0, 600.0));
809 assert!(
810 !controls_are_live(&tree, bar),
811 "the cluster parks when the gate goes false"
812 );
813 let (n, drag_fills, row_w) = shape(&tree);
814 assert_eq!(n, 4, "the cluster parks, it is not removed");
815 assert!(drag_fills && (row_w - 900.0).abs() < 1.0, "slots intact");
816
817 visible.set(true);
818 tree.layout(SizeProposal::exact(900.0, 600.0));
819 assert!(controls_are_live(&tree, bar), "and comes back");
820 let (n, drag_fills, row_w) = shape(&tree);
821 assert_eq!(
822 n, 4,
823 "the leading/center/trailing slots must survive a gate flip — \
824 `build` consumes them, so anything that rebuilds this bar empties it"
825 );
826 assert!(
827 drag_fills && (row_w - 900.0).abs() < 1.0,
828 "slots still intact"
829 );
830 }
831
832 #[test]
833 fn controls_visible_false_parks_the_cluster() {
834 let host = Rc::new(TestHost::default());
835 let (tree, bar) = build_realistic_tree(host, |b| b.controls_visible(false));
836 assert!(
837 !controls_are_live(&tree, bar),
838 "controls_visible(false) must leave the cluster dormant and zero-width"
839 );
840 }
841
842 #[test]
843 fn controls_visible_defaults_to_showing_them() {
844 let host = Rc::new(TestHost::default());
845 let (tree, bar) = build_realistic_tree(host, |b| b);
846 assert!(controls_are_live(&tree, bar), "default is shown");
847 }
848
849 #[test]
854 fn controls_visible_flips_a_mounted_bar_both_ways() {
855 use crate::primitives::VStack;
856 let host = Rc::new(TestHost::default());
857 let visible = Signal::new(true);
858 let mut tree = WidgetTree::new()
859 .with_theme(teksilo_core::presets::intui::light())
860 .with_text_backend(Rc::new(std::cell::RefCell::new(
861 teksilo_canvas::MockTextBackend::new(),
862 )));
863 let bar = tree.add(
864 TitleBar::new(host as Rc<dyn PlatformTitleBarHost>)
865 .height(40.0)
866 .controls_visible(visible.clone()),
867 );
868 let body = tree.add(Expand::new());
869 let _root = tree.add(VStack::new().spacing(0.0).child(bar).child(body));
870 tree.layout(SizeProposal::exact(900.0, 600.0));
871 assert!(controls_are_live(&tree, bar), "starts shown");
872
873 visible.set(false);
874 tree.layout(SizeProposal::exact(900.0, 600.0));
875 assert!(
876 !controls_are_live(&tree, bar),
877 "hiding must park the cluster on a mounted bar"
878 );
879
880 visible.set(true);
881 tree.layout(SizeProposal::exact(900.0, 600.0));
882 assert!(
883 controls_are_live(&tree, bar),
884 "and a hidden cluster must still learn to come back"
885 );
886 }
887
888 #[test]
892 fn fullscreen_shows_the_restore_page_not_maximize() {
893 use teksilo_core::WindowPlacement as P;
894 let (tree, bar) = tree_at_placement(P::Floating);
895 assert_eq!(
896 visible_maximize_page(&tree, bar).0,
897 0,
898 "floating offers Maximize"
899 );
900
901 let (tree, bar) = tree_at_placement(P::Maximized);
902 assert_eq!(
903 visible_maximize_page(&tree, bar).0,
904 1,
905 "maximized offers Restore"
906 );
907
908 let (tree, bar) = tree_at_placement(P::Fullscreen);
909 assert_eq!(
910 visible_maximize_page(&tree, bar).0,
911 1,
912 "fullscreen must offer Restore — maximize is meaningless there"
913 );
914 }
915
916 #[test]
920 fn activating_restore_from_fullscreen_leaves_fullscreen() {
921 use teksilo_core::WindowPlacement as P;
922 let (mut tree, bar) = tree_at_placement(P::Fullscreen);
923 let (_, restore) = visible_maximize_page(&tree, bar);
924 let b = tree.bounds(restore);
925 let centre = Point::new(b.x + b.width / 2.0, b.y + b.height / 2.0);
926 tree.pointer_down_button(centre, PointerButton::Primary);
927 tree.pointer_up_button(centre, PointerButton::Primary);
928
929 let placement = tree
930 .window_state()
931 .expect("window state attached")
932 .placement()
933 .get();
934 assert_eq!(
935 placement,
936 P::Floating,
937 "restore from fullscreen must not land on Maximized"
938 );
939 }
940
941 #[test]
942 fn title_bar_claims_full_width_and_configured_height() {
943 let host = Rc::new(TestHost::default());
944 let (tree, bar) = build_realistic_tree(host, |b| b);
945 let b = tree.bounds(bar);
946 assert!((b.width - 900.0).abs() < 0.01, "width = {}", b.width);
947 assert!((b.height - 40.0).abs() < 0.01, "height = {}", b.height);
948 }
949
950 #[test]
951 fn drag_region_is_a_spacer_so_controls_sit_flush_right() {
952 let host = Rc::new(TestHost::default());
956 let (tree, bar) = build_realistic_tree(host, |b| b);
957
958 let [_minimize, _maximize, close] = locate_control_buttons(&tree, bar);
959 let close_b = tree.bounds(close);
960
961 assert!(
964 (close_b.right() - 900.0).abs() < 1.0,
965 "close right edge = {}, expected ~900",
966 close_b.right()
967 );
968 assert!(
969 (close_b.width - 46.0).abs() < 1.0,
970 "close cell width = {}, expected 46",
971 close_b.width
972 );
973 }
974
975 #[test]
976 fn close_action_override_is_invoked_instead_of_host_close() {
977 let host = Rc::new(TestHost::default());
978 let close_calls = Rc::new(Cell::new(0u32));
979 let close_calls_clone = close_calls.clone();
980
981 let host_clone = host.clone();
982 let (mut tree, bar) = build_realistic_tree(host_clone, move |b| {
983 b.close_action(move |_ctx| {
984 close_calls_clone.set(close_calls_clone.get() + 1);
985 })
986 });
987
988 let [_min, _max, close] = locate_control_buttons(&tree, bar);
989 tree.click(close);
990
991 assert!(
992 close_calls.get() >= 1,
993 "close_action should have been called, got {}",
994 close_calls.get()
995 );
996 assert_eq!(
997 host.closed.get(),
998 0,
999 "host.close() must NOT be called when close_action override is set"
1000 );
1001 }
1002
1003 fn attach_window_state(tree: &mut WidgetTree) -> teksilo_core::WindowState {
1007 let state = teksilo_core::WindowState::new(teksilo_core::WindowStateInit {
1008 id: teksilo_core::TeksiloWindowId::new(1),
1009 string_id: None,
1010 placement: teksilo_core::WindowPlacement::Floating,
1011 title: "Test".to_string(),
1012 size: (800, 600),
1013 position: (0, 0),
1014 focused: true,
1015 resizable: true,
1016 always_on_top: false,
1017 });
1018 tree.set_window_state(state.clone());
1019 state
1020 }
1021
1022 #[test]
1023 fn minimize_button_sets_placement_to_minimized() {
1024 let host = Rc::new(TestHost::default());
1025 let (mut tree, bar) = build_realistic_tree(host.clone(), |b| b);
1026 let state = attach_window_state(&mut tree);
1027
1028 let [minimize, _max, _close] = locate_control_buttons(&tree, bar);
1029 tree.click(minimize);
1030
1031 assert_eq!(
1032 state.placement().get(),
1033 teksilo_core::WindowPlacement::Minimized,
1034 "minimize button should flip WindowState::placement to Minimized"
1035 );
1036 }
1037
1038 #[test]
1039 fn maximize_button_toggles_placement() {
1040 let host = Rc::new(TestHost::default());
1041 let (mut tree, bar) = build_realistic_tree(host.clone(), |b| b);
1042 let state = attach_window_state(&mut tree);
1043
1044 let [_min, maximize, _close] = locate_control_buttons(&tree, bar);
1045 tree.click(maximize);
1046 assert_eq!(
1047 state.placement().get(),
1048 teksilo_core::WindowPlacement::Maximized
1049 );
1050
1051 tree.click(maximize);
1052 assert_eq!(
1053 state.placement().get(),
1054 teksilo_core::WindowPlacement::Floating
1055 );
1056 }
1057
1058 #[test]
1064 fn access_click_drives_window_controls() {
1065 let host = Rc::new(TestHost::default());
1066 let (mut tree, bar) = build_realistic_tree(host.clone(), |b| b);
1067 let state = attach_window_state(&mut tree);
1068
1069 let [minimize, maximize, _close] = locate_control_buttons(&tree, bar);
1070
1071 let at_click = |tree: &mut WidgetTree, id: WidgetId| {
1072 tree.dispatch_event(teksilo_core::event::WidgetEvent::AccessAction {
1073 action: teksilo_core::accesskit::Action::Click,
1074 target: Some(id),
1075 target_node: teksilo_core::accessibility::root_node_id(),
1076 data: None,
1077 });
1078 };
1079
1080 at_click(&mut tree, minimize);
1081 assert_eq!(
1082 state.placement().get(),
1083 teksilo_core::WindowPlacement::Minimized,
1084 "AT click on minimize must flip placement to Minimized"
1085 );
1086
1087 state
1088 .placement()
1089 .set(teksilo_core::WindowPlacement::Floating);
1090 at_click(&mut tree, maximize);
1091 assert_eq!(
1092 state.placement().get(),
1093 teksilo_core::WindowPlacement::Maximized,
1094 "AT click on maximize must flip placement to Maximized"
1095 );
1096 }
1097
1098 #[test]
1101 fn access_click_invokes_close_action() {
1102 let host = Rc::new(TestHost::default());
1103 let close_calls = Rc::new(Cell::new(0u32));
1104 let close_calls_clone = close_calls.clone();
1105
1106 let (mut tree, bar) = build_realistic_tree(host.clone(), move |b| {
1107 b.close_action(move |_ctx| {
1108 close_calls_clone.set(close_calls_clone.get() + 1);
1109 })
1110 });
1111
1112 let [_min, _max, close] = locate_control_buttons(&tree, bar);
1113 tree.dispatch_event(teksilo_core::event::WidgetEvent::AccessAction {
1114 action: teksilo_core::accesskit::Action::Click,
1115 target: Some(close),
1116 target_node: teksilo_core::accessibility::root_node_id(),
1117 data: None,
1118 });
1119
1120 assert_eq!(
1121 close_calls.get(),
1122 1,
1123 "AT click on close must invoke the close action"
1124 );
1125 }
1126
1127 fn locate_drag_region(tree: &WidgetTree, bar: WidgetId) -> WidgetId {
1130 let bar_kids = tree.children(bar);
1131 let row = bar_kids[0];
1132 let row_kids = tree.children(row);
1133 row_kids[0]
1134 }
1135
1136 #[test]
1137 fn dragging_inside_drag_region_calls_host_begin_drag() {
1138 let host = Rc::new(TestHost::default());
1144 let (mut tree, bar) = build_realistic_tree(host.clone(), |b| b);
1145
1146 let drag = locate_drag_region(&tree, bar);
1147 let drag_b = tree.bounds(drag);
1148 let from = Point::new(drag_b.x + 50.0, drag_b.y + drag_b.height / 2.0);
1149 let to = Point::new(drag_b.x + 200.0, drag_b.y + drag_b.height / 2.0);
1150
1151 tree.drag(from, to);
1152
1153 assert!(
1154 host.drags_started.get() >= 1,
1155 "host.begin_drag() should be called on drag-start, got {}",
1156 host.drags_started.get()
1157 );
1158 }
1159
1160 #[test]
1161 fn title_bar_exposes_banner_landmark() {
1162 let host = Rc::new(TestHost::default());
1163 let (tree, bar) = build_realistic_tree(host, |b| b);
1164 let info = tree.accessibility_node(bar);
1165 assert_eq!(info.role(), teksilo_core::accesskit::Role::Banner);
1166 assert!(
1167 info.name().is_some(),
1168 "TitleBar Banner landmark should have a localised name"
1169 );
1170 }
1171
1172 #[test]
1173 fn window_control_glyphs_retint_on_theme_switch() {
1174 use crate::primitives::{Expand, VStack};
1184 use std::cell::RefCell;
1185 use teksilo_canvas::MockTextBackend;
1186
1187 let host = Rc::new(TestHost::default());
1188 let bar_widget = TitleBar::new(host as Rc<dyn PlatformTitleBarHost>).height(40.0);
1189
1190 let mut tree = WidgetTree::new()
1191 .with_theme(teksilo_core::presets::intui::light())
1192 .with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())));
1193 let bar_id = tree.add(bar_widget);
1194 let body_id = tree.add(Expand::new());
1195 tree.add(VStack::new().spacing(0.0).child(bar_id).child(body_id));
1196
1197 tree.layout(SizeProposal::exact(900.0, 600.0));
1198 let light_glyphs: Vec<[f32; 4]> = tree.render().glyphs.iter().map(|g| g.color).collect();
1199 assert!(
1200 !light_glyphs.is_empty(),
1201 "control glyphs (—, □, ×) should have rendered"
1202 );
1203
1204 let light_primary = teksilo_core::presets::intui::light()
1207 .colors
1208 .text_primary
1209 .to_array();
1210 assert!(
1211 light_glyphs.iter().all(|c| *c == light_primary),
1212 "control glyphs should paint with the light theme's text_primary, got {light_glyphs:?}"
1213 );
1214
1215 tree.set_theme(teksilo_core::presets::intui::dark());
1216 tree.layout(SizeProposal::exact(900.0, 600.0));
1217 let dark_glyphs: Vec<[f32; 4]> = tree.render().glyphs.iter().map(|g| g.color).collect();
1218
1219 let dark_primary = teksilo_core::presets::intui::dark()
1220 .colors
1221 .text_primary
1222 .to_array();
1223 assert!(
1224 dark_glyphs.iter().all(|c| *c == dark_primary),
1225 "control glyphs should retint to the dark theme's text_primary, got {dark_glyphs:?}"
1226 );
1227 assert_ne!(
1228 light_glyphs, dark_glyphs,
1229 "control glyph colors must change across a theme switch"
1230 );
1231 }
1232
1233 #[test]
1234 fn window_controls_have_semantic_names_not_glyphs() {
1235 let host = Rc::new(TestHost::default());
1236 let (tree, bar) = build_realistic_tree(host, |b| b);
1237 let [minimize, maximize, close] = locate_control_buttons(&tree, bar);
1238
1239 let min_info = tree.accessibility_node(minimize);
1240 let max_info = tree.accessibility_node(maximize);
1241 let close_info = tree.accessibility_node(close);
1242
1243 for info in [&min_info, &max_info, &close_info] {
1247 let name = info.name().expect("control button must have a name");
1248 assert!(!name.is_empty(), "name empty");
1249 assert_ne!(name, "\u{2014}", "minimize reads glyph literal");
1250 assert_ne!(name, "\u{25A1}", "maximize reads glyph literal");
1251 assert_ne!(name, "\u{00D7}", "close reads glyph literal");
1252 assert_eq!(info.role(), teksilo_core::accesskit::Role::Button);
1253 }
1254 }
1255
1256 #[test]
1257 fn drag_region_is_no_stop_for_assistive_technology() {
1258 let host = Rc::new(TestHost::default());
1264 let (mut tree, bar) = build_realistic_tree(host, |b| b);
1265 let drag = locate_drag_region(&tree, bar);
1266 let info = tree.accessibility_node(drag);
1267 assert_eq!(info.role(), teksilo_core::accesskit::Role::GenericContainer);
1268 assert!(
1269 !info.is_hidden(),
1270 "a hidden DragRegion hides the title bar's centre content with it"
1271 );
1272
1273 let update = tree.sync_accessibility();
1274 let target = teksilo_core::accessibility::widget_id_to_node_id(drag);
1275 let consumer = accesskit_consumer::Tree::new(update, false);
1276 let mut stack = vec![consumer.state().root()];
1277 while let Some(node) = stack.pop() {
1278 assert_ne!(
1279 node.locate().0,
1280 target,
1281 "the DragRegion must not be a stop in the tree an adapter walks"
1282 );
1283 stack.extend(node.filtered_children(&accesskit_consumer::common_filter));
1284 }
1285 }
1286
1287 fn paint_once(tree: &mut WidgetTree) {
1290 tree.layout(SizeProposal::exact(900.0, 600.0));
1291 let _ = tree.render();
1292 }
1293
1294 #[test]
1295 fn dead_zone_in_center_is_published_as_a_no_drag_hole() {
1296 let host = Rc::new(TestHost::default());
1304 let host_for_bar = host.clone();
1305 let (mut tree, _bar) = build_realistic_tree(host_for_bar, |b| {
1306 b.center(
1307 HStack::new()
1308 .child(DeadZone::new().child(FixedSize::new().width(60.0).height(30.0)))
1309 .child(Expand::new()),
1310 )
1311 });
1312 paint_once(&mut tree);
1313
1314 let regions = host.last_regions.borrow();
1315 assert_eq!(
1316 regions.drag.len(),
1317 1,
1318 "the drag region should still be published"
1319 );
1320 assert_eq!(
1321 regions.no_drag.len(),
1322 1,
1323 "the DeadZone in `center` must be published as one no_drag hole, got {:?}",
1324 regions.no_drag
1325 );
1326 let hole = regions.no_drag[0];
1327 let drag = regions.drag[0];
1328 assert!(
1329 (hole.width - 60.0).abs() < 1.0,
1330 "the hole should match the dead zone's width, got {}",
1331 hole.width
1332 );
1333 assert!(
1336 hole.x >= drag.x - 0.01 && hole.right() <= drag.right() + 0.01,
1337 "hole {hole:?} must be clipped to the drag rect {drag:?}"
1338 );
1339 }
1340
1341 #[test]
1342 fn passive_center_content_punches_no_hole() {
1343 let host = Rc::new(TestHost::default());
1347 let host_for_bar = host.clone();
1348 let (mut tree, _bar) = build_realistic_tree(host_for_bar, |b| {
1349 b.center(crate::TextWidget::new(teksilo_i18n::lit!("My App")))
1350 });
1351 paint_once(&mut tree);
1352
1353 let regions = host.last_regions.borrow();
1354 assert_eq!(regions.drag.len(), 1, "drag region still published");
1355 assert!(
1356 regions.no_drag.is_empty(),
1357 "a passive centred title must not punch a hole in the caption, got {:?}",
1358 regions.no_drag
1359 );
1360 }
1361
1362 #[test]
1363 fn overlay_over_the_caption_is_published_as_a_no_drag_hole() {
1364 use teksilo_core::overlay::{DismissBehavior, OverlayPlacement, OverlayRequest};
1373
1374 let host = Rc::new(TestHost::default());
1375 let (mut tree, bar) = build_realistic_tree(host.clone(), |b| b);
1376
1377 let content = tree.add(FixedSize::new().width(200.0).height(60.0));
1381 tree.show_overlay(OverlayRequest::new(
1382 content,
1383 bar,
1384 OverlayPlacement::AtPointer(Point::new(100.0, 20.0)),
1385 DismissBehavior::Manual,
1386 ));
1387 paint_once(&mut tree);
1388
1389 let regions = host.last_regions.borrow();
1390 assert_eq!(regions.drag.len(), 1, "the drag region is still published");
1391 assert_eq!(
1392 regions.no_drag.len(),
1393 1,
1394 "the overlay's caption overlap must be published as one no_drag \
1395 hole, got {:?}",
1396 regions.no_drag
1397 );
1398 let hole = regions.no_drag[0];
1399 assert!(
1400 (hole.x - 100.0).abs() < 0.01 && (hole.width - 200.0).abs() < 0.01,
1401 "the hole should span the overlay's width at its position, got {hole:?}"
1402 );
1403 assert!(
1406 (hole.y - 20.0).abs() < 0.01 && (hole.bottom() - 40.0).abs() < 0.01,
1407 "the hole must be clipped to the title bar strip, got {hole:?}"
1408 );
1409 }
1410
1411 #[test]
1412 fn overlay_below_the_caption_punches_no_hole() {
1413 use teksilo_core::overlay::{DismissBehavior, OverlayPlacement, OverlayRequest};
1418
1419 let host = Rc::new(TestHost::default());
1420 let (mut tree, bar) = build_realistic_tree(host.clone(), |b| b);
1421
1422 let content = tree.add(FixedSize::new().width(200.0).height(60.0));
1423 tree.show_overlay(OverlayRequest::new(
1424 content,
1425 bar,
1426 OverlayPlacement::AtPointer(Point::new(100.0, 300.0)),
1427 DismissBehavior::Manual,
1428 ));
1429 paint_once(&mut tree);
1430
1431 let regions = host.last_regions.borrow();
1432 assert!(
1433 regions.no_drag.is_empty(),
1434 "an overlay fully below the caption must not punch a hole, got {:?}",
1435 regions.no_drag
1436 );
1437 }
1438
1439 #[test]
1440 fn dead_zone_in_center_does_not_arm_the_window_drag() {
1441 let host = Rc::new(TestHost::default());
1448 let host_for_bar = host.clone();
1449 let (mut tree, _bar) = build_realistic_tree(host_for_bar, |b| {
1450 b.center(
1451 HStack::new()
1452 .child(DeadZone::new().child(FixedSize::new().width(60.0).height(30.0)))
1453 .child(Expand::new()),
1454 )
1455 });
1456 paint_once(&mut tree);
1457
1458 let hole = host.last_regions.borrow().no_drag[0];
1459 let (cx, cy) = (hole.x + hole.width / 2.0, hole.y + hole.height / 2.0);
1460
1461 tree.pointer_down_button(Point::new(cx, cy), PointerButton::Primary);
1463 for i in 1..=10 {
1464 tree.pointer_move(Point::new(cx + (i as f32) * 3.0, cy + 1.0));
1465 }
1466 tree.pointer_up_button(Point::new(cx + 30.0, cy + 1.0), PointerButton::Primary);
1467
1468 assert_eq!(
1469 host.drags_started.get(),
1470 0,
1471 "a jittery click on a DeadZone inside the title bar must not drag the window"
1472 );
1473 }
1474
1475 #[test]
1476 fn dragging_the_bare_drag_region_still_works_with_a_dead_zone_present() {
1477 let host = Rc::new(TestHost::default());
1481 let host_for_bar = host.clone();
1482 let (mut tree, bar) = build_realistic_tree(host_for_bar, |b| {
1483 b.center(
1484 HStack::new()
1485 .child(DeadZone::new().child(FixedSize::new().width(60.0).height(30.0)))
1486 .child(Expand::new()),
1487 )
1488 });
1489 paint_once(&mut tree);
1490
1491 let drag_b = tree.bounds(locate_drag_region(&tree, bar));
1493 let from = Point::new(drag_b.right() - 40.0, drag_b.y + drag_b.height / 2.0);
1494 let to = Point::new(drag_b.right() - 200.0, drag_b.y + drag_b.height / 2.0);
1495 tree.drag(from, to);
1496
1497 assert!(
1498 host.drags_started.get() >= 1,
1499 "the drag region outside the hole must still move the window"
1500 );
1501 }
1502
1503 #[test]
1504 fn double_clicking_drag_region_toggles_placement() {
1505 let host = Rc::new(TestHost::default());
1509 let (mut tree, bar) = build_realistic_tree(host.clone(), |b| b);
1510 let state = attach_window_state(&mut tree);
1511
1512 let drag = locate_drag_region(&tree, bar);
1513 tree.click(drag);
1514 tree.click(drag);
1515
1516 assert_eq!(
1517 state.placement().get(),
1518 teksilo_core::WindowPlacement::Maximized,
1519 "double-tap on drag region should flip WindowState::placement to Maximized"
1520 );
1521 }
1522}