teksilo_widgets/title_bar/
window_frame.rs1use std::rc::Rc;
35
36use teksilo_canvas::{Point, Rect, Size, SizeProposal};
37use teksilo_core::widget::{LayoutContext, PaintContext, PendingChild, Widget, WidgetPlacement};
38use teksilo_core::widget_id::WidgetId;
39use teksilo_core::{PlatformTitleBarHost, ResizeEdge};
40
41use super::resize_strip::ResizeStrip;
42
43pub struct WindowFrame {
47 host: Rc<dyn PlatformTitleBarHost>,
48 thickness: f32,
49 pending_content: Option<PendingChild>,
50 content_id: Option<WidgetId>,
51 strip_ids: [Option<WidgetId>; 4],
53 corner_ids: [Option<WidgetId>; 4],
55}
56
57impl std::fmt::Debug for WindowFrame {
58 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59 f.debug_struct("WindowFrame")
60 .field("thickness", &self.thickness)
61 .field("has_content", &self.pending_content.is_some())
62 .finish_non_exhaustive()
63 }
64}
65
66impl WindowFrame {
67 pub fn new(host: Rc<dyn PlatformTitleBarHost>) -> Self {
70 Self {
71 host,
72 thickness: 6.0,
73 pending_content: None,
74 content_id: None,
75 strip_ids: [None; 4],
76 corner_ids: [None; 4],
77 }
78 }
79
80 pub fn thickness(mut self, t: f32) -> Self {
82 self.thickness = t;
83 self
84 }
85
86 pub fn content(mut self, w: impl Widget + 'static) -> Self {
89 self.pending_content = Some(PendingChild::Deferred(Box::new(w)));
90 self
91 }
92
93 pub fn content_boxed(mut self, w: Box<dyn Widget>) -> Self {
96 self.pending_content = Some(PendingChild::Deferred(w));
97 self
98 }
99
100 pub fn content_id(mut self, id: WidgetId) -> Self {
103 self.pending_content = Some(PendingChild::Id(id));
104 self
105 }
106}
107
108impl Widget for WindowFrame {
109 fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
110 if let Some(pending) = self.pending_content.take() {
115 self.content_id = Some(match pending {
116 PendingChild::Id(id) => id,
117 PendingChild::Deferred(w) => ctx.add_boxed(w),
118 });
119 }
120
121 self.strip_ids[0] = Some(ctx.add(ResizeStrip::horizontal(
122 self.host.clone(),
123 ResizeEdge::Top,
124 self.thickness,
125 )));
126 self.strip_ids[1] = Some(ctx.add(ResizeStrip::horizontal(
127 self.host.clone(),
128 ResizeEdge::Bottom,
129 self.thickness,
130 )));
131 self.strip_ids[2] = Some(ctx.add(ResizeStrip::vertical(
132 self.host.clone(),
133 ResizeEdge::Left,
134 self.thickness,
135 )));
136 self.strip_ids[3] = Some(ctx.add(ResizeStrip::vertical(
137 self.host.clone(),
138 ResizeEdge::Right,
139 self.thickness,
140 )));
141
142 self.corner_ids[0] = Some(ctx.add(ResizeStrip::corner(
149 self.host.clone(),
150 ResizeEdge::TopLeft,
151 self.thickness,
152 )));
153 self.corner_ids[1] = Some(ctx.add(ResizeStrip::corner(
154 self.host.clone(),
155 ResizeEdge::TopRight,
156 self.thickness,
157 )));
158 self.corner_ids[2] = Some(ctx.add(ResizeStrip::corner(
159 self.host.clone(),
160 ResizeEdge::BottomLeft,
161 self.thickness,
162 )));
163 self.corner_ids[3] = Some(ctx.add(ResizeStrip::corner(
164 self.host.clone(),
165 ResizeEdge::BottomRight,
166 self.thickness,
167 )));
168
169 let mut ids = Vec::with_capacity(9);
170 if let Some(c) = self.content_id {
171 ids.push(c);
172 }
173 for s in self.strip_ids.iter().flatten() {
174 ids.push(*s);
175 }
176 for c in self.corner_ids.iter().flatten() {
177 ids.push(*c);
178 }
179 ids
180 }
181
182 fn layout_response(
183 &self,
184 proposal: SizeProposal,
185 _ctx: &LayoutContext,
186 ) -> teksilo_core::widget::LayoutResponse {
187 Size::new(
191 proposal.width.unwrap_or(0.0),
192 proposal.height.unwrap_or(0.0),
193 )
194 .into()
195 }
196
197 fn place_children(
198 &self,
199 bounds: Rect,
200 _proposal: SizeProposal,
201 children: &mut [WidgetPlacement],
202 _ctx: &LayoutContext,
203 ) {
204 let t = self.thickness;
205
206 let mut i = 0;
214
215 if self.content_id.is_some() {
216 children[i].origin = bounds.origin();
219 children[i].size = bounds.size();
220 i += 1;
221 }
222
223 children[i].origin = bounds.origin();
228 children[i].size = Size::new(bounds.width, t);
229 i += 1;
230
231 children[i].origin = Point::new(bounds.x, bounds.bottom() - t);
233 children[i].size = Size::new(bounds.width, t);
234 i += 1;
235
236 children[i].origin = bounds.origin();
238 children[i].size = Size::new(t, bounds.height);
239 i += 1;
240
241 children[i].origin = Point::new(bounds.right() - t, bounds.y);
243 children[i].size = Size::new(t, bounds.height);
244 i += 1;
245
246 children[i].origin = bounds.origin();
249 children[i].size = Size::new(t, t);
250 i += 1;
251
252 children[i].origin = Point::new(bounds.right() - t, bounds.y);
254 children[i].size = Size::new(t, t);
255 i += 1;
256
257 children[i].origin = Point::new(bounds.x, bounds.bottom() - t);
259 children[i].size = Size::new(t, t);
260 i += 1;
261
262 children[i].origin = Point::new(bounds.right() - t, bounds.bottom() - t);
264 children[i].size = Size::new(t, t);
265 }
266
267 fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {
268 }
270
271 fn children(&self) -> Vec<WidgetId> {
272 let mut ids = Vec::with_capacity(9);
273 if let Some(c) = self.content_id {
274 ids.push(c);
275 }
276 for s in self.strip_ids.iter().flatten() {
277 ids.push(*s);
278 }
279 for c in self.corner_ids.iter().flatten() {
280 ids.push(*c);
281 }
282 ids
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289 use std::cell::Cell;
290 use teksilo_canvas::Point;
291 use teksilo_core::Signal;
292 use teksilo_core::widget_tree::WidgetTree;
293 use teksilo_core::{HitRegions, PlatformError};
294
295 struct TestHost {
296 last_resize_edge: Cell<Option<ResizeEdge>>,
297 is_max: Signal<bool>,
298 }
299
300 impl Default for TestHost {
301 fn default() -> Self {
302 Self {
303 last_resize_edge: Cell::new(None),
304 is_max: Signal::new(false),
305 }
306 }
307 }
308
309 impl PlatformTitleBarHost for TestHost {
310 fn reserved_leading_inset(&self) -> Size {
311 Size::ZERO
312 }
313 fn reserved_trailing_inset(&self) -> Size {
314 Size::ZERO
315 }
316 fn renders_custom_controls(&self) -> bool {
317 true
318 }
319 fn needs_custom_resize_handles(&self) -> bool {
320 true
321 }
322 fn begin_drag(&self) -> Result<(), PlatformError> {
323 Ok(())
324 }
325 fn begin_resize(&self, edge: ResizeEdge) -> Result<(), PlatformError> {
326 self.last_resize_edge.set(Some(edge));
327 Ok(())
328 }
329 fn show_window_menu(&self, _at: Point) -> Result<(), PlatformError> {
330 Ok(())
331 }
332 fn update_hit_regions(&self, _regions: &HitRegions) {}
333 }
334
335 #[derive(Debug)]
336 struct ContentLeaf;
337 impl Widget for ContentLeaf {
338 fn layout_response(
339 &self,
340 proposal: SizeProposal,
341 _ctx: &LayoutContext,
342 ) -> teksilo_core::widget::LayoutResponse {
343 Size::new(
344 proposal.width.unwrap_or(0.0),
345 proposal.height.unwrap_or(0.0),
346 )
347 .into()
348 }
349 }
350
351 #[test]
352 fn frame_content_fills_full_window_no_visible_padding() {
353 let host: Rc<dyn PlatformTitleBarHost> = Rc::new(TestHost::default());
354 let mut tree = WidgetTree::new();
355 let frame = tree.add(WindowFrame::new(host).thickness(6.0).content(ContentLeaf));
356 tree.layout(SizeProposal::exact(900.0, 600.0));
357
358 let f = tree.bounds(frame);
360 assert!((f.width - 900.0).abs() < 0.01);
361 assert!((f.height - 600.0).abs() < 0.01);
362
363 let kids = tree.children(frame);
366 let content = kids[0];
367 let cb = tree.bounds(content);
368 assert!((cb.x - 0.0).abs() < 0.01, "content x = {}", cb.x);
369 assert!((cb.y - 0.0).abs() < 0.01, "content y = {}", cb.y);
370 assert!((cb.width - 900.0).abs() < 0.01, "content w = {}", cb.width);
371 assert!(
372 (cb.height - 600.0).abs() < 0.01,
373 "content h = {}",
374 cb.height
375 );
376 }
377
378 #[test]
379 fn clicking_top_strip_calls_begin_resize_top() {
380 let host = Rc::new(TestHost::default());
381 let mut tree = WidgetTree::new();
382 let _frame = tree.add(
383 WindowFrame::new(host.clone() as Rc<dyn PlatformTitleBarHost>)
384 .thickness(6.0)
385 .content(ContentLeaf),
386 );
387 tree.layout(SizeProposal::exact(900.0, 600.0));
388
389 tree.pointer_move(Point::new(450.0, 3.0));
391 tree.pointer_down_button(
392 Point::new(450.0, 3.0),
393 teksilo_core::event::PointerButton::Primary,
394 );
395 tree.pointer_up_button(
396 Point::new(450.0, 3.0),
397 teksilo_core::event::PointerButton::Primary,
398 );
399
400 assert_eq!(host.last_resize_edge.get(), Some(ResizeEdge::Top));
401 }
402
403 #[test]
404 fn clicking_top_left_corner_calls_begin_resize_top_left() {
405 let host = Rc::new(TestHost::default());
406 let mut tree = WidgetTree::new();
407 let _frame = tree.add(
408 WindowFrame::new(host.clone() as Rc<dyn PlatformTitleBarHost>)
409 .thickness(6.0)
410 .content(ContentLeaf),
411 );
412 tree.layout(SizeProposal::exact(900.0, 600.0));
413
414 tree.pointer_move(Point::new(2.0, 2.0));
416 tree.pointer_down_button(
417 Point::new(2.0, 2.0),
418 teksilo_core::event::PointerButton::Primary,
419 );
420 tree.pointer_up_button(
421 Point::new(2.0, 2.0),
422 teksilo_core::event::PointerButton::Primary,
423 );
424
425 assert_eq!(host.last_resize_edge.get(), Some(ResizeEdge::TopLeft));
426 }
427
428 #[test]
429 fn clicking_bottom_right_corner_calls_begin_resize_bottom_right() {
430 let host = Rc::new(TestHost::default());
431 let mut tree = WidgetTree::new();
432 let _frame = tree.add(
433 WindowFrame::new(host.clone() as Rc<dyn PlatformTitleBarHost>)
434 .thickness(6.0)
435 .content(ContentLeaf),
436 );
437 tree.layout(SizeProposal::exact(900.0, 600.0));
438
439 let p = Point::new(897.0, 597.0);
442 tree.pointer_move(p);
443 tree.pointer_down_button(p, teksilo_core::event::PointerButton::Primary);
444 tree.pointer_up_button(p, teksilo_core::event::PointerButton::Primary);
445
446 assert_eq!(host.last_resize_edge.get(), Some(ResizeEdge::BottomRight));
447 }
448
449 #[test]
450 fn clicking_in_content_area_does_not_resize() {
451 let host = Rc::new(TestHost::default());
452 let mut tree = WidgetTree::new();
453 let _frame = tree.add(
454 WindowFrame::new(host.clone() as Rc<dyn PlatformTitleBarHost>)
455 .thickness(6.0)
456 .content(ContentLeaf),
457 );
458 tree.layout(SizeProposal::exact(900.0, 600.0));
459
460 tree.pointer_move(Point::new(450.0, 300.0));
462 tree.pointer_down_button(
463 Point::new(450.0, 300.0),
464 teksilo_core::event::PointerButton::Primary,
465 );
466 tree.pointer_up_button(
467 Point::new(450.0, 300.0),
468 teksilo_core::event::PointerButton::Primary,
469 );
470
471 assert_eq!(
472 host.last_resize_edge.get(),
473 None,
474 "interior clicks must not trigger resize"
475 );
476 }
477}