1use azul_core::{
6 dom::{Dom, DomVec, IdOrClass, IdOrClass::Class, IdOrClass::Id, IdOrClassVec},
7 refany::RefAny,
8};
9#[allow(clippy::wildcard_imports)] use azul_css::{
11 dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec},
12 props::{
13 basic::{
14 color::ColorU,
15 font::{StyleFontFamily, StyleFontFamilyVec},
16 *,
17 },
18 layout::*,
19 property::{CssProperty, *},
20 style::*,
21 },
22 system::{SystemFontType, SystemStyle, TitlebarButtonSide, TitlebarButtons, TitlebarMetrics},
23 *,
24};
25
26#[cfg(target_os = "macos")]
30const DEFAULT_TITLEBAR_HEIGHT: f32 = 28.0;
31#[cfg(target_os = "windows")]
32const DEFAULT_TITLEBAR_HEIGHT: f32 = 32.0;
33#[cfg(target_os = "linux")]
34const DEFAULT_TITLEBAR_HEIGHT: f32 = 30.0;
35#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
36const DEFAULT_TITLEBAR_HEIGHT: f32 = 32.0;
37
38#[cfg(target_os = "macos")]
39const DEFAULT_TITLE_FONT_SIZE: f32 = 13.0;
40#[cfg(target_os = "windows")]
41const DEFAULT_TITLE_FONT_SIZE: f32 = 12.0;
42#[cfg(target_os = "linux")]
43const DEFAULT_TITLE_FONT_SIZE: f32 = 13.0;
44#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
45const DEFAULT_TITLE_FONT_SIZE: f32 = 13.0;
46
47#[cfg(target_os = "macos")]
49const DEFAULT_BUTTON_AREA_WIDTH: f32 = 78.0;
50#[cfg(target_os = "windows")]
52const DEFAULT_BUTTON_AREA_WIDTH: f32 = 138.0;
53#[cfg(target_os = "linux")]
54const DEFAULT_BUTTON_AREA_WIDTH: f32 = 100.0;
55#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
56const DEFAULT_BUTTON_AREA_WIDTH: f32 = 100.0;
57
58#[cfg(target_os = "macos")]
60const DEFAULT_BUTTON_SIDE_LEFT: bool = true;
61#[cfg(not(target_os = "macos"))]
62const DEFAULT_BUTTON_SIDE_LEFT: bool = false;
63
64const DEFAULT_TITLE_COLOR_LIGHT: ColorU = ColorU { r: 76, g: 76, b: 76, a: 255 }; const DEFAULT_TITLE_COLOR_DARK: ColorU = ColorU { r: 229, g: 229, b: 229, a: 255 }; #[derive(Debug, Clone, PartialEq, PartialOrd)]
104#[repr(C)]
105pub struct Titlebar {
106 pub title: AzString,
108 pub height: f32,
110 pub font_size: f32,
112 pub padding_left: f32,
114 pub padding_right: f32,
116 pub title_color: ColorU,
118}
119
120impl Titlebar {
121 #[inline]
126 #[must_use] pub fn new(title: AzString) -> Self {
127 let half = DEFAULT_BUTTON_AREA_WIDTH / 2.0;
130 let (padding_left, padding_right) = (half, half);
131 Self {
132 title,
133 height: DEFAULT_TITLEBAR_HEIGHT,
134 font_size: DEFAULT_TITLE_FONT_SIZE,
135 padding_left,
136 padding_right,
137 title_color: DEFAULT_TITLE_COLOR_LIGHT,
138 }
139 }
140
141 #[inline]
143 #[must_use] pub fn create(title: AzString) -> Self {
144 Self::new(title)
145 }
146
147 #[inline]
149 #[must_use] pub fn with_height(title: AzString, height: f32) -> Self {
150 let mut tb = Self::new(title);
151 tb.height = height;
152 tb
153 }
154
155 #[inline]
157 pub const fn set_height(&mut self, height: f32) {
158 self.height = height;
159 }
160
161 #[inline]
163 pub fn set_title(&mut self, title: AzString) {
164 self.title = title;
165 }
166
167 #[inline]
169 #[must_use]
170 pub fn swap_with_default(&mut self) -> Self {
171 let mut s = Self::new(AzString::from_const_str(""));
172 core::mem::swap(&mut s, self);
173 s
174 }
175
176 #[must_use] pub fn from_system_style(title: AzString, system_style: &SystemStyle) -> Self {
179 let tm = &system_style.metrics.titlebar;
180 let height = tm.height.as_ref()
181 .map_or(DEFAULT_TITLEBAR_HEIGHT, |pv| pv.to_pixels_internal(0.0, 0.0, 0.0));
182 let font_size = tm.title_font_size
183 .into_option()
184 .unwrap_or(DEFAULT_TITLE_FONT_SIZE);
185 let button_area = tm.button_area_width.as_ref()
186 .map_or(DEFAULT_BUTTON_AREA_WIDTH, |pv| pv.to_pixels_internal(0.0, 0.0, 0.0));
187 let safe_left = tm.safe_area.left.as_ref()
188 .map_or(0.0, |pv| pv.to_pixels_internal(0.0, 0.0, 0.0));
189 let safe_right = tm.safe_area.right.as_ref()
190 .map_or(0.0, |pv| pv.to_pixels_internal(0.0, 0.0, 0.0));
191 let pad_h = tm.padding_horizontal.as_ref()
193 .map_or(0.0, |pv| pv.to_pixels_internal(0.0, 0.0, 0.0));
194
195 let half_btn = button_area / 2.0;
199 let (padding_left, padding_right) = (
200 half_btn + safe_left + pad_h,
201 half_btn + safe_right + pad_h,
202 );
203
204 let title_color = system_style.colors.text.into_option().unwrap_or(
206 match system_style.theme {
207 system::Theme::Dark => DEFAULT_TITLE_COLOR_DARK,
208 system::Theme::Light => DEFAULT_TITLE_COLOR_LIGHT,
209 }
210 );
211
212 Self { title, height, font_size, padding_left, padding_right, title_color }
213 }
214
215 #[must_use] pub fn from_system_style_csd(title: AzString, system_style: &SystemStyle) -> Self {
218 let tm = &system_style.metrics.titlebar;
219 let height = tm.height.as_ref()
220 .map_or(DEFAULT_TITLEBAR_HEIGHT, |pv| pv.to_pixels_internal(0.0, 0.0, 0.0));
221 let font_size = tm.title_font_size
222 .into_option()
223 .unwrap_or(DEFAULT_TITLE_FONT_SIZE);
224 let title_color = system_style.colors.text.into_option().unwrap_or(
225 match system_style.theme {
226 system::Theme::Dark => DEFAULT_TITLE_COLOR_DARK,
227 system::Theme::Light => DEFAULT_TITLE_COLOR_LIGHT,
228 }
229 );
230 Self { title, height, font_size, padding_left: 0.0, padding_right: 0.0, title_color }
231 }
232
233 #[allow(clippy::cast_possible_truncation)] fn build_container_style(&self, show_buttons: bool) -> CssPropertyWithConditionsVec {
236 let mut props = Vec::with_capacity(8);
237 if show_buttons {
238 props.push(CssPropertyWithConditions::simple(
240 CssProperty::const_display(LayoutDisplay::Flex),
241 ));
242 props.push(CssPropertyWithConditions::simple(
243 CssProperty::const_flex_direction(LayoutFlexDirection::Row),
244 ));
245 props.push(CssPropertyWithConditions::simple(
246 CssProperty::const_align_items(LayoutAlignItems::Center),
247 ));
248 } else {
249 props.push(CssPropertyWithConditions::simple(
252 CssProperty::const_display(LayoutDisplay::Block),
253 ));
254 }
255 props.push(CssPropertyWithConditions::simple(
256 CssProperty::const_height(LayoutHeight::const_px(self.height as isize)),
257 ));
258 props.push(CssPropertyWithConditions::simple(
260 CssProperty::const_cursor(StyleCursor::Grab),
261 ));
262 props.push(CssPropertyWithConditions::simple(
263 CssProperty::user_select(StyleUserSelect::None),
264 ));
265 if self.padding_left > 0.0 {
266 props.push(CssPropertyWithConditions::simple(
267 CssProperty::const_padding_left(LayoutPaddingLeft::const_px(
268 self.padding_left as isize,
269 )),
270 ));
271 }
272 if self.padding_right > 0.0 {
273 props.push(CssPropertyWithConditions::simple(
274 CssProperty::const_padding_right(LayoutPaddingRight::const_px(
275 self.padding_right as isize,
276 )),
277 ));
278 }
279 CssPropertyWithConditionsVec::from_vec(props)
280 }
281
282 #[allow(clippy::cast_possible_truncation)] fn build_title_style(&self, show_buttons: bool) -> CssPropertyWithConditionsVec {
285 let font_family = StyleFontFamilyVec::from_vec(vec![
286 StyleFontFamily::SystemType(SystemFontType::TitleBold),
287 ]);
288 let mut props = Vec::with_capacity(10);
289 props.push(CssPropertyWithConditions::simple(
290 CssProperty::const_font_size(StyleFontSize::const_px(self.font_size as isize)),
291 ));
292 props.push(CssPropertyWithConditions::simple(
293 CssProperty::const_font_family(font_family),
294 ));
295 props.push(CssPropertyWithConditions::simple(
297 CssProperty::const_text_color(StyleTextColor { inner: self.title_color }),
298 ));
299 if show_buttons {
301 props.push(CssPropertyWithConditions::simple(
302 CssProperty::const_flex_grow(LayoutFlexGrow::const_new(1)),
303 ));
304 props.push(CssPropertyWithConditions::simple(
305 CssProperty::const_min_width(LayoutMinWidth::const_px(0)),
306 ));
307 }
308 props.push(CssPropertyWithConditions::simple(
309 CssProperty::const_text_align(StyleTextAlign::Center),
310 ));
311 props.push(CssPropertyWithConditions::simple(
312 CssProperty::WhiteSpace(StyleWhiteSpaceValue::Exact(StyleWhiteSpace::Nowrap)),
313 ));
314 props.push(CssPropertyWithConditions::simple(
315 CssProperty::const_overflow_x(LayoutOverflow::Hidden),
316 ));
317 let v_pad = ((self.height - self.font_size) / 2.0).max(0.0);
319 if v_pad > 0.0 {
320 props.push(CssPropertyWithConditions::simple(
321 CssProperty::const_padding_top(LayoutPaddingTop::const_px(v_pad as isize)),
322 ));
323 }
324 CssPropertyWithConditionsVec::from_vec(props)
325 }
326
327 #[inline]
332 #[must_use] pub fn dom(self) -> Dom {
333 self.dom_inner(false, &TitlebarButtons::default(), TitlebarButtonSide::Right)
334 }
335
336 #[must_use] pub fn dom_with_buttons(
341 self,
342 buttons: &TitlebarButtons,
343 button_side: TitlebarButtonSide,
344 ) -> Dom {
345 self.dom_inner(true, buttons, button_side)
346 }
347
348 #[allow(clippy::trivially_copy_pass_by_ref)] fn dom_inner(
351 self,
352 show_buttons: bool,
353 buttons: &TitlebarButtons,
354 button_side: TitlebarButtonSide,
355 ) -> Dom {
356 use azul_core::{
357 callbacks::{CoreCallback, CoreCallbackData},
358 dom::{EventFilter, HoverEventFilter},
359 };
360
361 #[derive(Debug, Clone, Copy)]
362 struct DragMarker;
363
364 let title_style = self.build_title_style(show_buttons);
366 let container_style = self.build_container_style(show_buttons);
367
368 let title_classes = IdOrClassVec::from_vec(vec![Class("csd-title".into())]);
370
371 let title_node = Dom::create_div()
372 .with_ids_and_classes(title_classes)
373 .with_css_props(title_style)
374 .with_child(Dom::create_text(self.title)) .with_callbacks(vec![
376 CoreCallbackData {
377 event: EventFilter::Hover(HoverEventFilter::DragStart),
378 callback: CoreCallback {
379 cb: callbacks::titlebar_drag_start as usize,
380 ctx: azul_core::refany::OptionRefAny::None,
381 },
382 refany: RefAny::new(DragMarker),
383 },
384 CoreCallbackData {
385 event: EventFilter::Hover(HoverEventFilter::Drag),
386 callback: CoreCallback {
387 cb: callbacks::titlebar_drag as usize,
388 ctx: azul_core::refany::OptionRefAny::None,
389 },
390 refany: RefAny::new(DragMarker),
391 },
392 CoreCallbackData {
393 event: EventFilter::Hover(HoverEventFilter::DoubleClick),
394 callback: CoreCallback {
395 cb: callbacks::titlebar_double_click as usize,
396 ctx: azul_core::refany::OptionRefAny::None,
397 },
398 refany: RefAny::new(DragMarker),
399 },
400 ].into());
401
402 let button_container = if show_buttons {
404 Some(build_button_container(buttons))
405 } else {
406 None
407 };
408
409 let container_classes = IdOrClassVec::from_vec(vec![
411 Class("csd-titlebar".into()),
412 Class("__azul-native-titlebar".into()),
413 ]);
414 let mut root = Dom::create_div()
415 .with_ids_and_classes(container_classes)
416 .with_css_props(container_style);
417
418 match button_side {
422 TitlebarButtonSide::Left => {
423 if let Some(btn) = button_container { root = root.with_child(btn); }
424 root = root.with_child(title_node);
425 }
426 TitlebarButtonSide::Right => {
427 root = root.with_child(title_node);
428 if let Some(btn) = button_container { root = root.with_child(btn); }
429 }
430 }
431
432 root
433 }
434}
435
436#[allow(clippy::trivially_copy_pass_by_ref)] fn build_button_container(buttons: &TitlebarButtons) -> Dom {
439 use azul_core::{
440 callbacks::{CoreCallback, CoreCallbackData},
441 dom::{EventFilter, HoverEventFilter},
442 };
443
444 let mut children = Vec::new();
445
446 if buttons.has_minimize {
447 let classes = IdOrClassVec::from_vec(vec![
448 Id("csd-button-minimize".into()),
449 Class("csd-button".into()),
450 Class("csd-minimize".into()),
451 ]);
452 children.push(Dom::create_div()
453 .with_ids_and_classes(classes)
454 .with_child(Dom::create_icon("minimize"))
455 .with_callbacks(vec![CoreCallbackData {
456 event: EventFilter::Hover(HoverEventFilter::MouseDown),
457 callback: CoreCallback {
458 cb: callbacks::csd_minimize as usize,
459 ctx: azul_core::refany::OptionRefAny::None,
460 },
461 refany: RefAny::new(()),
462 }].into()));
463 }
464
465 if buttons.has_maximize {
466 let classes = IdOrClassVec::from_vec(vec![
467 Id("csd-button-maximize".into()),
468 Class("csd-button".into()),
469 Class("csd-maximize".into()),
470 ]);
471 children.push(Dom::create_div()
472 .with_ids_and_classes(classes)
473 .with_child(Dom::create_icon("maximize"))
474 .with_callbacks(vec![CoreCallbackData {
475 event: EventFilter::Hover(HoverEventFilter::MouseDown),
476 callback: CoreCallback {
477 cb: callbacks::csd_maximize as usize,
478 ctx: azul_core::refany::OptionRefAny::None,
479 },
480 refany: RefAny::new(()),
481 }].into()));
482 }
483
484 if buttons.has_close {
485 let classes = IdOrClassVec::from_vec(vec![
486 Id("csd-button-close".into()),
487 Class("csd-button".into()),
488 Class("csd-close".into()),
489 ]);
490 children.push(Dom::create_div()
491 .with_ids_and_classes(classes)
492 .with_child(Dom::create_icon("close"))
493 .with_callbacks(vec![CoreCallbackData {
494 event: EventFilter::Hover(HoverEventFilter::MouseDown),
495 callback: CoreCallback {
496 cb: callbacks::csd_close as usize,
497 ctx: azul_core::refany::OptionRefAny::None,
498 },
499 refany: RefAny::new(()),
500 }].into()));
501 }
502
503 let classes = IdOrClassVec::from_vec(vec![Class("csd-buttons".into())]);
504 Dom::create_div()
505 .with_ids_and_classes(classes)
506 .with_children(DomVec::from_vec(children))
507}
508
509impl From<Titlebar> for Dom {
510 fn from(t: Titlebar) -> Self { t.dom() }
511}
512
513impl Default for Titlebar {
514 fn default() -> Self {
515 Self::new(AzString::from_const_str(""))
516 }
517}
518
519pub(crate) mod callbacks {
526 use azul_core::callbacks::Update;
527 use azul_core::refany::RefAny;
528 use crate::callbacks::CallbackInfo;
529
530 pub(super) extern "C" fn titlebar_drag_start(
533 _data: RefAny, mut info: CallbackInfo,
534 ) -> Update {
535 let ws = info.get_current_window_state().clone();
543 let native_move = matches!(ws.position, azul_core::window::WindowPosition::Uninitialized)
544 || cfg!(target_os = "macos");
545 if native_move {
546 info.begin_interactive_move();
547 } else {
548 RESIDUAL_X_BITS.store(0f32.to_bits(), core::sync::atomic::Ordering::Relaxed);
551 RESIDUAL_Y_BITS.store(0f32.to_bits(), core::sync::atomic::Ordering::Relaxed);
552 if ws.flags.frame == azul_core::window::WindowFrame::Maximized {
556 let mut s = ws;
557 s.flags.frame = azul_core::window::WindowFrame::Normal;
558 info.modify_window_state(s);
559 }
560 }
561 Update::DoNothing
562 }
563
564 static RESIDUAL_X_BITS: core::sync::atomic::AtomicU32 =
570 core::sync::atomic::AtomicU32::new(0);
571 static RESIDUAL_Y_BITS: core::sync::atomic::AtomicU32 =
572 core::sync::atomic::AtomicU32::new(0);
573
574 #[allow(clippy::cast_possible_truncation)] pub(super) extern "C" fn titlebar_drag(
586 _data: RefAny, mut info: CallbackInfo,
587 ) -> Update {
588 use azul_core::window::WindowPosition;
589 use azul_core::geom::PhysicalPositionI32;
590
591 let delta = info.get_drag_delta_screen_incremental();
592 let current_pos = info.get_current_window_state().position;
593
594 if let (azul_core::geom::OptionDragDelta::Some(d), WindowPosition::Initialized(pos)) = (delta, current_pos) {
595 use core::sync::atomic::Ordering;
596 let total_x = d.dx + f32::from_bits(RESIDUAL_X_BITS.load(Ordering::Relaxed));
601 let total_y = d.dy + f32::from_bits(RESIDUAL_Y_BITS.load(Ordering::Relaxed));
602 let apply_x = total_x.round();
603 let apply_y = total_y.round();
604 RESIDUAL_X_BITS.store((total_x - apply_x).to_bits(), Ordering::Relaxed);
605 RESIDUAL_Y_BITS.store((total_y - apply_y).to_bits(), Ordering::Relaxed);
606 let new_pos = WindowPosition::Initialized(PhysicalPositionI32::new(
607 pos.x + apply_x as i32,
608 pos.y + apply_y as i32,
609 ));
610 let mut ws = info.get_current_window_state().clone();
611 ws.position = new_pos;
612 info.modify_window_state(ws);
613 }
614 Update::DoNothing
616 }
617
618 pub(super) extern "C" fn titlebar_double_click(
620 _data: RefAny, mut info: CallbackInfo,
621 ) -> Update {
622 use azul_core::window::WindowFrame;
623 let mut s = info.get_current_window_state().clone();
624 s.flags.frame = if s.flags.frame == WindowFrame::Maximized {
625 WindowFrame::Normal } else { WindowFrame::Maximized };
626 info.modify_window_state(s);
627 Update::DoNothing
628 }
629
630 pub(super) extern "C" fn csd_close(
632 _data: RefAny, mut info: CallbackInfo,
633 ) -> Update {
634 let mut s = info.get_current_window_state().clone();
635 s.flags.close_requested = true;
636 info.modify_window_state(s);
637 Update::DoNothing
638 }
639
640 pub(super) extern "C" fn csd_minimize(
642 _data: RefAny, mut info: CallbackInfo,
643 ) -> Update {
644 use azul_core::window::WindowFrame;
645 let mut s = info.get_current_window_state().clone();
646 s.flags.frame = WindowFrame::Minimized;
647 info.modify_window_state(s);
648 Update::DoNothing
649 }
650
651 pub(super) extern "C" fn csd_maximize(
653 _data: RefAny, mut info: CallbackInfo,
654 ) -> Update {
655 use azul_core::window::WindowFrame;
656 let mut s = info.get_current_window_state().clone();
657 s.flags.frame = if s.flags.frame == WindowFrame::Maximized {
658 WindowFrame::Normal } else { WindowFrame::Maximized };
659 info.modify_window_state(s);
660 Update::DoNothing
661 }
662}
663
664#[cfg(test)]
665#[allow(
666 clippy::float_cmp,
667 clippy::cast_possible_truncation,
668 clippy::cast_precision_loss,
669 clippy::too_many_lines,
670 clippy::unreadable_literal
671)]
672mod autotest_generated {
673 use std::{
674 collections::BTreeMap,
675 sync::{Arc, Mutex},
676 };
677
678 use azul_core::{
679 callbacks::Update,
680 dom::{DomId, DomNodeId, EventFilter, HoverEventFilter, NodeId, NodeType},
681 geom::{OptionLogicalPosition, PhysicalPositionI32},
682 gl::OptionGlContextPtr,
683 hit_test::ScrollPosition,
684 refany::OptionRefAny,
685 resources::RendererResources,
686 styled_dom::NodeHierarchyItemId,
687 window::{MonitorVec, RawWindowHandle, WindowFrame, WindowPosition},
688 };
689 use azul_css::{
690 props::basic::{length::SizeMetric, pixel::PixelValue},
691 system::SafeAreaInsets,
692 };
693 use rust_fontconfig::FcFontCache;
694
695 use super::*;
696 #[cfg(feature = "icu")]
697 use crate::icu::IcuLocalizerHandle;
698 use crate::{
699 callbacks::{CallbackChange, CallbackInfo, CallbackInfoRefData, ExternalSystemCallbacks},
700 window::LayoutWindow,
701 window_state::FullWindowState,
702 };
703
704 const ADVERSARIAL_TITLES: [&str; 10] = [
713 "",
714 " ",
715 "My Window",
716 "a\0b",
717 "\0",
718 "e\u{0301}\u{0301}\u{0301}",
719 "\u{1F469}\u{200D}\u{1F469}\u{200D}\u{1F467}",
720 "\u{202E}gnirts desrever\u{202C}",
721 "\u{FFFD}\u{FEFF}\t\n",
722 "\u{200B}",
723 ];
724
725 const TAME_FLOATS: [f32; 14] = [
729 0.0,
730 -0.0,
731 1.0,
732 -1.0,
733 0.5,
734 -0.5,
735 30.0,
736 1000.0,
737 -1000.0,
738 0.999,
739 f32::EPSILON,
740 f32::MIN_POSITIVE,
741 -f32::MIN_POSITIVE,
742 f32::NAN,
743 ];
744
745 const UNENCODABLE_FLOATS: [f32; 4] =
748 [f32::INFINITY, f32::NEG_INFINITY, f32::MAX, f32::MIN];
749
750 const BOTH_SIDES: [TitlebarButtonSide; 2] =
752 [TitlebarButtonSide::Left, TitlebarButtonSide::Right];
753
754 const ALL_FRAMES: [WindowFrame; 4] = [
756 WindowFrame::Normal,
757 WindowFrame::Minimized,
758 WindowFrame::Maximized,
759 WindowFrame::Fullscreen,
760 ];
761
762 fn tb(title: &str) -> Titlebar {
763 Titlebar::new(AzString::from(title))
764 }
765
766 fn properties(v: &CssPropertyWithConditionsVec) -> Vec<CssProperty> {
768 v.as_ref().iter().map(|p| p.property.clone()).collect()
769 }
770
771 fn all_unconditional(v: &CssPropertyWithConditionsVec) -> bool {
774 v.as_ref().iter().all(|p| p.apply_if.as_ref().is_empty())
775 }
776
777 fn px(pv: &PixelValue) -> f32 {
781 assert_eq!(
782 pv.metric,
783 SizeMetric::Px,
784 "titlebar geometry must be absolute px, got {:?}",
785 pv.metric
786 );
787 pv.number.get()
788 }
789
790 fn height_px(v: &CssPropertyWithConditionsVec) -> Option<f32> {
791 v.as_ref().iter().find_map(|p| match &p.property {
792 CssProperty::Height(h) => match h.get_property() {
793 Some(LayoutHeight::Px(pv)) => Some(px(pv)),
794 _ => None,
795 },
796 _ => None,
797 })
798 }
799
800 fn padding_left_px(v: &CssPropertyWithConditionsVec) -> Option<f32> {
801 v.as_ref().iter().find_map(|p| match &p.property {
802 CssProperty::PaddingLeft(x) => x.get_property().map(|x| px(&x.inner)),
803 _ => None,
804 })
805 }
806
807 fn padding_right_px(v: &CssPropertyWithConditionsVec) -> Option<f32> {
808 v.as_ref().iter().find_map(|p| match &p.property {
809 CssProperty::PaddingRight(x) => x.get_property().map(|x| px(&x.inner)),
810 _ => None,
811 })
812 }
813
814 fn padding_top_px(v: &CssPropertyWithConditionsVec) -> Option<f32> {
815 v.as_ref().iter().find_map(|p| match &p.property {
816 CssProperty::PaddingTop(x) => x.get_property().map(|x| px(&x.inner)),
817 _ => None,
818 })
819 }
820
821 fn font_size_px(v: &CssPropertyWithConditionsVec) -> Option<f32> {
822 v.as_ref().iter().find_map(|p| match &p.property {
823 CssProperty::FontSize(f) => f.get_property().map(|f| px(&f.inner)),
824 _ => None,
825 })
826 }
827
828 fn text_color(v: &CssPropertyWithConditionsVec) -> Option<ColorU> {
829 v.as_ref().iter().find_map(|p| match &p.property {
830 CssProperty::TextColor(c) => c.get_property().map(|c| c.inner),
831 _ => None,
832 })
833 }
834
835 fn expected_container(t: &Titlebar, show_buttons: bool) -> Vec<CssProperty> {
837 let mut v = Vec::new();
838 if show_buttons {
839 v.push(CssProperty::const_display(LayoutDisplay::Flex));
840 v.push(CssProperty::const_flex_direction(LayoutFlexDirection::Row));
841 v.push(CssProperty::const_align_items(LayoutAlignItems::Center));
842 } else {
843 v.push(CssProperty::const_display(LayoutDisplay::Block));
844 }
845 v.push(CssProperty::const_height(LayoutHeight::const_px(t.height as isize)));
846 v.push(CssProperty::const_cursor(StyleCursor::Grab));
847 v.push(CssProperty::user_select(StyleUserSelect::None));
848 if t.padding_left > 0.0 {
849 v.push(CssProperty::const_padding_left(LayoutPaddingLeft::const_px(
850 t.padding_left as isize,
851 )));
852 }
853 if t.padding_right > 0.0 {
854 v.push(CssProperty::const_padding_right(LayoutPaddingRight::const_px(
855 t.padding_right as isize,
856 )));
857 }
858 v
859 }
860
861 fn expected_title(t: &Titlebar, show_buttons: bool) -> Vec<CssProperty> {
863 let font_family = StyleFontFamilyVec::from_vec(vec![StyleFontFamily::SystemType(
864 SystemFontType::TitleBold,
865 )]);
866 let mut v = vec![
867 CssProperty::const_font_size(StyleFontSize::const_px(t.font_size as isize)),
868 CssProperty::const_font_family(font_family),
869 CssProperty::const_text_color(StyleTextColor { inner: t.title_color }),
870 ];
871 if show_buttons {
872 v.push(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(1)));
873 v.push(CssProperty::const_min_width(LayoutMinWidth::const_px(0)));
874 }
875 v.push(CssProperty::const_text_align(StyleTextAlign::Center));
876 v.push(CssProperty::WhiteSpace(StyleWhiteSpaceValue::Exact(
877 StyleWhiteSpace::Nowrap,
878 )));
879 v.push(CssProperty::const_overflow_x(LayoutOverflow::Hidden));
880 let v_pad = ((t.height - t.font_size) / 2.0).max(0.0);
881 if v_pad > 0.0 {
882 v.push(CssProperty::const_padding_top(LayoutPaddingTop::const_px(
883 v_pad as isize,
884 )));
885 }
886 v
887 }
888
889 fn has_class(node: &Dom, name: &str) -> bool {
891 node.root
892 .get_ids_and_classes()
893 .as_ref()
894 .iter()
895 .any(|c| matches!(c, IdOrClass::Class(s) if s.as_str() == name))
896 }
897
898 fn ids(node: &Dom) -> Vec<String> {
900 node.root
901 .get_ids_and_classes()
902 .as_ref()
903 .iter()
904 .filter_map(|c| match c {
905 IdOrClass::Id(s) => Some(s.as_str().to_string()),
906 IdOrClass::Class(_) => None,
907 })
908 .collect()
909 }
910
911 fn classes(node: &Dom) -> Vec<String> {
913 node.root
914 .get_ids_and_classes()
915 .as_ref()
916 .iter()
917 .filter_map(|c| match c {
918 IdOrClass::Class(s) => Some(s.as_str().to_string()),
919 IdOrClass::Id(_) => None,
920 })
921 .collect()
922 }
923
924 fn text_of(node: &Dom) -> Option<&str> {
926 match node.root.get_node_type() {
927 NodeType::Text(s) => Some(s.as_ref().as_str()),
928 _ => None,
929 }
930 }
931
932 fn icon_of(node: &Dom) -> Option<&str> {
934 match node.root.get_node_type() {
935 NodeType::Icon(s) => Some(s.as_ref().as_str()),
936 _ => None,
937 }
938 }
939
940 fn inline_props(node: &Dom) -> Vec<CssProperty> {
942 node.root.style.iter_inline_properties().map(|(p, _)| p.clone()).collect()
943 }
944
945 fn callbacks_of(node: &Dom) -> Vec<(EventFilter, usize)> {
947 node.root
948 .get_callbacks()
949 .as_ref()
950 .iter()
951 .map(|c| (c.event, c.callback.cb))
952 .collect()
953 }
954
955 fn count_descendants(dom: &Dom) -> usize {
960 dom.children.as_ref().iter().map(|c| 1 + count_descendants(c)).sum()
961 }
962
963 fn fingerprint(dom: &Dom) -> Vec<String> {
968 fn walk(d: &Dom, depth: usize, out: &mut Vec<String>) {
969 out.push(format!(
970 "{depth}|{:?}|{:?}|{:?}|{:?}|{:?}",
971 d.root.get_node_type(),
972 ids(d),
973 classes(d),
974 callbacks_of(d),
975 inline_props(d),
976 ));
977 for c in d.children.as_ref() {
978 walk(c, depth + 1, out);
979 }
980 }
981 let mut out = Vec::new();
982 walk(dom, 0, &mut out);
983 out
984 }
985
986 fn title_node(dom: &Dom) -> &Dom {
988 dom.children
989 .as_ref()
990 .iter()
991 .find(|c| has_class(c, "csd-title"))
992 .expect("every titlebar must render a .csd-title node")
993 }
994
995 fn buttons_node(dom: &Dom) -> Option<&Dom> {
997 dom.children.as_ref().iter().find(|c| has_class(c, "csd-buttons"))
998 }
999
1000 fn all_button_combinations() -> Vec<TitlebarButtons> {
1001 let mut out = Vec::new();
1002 for &close in &[false, true] {
1003 for &min in &[false, true] {
1004 for &max in &[false, true] {
1005 for &full in &[false, true] {
1006 out.push(TitlebarButtons {
1007 has_close: close,
1008 has_minimize: min,
1009 has_maximize: max,
1010 has_fullscreen: full,
1011 });
1012 }
1013 }
1014 }
1015 }
1016 out
1017 }
1018
1019 fn blank_system_style() -> SystemStyle {
1022 SystemStyle::default()
1023 }
1024
1025 fn with_callback_info<R>(
1029 state: FullWindowState,
1030 f: impl FnOnce(CallbackInfo) -> R,
1031 ) -> (R, Vec<CallbackChange>) {
1032 let layout_window =
1033 LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
1034
1035 let renderer_resources = RendererResources::default();
1036 let previous_window_state: Option<FullWindowState> = None;
1037 let current_window_state = state;
1038 let gl_context = OptionGlContextPtr::None;
1039 let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
1040 BTreeMap::new();
1041 let window_handle = RawWindowHandle::Unsupported;
1042 let system_callbacks = ExternalSystemCallbacks::rust_internal();
1043
1044 let ref_data = CallbackInfoRefData {
1045 layout_window: &layout_window,
1046 renderer_resources: &renderer_resources,
1047 previous_window_state: &previous_window_state,
1048 current_window_state: ¤t_window_state,
1049 gl_context: &gl_context,
1050 current_scroll_manager: &scroll_states,
1051 current_window_handle: &window_handle,
1052 system_callbacks: &system_callbacks,
1053 system_style: Arc::new(SystemStyle::default()),
1054 monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
1055 #[cfg(feature = "icu")]
1056 icu_localizer: IcuLocalizerHandle::default(),
1057 ctx: OptionRefAny::None,
1058 };
1059
1060 let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
1061
1062 let info = CallbackInfo::new(
1063 &ref_data,
1064 &changes,
1065 DomNodeId {
1066 dom: DomId::ROOT_ID,
1067 node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(0))),
1068 },
1069 OptionLogicalPosition::None,
1070 OptionLogicalPosition::None,
1071 );
1072
1073 let out = f(info);
1074 let recorded = core::mem::take(&mut *changes.lock().expect("change log poisoned"));
1075 (out, recorded)
1076 }
1077
1078 fn state_writes(changes: &[CallbackChange]) -> Vec<FullWindowState> {
1080 changes
1081 .iter()
1082 .filter_map(|c| match c {
1083 CallbackChange::ModifyWindowState { state } => Some(state.clone()),
1084 _ => None,
1085 })
1086 .collect()
1087 }
1088
1089 fn interactive_moves(changes: &[CallbackChange]) -> usize {
1090 changes
1091 .iter()
1092 .filter(|c| matches!(c, CallbackChange::BeginInteractiveMove))
1093 .count()
1094 }
1095
1096 fn state_with(frame: WindowFrame, position: WindowPosition) -> FullWindowState {
1097 let mut s = FullWindowState::default();
1098 s.flags.frame = frame;
1099 s.position = position;
1100 s
1101 }
1102
1103 #[test]
1108 fn new_uses_the_compile_time_platform_defaults() {
1109 let t = tb("hello");
1110
1111 assert_eq!(t.title.as_str(), "hello");
1112 assert_eq!(t.height, DEFAULT_TITLEBAR_HEIGHT);
1113 assert_eq!(t.font_size, DEFAULT_TITLE_FONT_SIZE);
1114 assert_eq!(t.title_color, DEFAULT_TITLE_COLOR_LIGHT);
1115 assert_eq!(t.padding_left, DEFAULT_BUTTON_AREA_WIDTH / 2.0);
1116 assert_eq!(t.padding_right, DEFAULT_BUTTON_AREA_WIDTH / 2.0);
1117 }
1118
1119 #[test]
1120 fn new_pads_both_sides_equally_so_centering_lands_at_the_window_midpoint() {
1121 let t = tb("x");
1125 assert_eq!(
1126 t.padding_left, t.padding_right,
1127 "title-only padding must stay symmetric",
1128 );
1129 assert!(t.padding_left >= 0.0, "negative reserved space is meaningless");
1130 assert!(t.height > 0.0 && t.height.is_finite());
1131 assert!(t.font_size > 0.0 && t.font_size.is_finite());
1132 assert!(
1133 t.font_size < t.height,
1134 "the default font must fit inside the default titlebar height",
1135 );
1136 }
1137
1138 #[test]
1139 fn new_stores_pathological_titles_byte_for_byte() {
1140 for title in ADVERSARIAL_TITLES {
1141 let t = tb(title);
1142 assert_eq!(t.title.as_str(), title, "the title was mangled or normalised");
1143 assert_eq!(
1144 t.title.as_str().len(),
1145 title.len(),
1146 "the title was truncated (an embedded NUL must not terminate it)",
1147 );
1148 }
1149 }
1150
1151 #[test]
1152 fn new_accepts_a_title_far_longer_than_any_real_window_caption() {
1153 let huge = "a".repeat(1_000_000);
1154 let t = Titlebar::new(AzString::from(huge.clone()));
1155 assert_eq!(t.title.as_str().len(), 1_000_000);
1156 let dom = t.dom();
1158 assert_eq!(text_of(&title_node(&dom).children.as_ref()[0]), Some(huge.as_str()));
1159 }
1160
1161 #[test]
1162 fn create_is_indistinguishable_from_new() {
1163 for title in ADVERSARIAL_TITLES {
1164 assert_eq!(
1165 Titlebar::create(AzString::from(title)),
1166 Titlebar::new(AzString::from(title)),
1167 "the FFI alias drifted away from Titlebar::new",
1168 );
1169 }
1170 }
1171
1172 #[test]
1173 fn default_is_new_with_an_empty_title() {
1174 let d = Titlebar::default();
1175 assert_eq!(d, tb(""));
1176 assert_eq!(d.title.as_str(), "");
1177 }
1178
1179 #[test]
1184 fn with_height_stores_every_float_bit_exactly_and_touches_nothing_else() {
1185 let base = tb("t");
1186 for h in TAME_FLOATS.into_iter().chain(UNENCODABLE_FLOATS) {
1187 let t = Titlebar::with_height(AzString::from("t"), h);
1188
1189 assert_eq!(
1191 t.height.to_bits(),
1192 h.to_bits(),
1193 "with_height({h}) did not store the value verbatim",
1194 );
1195 assert_eq!(t.title.as_str(), "t");
1196 assert_eq!(t.font_size, base.font_size, "with_height({h}) moved the font size");
1197 assert_eq!(t.padding_left, base.padding_left, "with_height({h}) moved the padding");
1198 assert_eq!(t.padding_right, base.padding_right, "with_height({h}) moved the padding");
1199 assert_eq!(t.title_color, base.title_color, "with_height({h}) moved the colour");
1200 }
1201 }
1202
1203 #[test]
1204 fn set_height_is_a_bit_exact_last_write_wins_store() {
1205 let mut t = tb("t");
1206 let base = tb("t");
1207
1208 for h in TAME_FLOATS.into_iter().chain(UNENCODABLE_FLOATS) {
1209 t.set_height(h);
1210 assert_eq!(t.height.to_bits(), h.to_bits(), "set_height({h}) was not verbatim");
1211 assert_eq!(t.font_size, base.font_size);
1212 assert_eq!(t.padding_left, base.padding_left);
1213 assert_eq!(t.padding_right, base.padding_right);
1214 assert_eq!(t.title.as_str(), "t", "set_height({h}) disturbed the title");
1215 }
1216
1217 t.set_height(7.5);
1219 t.set_height(9.25);
1220 assert_eq!(t.height, 9.25);
1221 }
1222
1223 #[test]
1224 fn set_height_zero_and_negative_are_stored_not_clamped() {
1225 let mut t = tb("t");
1228
1229 t.set_height(0.0);
1230 assert_eq!(t.height.to_bits(), 0_f32.to_bits(), "0.0 must stay +0.0");
1231
1232 t.set_height(-0.0);
1233 assert_eq!(t.height.to_bits(), (-0.0_f32).to_bits(), "-0.0 must not be normalised");
1234
1235 t.set_height(-42.0);
1236 assert_eq!(t.height, -42.0, "a negative height must not be clamped by the setter");
1237 }
1238
1239 #[test]
1244 fn set_title_replaces_the_title_and_leaves_the_geometry_alone() {
1245 let mut t = Titlebar::with_height(AzString::from("first"), 44.0);
1246 for title in ADVERSARIAL_TITLES {
1247 t.set_title(AzString::from(title));
1248 assert_eq!(t.title.as_str(), title);
1249 assert_eq!(t.title.as_str().len(), title.len());
1250 assert_eq!(t.height, 44.0, "set_title moved the height");
1251 assert_eq!(t.padding_left, tb("").padding_left, "set_title moved the padding");
1252 }
1253 }
1254
1255 #[test]
1260 fn swap_with_default_hands_back_the_old_value_and_leaves_a_default() {
1261 let mut t = Titlebar::with_height(AzString::from("payload"), 99.5);
1262 t.title_color = ColorU { r: 1, g: 2, b: 3, a: 4 };
1263
1264 let taken = t.swap_with_default();
1265
1266 assert_eq!(taken.title.as_str(), "payload", "the title did not travel out");
1267 assert_eq!(taken.height, 99.5, "the height did not travel out");
1268 assert_eq!(taken.title_color, ColorU { r: 1, g: 2, b: 3, a: 4 });
1269
1270 assert_eq!(t, Titlebar::default(), "what was left behind is not a default titlebar");
1271 assert_eq!(t.title.as_str(), "");
1272 }
1273
1274 #[test]
1275 fn swap_with_default_moves_a_nan_height_out_without_losing_its_bits() {
1276 let mut t = tb("x");
1279 t.set_height(f32::NAN);
1280
1281 let taken = t.swap_with_default();
1282
1283 assert!(taken.height.is_nan(), "the NaN height did not travel out");
1284 assert_eq!(t.height, DEFAULT_TITLEBAR_HEIGHT, "the leftover kept the NaN");
1285 assert_eq!(t, Titlebar::default());
1286 }
1287
1288 #[test]
1289 fn repeated_swap_with_default_never_accumulates_state() {
1290 let mut t = Titlebar::with_height(AzString::from("x"), 1.0);
1291 let _first = t.swap_with_default();
1292
1293 for i in 0..8 {
1294 let taken = t.swap_with_default();
1295 assert_eq!(taken, Titlebar::default(), "swap #{i} handed back a non-default");
1296 assert_eq!(t, Titlebar::default(), "swap #{i} left a non-default behind");
1297 }
1298
1299 let dom = t.dom();
1301 assert_eq!(dom.children.as_ref().len(), 1);
1302 assert_eq!(text_of(&title_node(&dom).children.as_ref()[0]), Some(""));
1303 }
1304
1305 #[test]
1310 fn from_system_style_falls_back_to_the_compile_time_defaults_when_nothing_is_detected() {
1311 let ss = blank_system_style();
1312 let t = Titlebar::from_system_style(AzString::from("sys"), &ss);
1313
1314 assert_eq!(t.title.as_str(), "sys");
1315 assert_eq!(t.height, DEFAULT_TITLEBAR_HEIGHT, "an undetected height must fall back");
1316 assert_eq!(t.font_size, 13.0);
1319 assert_eq!(t.padding_left, DEFAULT_BUTTON_AREA_WIDTH / 2.0);
1320 assert_eq!(t.padding_right, DEFAULT_BUTTON_AREA_WIDTH / 2.0);
1321 assert_eq!(t.title_color, DEFAULT_TITLE_COLOR_LIGHT);
1322 }
1323
1324 #[test]
1325 fn from_system_style_with_no_font_size_falls_back_to_the_platform_constant() {
1326 let mut ss = blank_system_style();
1327 ss.metrics.titlebar.title_font_size = OptionF32::None;
1328 let t = Titlebar::from_system_style(AzString::from("x"), &ss);
1329 assert_eq!(t.font_size, DEFAULT_TITLE_FONT_SIZE);
1330 }
1331
1332 #[test]
1333 fn from_system_style_adds_the_safe_area_and_padding_to_each_side_separately() {
1334 let mut ss = blank_system_style();
1335 ss.metrics.titlebar.button_area_width = OptionPixelValue::Some(PixelValue::px(100.0));
1336 ss.metrics.titlebar.padding_horizontal = OptionPixelValue::Some(PixelValue::px(5.0));
1337 ss.metrics.titlebar.safe_area = SafeAreaInsets {
1338 top: OptionPixelValue::None,
1339 bottom: OptionPixelValue::None,
1340 left: OptionPixelValue::Some(PixelValue::px(10.0)),
1341 right: OptionPixelValue::Some(PixelValue::px(20.0)),
1342 };
1343
1344 let t = Titlebar::from_system_style(AzString::from("x"), &ss);
1345
1346 assert_eq!(t.padding_left, 50.0 + 10.0 + 5.0);
1347 assert_eq!(t.padding_right, 50.0 + 20.0 + 5.0);
1348 assert_ne!(t.padding_left, t.padding_right);
1351 }
1352
1353 #[test]
1354 fn from_system_style_reads_the_height_and_font_size_it_was_given() {
1355 let mut ss = blank_system_style();
1356 ss.metrics.titlebar.height = OptionPixelValue::Some(PixelValue::px(41.0));
1357 ss.metrics.titlebar.title_font_size = OptionF32::Some(17.5);
1358
1359 let t = Titlebar::from_system_style(AzString::from("x"), &ss);
1360
1361 assert_eq!(t.height, 41.0);
1362 assert_eq!(t.font_size, 17.5);
1363 }
1364
1365 #[test]
1366 fn from_system_style_converts_absolute_units_but_collapses_relative_ones_to_zero() {
1367 let cases: [(PixelValue, f32); 6] = [
1371 (PixelValue::px(30.0), 30.0),
1372 (PixelValue::pt(30.0), 30.0 * (96.0 / 72.0)),
1373 (PixelValue::em(2.0), 0.0),
1374 (PixelValue::rem(2.0), 0.0),
1375 (PixelValue::percent(50.0), 0.0),
1376 (PixelValue::const_from_metric(SizeMetric::Vh, 50), 0.0),
1377 ];
1378
1379 for (pv, expected) in cases {
1380 let mut ss = blank_system_style();
1381 ss.metrics.titlebar.height = OptionPixelValue::Some(pv);
1382 let t = Titlebar::from_system_style(AzString::from("x"), &ss);
1383 assert!(
1384 (t.height - expected).abs() < 0.01,
1385 "{pv:?} resolved to {} px, expected {expected} px",
1386 t.height,
1387 );
1388 }
1389 }
1390
1391 #[test]
1392 fn from_system_style_saturates_a_non_finite_metric_instead_of_propagating_it() {
1393 for bogus in [f32::INFINITY, f32::NEG_INFINITY, f32::MAX, f32::MIN, f32::NAN] {
1397 let mut ss = blank_system_style();
1398 ss.metrics.titlebar.height = OptionPixelValue::Some(PixelValue::px(bogus));
1399 ss.metrics.titlebar.button_area_width = OptionPixelValue::Some(PixelValue::px(bogus));
1400
1401 let t = Titlebar::from_system_style(AzString::from("x"), &ss);
1402
1403 assert!(t.height.is_finite(), "{bogus} produced a non-finite height {}", t.height);
1404 assert!(
1405 t.padding_left.is_finite() && t.padding_right.is_finite(),
1406 "{bogus} produced non-finite padding",
1407 );
1408 }
1409
1410 let mut ss = blank_system_style();
1412 ss.metrics.titlebar.height = OptionPixelValue::Some(PixelValue::px(f32::NAN));
1413 assert_eq!(Titlebar::from_system_style(AzString::from("x"), &ss).height, 0.0);
1414 }
1415
1416 #[test]
1417 fn from_system_style_prefers_the_detected_text_colour_over_both_theme_fallbacks() {
1418 let detected = ColorU { r: 9, g: 8, b: 7, a: 6 };
1419 for theme in [system::Theme::Light, system::Theme::Dark] {
1420 let mut ss = blank_system_style();
1421 ss.theme = theme;
1422 ss.colors.text = OptionColorU::Some(detected);
1423 assert_eq!(
1424 Titlebar::from_system_style(AzString::from("x"), &ss).title_color,
1425 detected,
1426 "{theme:?}: the detected system text colour must win",
1427 );
1428 }
1429 }
1430
1431 #[test]
1432 fn from_system_style_picks_the_theme_appropriate_fallback_colour() {
1433 let mut light = blank_system_style();
1434 light.theme = system::Theme::Light;
1435 light.colors.text = OptionColorU::None;
1436 assert_eq!(
1437 Titlebar::from_system_style(AzString::from("x"), &light).title_color,
1438 DEFAULT_TITLE_COLOR_LIGHT,
1439 );
1440
1441 let mut dark = blank_system_style();
1442 dark.theme = system::Theme::Dark;
1443 dark.colors.text = OptionColorU::None;
1444 assert_eq!(
1445 Titlebar::from_system_style(AzString::from("x"), &dark).title_color,
1446 DEFAULT_TITLE_COLOR_DARK,
1447 );
1448
1449 assert_ne!(DEFAULT_TITLE_COLOR_LIGHT, DEFAULT_TITLE_COLOR_DARK);
1451 }
1452
1453 #[test]
1454 fn from_system_style_carries_pathological_titles_through_untouched() {
1455 let ss = blank_system_style();
1456 for title in ADVERSARIAL_TITLES {
1457 let t = Titlebar::from_system_style(AzString::from(title), &ss);
1458 assert_eq!(t.title.as_str(), title);
1459 let csd = Titlebar::from_system_style_csd(AzString::from(title), &ss);
1460 assert_eq!(csd.title.as_str(), title);
1461 }
1462 }
1463
1464 #[test]
1469 fn from_system_style_csd_zeroes_the_padding_and_keeps_everything_else() {
1470 let mut ss = blank_system_style();
1471 ss.metrics.titlebar.height = OptionPixelValue::Some(PixelValue::px(41.0));
1472 ss.metrics.titlebar.title_font_size = OptionF32::Some(17.5);
1473 ss.theme = system::Theme::Dark;
1474
1475 let title_only = Titlebar::from_system_style(AzString::from("x"), &ss);
1476 let csd = Titlebar::from_system_style_csd(AzString::from("x"), &ss);
1477
1478 assert_eq!(csd.height, title_only.height);
1479 assert_eq!(csd.font_size, title_only.font_size);
1480 assert_eq!(csd.title_color, title_only.title_color);
1481 assert_eq!(csd.title_color, DEFAULT_TITLE_COLOR_DARK);
1482
1483 assert_eq!(csd.padding_left.to_bits(), 0_f32.to_bits());
1485 assert_eq!(csd.padding_right.to_bits(), 0_f32.to_bits());
1486 }
1487
1488 #[test]
1489 fn from_system_style_csd_ignores_the_button_area_and_safe_area_entirely() {
1490 let mut ss = blank_system_style();
1491 ss.metrics.titlebar.button_area_width = OptionPixelValue::Some(PixelValue::px(500.0));
1492 ss.metrics.titlebar.padding_horizontal = OptionPixelValue::Some(PixelValue::px(77.0));
1493 ss.metrics.titlebar.safe_area = SafeAreaInsets {
1494 top: OptionPixelValue::Some(PixelValue::px(1.0)),
1495 bottom: OptionPixelValue::Some(PixelValue::px(2.0)),
1496 left: OptionPixelValue::Some(PixelValue::px(3.0)),
1497 right: OptionPixelValue::Some(PixelValue::px(4.0)),
1498 };
1499
1500 let csd = Titlebar::from_system_style_csd(AzString::from("x"), &ss);
1501 assert_eq!(csd.padding_left, 0.0);
1502 assert_eq!(csd.padding_right, 0.0);
1503 }
1504
1505 #[test]
1510 fn build_container_style_emits_the_documented_declarations_in_both_modes() {
1511 let t = tb("x");
1512 for show_buttons in [false, true] {
1513 let style = t.build_container_style(show_buttons);
1514 assert_eq!(
1515 properties(&style),
1516 expected_container(&t, show_buttons),
1517 "container declarations drifted (show_buttons = {show_buttons})",
1518 );
1519 assert!(all_unconditional(&style), "a container declaration became conditional");
1520 }
1521 }
1522
1523 #[test]
1524 fn build_container_style_switches_flex_only_for_the_csd_mode() {
1525 let t = tb("x");
1526
1527 let block = t.build_container_style(false);
1528 let flex = t.build_container_style(true);
1529
1530 assert!(properties(&block).contains(&CssProperty::const_display(LayoutDisplay::Block)));
1531 assert!(properties(&flex).contains(&CssProperty::const_display(LayoutDisplay::Flex)));
1532 assert!(
1535 !properties(&block)
1536 .iter()
1537 .any(|p| matches!(p, CssProperty::FlexDirection(_) | CssProperty::AlignItems(_))),
1538 "title-only mode leaked flex declarations",
1539 );
1540 assert_eq!(height_px(&block), height_px(&flex));
1542 assert_eq!(padding_left_px(&block), padding_left_px(&flex));
1543 assert_eq!(padding_right_px(&block), padding_right_px(&flex));
1544 }
1545
1546 #[test]
1547 fn build_container_style_always_declares_the_grab_cursor_and_disables_selection() {
1548 for show_buttons in [false, true] {
1550 let style = tb("x").build_container_style(show_buttons);
1551 let props = properties(&style);
1552 assert!(props.contains(&CssProperty::const_cursor(StyleCursor::Grab)));
1553 assert!(props.contains(&CssProperty::user_select(StyleUserSelect::None)));
1554 }
1555 }
1556
1557 #[test]
1558 fn build_container_style_truncates_the_height_toward_zero() {
1559 for (h, expected) in [
1561 (30.0_f32, 30.0_f32),
1562 (30.9, 30.0),
1563 (-30.9, -30.0),
1564 (0.0, 0.0),
1565 (-0.0, 0.0),
1566 (0.5, 0.0),
1567 (-0.5, 0.0),
1568 (0.999, 0.0),
1569 ] {
1570 let mut t = tb("x");
1571 t.set_height(h);
1572 assert_eq!(
1573 height_px(&t.build_container_style(false)),
1574 Some(expected),
1575 "height {h} encoded wrongly",
1576 );
1577 }
1578 }
1579
1580 #[test]
1581 fn build_container_style_encodes_a_nan_height_as_zero_pixels() {
1582 let mut t = tb("x");
1585 t.set_height(f32::NAN);
1586 assert_eq!(height_px(&t.build_container_style(false)), Some(0.0));
1587 assert_eq!(height_px(&t.build_container_style(true)), Some(0.0));
1588 }
1589
1590 #[test]
1591 fn build_container_style_omits_padding_that_is_not_strictly_positive() {
1592 for pad in [0.0_f32, -0.0, -1.0, -1e30, f32::NAN, f32::NEG_INFINITY] {
1593 let mut t = tb("x");
1594 t.padding_left = pad;
1595 t.padding_right = pad;
1596 let style = t.build_container_style(false);
1597 assert_eq!(
1598 padding_left_px(&style),
1599 None,
1600 "padding-left {pad} must not be declared at all",
1601 );
1602 assert_eq!(padding_right_px(&style), None, "padding-right {pad} was declared");
1603 }
1604 }
1605
1606 #[test]
1607 fn build_container_style_emits_sub_pixel_padding_as_a_zero_px_declaration() {
1608 let mut t = tb("x");
1611 t.padding_left = 0.4;
1612 t.padding_right = 0.6;
1613 let style = t.build_container_style(false);
1614 assert_eq!(padding_left_px(&style), Some(0.0));
1615 assert_eq!(padding_right_px(&style), Some(0.0));
1616 }
1617
1618 #[test]
1619 fn build_container_style_keeps_the_two_paddings_independent() {
1620 let mut t = tb("x");
1621 t.padding_left = 12.0;
1622 t.padding_right = 0.0;
1623 let style = t.build_container_style(true);
1624 assert_eq!(padding_left_px(&style), Some(12.0));
1625 assert_eq!(padding_right_px(&style), None);
1626 }
1627
1628 #[test]
1633 fn build_title_style_emits_the_documented_declarations_in_both_modes() {
1634 let t = tb("x");
1635 for show_buttons in [false, true] {
1636 let style = t.build_title_style(show_buttons);
1637 assert_eq!(
1638 properties(&style),
1639 expected_title(&t, show_buttons),
1640 "title declarations drifted (show_buttons = {show_buttons})",
1641 );
1642 assert!(all_unconditional(&style), "a title declaration became conditional");
1643 }
1644 }
1645
1646 #[test]
1647 fn build_title_style_only_grows_the_title_in_csd_mode() {
1648 let t = tb("x");
1651 let flex = properties(&t.build_title_style(true));
1652 assert!(flex.contains(&CssProperty::const_flex_grow(LayoutFlexGrow::const_new(1))));
1653 assert!(flex.contains(&CssProperty::const_min_width(LayoutMinWidth::const_px(0))));
1654
1655 let block = properties(&t.build_title_style(false));
1656 assert!(
1657 !block
1658 .iter()
1659 .any(|p| matches!(p, CssProperty::FlexGrow(_) | CssProperty::MinWidth(_))),
1660 "title-only mode leaked flex-grow / min-width",
1661 );
1662 }
1663
1664 #[test]
1665 fn build_title_style_always_centres_clips_and_never_wraps() {
1666 for show_buttons in [false, true] {
1667 let props = properties(&tb("x").build_title_style(show_buttons));
1668 assert!(props.contains(&CssProperty::const_text_align(StyleTextAlign::Center)));
1669 assert!(props.contains(&CssProperty::WhiteSpace(StyleWhiteSpaceValue::Exact(
1670 StyleWhiteSpace::Nowrap
1671 ))));
1672 assert!(props.contains(&CssProperty::const_overflow_x(LayoutOverflow::Hidden)));
1673 }
1674 }
1675
1676 #[test]
1677 fn build_title_style_forwards_the_resolved_title_colour_verbatim() {
1678 for c in [
1679 ColorU { r: 0, g: 0, b: 0, a: 0 },
1680 ColorU { r: 255, g: 255, b: 255, a: 255 },
1681 ColorU { r: 1, g: 2, b: 3, a: 4 },
1682 DEFAULT_TITLE_COLOR_DARK,
1683 ] {
1684 let mut t = tb("x");
1685 t.title_color = c;
1686 assert_eq!(text_color(&t.build_title_style(false)), Some(c));
1687 assert_eq!(text_color(&t.build_title_style(true)), Some(c));
1688 }
1689 }
1690
1691 #[test]
1692 fn build_title_style_centres_vertically_with_half_the_leftover_height() {
1693 for (h, fs, expected) in [
1694 (30.0_f32, 13.0_f32, Some(8.0_f32)), (32.0, 12.0, Some(10.0)),
1696 (40.0, 20.0, Some(10.0)),
1697 (14.0, 13.0, Some(0.0)), ] {
1699 let mut t = tb("x");
1700 t.set_height(h);
1701 t.font_size = fs;
1702 assert_eq!(
1703 padding_top_px(&t.build_title_style(false)),
1704 expected,
1705 "h={h} fs={fs} produced the wrong vertical padding",
1706 );
1707 }
1708 }
1709
1710 #[test]
1711 fn build_title_style_omits_the_vertical_padding_when_the_text_does_not_fit() {
1712 for (h, fs) in [
1715 (13.0_f32, 13.0_f32),
1716 (10.0, 20.0),
1717 (0.0, 13.0),
1718 (-100.0, 13.0),
1719 (f32::NEG_INFINITY, 13.0),
1720 (f32::NAN, 13.0),
1721 (13.0, f32::NAN),
1722 ] {
1723 let mut t = tb("x");
1724 t.set_height(h);
1725 t.font_size = fs;
1726 assert_eq!(
1727 padding_top_px(&t.build_title_style(false)),
1728 None,
1729 "h={h} fs={fs} declared a vertical padding it should have clamped away",
1730 );
1731 }
1732 }
1733
1734 #[test]
1735 fn build_title_style_encodes_a_nan_font_size_as_zero_pixels() {
1736 let mut t = tb("x");
1737 t.font_size = f32::NAN;
1738 assert_eq!(font_size_px(&t.build_title_style(false)), Some(0.0));
1739 }
1740
1741 #[test]
1742 fn build_title_style_truncates_the_font_size_toward_zero() {
1743 for (fs, expected) in [(13.0_f32, 13.0_f32), (13.9, 13.0), (0.5, 0.0), (-13.9, -13.0)] {
1744 let mut t = tb("x");
1745 t.font_size = fs;
1746 assert_eq!(font_size_px(&t.build_title_style(false)), Some(expected));
1747 }
1748 }
1749
1750 #[cfg(panic = "unwind")]
1755 #[test]
1756 fn heights_outside_the_encodable_range_are_not_saturated() {
1757 use std::{
1758 hint::black_box,
1759 panic::{catch_unwind, AssertUnwindSafe},
1760 };
1761
1762 let profile_traps_overflow = catch_unwind(AssertUnwindSafe(|| {
1769 let big = black_box(isize::MAX);
1770 let _ = black_box(big * black_box(1000_isize));
1771 }))
1772 .is_err();
1773
1774 for bogus in UNENCODABLE_FLOATS {
1775 let mut t = tb("x");
1776 t.set_height(bogus);
1777 let panicked =
1778 catch_unwind(AssertUnwindSafe(|| drop(t.build_container_style(false)))).is_err();
1779 assert_eq!(
1780 panicked, profile_traps_overflow,
1781 "height {bogus}: the fixed-point encoding no longer behaves like a raw multiply",
1782 );
1783
1784 let mut f = tb("x");
1785 f.font_size = bogus;
1786 let panicked =
1787 catch_unwind(AssertUnwindSafe(|| drop(f.build_title_style(false)))).is_err();
1788 assert_eq!(
1789 panicked, profile_traps_overflow,
1790 "font size {bogus}: the fixed-point encoding no longer behaves like a raw multiply",
1791 );
1792 }
1793 }
1794
1795 #[cfg(panic = "unwind")]
1796 #[test]
1797 fn an_unencodable_vertical_gap_reaches_the_padding_encoder_unclamped() {
1798 use std::{
1799 hint::black_box,
1800 panic::{catch_unwind, AssertUnwindSafe},
1801 };
1802
1803 let profile_traps_overflow = catch_unwind(AssertUnwindSafe(|| {
1804 let big = black_box(isize::MAX);
1805 let _ = black_box(big * black_box(1000_isize));
1806 }))
1807 .is_err();
1808
1809 for bogus in [f32::INFINITY, f32::MAX] {
1814 let mut t = tb("x");
1815 t.set_height(bogus);
1816 let panicked =
1817 catch_unwind(AssertUnwindSafe(|| drop(t.build_title_style(false)))).is_err();
1818 assert_eq!(panicked, profile_traps_overflow, "height {bogus} via padding-top");
1819 }
1820
1821 for safe in [f32::NEG_INFINITY, f32::MIN] {
1822 let mut t = tb("x");
1823 t.set_height(safe);
1824 assert_eq!(
1825 padding_top_px(&t.build_title_style(false)),
1826 None,
1827 "height {safe} must be clamped away by .max(0.0)",
1828 );
1829 }
1830 }
1831
1832 #[test]
1837 fn dom_builds_the_documented_title_only_tree() {
1838 let dom = tb("caption").dom();
1839
1840 assert_eq!(classes(&dom), vec!["csd-titlebar", "__azul-native-titlebar"]);
1841 assert!(ids(&dom).is_empty(), "the container must not claim an id");
1842 assert_eq!(dom.children.as_ref().len(), 1, "title-only mode has exactly one child");
1843
1844 let title = title_node(&dom);
1845 assert_eq!(classes(title), vec!["csd-title"]);
1846 assert_eq!(title.children.as_ref().len(), 1);
1847 assert_eq!(text_of(&title.children.as_ref()[0]), Some("caption"));
1848 assert!(buttons_node(&dom).is_none(), "title-only mode must render no buttons");
1849 }
1850
1851 #[test]
1852 fn dom_puts_the_container_and_title_styles_on_the_right_nodes() {
1853 let t = tb("caption");
1854 let dom = t.clone().dom();
1855
1856 assert_eq!(inline_props(&dom), expected_container(&t, false));
1857 assert_eq!(inline_props(title_node(&dom)), expected_title(&t, false));
1858 assert!(inline_props(&title_node(&dom).children.as_ref()[0]).is_empty());
1860 }
1861
1862 #[test]
1863 fn dom_registers_exactly_the_three_drag_callbacks_on_the_title_node() {
1864 let dom = tb("x").dom();
1865
1866 assert!(
1867 callbacks_of(&dom).is_empty(),
1868 "the container must carry no callbacks — the title node owns the drag",
1869 );
1870 assert_eq!(
1871 callbacks_of(title_node(&dom)),
1872 vec![
1873 (
1874 EventFilter::Hover(HoverEventFilter::DragStart),
1875 callbacks::titlebar_drag_start as usize,
1876 ),
1877 (EventFilter::Hover(HoverEventFilter::Drag), callbacks::titlebar_drag as usize),
1878 (
1879 EventFilter::Hover(HoverEventFilter::DoubleClick),
1880 callbacks::titlebar_double_click as usize,
1881 ),
1882 ],
1883 );
1884 }
1885
1886 #[test]
1887 fn dom_carries_pathological_titles_into_the_text_node_verbatim() {
1888 for title in ADVERSARIAL_TITLES {
1889 let dom = tb(title).dom();
1890 let text = &title_node(&dom).children.as_ref()[0];
1891 assert_eq!(text_of(text), Some(title), "the title was mangled on the way in");
1892 assert_eq!(title_node(&dom).children.as_ref().len(), 1);
1894 }
1895 }
1896
1897 #[test]
1898 fn dom_keeps_the_cached_child_count_in_sync() {
1899 let dom = tb("x").dom();
1902 assert_eq!(dom.estimated_total_children, count_descendants(&dom));
1903 assert_eq!(dom.estimated_total_children, 2, "title div + text node");
1904 }
1905
1906 #[test]
1907 fn the_dom_conversion_is_exactly_dom() {
1908 for title in ADVERSARIAL_TITLES {
1909 let via_from: Dom = tb(title).into();
1910 assert_eq!(
1911 fingerprint(&via_from),
1912 fingerprint(&tb(title).dom()),
1913 "From<Titlebar> for Dom drifted away from Titlebar::dom",
1914 );
1915 }
1916 }
1917
1918 #[test]
1923 fn dom_with_buttons_orders_the_children_by_button_side() {
1924 let buttons = TitlebarButtons::default();
1925
1926 let left = tb("x").dom_with_buttons(&buttons, TitlebarButtonSide::Left);
1927 let left_kids = left.children.as_ref();
1928 assert_eq!(left_kids.len(), 2);
1929 assert!(has_class(&left_kids[0], "csd-buttons"), "macOS puts the buttons first");
1930 assert!(has_class(&left_kids[1], "csd-title"));
1931
1932 let right = tb("x").dom_with_buttons(&buttons, TitlebarButtonSide::Right);
1933 let right_kids = right.children.as_ref();
1934 assert_eq!(right_kids.len(), 2);
1935 assert!(has_class(&right_kids[0], "csd-title"), "Windows/Linux put the title first");
1936 assert!(has_class(&right_kids[1], "csd-buttons"));
1937 }
1938
1939 #[test]
1940 fn dom_with_buttons_emits_one_node_per_enabled_button_in_minimize_maximize_close_order() {
1941 for buttons in all_button_combinations() {
1942 for side in BOTH_SIDES {
1943 let dom = tb("x").dom_with_buttons(&buttons, side);
1944 let container = buttons_node(&dom).expect("the CSD button container is mandatory");
1945
1946 let mut expected: Vec<&str> = Vec::new();
1947 if buttons.has_minimize {
1948 expected.push("csd-button-minimize");
1949 }
1950 if buttons.has_maximize {
1951 expected.push("csd-button-maximize");
1952 }
1953 if buttons.has_close {
1954 expected.push("csd-button-close");
1955 }
1956
1957 let actual: Vec<String> = container
1958 .children
1959 .as_ref()
1960 .iter()
1961 .flat_map(ids)
1962 .collect();
1963 assert_eq!(actual, expected, "{buttons:?} on {side:?} produced the wrong buttons");
1964 }
1965 }
1966 }
1967
1968 #[test]
1969 fn has_fullscreen_is_never_rendered() {
1970 for &(close, min, max) in &[(true, true, true), (false, false, false), (true, false, true)]
1973 {
1974 let off = TitlebarButtons {
1975 has_close: close,
1976 has_minimize: min,
1977 has_maximize: max,
1978 has_fullscreen: false,
1979 };
1980 let on = TitlebarButtons { has_fullscreen: true, ..off };
1981 assert_eq!(
1982 fingerprint(&build_button_container(&off)),
1983 fingerprint(&build_button_container(&on)),
1984 "has_fullscreen changed the rendered buttons",
1985 );
1986 }
1987 }
1988
1989 #[test]
1990 fn all_buttons_disabled_still_emits_an_empty_button_container() {
1991 let none = TitlebarButtons {
1992 has_close: false,
1993 has_minimize: false,
1994 has_maximize: false,
1995 has_fullscreen: false,
1996 };
1997 let container = build_button_container(&none);
1998
1999 assert_eq!(classes(&container), vec!["csd-buttons"]);
2000 assert!(container.children.as_ref().is_empty());
2001 assert_eq!(container.estimated_total_children, 0);
2002
2003 let dom = tb("x").dom_with_buttons(&none, TitlebarButtonSide::Right);
2005 assert_eq!(dom.children.as_ref().len(), 2);
2006 assert!(buttons_node(&dom).is_some());
2007 }
2008
2009 #[test]
2010 fn every_button_carries_one_mousedown_callback_and_the_matching_icon() {
2011 let expected: [(&str, &str, usize); 3] = [
2012 ("csd-button-minimize", "minimize", callbacks::csd_minimize as usize),
2013 ("csd-button-maximize", "maximize", callbacks::csd_maximize as usize),
2014 ("csd-button-close", "close", callbacks::csd_close as usize),
2015 ];
2016
2017 let container = build_button_container(&TitlebarButtons::default());
2018 let kids = container.children.as_ref();
2019 assert_eq!(kids.len(), 3);
2020
2021 for (node, (id, icon, cb)) in kids.iter().zip(expected) {
2022 assert_eq!(ids(node), vec![id]);
2023 assert_eq!(
2024 callbacks_of(node),
2025 vec![(EventFilter::Hover(HoverEventFilter::MouseDown), cb)],
2026 "{id} must carry exactly one MouseDown callback",
2027 );
2028 assert_eq!(node.children.as_ref().len(), 1);
2029 assert_eq!(
2030 icon_of(&node.children.as_ref()[0]),
2031 Some(icon),
2032 "{id} rendered the wrong icon",
2033 );
2034 }
2035 }
2036
2037 #[test]
2038 fn every_button_carries_the_shared_and_the_specific_class() {
2039 let container = build_button_container(&TitlebarButtons::default());
2040 for (node, specific) in container
2041 .children
2042 .as_ref()
2043 .iter()
2044 .zip(["csd-minimize", "csd-maximize", "csd-close"])
2045 {
2046 assert_eq!(
2047 classes(node),
2048 vec!["csd-button".to_string(), specific.to_string()],
2049 "the stylesheet hooks documented on Titlebar are missing",
2050 );
2051 }
2052 }
2053
2054 #[test]
2055 fn dom_with_buttons_keeps_the_cached_child_count_in_sync_for_every_combination() {
2056 for buttons in all_button_combinations() {
2057 for side in BOTH_SIDES {
2058 let dom = tb("x").dom_with_buttons(&buttons, side);
2059 assert_eq!(
2060 dom.estimated_total_children,
2061 count_descendants(&dom),
2062 "{buttons:?} on {side:?} desynced the cached child count",
2063 );
2064
2065 let enabled = usize::from(buttons.has_close)
2066 + usize::from(buttons.has_minimize)
2067 + usize::from(buttons.has_maximize);
2068 assert_eq!(dom.estimated_total_children, 3 + 2 * enabled);
2070 }
2071 }
2072 }
2073
2074 #[test]
2075 fn dom_with_buttons_uses_the_csd_container_and_title_styles() {
2076 let t = tb("x");
2077 let dom = t.clone().dom_with_buttons(&TitlebarButtons::default(), TitlebarButtonSide::Right);
2078
2079 assert_eq!(inline_props(&dom), expected_container(&t, true));
2080 assert_eq!(inline_props(title_node(&dom)), expected_title(&t, true));
2081 assert!(inline_props(buttons_node(&dom).unwrap()).is_empty());
2083 }
2084
2085 #[test]
2086 fn the_button_side_changes_only_the_child_order() {
2087 let buttons = TitlebarButtons::default();
2088 let left = tb("x").dom_with_buttons(&buttons, TitlebarButtonSide::Left);
2089 let right = tb("x").dom_with_buttons(&buttons, TitlebarButtonSide::Right);
2090
2091 assert_eq!(inline_props(&left), inline_props(&right));
2092 assert_eq!(classes(&left), classes(&right));
2093 assert_eq!(
2094 fingerprint(title_node(&left)),
2095 fingerprint(title_node(&right)),
2096 "the title node must not depend on the button side",
2097 );
2098 assert_eq!(
2099 fingerprint(buttons_node(&left).unwrap()),
2100 fingerprint(buttons_node(&right).unwrap()),
2101 "the button container must not depend on the button side",
2102 );
2103 }
2104
2105 #[test]
2106 fn dom_with_buttons_carries_pathological_titles_verbatim() {
2107 for title in ADVERSARIAL_TITLES {
2108 let dom = tb(title).dom_with_buttons(&TitlebarButtons::default(), TitlebarButtonSide::Left);
2109 let text = &title_node(&dom).children.as_ref()[0];
2110 assert_eq!(text_of(text), Some(title));
2111 }
2112 }
2113
2114 #[test]
2119 fn drag_start_with_an_unknown_position_hands_the_move_to_the_compositor() {
2120 let (update, changes) = with_callback_info(
2123 state_with(WindowFrame::Normal, WindowPosition::Uninitialized),
2124 |info| callbacks::titlebar_drag_start(RefAny::new(()), info),
2125 );
2126
2127 assert_eq!(update, Update::DoNothing);
2128 assert_eq!(interactive_moves(&changes), 1, "the compositor move was not requested");
2129 assert!(state_writes(&changes).is_empty(), "the native path must not write state");
2130 }
2131
2132 #[test]
2133 fn drag_start_with_a_known_position_takes_the_platform_appropriate_path() {
2134 let (update, changes) = with_callback_info(
2139 state_with(
2140 WindowFrame::Normal,
2141 WindowPosition::Initialized(PhysicalPositionI32::new(100, 200)),
2142 ),
2143 |info| callbacks::titlebar_drag_start(RefAny::new(()), info),
2144 );
2145
2146 assert_eq!(update, Update::DoNothing);
2147 if cfg!(target_os = "macos") {
2148 assert_eq!(interactive_moves(&changes), 1);
2149 } else {
2150 assert_eq!(interactive_moves(&changes), 0, "the manual path must not ask the OS");
2151 assert!(
2152 changes.is_empty(),
2153 "a normal-frame manual drag start must record nothing at all",
2154 );
2155 }
2156 }
2157
2158 #[test]
2159 fn drag_start_restores_a_maximized_window_before_the_manual_move() {
2160 let before = state_with(
2163 WindowFrame::Maximized,
2164 WindowPosition::Initialized(PhysicalPositionI32::new(0, 0)),
2165 );
2166 let (update, changes) = with_callback_info(before.clone(), |info| {
2167 callbacks::titlebar_drag_start(RefAny::new(()), info)
2168 });
2169
2170 assert_eq!(update, Update::DoNothing);
2171 if cfg!(target_os = "macos") {
2172 assert_eq!(interactive_moves(&changes), 1);
2173 assert!(state_writes(&changes).is_empty());
2174 } else {
2175 let writes = state_writes(&changes);
2176 assert_eq!(writes.len(), 1, "the un-maximize write is missing");
2177 assert_eq!(writes[0].flags.frame, WindowFrame::Normal);
2178
2179 let mut expected = before;
2181 expected.flags.frame = WindowFrame::Normal;
2182 assert_eq!(writes[0], expected, "drag start changed more than the frame");
2183 }
2184 }
2185
2186 #[test]
2187 fn drag_start_leaves_a_fullscreen_or_minimized_frame_alone() {
2188 for frame in [WindowFrame::Fullscreen, WindowFrame::Minimized, WindowFrame::Normal] {
2189 let (_, changes) = with_callback_info(
2190 state_with(frame, WindowPosition::Initialized(PhysicalPositionI32::new(1, 1))),
2191 |info| callbacks::titlebar_drag_start(RefAny::new(()), info),
2192 );
2193 if !cfg!(target_os = "macos") {
2194 assert!(
2195 state_writes(&changes).is_empty(),
2196 "{frame:?} must not be rewritten — only Maximized is restored",
2197 );
2198 }
2199 }
2200 }
2201
2202 #[test]
2207 fn drag_without_an_active_gesture_is_a_no_op() {
2208 for position in [
2212 WindowPosition::Uninitialized,
2213 WindowPosition::Initialized(PhysicalPositionI32::new(-5, 7)),
2214 ] {
2215 let (update, changes) =
2216 with_callback_info(state_with(WindowFrame::Normal, position), |info| {
2217 callbacks::titlebar_drag(RefAny::new(()), info)
2218 });
2219 assert_eq!(update, Update::DoNothing);
2220 assert!(changes.is_empty(), "{position:?}: a no-delta drag recorded a change");
2221 }
2222 }
2223
2224 #[test]
2225 fn drag_is_idempotent_when_repeated_without_a_gesture() {
2226 for _ in 0..4 {
2227 let (update, changes) = with_callback_info(
2228 state_with(
2229 WindowFrame::Maximized,
2230 WindowPosition::Initialized(PhysicalPositionI32::new(i32::MAX, i32::MIN)),
2231 ),
2232 |info| callbacks::titlebar_drag(RefAny::new(()), info),
2233 );
2234 assert_eq!(update, Update::DoNothing);
2235 assert!(changes.is_empty());
2238 }
2239 }
2240
2241 #[test]
2246 fn double_click_toggles_maximized_and_normalises_every_other_frame() {
2247 for frame in ALL_FRAMES {
2248 let before = state_with(frame, WindowPosition::Uninitialized);
2249 let (update, changes) = with_callback_info(before.clone(), |info| {
2250 callbacks::titlebar_double_click(RefAny::new(()), info)
2251 });
2252
2253 assert_eq!(update, Update::DoNothing);
2254 let writes = state_writes(&changes);
2255 assert_eq!(writes.len(), 1, "{frame:?}: exactly one state write expected");
2256
2257 let expected_frame = if frame == WindowFrame::Maximized {
2258 WindowFrame::Normal
2259 } else {
2260 WindowFrame::Maximized
2261 };
2262 assert_eq!(writes[0].flags.frame, expected_frame, "{frame:?} toggled wrongly");
2263
2264 let mut expected = before;
2265 expected.flags.frame = expected_frame;
2266 assert_eq!(writes[0], expected, "{frame:?}: more than the frame changed");
2267 }
2268 }
2269
2270 #[test]
2271 fn double_clicking_twice_returns_to_the_original_frame() {
2272 let (_, changes) = with_callback_info(
2273 state_with(WindowFrame::Normal, WindowPosition::Uninitialized),
2274 |info| callbacks::titlebar_double_click(RefAny::new(()), info),
2275 );
2276 let once = state_writes(&changes).remove(0);
2277 assert_eq!(once.flags.frame, WindowFrame::Maximized);
2278
2279 let (_, changes) = with_callback_info(once, |info| {
2280 callbacks::titlebar_double_click(RefAny::new(()), info)
2281 });
2282 assert_eq!(state_writes(&changes)[0].flags.frame, WindowFrame::Normal);
2283 }
2284
2285 #[test]
2286 fn the_maximize_button_agrees_with_the_double_click_for_every_frame() {
2287 for frame in ALL_FRAMES {
2288 let before = state_with(frame, WindowPosition::Uninitialized);
2289 let (_, via_button) = with_callback_info(before.clone(), |info| {
2290 callbacks::csd_maximize(RefAny::new(()), info)
2291 });
2292 let (_, via_double) = with_callback_info(before, |info| {
2293 callbacks::titlebar_double_click(RefAny::new(()), info)
2294 });
2295 assert_eq!(
2296 state_writes(&via_button),
2297 state_writes(&via_double),
2298 "{frame:?}: the maximize button and the double-click diverged",
2299 );
2300 }
2301 }
2302
2303 #[test]
2308 fn close_sets_close_requested_and_nothing_else() {
2309 for frame in ALL_FRAMES {
2310 let before = state_with(frame, WindowPosition::Uninitialized);
2311 assert!(!before.flags.close_requested, "fixture must start un-closed");
2312
2313 let (update, changes) =
2314 with_callback_info(before.clone(), |info| callbacks::csd_close(RefAny::new(()), info));
2315
2316 assert_eq!(update, Update::DoNothing);
2317 let writes = state_writes(&changes);
2318 assert_eq!(writes.len(), 1);
2319 assert!(writes[0].flags.close_requested);
2320 assert_eq!(writes[0].flags.frame, frame, "close must not move the frame");
2321
2322 let mut expected = before;
2323 expected.flags.close_requested = true;
2324 assert_eq!(writes[0], expected, "close changed more than close_requested");
2325 }
2326 }
2327
2328 #[test]
2329 fn close_is_idempotent_on_an_already_closing_window() {
2330 let mut before = state_with(WindowFrame::Normal, WindowPosition::Uninitialized);
2331 before.flags.close_requested = true;
2332
2333 let (_, changes) =
2334 with_callback_info(before.clone(), |info| callbacks::csd_close(RefAny::new(()), info));
2335
2336 assert_eq!(state_writes(&changes), vec![before], "a second close must be a re-assert");
2337 }
2338
2339 #[test]
2340 fn minimize_always_minimizes_regardless_of_the_current_frame() {
2341 for frame in ALL_FRAMES {
2342 let before = state_with(frame, WindowPosition::Uninitialized);
2343 let (update, changes) = with_callback_info(before.clone(), |info| {
2344 callbacks::csd_minimize(RefAny::new(()), info)
2345 });
2346
2347 assert_eq!(update, Update::DoNothing);
2348 let writes = state_writes(&changes);
2349 assert_eq!(writes.len(), 1);
2350 assert_eq!(writes[0].flags.frame, WindowFrame::Minimized, "{frame:?} was not minimized");
2351
2352 let mut expected = before;
2353 expected.flags.frame = WindowFrame::Minimized;
2354 assert_eq!(writes[0], expected, "{frame:?}: minimize changed more than the frame");
2355 }
2356 }
2357
2358 #[test]
2359 fn minimize_never_requests_a_close() {
2360 let (_, changes) = with_callback_info(
2361 state_with(WindowFrame::Normal, WindowPosition::Uninitialized),
2362 |info| callbacks::csd_minimize(RefAny::new(()), info),
2363 );
2364 assert!(!state_writes(&changes)[0].flags.close_requested);
2365 assert_eq!(interactive_moves(&changes), 0);
2366 }
2367}
2368