1use std::rc::Rc;
64
65use teksilo_canvas::{Point, Rect, Size, SizeProposal};
66use teksilo_core::styles::density::dp;
67use teksilo_core::widget::{
68 LayoutContext, PaintContext, PendingChild, Widget, WidgetPlacement, WidgetTreeView,
69};
70use teksilo_core::widget_id::WidgetId;
71use teksilo_core::{HitRegions, PlatformTitleBarHost, ResizeBorders, ResizeEdge};
72use teksilo_tokens::{InputTokens, TargetRole};
73
74use super::resize_strip::ResizeStrip;
75
76pub struct WindowFrame {
80 host: Rc<dyn PlatformTitleBarHost>,
81 thickness: f32,
82 pending_content: Option<PendingChild>,
83 content_id: Option<WidgetId>,
84 strip_ids: [Option<WidgetId>; 4],
86 corner_ids: [Option<WidgetId>; 4],
88}
89
90impl std::fmt::Debug for WindowFrame {
91 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92 f.debug_struct("WindowFrame")
93 .field("thickness", &self.thickness)
94 .field("has_content", &self.pending_content.is_some())
95 .finish_non_exhaustive()
96 }
97}
98
99pub const WINDOW_FRAME_RESIZE_THICKNESS: f32 = 6.0;
108
109impl WindowFrame {
110 pub fn new(host: Rc<dyn PlatformTitleBarHost>) -> Self {
113 Self {
114 host,
115 thickness: WINDOW_FRAME_RESIZE_THICKNESS,
116 pending_content: None,
117 content_id: None,
118 strip_ids: [None; 4],
119 corner_ids: [None; 4],
120 }
121 }
122
123 pub fn thickness(mut self, t: f32) -> Self {
126 self.thickness = t;
127 self
128 }
129
130 pub fn content(mut self, w: impl teksilo_core::IntoTeksiChild) -> Self {
133 self.pending_content = Some(teksilo_core::IntoTeksiChild::into_pending(w));
134 self
135 }
136
137 pub fn content_boxed(mut self, w: Box<dyn Widget>) -> Self {
140 self.pending_content = Some(PendingChild::Deferred(w));
141 self
142 }
143
144 pub fn coarse_resize_borders(&self, tokens: &InputTokens) -> ResizeBorders {
156 ResizeBorders::uniform(dp(self.thickness, TargetRole::Target, tokens))
157 }
158}
159
160impl Widget for WindowFrame {
161 fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
162 if let Some(pending) = self.pending_content.take() {
167 self.content_id = Some(match pending {
168 PendingChild::Id(id) => id,
169 PendingChild::Deferred(w) => ctx.add_boxed(w),
170 });
171 }
172
173 self.strip_ids[0] = Some(ctx.add(ResizeStrip::horizontal(
174 self.host.clone(),
175 ResizeEdge::Top,
176 self.thickness,
177 )));
178 self.strip_ids[1] = Some(ctx.add(ResizeStrip::horizontal(
179 self.host.clone(),
180 ResizeEdge::Bottom,
181 self.thickness,
182 )));
183 self.strip_ids[2] = Some(ctx.add(ResizeStrip::vertical(
184 self.host.clone(),
185 ResizeEdge::Left,
186 self.thickness,
187 )));
188 self.strip_ids[3] = Some(ctx.add(ResizeStrip::vertical(
189 self.host.clone(),
190 ResizeEdge::Right,
191 self.thickness,
192 )));
193
194 self.corner_ids[0] = Some(ctx.add(ResizeStrip::corner(
201 self.host.clone(),
202 ResizeEdge::TopLeft,
203 self.thickness,
204 )));
205 self.corner_ids[1] = Some(ctx.add(ResizeStrip::corner(
206 self.host.clone(),
207 ResizeEdge::TopRight,
208 self.thickness,
209 )));
210 self.corner_ids[2] = Some(ctx.add(ResizeStrip::corner(
211 self.host.clone(),
212 ResizeEdge::BottomLeft,
213 self.thickness,
214 )));
215 self.corner_ids[3] = Some(ctx.add(ResizeStrip::corner(
216 self.host.clone(),
217 ResizeEdge::BottomRight,
218 self.thickness,
219 )));
220
221 let mut ids = Vec::with_capacity(9);
222 if let Some(c) = self.content_id {
223 ids.push(c);
224 }
225 for s in self.strip_ids.iter().flatten() {
226 ids.push(*s);
227 }
228 for c in self.corner_ids.iter().flatten() {
229 ids.push(*c);
230 }
231 ids
232 }
233
234 fn layout_response(
235 &self,
236 proposal: SizeProposal,
237 _ctx: &LayoutContext,
238 ) -> teksilo_core::widget::LayoutResponse {
239 Size::new(
243 proposal.width.unwrap_or(0.0),
244 proposal.height.unwrap_or(0.0),
245 )
246 .into()
247 }
248
249 fn place_children(
250 &self,
251 bounds: Rect,
252 _proposal: SizeProposal,
253 children: &mut [WidgetPlacement],
254 _ctx: &LayoutContext,
255 ) {
256 let t = self.thickness;
257
258 let mut i = 0;
266
267 if self.content_id.is_some() {
268 children[i].origin = bounds.origin();
271 children[i].size = bounds.size();
272 i += 1;
273 }
274
275 children[i].origin = bounds.origin();
280 children[i].size = Size::new(bounds.width, t);
281 i += 1;
282
283 children[i].origin = Point::new(bounds.x, bounds.bottom() - t);
285 children[i].size = Size::new(bounds.width, t);
286 i += 1;
287
288 children[i].origin = bounds.origin();
290 children[i].size = Size::new(t, bounds.height);
291 i += 1;
292
293 children[i].origin = Point::new(bounds.right() - t, bounds.y);
295 children[i].size = Size::new(t, bounds.height);
296 i += 1;
297
298 children[i].origin = bounds.origin();
301 children[i].size = Size::new(t, t);
302 i += 1;
303
304 children[i].origin = Point::new(bounds.right() - t, bounds.y);
306 children[i].size = Size::new(t, t);
307 i += 1;
308
309 children[i].origin = Point::new(bounds.x, bounds.bottom() - t);
311 children[i].size = Size::new(t, t);
312 i += 1;
313
314 children[i].origin = Point::new(bounds.right() - t, bounds.bottom() - t);
316 children[i].size = Size::new(t, t);
317 }
318
319 fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {
320 }
322
323 fn wants_after_paint(&self) -> bool {
324 true
329 }
330
331 fn after_paint(&self, _view: &WidgetTreeView<'_>, ctx: &PaintContext) {
332 let regions = HitRegions {
333 resize_borders: self.coarse_resize_borders(&ctx.theme.input),
334 ..HitRegions::default()
335 };
336 self.host.update_hit_regions(®ions);
337 }
338
339 fn children(&self) -> Vec<WidgetId> {
340 let mut ids = Vec::with_capacity(9);
341 if let Some(c) = self.content_id {
342 ids.push(c);
343 }
344 for s in self.strip_ids.iter().flatten() {
345 ids.push(*s);
346 }
347 for c in self.corner_ids.iter().flatten() {
348 ids.push(*c);
349 }
350 ids
351 }
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357 use std::cell::Cell;
358 use teksilo_canvas::Point;
359 use teksilo_core::Signal;
360 use teksilo_core::widget_tree::WidgetTree;
361 use teksilo_core::{HitRegions, PlatformError};
362
363 struct TestHost {
364 last_resize_edge: Cell<Option<ResizeEdge>>,
365 resize_calls: Cell<u32>,
366 last_regions: std::cell::RefCell<Option<HitRegions>>,
369 is_max: Signal<bool>,
370 }
371
372 impl Default for TestHost {
373 fn default() -> Self {
374 Self {
375 last_resize_edge: Cell::new(None),
376 resize_calls: Cell::new(0),
377 last_regions: std::cell::RefCell::new(None),
378 is_max: Signal::new(false),
379 }
380 }
381 }
382
383 impl PlatformTitleBarHost for TestHost {
384 fn reserved_leading_inset(&self) -> Size {
385 Size::ZERO
386 }
387 fn reserved_trailing_inset(&self) -> Size {
388 Size::ZERO
389 }
390 fn renders_custom_controls(&self) -> bool {
391 true
392 }
393 fn needs_custom_resize_handles(&self) -> bool {
394 true
395 }
396 fn begin_drag(&self) -> Result<(), PlatformError> {
397 Ok(())
398 }
399 fn begin_resize(&self, edge: ResizeEdge) -> Result<(), PlatformError> {
400 self.last_resize_edge.set(Some(edge));
401 self.resize_calls.set(self.resize_calls.get() + 1);
402 Ok(())
403 }
404 fn show_window_menu(&self, _at: Point) -> Result<(), PlatformError> {
405 Ok(())
406 }
407 fn update_hit_regions(&self, regions: &HitRegions) {
408 *self.last_regions.borrow_mut() = Some(regions.clone());
409 }
410 }
411
412 #[derive(Debug)]
413 struct ContentLeaf;
414 impl Widget for ContentLeaf {
415 fn layout_response(
416 &self,
417 proposal: SizeProposal,
418 _ctx: &LayoutContext,
419 ) -> teksilo_core::widget::LayoutResponse {
420 Size::new(
421 proposal.width.unwrap_or(0.0),
422 proposal.height.unwrap_or(0.0),
423 )
424 .into()
425 }
426 }
427
428 #[test]
429 fn frame_content_fills_full_window_no_visible_padding() {
430 let host: Rc<dyn PlatformTitleBarHost> = Rc::new(TestHost::default());
431 let mut tree = WidgetTree::new();
432 let frame = tree.add(WindowFrame::new(host).thickness(6.0).content(ContentLeaf));
433 tree.layout(SizeProposal::exact(900.0, 600.0));
434
435 let f = tree.bounds(frame);
437 assert!((f.width - 900.0).abs() < 0.01);
438 assert!((f.height - 600.0).abs() < 0.01);
439
440 let kids = tree.children(frame);
443 let content = kids[0];
444 let cb = tree.bounds(content);
445 assert!((cb.x - 0.0).abs() < 0.01, "content x = {}", cb.x);
446 assert!((cb.y - 0.0).abs() < 0.01, "content y = {}", cb.y);
447 assert!((cb.width - 900.0).abs() < 0.01, "content w = {}", cb.width);
448 assert!(
449 (cb.height - 600.0).abs() < 0.01,
450 "content h = {}",
451 cb.height
452 );
453 }
454
455 #[test]
456 fn clicking_top_strip_calls_begin_resize_top() {
457 let host = Rc::new(TestHost::default());
458 let mut tree = WidgetTree::new();
459 let _frame = tree.add(
460 WindowFrame::new(host.clone() as Rc<dyn PlatformTitleBarHost>)
461 .thickness(6.0)
462 .content(ContentLeaf),
463 );
464 tree.layout(SizeProposal::exact(900.0, 600.0));
465
466 tree.pointer_move(Point::new(450.0, 3.0));
468 tree.pointer_down_button(
469 Point::new(450.0, 3.0),
470 teksilo_core::event::PointerButton::Primary,
471 );
472 tree.pointer_up_button(
473 Point::new(450.0, 3.0),
474 teksilo_core::event::PointerButton::Primary,
475 );
476
477 assert_eq!(host.last_resize_edge.get(), Some(ResizeEdge::Top));
478 }
479
480 #[test]
481 fn clicking_top_left_corner_calls_begin_resize_top_left() {
482 let host = Rc::new(TestHost::default());
483 let mut tree = WidgetTree::new();
484 let _frame = tree.add(
485 WindowFrame::new(host.clone() as Rc<dyn PlatformTitleBarHost>)
486 .thickness(6.0)
487 .content(ContentLeaf),
488 );
489 tree.layout(SizeProposal::exact(900.0, 600.0));
490
491 tree.pointer_move(Point::new(2.0, 2.0));
493 tree.pointer_down_button(
494 Point::new(2.0, 2.0),
495 teksilo_core::event::PointerButton::Primary,
496 );
497 tree.pointer_up_button(
498 Point::new(2.0, 2.0),
499 teksilo_core::event::PointerButton::Primary,
500 );
501
502 assert_eq!(host.last_resize_edge.get(), Some(ResizeEdge::TopLeft));
503 }
504
505 #[test]
506 fn clicking_bottom_right_corner_calls_begin_resize_bottom_right() {
507 let host = Rc::new(TestHost::default());
508 let mut tree = WidgetTree::new();
509 let _frame = tree.add(
510 WindowFrame::new(host.clone() as Rc<dyn PlatformTitleBarHost>)
511 .thickness(6.0)
512 .content(ContentLeaf),
513 );
514 tree.layout(SizeProposal::exact(900.0, 600.0));
515
516 let p = Point::new(897.0, 597.0);
519 tree.pointer_move(p);
520 tree.pointer_down_button(p, teksilo_core::event::PointerButton::Primary);
521 tree.pointer_up_button(p, teksilo_core::event::PointerButton::Primary);
522
523 assert_eq!(host.last_resize_edge.get(), Some(ResizeEdge::BottomRight));
524 }
525
526 fn frame_over_tappable_content(
536 host: Rc<TestHost>,
537 density: teksilo_tokens::TargetDensity,
538 ) -> WidgetTree {
539 use teksilo_core::widget_builder::WidgetBuilder;
540 let mut tree = WidgetTree::new()
541 .with_theme(teksilo_core::presets::intui::light().with_density(density))
542 .with_text_backend(Rc::new(std::cell::RefCell::new(
543 teksilo_canvas::MockTextBackend::new(),
544 )));
545 let _frame = tree.add(
546 WindowFrame::new(host as Rc<dyn PlatformTitleBarHost>)
547 .thickness(6.0)
548 .content(ContentLeaf.on_tap(|_, _| {})),
549 );
550 tree.layout(SizeProposal::exact(900.0, 600.0));
551 tree
552 }
553
554 fn finger(raw: u64, primary: bool) -> teksilo_core::pointer::PointerInfo {
555 use teksilo_core::pointer::{BackendDeviceKey, EventTime, PointerIdAllocator, PointerInfo};
556 let id = PointerIdAllocator::global().begin(BackendDeviceKey::new(0x50F2), raw);
557 let mut info = PointerInfo::touch(id, EventTime::ZERO);
558 info.primary = primary;
559 info
560 }
561
562 fn contact(
563 pointer: teksilo_core::pointer::PointerInfo,
564 phase: teksilo_core::pointer::PointerPhase,
565 at: Point,
566 ) -> teksilo_core::pointer::PointerSample {
567 teksilo_core::pointer::PointerSample {
568 pointer,
569 phase,
570 position: at,
571 button: None,
572 modifiers: teksilo_core::event::Modifiers::NONE,
573 coalesced: Vec::new(),
574 }
575 }
576
577 #[test]
581 fn a_finger_grabs_the_top_edge_from_inside_the_content() {
582 let host = Rc::new(TestHost::default());
583 let mut tree =
584 frame_over_tappable_content(host.clone(), teksilo_tokens::TargetDensity::Compact);
585 let at = Point::new(450.0, 14.0);
586 let f = finger(31, true);
587 tree.dispatch_pointer(contact(f, teksilo_core::pointer::PointerPhase::Down, at));
588 assert_eq!(host.last_resize_edge.get(), Some(ResizeEdge::Top));
589 }
590
591 #[test]
594 fn a_mouse_below_the_strip_is_still_content() {
595 let host = Rc::new(TestHost::default());
596 let mut tree =
597 frame_over_tappable_content(host.clone(), teksilo_tokens::TargetDensity::Compact);
598 let at = Point::new(450.0, 14.0);
599 tree.pointer_move(at);
600 tree.pointer_down_button(at, teksilo_core::event::PointerButton::Primary);
601 tree.pointer_up_button(at, teksilo_core::event::PointerButton::Primary);
602 assert_eq!(host.last_resize_edge.get(), None);
603 assert_eq!(host.resize_calls.get(), 0);
604 }
605
606 #[test]
609 fn the_grab_moves_no_layout_at_touch_density() {
610 let host = Rc::new(TestHost::default());
611 let mut tree =
612 frame_over_tappable_content(host.clone(), teksilo_tokens::TargetDensity::Touch);
613 let frame = tree.roots()[0];
614 let kids = tree.children(frame);
615 let content = tree.bounds(kids[0]);
616 assert_eq!(content, Rect::new(0.0, 0.0, 900.0, 600.0));
617 assert_eq!(tree.bounds(kids[1]).height, 6.0);
619
620 let at = Point::new(450.0, 30.0);
622 let f = finger(32, true);
623 tree.dispatch_pointer(contact(f, teksilo_core::pointer::PointerPhase::Down, at));
624 assert_eq!(host.last_resize_edge.get(), Some(ResizeEdge::Top));
625 }
626
627 #[test]
630 fn a_second_contact_does_not_start_a_second_resize() {
631 let host = Rc::new(TestHost::default());
632 let mut tree =
633 frame_over_tappable_content(host.clone(), teksilo_tokens::TargetDensity::Compact);
634 let first = finger(33, true);
635 tree.dispatch_pointer(contact(
636 first,
637 teksilo_core::pointer::PointerPhase::Down,
638 Point::new(450.0, 3.0),
639 ));
640 assert_eq!(host.resize_calls.get(), 1);
641
642 let second = finger(34, false);
643 tree.dispatch_pointer(contact(
644 second,
645 teksilo_core::pointer::PointerPhase::Down,
646 Point::new(300.0, 3.0),
647 ));
648 assert_eq!(
649 host.resize_calls.get(),
650 1,
651 "the second contact must not start a second resize"
652 );
653 }
654
655 #[test]
658 fn the_frame_publishes_the_coarse_band_it_will_catch() {
659 for (density, expected) in [
660 (teksilo_tokens::TargetDensity::Compact, 24.0_f32),
661 (teksilo_tokens::TargetDensity::Comfortable, 32.0),
662 (teksilo_tokens::TargetDensity::Touch, 44.0),
663 ] {
664 let host = Rc::new(TestHost::default());
665 let mut tree = frame_over_tappable_content(host.clone(), density);
666 let _ = tree.render();
667 let regions = host
668 .last_regions
669 .borrow()
670 .clone()
671 .expect("the frame publishes every frame");
672 assert_eq!(regions.resize_borders.top, expected, "{density:?}");
673 assert_eq!(regions.resize_borders.left, expected, "{density:?}");
674 assert!(regions.drag.is_empty());
677 assert!(regions.minimize.is_none());
678 }
679 }
680
681 #[test]
682 fn clicking_in_content_area_does_not_resize() {
683 let host = Rc::new(TestHost::default());
684 let mut tree = WidgetTree::new();
685 let _frame = tree.add(
686 WindowFrame::new(host.clone() as Rc<dyn PlatformTitleBarHost>)
687 .thickness(6.0)
688 .content(ContentLeaf),
689 );
690 tree.layout(SizeProposal::exact(900.0, 600.0));
691
692 tree.pointer_move(Point::new(450.0, 300.0));
694 tree.pointer_down_button(
695 Point::new(450.0, 300.0),
696 teksilo_core::event::PointerButton::Primary,
697 );
698 tree.pointer_up_button(
699 Point::new(450.0, 300.0),
700 teksilo_core::event::PointerButton::Primary,
701 );
702
703 assert_eq!(
704 host.last_resize_edge.get(),
705 None,
706 "interior clicks must not trigger resize"
707 );
708 }
709}