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::WindowFrame;
57
58pub type CloseAction = Rc<dyn Fn(&mut EventContext)>;
62
63pub struct TitleBar {
93 host: Rc<dyn PlatformTitleBarHost>,
94 leading: Option<PendingChild>,
95 center: Option<PendingChild>,
96 trailing: Option<PendingChild>,
97 height: f32,
98 background: ColorProp,
99 border_color: ColorProp,
100 border_width: f32,
101 close_action: Option<CloseAction>,
104 root_child_id: Option<WidgetId>,
105 drag_region_id: Cell<Option<WidgetId>>,
109 controls_layout: Rc<Cell<Option<WindowControlsLayout>>>,
113 controls_visible: Prop<bool>,
118}
119
120impl std::fmt::Debug for TitleBar {
121 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122 f.debug_struct("TitleBar")
123 .field("height", &self.height)
124 .field("has_leading", &self.leading.is_some())
125 .field("has_center", &self.center.is_some())
126 .field("has_trailing", &self.trailing.is_some())
127 .finish_non_exhaustive()
128 }
129}
130
131impl TitleBar {
132 pub fn new(host: Rc<dyn PlatformTitleBarHost>) -> Self {
138 Self {
139 host,
140 leading: None,
141 center: None,
142 trailing: None,
143 height: 40.0,
144 background: ColorProp::Static(Color::TRANSPARENT),
145 border_color: ColorProp::Static(Color::TRANSPARENT),
146 border_width: 0.0,
147 close_action: None,
148 root_child_id: None,
149 drag_region_id: Cell::new(None),
150 controls_layout: Rc::new(Cell::new(None)),
151 controls_visible: Prop::Static(true),
152 }
153 }
154
155 pub fn controls_visible(mut self, visible: impl Into<Prop<bool>>) -> Self {
178 self.controls_visible = visible.into();
179 self
180 }
181
182 pub fn height(mut self, height: f32) -> Self {
184 self.height = height;
185 self
186 }
187
188 pub fn background(mut self, color: impl Into<ColorProp>) -> Self {
196 self.background = color.into();
197 self
198 }
199
200 pub fn border(mut self, color: impl Into<ColorProp>, width: f32) -> Self {
206 self.border_color = color.into();
207 self.border_width = width;
208 self
209 }
210
211 pub fn leading(mut self, widget: impl Widget + 'static) -> Self {
214 self.leading = Some(PendingChild::Deferred(Box::new(widget)));
215 self
216 }
217
218 pub fn leading_id(mut self, id: WidgetId) -> Self {
220 self.leading = Some(PendingChild::Id(id));
221 self
222 }
223
224 pub fn center(mut self, widget: impl Widget + 'static) -> Self {
228 self.center = Some(PendingChild::Deferred(Box::new(widget)));
229 self
230 }
231
232 pub fn center_id(mut self, id: WidgetId) -> Self {
234 self.center = Some(PendingChild::Id(id));
235 self
236 }
237
238 pub fn trailing(mut self, widget: impl Widget + 'static) -> Self {
241 self.trailing = Some(PendingChild::Deferred(Box::new(widget)));
242 self
243 }
244
245 pub fn trailing_id(mut self, id: WidgetId) -> Self {
247 self.trailing = Some(PendingChild::Id(id));
248 self
249 }
250
251 pub fn close_action(mut self, action: impl Fn(&mut EventContext) + 'static) -> Self {
258 self.close_action = Some(Rc::new(action));
259 self
260 }
261}
262
263impl Widget for TitleBar {
264 fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
265 let self_id = ctx.self_id();
269 let registry = ctx.binding_registry();
270 self.background.register_if_bound(
271 self_id,
272 registry,
273 teksilo_core::binding::BindingLevel::RepaintOnly,
274 );
275 self.border_color.register_if_bound(
276 self_id,
277 registry,
278 teksilo_core::binding::BindingLevel::RepaintOnly,
279 );
280
281 let leading_inset = self.host.reserved_leading_inset();
282 let trailing_inset = self.host.reserved_trailing_inset();
283 let renders_controls = self.host.renders_custom_controls();
284 let height = self.height;
285
286 let drag_region = match self.center.take() {
290 Some(PendingChild::Deferred(child)) => DragRegion::with_child(self.host.clone(), child),
291 Some(PendingChild::Id(id)) => DragRegion::with_child_id(self.host.clone(), id),
292 None => DragRegion::new(self.host.clone()),
293 }
294 .close_action(self.close_action.clone());
297 let drag_region_id = ctx.add(drag_region);
298 self.drag_region_id.set(Some(drag_region_id));
299
300 let show_restore_signal = ctx
309 .window()
310 .map(|w| w.placement().map(|p| p.is_maximized() || p.is_fullscreen()))
311 .unwrap_or_else(|| Signal::new(false));
312 let controls_id: Option<WidgetId> = if renders_controls {
325 let controls = WindowControls::new(
326 self.host.clone(),
327 show_restore_signal,
328 self.close_action.clone(),
329 )
330 .layout_sink(self.controls_layout.clone());
331 let id = ctx.add(controls);
332 ctx.visible_when(id, self.controls_visible.clone());
333 Some(id)
334 } else {
335 None
336 };
337
338 let mut row = HStack::new().spacing(0.0);
344
345 if leading_inset.width > 0.0 {
346 row = row.child(FixedSize::new().width(leading_inset.width).height(height));
347 }
348
349 if let Some(leading) = self.leading.take() {
350 let id = match leading {
351 PendingChild::Id(id) => id,
352 PendingChild::Deferred(w) => ctx.add_boxed(w),
353 };
354 row = row.add_child(id);
355 }
356
357 row = row.add_child(drag_region_id);
358
359 if let Some(trailing) = self.trailing.take() {
360 let id = match trailing {
361 PendingChild::Id(id) => id,
362 PendingChild::Deferred(w) => ctx.add_boxed(w),
363 };
364 row = row.add_child(id);
365 }
366
367 if trailing_inset.width > 0.0 {
368 row = row.child(FixedSize::new().width(trailing_inset.width).height(height));
369 }
370
371 if let Some(id) = controls_id {
372 row = row.add_child(id);
373 }
374
375 let root = ctx.add(row);
376 self.root_child_id = Some(root);
377 vec![root]
378 }
379
380 fn layout_response(
381 &self,
382 proposal: SizeProposal,
383 _ctx: &LayoutContext,
384 ) -> teksilo_core::widget::LayoutResponse {
385 Size::new(proposal.width.unwrap_or(0.0), self.height).into()
392 }
393
394 fn place_children(
395 &self,
396 bounds: Rect,
397 _proposal: SizeProposal,
398 children: &mut [WidgetPlacement],
399 _ctx: &LayoutContext,
400 ) {
401 for child in children.iter_mut() {
402 child.origin = bounds.origin();
403 child.size = bounds.size();
404 }
405 }
406
407 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
408 let bg = self.background.resolve(ctx.theme, ctx.effective_enabled);
409 if bg.a() > 0.0 {
410 canvas.fill_rounded_rect(bounds, CornerRadius::ZERO, bg);
411 }
412 if self.border_width > 0.0 {
413 let border = self.border_color.resolve(ctx.theme, ctx.effective_enabled);
414 if border.a() > 0.0 {
415 canvas.draw_border_bottom(bounds, border, self.border_width);
416 }
417 }
418 }
419
420 fn wants_after_paint(&self) -> bool {
421 true
426 }
427
428 fn after_paint(&self, view: &WidgetTreeView<'_>, _ctx: &PaintContext) {
429 let mut regions = HitRegions::new();
434
435 if let Some(drag_id) = self.drag_region_id.get() {
436 let drag_bounds = view.bounds(drag_id);
437 if drag_bounds.width > 0.0 && drag_bounds.height > 0.0 {
441 regions.drag.push(drag_bounds);
442 collect_dead_zones(view, drag_id, drag_bounds, &mut regions.no_drag);
451 }
452 }
453
454 if let Some(strip_id) = self.root_child_id {
467 let strip = view.bounds(strip_id);
468 for &overlay in view.overlay_rects() {
469 if let Some(hole) = intersect(overlay, strip) {
470 regions.no_drag.push(hole);
471 }
472 }
473 }
474
475 if !self.controls_visible.get() {
481 self.host.update_hit_regions(®ions);
482 return;
483 }
484
485 if let Some(layout) = self.controls_layout.take() {
486 regions.minimize = Some(view.bounds(layout.minimize_id));
487 regions.minimize_id = Some(layout.minimize_id);
488
489 regions.maximize = Some(view.bounds(layout.maximize_id));
497 regions.maximize_id = Some(layout.maximize_id);
498
499 regions.close = Some(view.bounds(layout.close_id));
500 regions.close_id = Some(layout.close_id);
501
502 self.controls_layout.set(Some(layout));
505 }
506
507 self.host.update_hit_regions(®ions);
508 }
509
510 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
511 builder.set_role(teksilo_core::accesskit::Role::Banner);
512 builder.set_name(teksilo_i18n::tr_widget!(a11y_title_bar_name()).resolve_now());
513 }
514
515 fn children(&self) -> Vec<WidgetId> {
516 self.root_child_id.into_iter().collect()
517 }
518}
519
520fn collect_dead_zones(view: &WidgetTreeView<'_>, root: WidgetId, clip: Rect, out: &mut Vec<Rect>) {
529 for &child in view.children(root) {
530 if !view.is_active(child) {
531 continue;
532 }
533 let Some(hit) = intersect(view.bounds(child), clip) else {
534 continue;
535 };
536 if view.is_gesture_dead_zone(child) {
537 out.push(hit);
538 continue;
539 }
540 collect_dead_zones(view, child, clip, out);
541 }
542}
543
544fn intersect(a: Rect, b: Rect) -> Option<Rect> {
548 let x0 = a.x.max(b.x);
549 let y0 = a.y.max(b.y);
550 let x1 = a.right().min(b.right());
551 let y1 = a.bottom().min(b.bottom());
552 (x1 > x0 && y1 > y0).then(|| Rect::new(x0, y0, x1 - x0, y1 - y0))
553}
554
555#[cfg(test)]
556mod tests {
557 use super::*;
558 use crate::primitives::{DeadZone, Expand};
559 use std::cell::{Cell, RefCell};
560 use teksilo_canvas::Point;
561 use teksilo_core::event::PointerButton;
562 use teksilo_core::widget_tree::WidgetTree;
563 use teksilo_core::{HitRegions, PlatformError, PlatformTitleBarHost, ResizeEdge};
564
565 struct TestHost {
569 minimized: Cell<u32>,
570 maximize_toggled: Cell<u32>,
571 closed: Cell<u32>,
572 drags_started: Cell<u32>,
573 is_max: Signal<bool>,
574 last_regions: RefCell<HitRegions>,
577 }
578
579 impl Default for TestHost {
580 fn default() -> Self {
581 Self {
582 minimized: Cell::new(0),
583 maximize_toggled: Cell::new(0),
584 closed: Cell::new(0),
585 drags_started: Cell::new(0),
586 is_max: Signal::new(false),
587 last_regions: RefCell::new(HitRegions::default()),
588 }
589 }
590 }
591
592 impl PlatformTitleBarHost for TestHost {
593 fn reserved_leading_inset(&self) -> Size {
594 Size::ZERO
595 }
596 fn reserved_trailing_inset(&self) -> Size {
597 Size::ZERO
598 }
599 fn renders_custom_controls(&self) -> bool {
600 true
601 }
602 fn needs_custom_resize_handles(&self) -> bool {
603 true
604 }
605 fn begin_drag(&self) -> Result<(), PlatformError> {
606 self.drags_started.set(self.drags_started.get() + 1);
607 Ok(())
608 }
609 fn begin_resize(&self, _edge: ResizeEdge) -> Result<(), PlatformError> {
610 Ok(())
611 }
612 fn show_window_menu(&self, _at: Point) -> Result<(), PlatformError> {
613 Ok(())
614 }
615 fn update_hit_regions(&self, regions: &HitRegions) {
616 *self.last_regions.borrow_mut() = regions.clone();
617 }
618 }
619
620 fn build_realistic_tree(
624 host: Rc<TestHost>,
625 bar_setup: impl FnOnce(TitleBar) -> TitleBar,
626 ) -> (WidgetTree, WidgetId) {
627 use crate::primitives::{Expand, VStack};
628
629 let bar_widget =
630 bar_setup(TitleBar::new(host as Rc<dyn PlatformTitleBarHost>).height(40.0));
631
632 let mut tree = WidgetTree::new()
636 .with_theme(teksilo_core::presets::intui::light())
637 .with_text_backend(Rc::new(std::cell::RefCell::new(
638 teksilo_canvas::MockTextBackend::new(),
639 )));
640 let bar_id = tree.add(bar_widget);
641 let body_id = tree.add(Expand::new());
642 let _root = tree.add(
643 VStack::new()
644 .spacing(0.0)
645 .add_child(bar_id)
646 .add_child(body_id),
647 );
648 tree.layout(SizeProposal::exact(900.0, 600.0));
649 (tree, bar_id)
650 }
651
652 fn locate_control_buttons(tree: &WidgetTree, bar: WidgetId) -> [WidgetId; 3] {
665 let bar_kids = tree.children(bar);
667 assert_eq!(
668 bar_kids.len(),
669 1,
670 "TitleBar should have a single root: {bar_kids:?}"
671 );
672 let row = bar_kids[0];
673
674 let row_kids = tree.children(row);
676 assert_eq!(
677 row_kids.len(),
678 2,
679 "row should have drag_region + controls, got {row_kids:?}"
680 );
681 let controls = row_kids[1];
682
683 let controls_kids = tree.children(controls);
685 assert_eq!(controls_kids.len(), 1, "controls should wrap one HStack");
686 let inner_row = controls_kids[0];
687
688 let inner_kids = tree.children(inner_row);
690 assert_eq!(
691 inner_kids.len(),
692 3,
693 "inner controls row should contain 3 items, got {inner_kids:?}"
694 );
695 let max_buttons = tree.children(inner_kids[1]);
699 assert_eq!(
700 max_buttons.len(),
701 2,
702 "maximize Switcher should expose 2 ControlButtons (□ + ❐), got {max_buttons:?}"
703 );
704 [inner_kids[0], max_buttons[0], inner_kids[2]]
705 }
706
707 fn controls_are_live(tree: &WidgetTree, bar: WidgetId) -> bool {
713 let row = tree.children(bar)[0];
714 tree.children(row)
715 .last()
716 .is_some_and(|&id| tree.is_active(id) && tree.bounds(id).width > 0.0)
717 }
718
719 fn tree_at_placement(placement: teksilo_core::WindowPlacement) -> (WidgetTree, WidgetId) {
723 use crate::primitives::VStack;
724 use teksilo_core::window::WindowState;
725 use teksilo_core::{TeksiloWindowId, WindowStateInit};
726
727 let host = Rc::new(TestHost::default());
728 let mut tree = WidgetTree::new()
729 .with_theme(teksilo_core::presets::intui::light())
730 .with_text_backend(Rc::new(std::cell::RefCell::new(
731 teksilo_canvas::MockTextBackend::new(),
732 )));
733 tree.set_window_state(WindowState::new(WindowStateInit {
734 id: TeksiloWindowId::new(1),
735 string_id: Some("w1".to_string()),
736 placement,
737 title: "Test".to_string(),
738 size: (900, 600),
739 position: (0, 0),
740 focused: true,
741 resizable: true,
742 always_on_top: false,
743 }));
744 let bar_id = tree.add(TitleBar::new(host as Rc<dyn PlatformTitleBarHost>).height(40.0));
745 let body_id = tree.add(Expand::new());
746 let _root = tree.add(
747 VStack::new()
748 .spacing(0.0)
749 .add_child(bar_id)
750 .add_child(body_id),
751 );
752 tree.layout(SizeProposal::exact(900.0, 600.0));
753 (tree, bar_id)
754 }
755
756 fn visible_maximize_page(tree: &WidgetTree, bar: WidgetId) -> (usize, WidgetId) {
765 let row = tree.children(bar)[0];
766 let controls = tree.children(row)[1];
767 let inner = tree.children(controls)[0];
768 let switcher = tree.children(inner)[1];
769 let pages = tree.children(switcher);
770 pages
771 .iter()
772 .enumerate()
773 .find(|&(_, &p)| tree.is_active(p))
774 .map(|(i, &p)| (i, p))
775 .expect("one maximize page must be active")
776 }
777
778 #[test]
792 fn rebuilding_keeps_the_leading_and_trailing_slots() {
793 use crate::TextWidget;
794 use crate::primitives::VStack;
795 use teksilo_i18n::lit;
796
797 let host = Rc::new(TestHost::default());
798 let visible = Signal::new(true);
799 let mut tree = WidgetTree::new()
800 .with_theme(teksilo_core::presets::intui::light())
801 .with_text_backend(Rc::new(std::cell::RefCell::new(
802 teksilo_canvas::MockTextBackend::new(),
803 )));
804 let bar = tree.add(
805 TitleBar::new(host as Rc<dyn PlatformTitleBarHost>)
806 .height(40.0)
807 .leading(TextWidget::new(lit!("MENU")))
808 .center(TextWidget::new(lit!("TITLE")))
809 .trailing(TextWidget::new(lit!("TOOLS")))
810 .controls_visible(visible.clone()),
811 );
812 let body = tree.add(Expand::new());
813 let _root = tree.add(VStack::new().spacing(0.0).add_child(bar).add_child(body));
814 tree.layout(SizeProposal::exact(900.0, 600.0));
815
816 let shape = |t: &WidgetTree| {
822 let row = t.children(bar)[0];
823 let kids = t.children(row);
824 let drag_w = kids.get(1).map(|&id| t.bounds(id).width).unwrap_or(0.0);
825 (kids.len(), drag_w > 0.0, t.bounds(row).width)
826 };
827
828 let (n, drag_fills, row_w) = shape(&tree);
829 assert_eq!(n, 4, "leading + drag + trailing + controls");
830 assert!(
831 drag_fills,
832 "the drag region is a spacer and must have width"
833 );
834 assert!((row_w - 900.0).abs() < 1.0, "row spans the bar: {row_w}");
835
836 visible.set(false);
838 tree.layout(SizeProposal::exact(900.0, 600.0));
839 assert!(
840 !controls_are_live(&tree, bar),
841 "the cluster parks when the gate goes false"
842 );
843 let (n, drag_fills, row_w) = shape(&tree);
844 assert_eq!(n, 4, "the cluster parks, it is not removed");
845 assert!(drag_fills && (row_w - 900.0).abs() < 1.0, "slots intact");
846
847 visible.set(true);
848 tree.layout(SizeProposal::exact(900.0, 600.0));
849 assert!(controls_are_live(&tree, bar), "and comes back");
850 let (n, drag_fills, row_w) = shape(&tree);
851 assert_eq!(
852 n, 4,
853 "the leading/center/trailing slots must survive a gate flip — \
854 `build` consumes them, so anything that rebuilds this bar empties it"
855 );
856 assert!(
857 drag_fills && (row_w - 900.0).abs() < 1.0,
858 "slots still intact"
859 );
860 }
861
862 #[test]
863 fn controls_visible_false_parks_the_cluster() {
864 let host = Rc::new(TestHost::default());
865 let (tree, bar) = build_realistic_tree(host, |b| b.controls_visible(false));
866 assert!(
867 !controls_are_live(&tree, bar),
868 "controls_visible(false) must leave the cluster dormant and zero-width"
869 );
870 }
871
872 #[test]
873 fn controls_visible_defaults_to_showing_them() {
874 let host = Rc::new(TestHost::default());
875 let (tree, bar) = build_realistic_tree(host, |b| b);
876 assert!(controls_are_live(&tree, bar), "default is shown");
877 }
878
879 #[test]
884 fn controls_visible_flips_a_mounted_bar_both_ways() {
885 use crate::primitives::VStack;
886 let host = Rc::new(TestHost::default());
887 let visible = Signal::new(true);
888 let mut tree = WidgetTree::new()
889 .with_theme(teksilo_core::presets::intui::light())
890 .with_text_backend(Rc::new(std::cell::RefCell::new(
891 teksilo_canvas::MockTextBackend::new(),
892 )));
893 let bar = tree.add(
894 TitleBar::new(host as Rc<dyn PlatformTitleBarHost>)
895 .height(40.0)
896 .controls_visible(visible.clone()),
897 );
898 let body = tree.add(Expand::new());
899 let _root = tree.add(VStack::new().spacing(0.0).add_child(bar).add_child(body));
900 tree.layout(SizeProposal::exact(900.0, 600.0));
901 assert!(controls_are_live(&tree, bar), "starts shown");
902
903 visible.set(false);
904 tree.layout(SizeProposal::exact(900.0, 600.0));
905 assert!(
906 !controls_are_live(&tree, bar),
907 "hiding must park the cluster on a mounted bar"
908 );
909
910 visible.set(true);
911 tree.layout(SizeProposal::exact(900.0, 600.0));
912 assert!(
913 controls_are_live(&tree, bar),
914 "and a hidden cluster must still learn to come back"
915 );
916 }
917
918 #[test]
922 fn fullscreen_shows_the_restore_page_not_maximize() {
923 use teksilo_core::WindowPlacement as P;
924 let (tree, bar) = tree_at_placement(P::Floating);
925 assert_eq!(
926 visible_maximize_page(&tree, bar).0,
927 0,
928 "floating offers Maximize"
929 );
930
931 let (tree, bar) = tree_at_placement(P::Maximized);
932 assert_eq!(
933 visible_maximize_page(&tree, bar).0,
934 1,
935 "maximized offers Restore"
936 );
937
938 let (tree, bar) = tree_at_placement(P::Fullscreen);
939 assert_eq!(
940 visible_maximize_page(&tree, bar).0,
941 1,
942 "fullscreen must offer Restore — maximize is meaningless there"
943 );
944 }
945
946 #[test]
950 fn activating_restore_from_fullscreen_leaves_fullscreen() {
951 use teksilo_core::WindowPlacement as P;
952 let (mut tree, bar) = tree_at_placement(P::Fullscreen);
953 let (_, restore) = visible_maximize_page(&tree, bar);
954 let b = tree.bounds(restore);
955 let centre = Point::new(b.x + b.width / 2.0, b.y + b.height / 2.0);
956 tree.pointer_down_button(centre, PointerButton::Primary);
957 tree.pointer_up_button(centre, PointerButton::Primary);
958
959 let placement = tree
960 .window_state()
961 .expect("window state attached")
962 .placement()
963 .get();
964 assert_eq!(
965 placement,
966 P::Floating,
967 "restore from fullscreen must not land on Maximized"
968 );
969 }
970
971 #[test]
972 fn title_bar_claims_full_width_and_configured_height() {
973 let host = Rc::new(TestHost::default());
974 let (tree, bar) = build_realistic_tree(host, |b| b);
975 let b = tree.bounds(bar);
976 assert!((b.width - 900.0).abs() < 0.01, "width = {}", b.width);
977 assert!((b.height - 40.0).abs() < 0.01, "height = {}", b.height);
978 }
979
980 #[test]
981 fn drag_region_is_a_spacer_so_controls_sit_flush_right() {
982 let host = Rc::new(TestHost::default());
986 let (tree, bar) = build_realistic_tree(host, |b| b);
987
988 let [_minimize, _maximize, close] = locate_control_buttons(&tree, bar);
989 let close_b = tree.bounds(close);
990
991 assert!(
994 (close_b.right() - 900.0).abs() < 1.0,
995 "close right edge = {}, expected ~900",
996 close_b.right()
997 );
998 assert!(
999 (close_b.width - 46.0).abs() < 1.0,
1000 "close cell width = {}, expected 46",
1001 close_b.width
1002 );
1003 }
1004
1005 #[test]
1006 fn close_action_override_is_invoked_instead_of_host_close() {
1007 let host = Rc::new(TestHost::default());
1008 let close_calls = Rc::new(Cell::new(0u32));
1009 let close_calls_clone = close_calls.clone();
1010
1011 let host_clone = host.clone();
1012 let (mut tree, bar) = build_realistic_tree(host_clone, move |b| {
1013 b.close_action(move |_ctx| {
1014 close_calls_clone.set(close_calls_clone.get() + 1);
1015 })
1016 });
1017
1018 let [_min, _max, close] = locate_control_buttons(&tree, bar);
1019 tree.click(close);
1020
1021 assert!(
1022 close_calls.get() >= 1,
1023 "close_action should have been called, got {}",
1024 close_calls.get()
1025 );
1026 assert_eq!(
1027 host.closed.get(),
1028 0,
1029 "host.close() must NOT be called when close_action override is set"
1030 );
1031 }
1032
1033 fn attach_window_state(tree: &mut WidgetTree) -> teksilo_core::WindowState {
1037 let state = teksilo_core::WindowState::new(teksilo_core::WindowStateInit {
1038 id: teksilo_core::TeksiloWindowId::new(1),
1039 string_id: None,
1040 placement: teksilo_core::WindowPlacement::Floating,
1041 title: "Test".to_string(),
1042 size: (800, 600),
1043 position: (0, 0),
1044 focused: true,
1045 resizable: true,
1046 always_on_top: false,
1047 });
1048 tree.set_window_state(state.clone());
1049 state
1050 }
1051
1052 #[test]
1053 fn minimize_button_sets_placement_to_minimized() {
1054 let host = Rc::new(TestHost::default());
1055 let (mut tree, bar) = build_realistic_tree(host.clone(), |b| b);
1056 let state = attach_window_state(&mut tree);
1057
1058 let [minimize, _max, _close] = locate_control_buttons(&tree, bar);
1059 tree.click(minimize);
1060
1061 assert_eq!(
1062 state.placement().get(),
1063 teksilo_core::WindowPlacement::Minimized,
1064 "minimize button should flip WindowState::placement to Minimized"
1065 );
1066 }
1067
1068 #[test]
1069 fn maximize_button_toggles_placement() {
1070 let host = Rc::new(TestHost::default());
1071 let (mut tree, bar) = build_realistic_tree(host.clone(), |b| b);
1072 let state = attach_window_state(&mut tree);
1073
1074 let [_min, maximize, _close] = locate_control_buttons(&tree, bar);
1075 tree.click(maximize);
1076 assert_eq!(
1077 state.placement().get(),
1078 teksilo_core::WindowPlacement::Maximized
1079 );
1080
1081 tree.click(maximize);
1082 assert_eq!(
1083 state.placement().get(),
1084 teksilo_core::WindowPlacement::Floating
1085 );
1086 }
1087
1088 #[test]
1094 fn access_click_drives_window_controls() {
1095 let host = Rc::new(TestHost::default());
1096 let (mut tree, bar) = build_realistic_tree(host.clone(), |b| b);
1097 let state = attach_window_state(&mut tree);
1098
1099 let [minimize, maximize, _close] = locate_control_buttons(&tree, bar);
1100
1101 let at_click = |tree: &mut WidgetTree, id: WidgetId| {
1102 tree.dispatch_event(teksilo_core::event::WidgetEvent::AccessAction {
1103 action: teksilo_core::accesskit::Action::Click,
1104 target: Some(id),
1105 target_node: teksilo_core::accessibility::root_node_id(),
1106 data: None,
1107 });
1108 };
1109
1110 at_click(&mut tree, minimize);
1111 assert_eq!(
1112 state.placement().get(),
1113 teksilo_core::WindowPlacement::Minimized,
1114 "AT click on minimize must flip placement to Minimized"
1115 );
1116
1117 state
1118 .placement()
1119 .set(teksilo_core::WindowPlacement::Floating);
1120 at_click(&mut tree, maximize);
1121 assert_eq!(
1122 state.placement().get(),
1123 teksilo_core::WindowPlacement::Maximized,
1124 "AT click on maximize must flip placement to Maximized"
1125 );
1126 }
1127
1128 #[test]
1131 fn access_click_invokes_close_action() {
1132 let host = Rc::new(TestHost::default());
1133 let close_calls = Rc::new(Cell::new(0u32));
1134 let close_calls_clone = close_calls.clone();
1135
1136 let (mut tree, bar) = build_realistic_tree(host.clone(), move |b| {
1137 b.close_action(move |_ctx| {
1138 close_calls_clone.set(close_calls_clone.get() + 1);
1139 })
1140 });
1141
1142 let [_min, _max, close] = locate_control_buttons(&tree, bar);
1143 tree.dispatch_event(teksilo_core::event::WidgetEvent::AccessAction {
1144 action: teksilo_core::accesskit::Action::Click,
1145 target: Some(close),
1146 target_node: teksilo_core::accessibility::root_node_id(),
1147 data: None,
1148 });
1149
1150 assert_eq!(
1151 close_calls.get(),
1152 1,
1153 "AT click on close must invoke the close action"
1154 );
1155 }
1156
1157 fn locate_drag_region(tree: &WidgetTree, bar: WidgetId) -> WidgetId {
1160 let bar_kids = tree.children(bar);
1161 let row = bar_kids[0];
1162 let row_kids = tree.children(row);
1163 row_kids[0]
1164 }
1165
1166 #[test]
1167 fn dragging_inside_drag_region_calls_host_begin_drag() {
1168 let host = Rc::new(TestHost::default());
1174 let (mut tree, bar) = build_realistic_tree(host.clone(), |b| b);
1175
1176 let drag = locate_drag_region(&tree, bar);
1177 let drag_b = tree.bounds(drag);
1178 let from = Point::new(drag_b.x + 50.0, drag_b.y + drag_b.height / 2.0);
1179 let to = Point::new(drag_b.x + 200.0, drag_b.y + drag_b.height / 2.0);
1180
1181 tree.drag(from, to);
1182
1183 assert!(
1184 host.drags_started.get() >= 1,
1185 "host.begin_drag() should be called on drag-start, got {}",
1186 host.drags_started.get()
1187 );
1188 }
1189
1190 #[test]
1191 fn title_bar_exposes_banner_landmark() {
1192 let host = Rc::new(TestHost::default());
1193 let (tree, bar) = build_realistic_tree(host, |b| b);
1194 let info = tree.accessibility_node(bar);
1195 assert_eq!(info.role(), teksilo_core::accesskit::Role::Banner);
1196 assert!(
1197 info.name().is_some(),
1198 "TitleBar Banner landmark should have a localised name"
1199 );
1200 }
1201
1202 #[test]
1203 fn window_control_glyphs_retint_on_theme_switch() {
1204 use crate::primitives::{Expand, VStack};
1214 use std::cell::RefCell;
1215 use teksilo_canvas::MockTextBackend;
1216
1217 let host = Rc::new(TestHost::default());
1218 let bar_widget = TitleBar::new(host as Rc<dyn PlatformTitleBarHost>).height(40.0);
1219
1220 let mut tree = WidgetTree::new()
1221 .with_theme(teksilo_core::presets::intui::light())
1222 .with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())));
1223 let bar_id = tree.add(bar_widget);
1224 let body_id = tree.add(Expand::new());
1225 tree.add(
1226 VStack::new()
1227 .spacing(0.0)
1228 .add_child(bar_id)
1229 .add_child(body_id),
1230 );
1231
1232 tree.layout(SizeProposal::exact(900.0, 600.0));
1233 let light_glyphs: Vec<[f32; 4]> = tree.render().glyphs.iter().map(|g| g.color).collect();
1234 assert!(
1235 !light_glyphs.is_empty(),
1236 "control glyphs (—, □, ×) should have rendered"
1237 );
1238
1239 let light_primary = teksilo_core::presets::intui::light()
1242 .colors
1243 .text_primary
1244 .to_array();
1245 assert!(
1246 light_glyphs.iter().all(|c| *c == light_primary),
1247 "control glyphs should paint with the light theme's text_primary, got {light_glyphs:?}"
1248 );
1249
1250 tree.set_theme(teksilo_core::presets::intui::dark());
1251 tree.layout(SizeProposal::exact(900.0, 600.0));
1252 let dark_glyphs: Vec<[f32; 4]> = tree.render().glyphs.iter().map(|g| g.color).collect();
1253
1254 let dark_primary = teksilo_core::presets::intui::dark()
1255 .colors
1256 .text_primary
1257 .to_array();
1258 assert!(
1259 dark_glyphs.iter().all(|c| *c == dark_primary),
1260 "control glyphs should retint to the dark theme's text_primary, got {dark_glyphs:?}"
1261 );
1262 assert_ne!(
1263 light_glyphs, dark_glyphs,
1264 "control glyph colors must change across a theme switch"
1265 );
1266 }
1267
1268 #[test]
1269 fn window_controls_have_semantic_names_not_glyphs() {
1270 let host = Rc::new(TestHost::default());
1271 let (tree, bar) = build_realistic_tree(host, |b| b);
1272 let [minimize, maximize, close] = locate_control_buttons(&tree, bar);
1273
1274 let min_info = tree.accessibility_node(minimize);
1275 let max_info = tree.accessibility_node(maximize);
1276 let close_info = tree.accessibility_node(close);
1277
1278 for info in [&min_info, &max_info, &close_info] {
1282 let name = info.name().expect("control button must have a name");
1283 assert!(!name.is_empty(), "name empty");
1284 assert_ne!(name, "\u{2014}", "minimize reads glyph literal");
1285 assert_ne!(name, "\u{25A1}", "maximize reads glyph literal");
1286 assert_ne!(name, "\u{00D7}", "close reads glyph literal");
1287 assert_eq!(info.role(), teksilo_core::accesskit::Role::Button);
1288 }
1289 }
1290
1291 #[test]
1292 fn drag_region_is_hidden_from_a11y() {
1293 let host = Rc::new(TestHost::default());
1294 let (tree, bar) = build_realistic_tree(host, |b| b);
1295 let drag = locate_drag_region(&tree, bar);
1296 let info = tree.accessibility_node(drag);
1297 assert!(
1298 info.is_hidden(),
1299 "DragRegion is pointer-only; should be hidden from AT"
1300 );
1301 }
1302
1303 fn paint_once(tree: &mut WidgetTree) {
1306 tree.layout(SizeProposal::exact(900.0, 600.0));
1307 let _ = tree.render();
1308 }
1309
1310 #[test]
1311 fn dead_zone_in_center_is_published_as_a_no_drag_hole() {
1312 let host = Rc::new(TestHost::default());
1320 let host_for_bar = host.clone();
1321 let (mut tree, _bar) = build_realistic_tree(host_for_bar, |b| {
1322 b.center(
1323 HStack::new()
1324 .child(DeadZone::new().child(FixedSize::new().width(60.0).height(30.0)))
1325 .child(Expand::new()),
1326 )
1327 });
1328 paint_once(&mut tree);
1329
1330 let regions = host.last_regions.borrow();
1331 assert_eq!(
1332 regions.drag.len(),
1333 1,
1334 "the drag region should still be published"
1335 );
1336 assert_eq!(
1337 regions.no_drag.len(),
1338 1,
1339 "the DeadZone in `center` must be published as one no_drag hole, got {:?}",
1340 regions.no_drag
1341 );
1342 let hole = regions.no_drag[0];
1343 let drag = regions.drag[0];
1344 assert!(
1345 (hole.width - 60.0).abs() < 1.0,
1346 "the hole should match the dead zone's width, got {}",
1347 hole.width
1348 );
1349 assert!(
1352 hole.x >= drag.x - 0.01 && hole.right() <= drag.right() + 0.01,
1353 "hole {hole:?} must be clipped to the drag rect {drag:?}"
1354 );
1355 }
1356
1357 #[test]
1358 fn passive_center_content_punches_no_hole() {
1359 let host = Rc::new(TestHost::default());
1363 let host_for_bar = host.clone();
1364 let (mut tree, _bar) = build_realistic_tree(host_for_bar, |b| {
1365 b.center(crate::TextWidget::new(teksilo_i18n::lit!("My App")))
1366 });
1367 paint_once(&mut tree);
1368
1369 let regions = host.last_regions.borrow();
1370 assert_eq!(regions.drag.len(), 1, "drag region still published");
1371 assert!(
1372 regions.no_drag.is_empty(),
1373 "a passive centred title must not punch a hole in the caption, got {:?}",
1374 regions.no_drag
1375 );
1376 }
1377
1378 #[test]
1379 fn overlay_over_the_caption_is_published_as_a_no_drag_hole() {
1380 use teksilo_core::overlay::{
1389 DismissBehavior, OverlayLayer, OverlayPlacement, OverlayRequest,
1390 };
1391
1392 let host = Rc::new(TestHost::default());
1393 let (mut tree, bar) = build_realistic_tree(host.clone(), |b| b);
1394
1395 let content = tree.add(FixedSize::new().width(200.0).height(60.0));
1399 tree.show_overlay(OverlayRequest {
1400 content_id: content,
1401 anchor: bar,
1402 placement: OverlayPlacement::AtPointer(Point::new(100.0, 20.0)),
1403 dismiss: DismissBehavior::Manual,
1404 layer: OverlayLayer::InTree,
1405 parent_overlay: None,
1406 on_dismiss: None,
1407 fade_duration: None,
1408 });
1409 paint_once(&mut tree);
1410
1411 let regions = host.last_regions.borrow();
1412 assert_eq!(regions.drag.len(), 1, "the drag region is still published");
1413 assert_eq!(
1414 regions.no_drag.len(),
1415 1,
1416 "the overlay's caption overlap must be published as one no_drag \
1417 hole, got {:?}",
1418 regions.no_drag
1419 );
1420 let hole = regions.no_drag[0];
1421 assert!(
1422 (hole.x - 100.0).abs() < 0.01 && (hole.width - 200.0).abs() < 0.01,
1423 "the hole should span the overlay's width at its position, got {hole:?}"
1424 );
1425 assert!(
1428 (hole.y - 20.0).abs() < 0.01 && (hole.bottom() - 40.0).abs() < 0.01,
1429 "the hole must be clipped to the title bar strip, got {hole:?}"
1430 );
1431 }
1432
1433 #[test]
1434 fn overlay_below_the_caption_punches_no_hole() {
1435 use teksilo_core::overlay::{
1440 DismissBehavior, OverlayLayer, OverlayPlacement, OverlayRequest,
1441 };
1442
1443 let host = Rc::new(TestHost::default());
1444 let (mut tree, bar) = build_realistic_tree(host.clone(), |b| b);
1445
1446 let content = tree.add(FixedSize::new().width(200.0).height(60.0));
1447 tree.show_overlay(OverlayRequest {
1448 content_id: content,
1449 anchor: bar,
1450 placement: OverlayPlacement::AtPointer(Point::new(100.0, 300.0)),
1451 dismiss: DismissBehavior::Manual,
1452 layer: OverlayLayer::InTree,
1453 parent_overlay: None,
1454 on_dismiss: None,
1455 fade_duration: None,
1456 });
1457 paint_once(&mut tree);
1458
1459 let regions = host.last_regions.borrow();
1460 assert!(
1461 regions.no_drag.is_empty(),
1462 "an overlay fully below the caption must not punch a hole, got {:?}",
1463 regions.no_drag
1464 );
1465 }
1466
1467 #[test]
1468 fn dead_zone_in_center_does_not_arm_the_window_drag() {
1469 let host = Rc::new(TestHost::default());
1476 let host_for_bar = host.clone();
1477 let (mut tree, _bar) = build_realistic_tree(host_for_bar, |b| {
1478 b.center(
1479 HStack::new()
1480 .child(DeadZone::new().child(FixedSize::new().width(60.0).height(30.0)))
1481 .child(Expand::new()),
1482 )
1483 });
1484 paint_once(&mut tree);
1485
1486 let hole = host.last_regions.borrow().no_drag[0];
1487 let (cx, cy) = (hole.x + hole.width / 2.0, hole.y + hole.height / 2.0);
1488
1489 tree.pointer_down_button(Point::new(cx, cy), PointerButton::Primary);
1491 for i in 1..=10 {
1492 tree.pointer_move(Point::new(cx + (i as f32) * 3.0, cy + 1.0));
1493 }
1494 tree.pointer_up_button(Point::new(cx + 30.0, cy + 1.0), PointerButton::Primary);
1495
1496 assert_eq!(
1497 host.drags_started.get(),
1498 0,
1499 "a jittery click on a DeadZone inside the title bar must not drag the window"
1500 );
1501 }
1502
1503 #[test]
1504 fn dragging_the_bare_drag_region_still_works_with_a_dead_zone_present() {
1505 let host = Rc::new(TestHost::default());
1509 let host_for_bar = host.clone();
1510 let (mut tree, bar) = build_realistic_tree(host_for_bar, |b| {
1511 b.center(
1512 HStack::new()
1513 .child(DeadZone::new().child(FixedSize::new().width(60.0).height(30.0)))
1514 .child(Expand::new()),
1515 )
1516 });
1517 paint_once(&mut tree);
1518
1519 let drag_b = tree.bounds(locate_drag_region(&tree, bar));
1521 let from = Point::new(drag_b.right() - 40.0, drag_b.y + drag_b.height / 2.0);
1522 let to = Point::new(drag_b.right() - 200.0, drag_b.y + drag_b.height / 2.0);
1523 tree.drag(from, to);
1524
1525 assert!(
1526 host.drags_started.get() >= 1,
1527 "the drag region outside the hole must still move the window"
1528 );
1529 }
1530
1531 #[test]
1532 fn double_clicking_drag_region_toggles_placement() {
1533 let host = Rc::new(TestHost::default());
1537 let (mut tree, bar) = build_realistic_tree(host.clone(), |b| b);
1538 let state = attach_window_state(&mut tree);
1539
1540 let drag = locate_drag_region(&tree, bar);
1541 tree.click(drag);
1542 tree.click(drag);
1543
1544 assert_eq!(
1545 state.placement().get(),
1546 teksilo_core::WindowPlacement::Maximized,
1547 "double-tap on drag region should flip WindowState::placement to Maximized"
1548 );
1549 }
1550}