Skip to main content

cranpose/
native_window.rs

1//! Declarative native window support.
2
3use cranpose_core::MutableState;
4use cranpose_ui::{composable, Modifier, Point, PointerEventKind, PointerInputScope, Size};
5#[cfg(all(
6    feature = "desktop",
7    feature = "renderer-wgpu",
8    not(target_arch = "wasm32")
9))]
10use std::cell::Cell;
11use std::cell::RefCell;
12#[cfg(all(
13    feature = "desktop",
14    feature = "renderer-wgpu",
15    not(target_arch = "wasm32")
16))]
17use std::collections::HashMap;
18use std::hash::{Hash, Hasher};
19use std::rc::Rc;
20#[cfg(all(
21    feature = "desktop",
22    feature = "renderer-wgpu",
23    not(target_arch = "wasm32")
24))]
25use std::rc::Weak;
26
27/// A stable identifier for a declarative operating-system window.
28#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
29pub struct WindowId(u64);
30
31impl WindowId {
32    /// Creates a window identifier from a static application identifier.
33    pub fn from_static(id: &'static str) -> Self {
34        Self(hash_id(id))
35    }
36}
37
38#[cfg(all(
39    feature = "desktop",
40    feature = "renderer-wgpu",
41    not(target_arch = "wasm32")
42))]
43pub(crate) type NativeWindowKey = WindowId;
44
45#[cfg(all(
46    feature = "desktop",
47    feature = "renderer-wgpu",
48    not(target_arch = "wasm32")
49))]
50#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
51pub(crate) struct WindowGroupId(u64);
52
53#[cfg(all(
54    feature = "desktop",
55    feature = "renderer-wgpu",
56    not(target_arch = "wasm32")
57))]
58impl WindowGroupId {
59    fn from_static(id: &'static str) -> Self {
60        Self(hash_id(id))
61    }
62}
63
64/// Coordinate space used by a native window's configured position.
65#[derive(Clone, Copy, Debug, Eq, PartialEq)]
66pub enum NativeWindowPositionOrigin {
67    /// Position is expressed in operating-system screen coordinates.
68    Screen,
69    /// Position is expressed relative to the owning application window.
70    HostWindow,
71}
72
73/// Configuration for a declarative native window.
74#[derive(Clone, Debug, PartialEq)]
75pub struct NativeWindowOptions {
76    /// Window title exposed to the operating system.
77    pub title: String,
78    /// Initial content width in logical pixels.
79    pub width: f32,
80    /// Initial content height in logical pixels.
81    pub height: f32,
82    /// Optional initial outer-window x position in the configured coordinate space.
83    pub x: Option<f32>,
84    /// Optional initial outer-window y position in the configured coordinate space.
85    pub y: Option<f32>,
86    /// Coordinate space used by `x` and `y`.
87    pub position_origin: NativeWindowPositionOrigin,
88    /// Whether the operating system should draw window decorations.
89    pub decorations: bool,
90    /// Whether the window surface requests compositor transparency.
91    pub transparent: bool,
92    /// Whether the operating system should allow interactive resizing.
93    pub resizable: bool,
94    /// Whether the window should be visible when created.
95    pub visible: bool,
96    /// Whether the window should be kept above normal windows.
97    pub always_on_top: bool,
98    /// Optional minimum content width in logical pixels.
99    pub min_width: Option<f32>,
100    /// Optional minimum content height in logical pixels.
101    pub min_height: Option<f32>,
102    /// Optional maximum content width in logical pixels.
103    pub max_width: Option<f32>,
104    /// Optional maximum content height in logical pixels.
105    pub max_height: Option<f32>,
106}
107
108/// Movement behavior for a group of attached peer windows.
109#[derive(Clone, Debug, PartialEq, Eq)]
110pub enum WindowMoveMode {
111    /// Dragging any window in the group moves its attached component.
112    AllAttached,
113    /// Only the listed windows move their attached component; other windows move alone.
114    DragLeaderOnly(Vec<WindowId>),
115}
116
117impl WindowMoveMode {
118    #[cfg(all(
119        feature = "desktop",
120        feature = "renderer-wgpu",
121        not(target_arch = "wasm32")
122    ))]
123    fn moves_attached_component(&self, window_id: WindowId) -> bool {
124        match self {
125            Self::AllAttached => true,
126            Self::DragLeaderOnly(leaders) => leaders.contains(&window_id),
127        }
128    }
129}
130
131/// Attachment and snapping policy for a declarative peer-window group.
132#[derive(Clone, Debug, PartialEq)]
133pub struct WindowAttachPolicy {
134    /// Maximum edge distance, in logical pixels, that counts as a snap target.
135    pub snap_distance: f32,
136    /// Maximum edge distance, in logical pixels, that counts as attached.
137    pub attach_epsilon: f32,
138    /// Determines which dragged windows move attached neighbors.
139    pub move_mode: WindowMoveMode,
140}
141
142impl WindowAttachPolicy {
143    /// Creates a peer-window attachment policy.
144    pub fn new(snap_distance: f32, attach_epsilon: f32, move_mode: WindowMoveMode) -> Self {
145        Self {
146            snap_distance,
147            attach_epsilon,
148            move_mode,
149        }
150    }
151}
152
153impl Default for WindowAttachPolicy {
154    fn default() -> Self {
155        Self {
156            snap_distance: 8.0,
157            attach_epsilon: 3.0,
158            move_mode: WindowMoveMode::AllAttached,
159        }
160    }
161}
162
163#[cfg(all(
164    feature = "desktop",
165    feature = "renderer-wgpu",
166    not(target_arch = "wasm32")
167))]
168#[derive(Clone, Debug, PartialEq)]
169pub(crate) struct NativeWindowGroupMembership {
170    pub(crate) id: WindowGroupId,
171    pub(crate) policy: WindowAttachPolicy,
172}
173
174impl NativeWindowOptions {
175    /// Creates a decorated native window with a fixed initial size.
176    pub fn new(title: impl Into<String>, width: f32, height: f32) -> Self {
177        Self {
178            title: title.into(),
179            width,
180            height,
181            x: None,
182            y: None,
183            position_origin: NativeWindowPositionOrigin::Screen,
184            decorations: true,
185            transparent: false,
186            resizable: true,
187            visible: true,
188            always_on_top: false,
189            min_width: None,
190            min_height: None,
191            max_width: None,
192            max_height: None,
193        }
194    }
195
196    /// Creates a borderless, non-resizable native window with a fixed initial size.
197    pub fn borderless(title: impl Into<String>, width: f32, height: f32) -> Self {
198        Self {
199            decorations: false,
200            resizable: false,
201            ..Self::new(title, width, height)
202        }
203    }
204
205    /// Sets the initial outer-window position in logical screen coordinates.
206    pub fn with_position(mut self, x: f32, y: f32) -> Self {
207        self.x = Some(x);
208        self.y = Some(y);
209        self.position_origin = NativeWindowPositionOrigin::Screen;
210        self
211    }
212
213    /// Sets the initial outer-window position relative to the host application window.
214    pub fn with_host_window_position(mut self, x: f32, y: f32) -> Self {
215        self.x = Some(x);
216        self.y = Some(y);
217        self.position_origin = NativeWindowPositionOrigin::HostWindow;
218        self
219    }
220
221    /// Sets whether compositor transparency should be requested.
222    pub fn with_transparent(mut self, transparent: bool) -> Self {
223        self.transparent = transparent;
224        self
225    }
226
227    /// Sets whether the operating system should allow interactive resizing.
228    pub fn with_resizable(mut self, resizable: bool) -> Self {
229        self.resizable = resizable;
230        self
231    }
232
233    /// Sets whether the window should be visible when created.
234    pub fn with_visible(mut self, visible: bool) -> Self {
235        self.visible = visible;
236        self
237    }
238
239    /// Sets whether the window should be kept above normal windows.
240    pub fn with_always_on_top(mut self, always_on_top: bool) -> Self {
241        self.always_on_top = always_on_top;
242        self
243    }
244
245    /// Sets the minimum content size in logical pixels.
246    pub fn with_min_size(mut self, width: f32, height: f32) -> Self {
247        self.min_width = Some(width);
248        self.min_height = Some(height);
249        self
250    }
251
252    /// Sets the maximum content size in logical pixels.
253    pub fn with_max_size(mut self, width: f32, height: f32) -> Self {
254        self.max_width = Some(width);
255        self.max_height = Some(height);
256        self
257    }
258}
259
260/// Event callbacks emitted by a declarative native window.
261#[derive(Clone, Default)]
262pub(crate) struct NativeWindowEvents {
263    pub(crate) on_moved: Option<Rc<dyn Fn(f32, f32)>>,
264    pub(crate) on_resized: Option<Rc<dyn Fn(f32, f32)>>,
265    pub(crate) on_close_requested: Option<Rc<dyn Fn()>>,
266}
267
268impl NativeWindowEvents {
269    fn new() -> Self {
270        Self::default()
271    }
272
273    fn with_on_moved(mut self, callback: impl Fn(f32, f32) + 'static) -> Self {
274        let next = Rc::new(callback);
275        self.on_moved = Some(match self.on_moved.take() {
276            Some(previous) => Rc::new(move |x, y| {
277                previous(x, y);
278                next(x, y);
279            }),
280            None => next,
281        });
282        self
283    }
284
285    fn with_on_resized(mut self, callback: impl Fn(f32, f32) + 'static) -> Self {
286        let next = Rc::new(callback);
287        self.on_resized = Some(match self.on_resized.take() {
288            Some(previous) => Rc::new(move |width, height| {
289                previous(width, height);
290                next(width, height);
291            }),
292            None => next,
293        });
294        self
295    }
296
297    fn with_on_close_requested(mut self, callback: impl Fn() + 'static) -> Self {
298        let next = Rc::new(callback);
299        self.on_close_requested = Some(match self.on_close_requested.take() {
300            Some(previous) => Rc::new(move || {
301                previous();
302                next();
303            }),
304            None => next,
305        });
306        self
307    }
308}
309
310/// Mutable position and size state for a declarative OS window.
311#[derive(Clone, Copy, Eq, PartialEq)]
312pub struct WindowState {
313    position: MutableState<Option<Point>>,
314    size: MutableState<Size>,
315}
316
317impl WindowState {
318    /// Returns the last known outer-window position in logical screen coordinates.
319    pub fn position(self) -> Option<Point> {
320        self.position.get()
321    }
322
323    /// Returns the last known outer-window position without subscribing to changes.
324    pub fn position_non_reactive(self) -> Option<Point> {
325        self.position.get_non_reactive()
326    }
327
328    /// Updates the stored outer-window position.
329    pub fn set_position(self, position: Option<Point>) {
330        if self.position.get_non_reactive() != position {
331            self.position.set(position);
332        }
333    }
334
335    /// Moves the stored outer-window position by a logical delta when a position is known.
336    pub fn translate(self, dx: f32, dy: f32) {
337        if let Some(position) = self.position_non_reactive() {
338            self.set_position(Some(Point::new(position.x + dx, position.y + dy)));
339        }
340    }
341
342    /// Returns the current content size in logical pixels.
343    pub fn size(self) -> Size {
344        self.size.get()
345    }
346
347    /// Returns the current content size without subscribing to changes.
348    pub fn size_non_reactive(self) -> Size {
349        self.size.get_non_reactive()
350    }
351
352    /// Updates the stored content size in logical pixels.
353    pub fn set_size(self, size: Size) {
354        if self.size.get_non_reactive() != size {
355            self.size.set(size);
356        }
357    }
358}
359
360/// Remembers native-window position and size across recompositions.
361#[allow(non_snake_case)]
362#[composable]
363pub fn rememberWindowState(width: f32, height: f32) -> WindowState {
364    WindowState {
365        position: cranpose_core::useState(|| None::<Point>),
366        size: cranpose_core::useState(move || Size::new(width, height)),
367    }
368}
369
370/// Declarative configuration for an operating-system window.
371///
372/// Use this with [`Window`] to render a composable subtree into a separate OS
373/// window on desktop. Platforms without native sub-window support render the
374/// content inline, so pointer input and other composable behavior stay shared.
375#[derive(Clone)]
376pub struct WindowConfig {
377    options: NativeWindowOptions,
378    callbacks: NativeWindowEvents,
379    state: Option<WindowState>,
380}
381
382impl WindowConfig {
383    /// Creates a decorated, resizable window with a fixed initial content size.
384    pub fn new(title: impl Into<String>, width: f32, height: f32) -> Self {
385        Self {
386            options: NativeWindowOptions::new(title, width, height),
387            callbacks: NativeWindowEvents::new(),
388            state: None,
389        }
390    }
391
392    /// Creates a decorated, resizable window using a remembered state size.
393    pub fn new_for_state(title: impl Into<String>, state: WindowState) -> Self {
394        let size = state.size();
395        Self::new(title, size.width, size.height).with_state(state)
396    }
397
398    /// Creates a borderless, non-resizable window with a fixed initial content size.
399    pub fn borderless(title: impl Into<String>, width: f32, height: f32) -> Self {
400        Self {
401            options: NativeWindowOptions::borderless(title, width, height),
402            callbacks: NativeWindowEvents::new(),
403            state: None,
404        }
405    }
406
407    /// Creates a borderless, non-resizable window using a remembered state size.
408    pub fn borderless_for_state(title: impl Into<String>, state: WindowState) -> Self {
409        let size = state.size();
410        Self::borderless(title, size.width, size.height).with_state(state)
411    }
412
413    /// Sets the initial outer-window position in logical screen coordinates.
414    pub fn with_position(mut self, x: f32, y: f32) -> Self {
415        self.options = self.options.with_position(x, y);
416        self
417    }
418
419    /// Sets the initial outer-window position relative to the host application window.
420    pub fn with_host_window_position(mut self, x: f32, y: f32) -> Self {
421        self.options = self.options.with_host_window_position(x, y);
422        self
423    }
424
425    /// Sets whether compositor transparency should be requested.
426    pub fn with_transparent(mut self, transparent: bool) -> Self {
427        self.options = self.options.with_transparent(transparent);
428        self
429    }
430
431    /// Sets whether the operating system should allow interactive resizing.
432    pub fn with_resizable(mut self, resizable: bool) -> Self {
433        self.options = self.options.with_resizable(resizable);
434        self
435    }
436
437    /// Sets whether the window should be visible when created.
438    pub fn with_visible(mut self, visible: bool) -> Self {
439        self.options = self.options.with_visible(visible);
440        self
441    }
442
443    /// Sets whether the window should be kept above normal windows.
444    pub fn with_always_on_top(mut self, always_on_top: bool) -> Self {
445        self.options = self.options.with_always_on_top(always_on_top);
446        self
447    }
448
449    /// Sets the minimum content size in logical pixels.
450    pub fn with_min_size(mut self, width: f32, height: f32) -> Self {
451        self.options = self.options.with_min_size(width, height);
452        self
453    }
454
455    /// Sets the maximum content size in logical pixels.
456    pub fn with_max_size(mut self, width: f32, height: f32) -> Self {
457        self.options = self.options.with_max_size(width, height);
458        self
459    }
460
461    /// Called when the operating system reports an external outer-window move.
462    ///
463    /// Position changes requested through [`WindowState`] are acknowledged by the
464    /// desktop host without re-entering this callback.
465    pub fn on_moved(mut self, callback: impl Fn(f32, f32) + 'static) -> Self {
466        self.callbacks = self.callbacks.with_on_moved(callback);
467        self
468    }
469
470    /// Called when the operating system reports a new content size.
471    pub fn on_resized(mut self, callback: impl Fn(f32, f32) + 'static) -> Self {
472        self.callbacks = self.callbacks.with_on_resized(callback);
473        self
474    }
475
476    /// Called when the operating system requests that this window close.
477    pub fn on_close_requested(mut self, callback: impl Fn() + 'static) -> Self {
478        self.callbacks = self.callbacks.with_on_close_requested(callback);
479        self
480    }
481
482    /// Binds this configuration to a remembered [`WindowState`].
483    ///
484    /// The current state supplies the requested position and size. The desktop
485    /// window host keeps the state in sync with the operating system unless an
486    /// explicit callback updates it first.
487    pub fn with_state(mut self, state: WindowState) -> Self {
488        let size = state.size();
489        self.options.width = size.width;
490        self.options.height = size.height;
491        if let Some(position) = state.position() {
492            self.options.x = Some(position.x);
493            self.options.y = Some(position.y);
494            self.options.position_origin = NativeWindowPositionOrigin::Screen;
495        }
496        self.state = Some(state);
497        self
498    }
499
500    pub(crate) fn into_parts(
501        self,
502    ) -> (NativeWindowOptions, NativeWindowEvents, Option<WindowState>) {
503        (self.options, self.callbacks, self.state)
504    }
505}
506
507/// Edge or corner used by [`WindowModifierExt::window_resize_area`].
508#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
509pub enum WindowResizeDirection {
510    /// Resize from the east edge.
511    East,
512    /// Resize from the north edge.
513    North,
514    /// Resize from the north-east corner.
515    NorthEast,
516    /// Resize from the north-west corner.
517    NorthWest,
518    /// Resize from the south edge.
519    South,
520    /// Resize from the south-east corner.
521    SouthEast,
522    /// Resize from the south-west corner.
523    SouthWest,
524    /// Resize from the west edge.
525    West,
526}
527
528/// Modifier helpers for composables rendered in OS windows.
529pub trait WindowModifierExt {
530    /// Marks this component as a drag target for its containing OS window.
531    ///
532    /// The modifier is inert when the component is not currently rendered in a
533    /// native desktop sub-window, so the same UI can be used inline.
534    fn window_drag_area(self) -> Modifier;
535
536    /// Marks this component as a drag target and reports the native drag lifecycle.
537    ///
538    /// The callbacks run only when an OS-window drag is actually accepted by
539    /// the current native desktop sub-window.
540    fn window_drag_area_with_callbacks(
541        self,
542        on_started: impl Fn() + 'static,
543        on_finished: impl Fn() + 'static,
544    ) -> Modifier;
545
546    /// Marks this component as a resize target for its containing OS window.
547    ///
548    /// The modifier is inert when the component is not currently rendered in a
549    /// native desktop sub-window.
550    fn window_resize_area(self, direction: WindowResizeDirection) -> Modifier;
551}
552
553impl WindowModifierExt for Modifier {
554    fn window_drag_area(self) -> Modifier {
555        self.window_drag_area_with_callbacks(|| {}, || {})
556    }
557
558    fn window_drag_area_with_callbacks(
559        self,
560        on_started: impl Fn() + 'static,
561        on_finished: impl Fn() + 'static,
562    ) -> Modifier {
563        let on_started: Rc<dyn Fn()> = Rc::new(on_started);
564        let on_finished: Rc<dyn Fn()> = Rc::new(on_finished);
565        self.pointer_input((), move |scope: PointerInputScope| {
566            let on_started = on_started.clone();
567            let on_finished = on_finished.clone();
568            async move {
569                scope
570                    .await_pointer_event_scope(|await_scope| async move {
571                        let mut dragging = false;
572                        loop {
573                            let event = await_scope.await_pointer_event().await;
574                            match event.kind {
575                                PointerEventKind::Down => {
576                                    if request_native_window_drag() {
577                                        dragging = true;
578                                        event.consume();
579                                        on_started();
580                                    }
581                                }
582                                PointerEventKind::Move => {
583                                    if dragging && event.buttons == Default::default() {
584                                        dragging = false;
585                                        on_finished();
586                                    }
587                                }
588                                PointerEventKind::Up | PointerEventKind::Cancel => {
589                                    if dragging {
590                                        dragging = false;
591                                        on_finished();
592                                    }
593                                }
594                                PointerEventKind::Scroll
595                                | PointerEventKind::Enter
596                                | PointerEventKind::Exit => {}
597                            }
598                        }
599                    })
600                    .await;
601            }
602        })
603    }
604
605    fn window_resize_area(self, direction: WindowResizeDirection) -> Modifier {
606        self.pointer_input(direction, move |scope: PointerInputScope| async move {
607            scope
608                .await_pointer_event_scope(|await_scope| async move {
609                    loop {
610                        let event = await_scope.await_pointer_event().await;
611                        if event.kind == PointerEventKind::Down
612                            && request_native_window_resize(direction)
613                        {
614                            event.consume();
615                        }
616                    }
617                })
618                .await;
619        })
620    }
621}
622
623#[cfg(all(
624    feature = "desktop",
625    feature = "renderer-wgpu",
626    not(target_arch = "wasm32")
627))]
628pub(crate) type NativeWindowContent = Rc<RefCell<Box<dyn FnMut()>>>;
629
630#[cfg(all(
631    feature = "desktop",
632    feature = "renderer-wgpu",
633    not(target_arch = "wasm32")
634))]
635type NativeWindowOwner = Rc<()>;
636
637type NativeWindowDragHandler = Rc<dyn Fn() -> bool>;
638type NativeWindowResizeHandler = Rc<dyn Fn(WindowResizeDirection)>;
639
640#[derive(Clone, Default)]
641struct NativeWindowDispatchContext {
642    drag_handler: Option<NativeWindowDragHandler>,
643    resize_handler: Option<NativeWindowResizeHandler>,
644    surface_origin: Option<Point>,
645}
646
647#[cfg(all(
648    feature = "desktop",
649    feature = "renderer-wgpu",
650    not(target_arch = "wasm32")
651))]
652#[derive(Clone)]
653pub(crate) struct NativeWindowRequest {
654    pub(crate) key: NativeWindowKey,
655    pub(crate) options: NativeWindowOptions,
656    pub(crate) events: NativeWindowEvents,
657    pub(crate) state: Option<WindowState>,
658    pub(crate) group: Option<NativeWindowGroupMembership>,
659    pub(crate) content: NativeWindowContent,
660    pub(crate) revision: u64,
661    owner: NativeWindowOwner,
662}
663
664#[cfg(all(
665    feature = "desktop",
666    feature = "renderer-wgpu",
667    not(target_arch = "wasm32")
668))]
669struct NativeWindowRegistration {
670    key: NativeWindowKey,
671    options: NativeWindowOptions,
672    events: NativeWindowEvents,
673    state: Option<WindowState>,
674    group: Option<NativeWindowGroupMembership>,
675    content: NativeWindowContent,
676    owner: NativeWindowOwner,
677}
678
679#[cfg(all(
680    feature = "desktop",
681    feature = "renderer-wgpu",
682    not(target_arch = "wasm32")
683))]
684#[derive(Default)]
685pub(crate) struct NativeWindowRegistry {
686    windows: RefCell<HashMap<NativeWindowKey, NativeWindowRequest>>,
687    next_revision: Cell<u64>,
688}
689
690#[cfg(all(
691    feature = "desktop",
692    feature = "renderer-wgpu",
693    not(target_arch = "wasm32")
694))]
695impl NativeWindowRegistry {
696    fn requests(&self) -> Vec<NativeWindowRequest> {
697        self.windows.borrow().values().cloned().collect()
698    }
699
700    fn has_requests(&self) -> bool {
701        !self.windows.borrow().is_empty()
702    }
703
704    #[cfg(test)]
705    fn clear(&self) {
706        self.windows.borrow_mut().clear();
707    }
708
709    fn register(&self, registration: NativeWindowRegistration) {
710        let revision = self.next_revision();
711        let key = registration.key;
712        self.windows.borrow_mut().insert(
713            key,
714            NativeWindowRequest {
715                key,
716                options: registration.options,
717                events: registration.events,
718                state: registration.state,
719                group: registration.group,
720                content: registration.content,
721                revision,
722                owner: registration.owner,
723            },
724        );
725    }
726
727    fn unregister(&self, key: NativeWindowKey, owner: NativeWindowOwner) {
728        let mut windows = self.windows.borrow_mut();
729        if windows
730            .get(&key)
731            .is_some_and(|request| Rc::ptr_eq(&request.owner, &owner))
732        {
733            windows.remove(&key);
734        }
735    }
736
737    fn next_revision(&self) -> u64 {
738        let current = self.next_revision.get().max(1);
739        self.next_revision.set(current.wrapping_add(1).max(1));
740        current
741    }
742}
743
744#[cfg(all(
745    feature = "desktop",
746    feature = "renderer-wgpu",
747    not(target_arch = "wasm32")
748))]
749thread_local! {
750    static CURRENT_NATIVE_WINDOW_REGISTRY: RefCell<Vec<Weak<NativeWindowRegistry>>> =
751        const { RefCell::new(Vec::new()) };
752}
753
754thread_local! {
755    static CURRENT_NATIVE_WINDOW_DISPATCH: RefCell<Vec<NativeWindowDispatchContext>> = const { RefCell::new(Vec::new()) };
756    #[cfg(all(
757        feature = "desktop",
758        feature = "renderer-wgpu",
759        not(target_arch = "wasm32")
760    ))]
761    static CURRENT_WINDOW_GROUP: RefCell<Option<NativeWindowGroupMembership>> = const { RefCell::new(None) };
762}
763
764/// Renders content in an operating-system window owned by the current composition.
765///
766/// On desktop this creates or updates a separate OS window. Other platforms compose
767/// the content inline so the same UI remains usable without native sub-window support.
768#[allow(non_snake_case)]
769#[composable(no_skip)]
770pub fn Window(id: &'static str, config: WindowConfig, content: impl FnMut() + 'static) {
771    let (options, events, state) = config.into_parts();
772    let window_id = WindowId::from_static(id);
773    NativeWindowWithEvents(window_id, options, events, state, content);
774}
775
776/// Renders content in a peer operating-system window.
777///
778/// This is the first-class multi-window spelling. [`Window`] remains a compact
779/// alias for the same peer-window declaration.
780#[allow(non_snake_case)]
781#[composable(no_skip)]
782pub fn WindowNode(id: WindowId, config: WindowConfig, content: impl FnMut() + 'static) {
783    let (options, events, state) = config.into_parts();
784    NativeWindowWithEvents(id, options, events, state, content);
785}
786
787/// Applies attachment and move policy to all peer windows declared inside it.
788#[allow(non_snake_case)]
789#[composable(no_skip)]
790pub fn WindowGroup(id: &'static str, policy: WindowAttachPolicy, content: impl FnMut() + 'static) {
791    #[cfg(all(
792        feature = "desktop",
793        feature = "renderer-wgpu",
794        not(target_arch = "wasm32")
795    ))]
796    {
797        with_window_group(
798            NativeWindowGroupMembership {
799                id: WindowGroupId::from_static(id),
800                policy,
801            },
802            content,
803        );
804    }
805
806    #[cfg(not(all(
807        feature = "desktop",
808        feature = "renderer-wgpu",
809        not(target_arch = "wasm32")
810    )))]
811    {
812        let _ = (id, policy);
813        let mut content = content;
814        content();
815    }
816}
817
818#[allow(non_snake_case)]
819#[composable(no_skip)]
820fn NativeWindowWithEvents(
821    id: WindowId,
822    options: NativeWindowOptions,
823    events: NativeWindowEvents,
824    state: Option<WindowState>,
825    content: impl FnMut() + 'static,
826) {
827    #[cfg(all(
828        feature = "desktop",
829        feature = "renderer-wgpu",
830        not(target_arch = "wasm32")
831    ))]
832    {
833        let key = id;
834        let group = current_window_group();
835        let owner = cranpose_core::remember(|| Rc::new(())).with(Rc::clone);
836        let content_cell =
837            cranpose_core::remember(|| Rc::new(RefCell::new(Box::new(|| {}) as Box<dyn FnMut()>)))
838                .with(Rc::clone);
839        *content_cell.borrow_mut() = Box::new(content);
840
841        {
842            let options = options.clone();
843            let events = events.clone();
844            let content = Rc::clone(&content_cell);
845            let owner = Rc::clone(&owner);
846            cranpose_core::SideEffect(move || {
847                register_native_window(key, options, events, state, group, content, owner);
848            });
849        }
850
851        {
852            let owner = Rc::clone(&owner);
853            cranpose_core::DisposableEffect!(key, move |scope| {
854                scope.on_dispose(move || unregister_native_window(key, owner))
855            });
856        }
857    }
858
859    #[cfg(not(all(
860        feature = "desktop",
861        feature = "renderer-wgpu",
862        not(target_arch = "wasm32")
863    )))]
864    {
865        let mut content = content;
866        let _ = (id, options, events, state);
867        content();
868    }
869}
870
871/// Requests that the current native window begin an operating-system window drag.
872///
873/// This returns `true` when a desktop native window is currently dispatching the
874/// pointer event and the request was forwarded to the platform window.
875fn request_native_window_drag() -> bool {
876    current_native_window_dispatch_context()
877        .and_then(|context| context.drag_handler)
878        .is_some_and(|handler| handler())
879}
880
881fn request_native_window_resize(direction: WindowResizeDirection) -> bool {
882    current_native_window_dispatch_context()
883        .and_then(|context| context.resize_handler)
884        .is_some_and(|handler| {
885            handler(direction);
886            true
887        })
888}
889
890/// Returns the desktop-space origin of the current native window surface while
891/// dispatching input inside a native window.
892///
893/// This is `None` for inline content and outside native-window input dispatch.
894pub fn current_native_window_surface_origin() -> Option<Point> {
895    current_native_window_dispatch_context().and_then(|context| context.surface_origin)
896}
897
898#[cfg(all(
899    feature = "desktop",
900    feature = "renderer-wgpu",
901    not(target_arch = "wasm32")
902))]
903pub(crate) fn with_native_window_registry<R>(
904    registry: &Rc<NativeWindowRegistry>,
905    f: impl FnOnce() -> R,
906) -> R {
907    struct RegistryGuard;
908
909    impl Drop for RegistryGuard {
910        fn drop(&mut self) {
911            CURRENT_NATIVE_WINDOW_REGISTRY.with(|stack| {
912                stack.borrow_mut().pop();
913            });
914        }
915    }
916
917    CURRENT_NATIVE_WINDOW_REGISTRY.with(|stack| {
918        stack.borrow_mut().push(Rc::downgrade(registry));
919    });
920    let _guard = RegistryGuard;
921    f()
922}
923
924#[cfg(all(
925    feature = "desktop",
926    feature = "renderer-wgpu",
927    not(target_arch = "wasm32")
928))]
929fn current_native_window_registry() -> Option<Rc<NativeWindowRegistry>> {
930    CURRENT_NATIVE_WINDOW_REGISTRY.with(|stack| {
931        let mut stack = stack.borrow_mut();
932        loop {
933            match stack.last().and_then(Weak::upgrade) {
934                Some(registry) => return Some(registry),
935                None if stack.is_empty() => return None,
936                None => {
937                    stack.pop();
938                }
939            }
940        }
941    })
942}
943
944#[cfg(all(
945    feature = "desktop",
946    feature = "renderer-wgpu",
947    not(target_arch = "wasm32")
948))]
949pub(crate) fn native_window_requests(registry: &NativeWindowRegistry) -> Vec<NativeWindowRequest> {
950    registry.requests()
951}
952
953#[cfg(all(
954    feature = "desktop",
955    feature = "renderer-wgpu",
956    not(target_arch = "wasm32")
957))]
958pub(crate) fn has_native_window_requests(registry: &NativeWindowRegistry) -> bool {
959    registry.has_requests()
960}
961
962#[cfg(all(
963    test,
964    feature = "desktop",
965    feature = "renderer-wgpu",
966    not(target_arch = "wasm32")
967))]
968pub(crate) fn clear_native_window_requests(registry: &NativeWindowRegistry) {
969    registry.clear();
970}
971
972fn current_native_window_dispatch_context() -> Option<NativeWindowDispatchContext> {
973    CURRENT_NATIVE_WINDOW_DISPATCH.with(|stack| stack.borrow().last().cloned())
974}
975
976#[cfg(all(
977    feature = "desktop",
978    feature = "renderer-wgpu",
979    not(target_arch = "wasm32")
980))]
981fn with_native_window_dispatch_context<R>(
982    context: NativeWindowDispatchContext,
983    f: impl FnOnce() -> R,
984) -> R {
985    struct DispatchContextGuard;
986
987    impl Drop for DispatchContextGuard {
988        fn drop(&mut self) {
989            CURRENT_NATIVE_WINDOW_DISPATCH.with(|stack| {
990                stack.borrow_mut().pop();
991            });
992        }
993    }
994
995    CURRENT_NATIVE_WINDOW_DISPATCH.with(|stack| {
996        stack.borrow_mut().push(context);
997    });
998    let _guard = DispatchContextGuard;
999    f()
1000}
1001
1002#[cfg(all(
1003    feature = "desktop",
1004    feature = "renderer-wgpu",
1005    not(target_arch = "wasm32")
1006))]
1007pub(crate) fn with_native_window_drag_handler<R>(
1008    handler: NativeWindowDragHandler,
1009    resize_handler: NativeWindowResizeHandler,
1010    f: impl FnOnce() -> R,
1011) -> R {
1012    let mut context = current_native_window_dispatch_context().unwrap_or_default();
1013    context.drag_handler = Some(handler);
1014    context.resize_handler = Some(resize_handler);
1015    with_native_window_dispatch_context(context, f)
1016}
1017
1018#[cfg(all(
1019    feature = "desktop",
1020    feature = "renderer-wgpu",
1021    not(target_arch = "wasm32")
1022))]
1023pub(crate) fn with_native_window_surface_origin<R>(
1024    origin: Option<Point>,
1025    f: impl FnOnce() -> R,
1026) -> R {
1027    let mut context = current_native_window_dispatch_context().unwrap_or_default();
1028    context.surface_origin = origin;
1029    with_native_window_dispatch_context(context, f)
1030}
1031
1032#[cfg(all(
1033    feature = "desktop",
1034    feature = "renderer-wgpu",
1035    not(target_arch = "wasm32")
1036))]
1037fn current_window_group() -> Option<NativeWindowGroupMembership> {
1038    CURRENT_WINDOW_GROUP.with(|slot| slot.borrow().clone())
1039}
1040
1041#[cfg(all(
1042    feature = "desktop",
1043    feature = "renderer-wgpu",
1044    not(target_arch = "wasm32")
1045))]
1046fn with_window_group<R>(group: NativeWindowGroupMembership, f: impl FnOnce() -> R) -> R {
1047    struct WindowGroupGuard(Option<NativeWindowGroupMembership>);
1048
1049    impl Drop for WindowGroupGuard {
1050        fn drop(&mut self) {
1051            CURRENT_WINDOW_GROUP.with(|slot| {
1052                *slot.borrow_mut() = self.0.take();
1053            });
1054        }
1055    }
1056
1057    let previous = CURRENT_WINDOW_GROUP.with(|slot| slot.borrow_mut().replace(group));
1058    let guard = WindowGroupGuard(previous);
1059    let result = f();
1060    drop(guard);
1061    result
1062}
1063
1064#[cfg(all(
1065    feature = "desktop",
1066    feature = "renderer-wgpu",
1067    not(target_arch = "wasm32")
1068))]
1069fn register_native_window(
1070    key: NativeWindowKey,
1071    options: NativeWindowOptions,
1072    events: NativeWindowEvents,
1073    state: Option<WindowState>,
1074    group: Option<NativeWindowGroupMembership>,
1075    content: NativeWindowContent,
1076    owner: NativeWindowOwner,
1077) {
1078    let Some(registry) = current_native_window_registry() else {
1079        log::error!(
1080            "native window declaration {key:?} ignored because no native-window registry is active"
1081        );
1082        return;
1083    };
1084    registry.register(NativeWindowRegistration {
1085        key,
1086        options,
1087        events,
1088        state,
1089        group,
1090        content,
1091        owner,
1092    });
1093}
1094
1095#[cfg(all(
1096    feature = "desktop",
1097    feature = "renderer-wgpu",
1098    not(target_arch = "wasm32")
1099))]
1100fn unregister_native_window(key: NativeWindowKey, owner: NativeWindowOwner) {
1101    let Some(registry) = current_native_window_registry() else {
1102        log::error!(
1103            "native window declaration {key:?} could not unregister because no native-window registry is active"
1104        );
1105        return;
1106    };
1107    registry.unregister(key, owner);
1108}
1109
1110fn hash_id(id: &'static str) -> u64 {
1111    let mut hasher = std::collections::hash_map::DefaultHasher::new();
1112    id.hash(&mut hasher);
1113    hasher.finish()
1114}
1115
1116#[cfg(all(
1117    feature = "desktop",
1118    feature = "renderer-wgpu",
1119    not(target_arch = "wasm32")
1120))]
1121#[derive(Clone, Copy, Debug, PartialEq)]
1122pub(crate) struct WindowGraphNodeSnapshot {
1123    pub(crate) id: WindowId,
1124    pub(crate) position: Point,
1125    pub(crate) size: Size,
1126}
1127
1128#[cfg(all(
1129    feature = "desktop",
1130    feature = "renderer-wgpu",
1131    not(target_arch = "wasm32")
1132))]
1133#[derive(Clone, Debug, PartialEq)]
1134pub(crate) struct WindowGraphPeerSnapshot {
1135    pub(crate) node: WindowGraphNodeSnapshot,
1136    pub(crate) group: Option<NativeWindowGroupMembership>,
1137}
1138
1139#[cfg(all(
1140    feature = "desktop",
1141    feature = "renderer-wgpu",
1142    not(target_arch = "wasm32")
1143))]
1144#[derive(Clone, Copy, Debug, PartialEq)]
1145pub(crate) struct WindowGraphMove {
1146    pub(crate) id: WindowId,
1147    pub(crate) position: Point,
1148}
1149
1150#[cfg(all(
1151    feature = "desktop",
1152    feature = "renderer-wgpu",
1153    not(target_arch = "wasm32")
1154))]
1155#[derive(Clone, Debug)]
1156struct WindowGraphDragSession {
1157    group: Option<NativeWindowGroupMembership>,
1158    dragged: WindowId,
1159    start_dragged_position: Point,
1160    captured: Vec<WindowGraphNodeSnapshot>,
1161}
1162
1163/// Framework-owned topology and drag state for peer operating-system windows.
1164#[cfg(all(
1165    feature = "desktop",
1166    feature = "renderer-wgpu",
1167    not(target_arch = "wasm32")
1168))]
1169#[derive(Default)]
1170pub(crate) struct WindowGraphState {
1171    active_drag: Option<WindowGraphDragSession>,
1172}
1173
1174#[cfg(all(
1175    feature = "desktop",
1176    feature = "renderer-wgpu",
1177    not(target_arch = "wasm32")
1178))]
1179impl WindowGraphState {
1180    pub(crate) fn start_drag(&mut self, windows: &[WindowGraphPeerSnapshot], dragged: WindowId) {
1181        let Some(dragged_window) = windows.iter().find(|window| window.node.id == dragged) else {
1182            self.active_drag = None;
1183            return;
1184        };
1185        let group = dragged_window.group.clone();
1186        let captured = if let Some(group) = &group {
1187            let group_windows = group_windows(windows, group);
1188            let moves_attached = group.policy.move_mode.moves_attached_component(dragged);
1189            let component = if moves_attached {
1190                attached_component(&group_windows, dragged, group.policy.attach_epsilon)
1191            } else {
1192                vec![dragged]
1193            };
1194            group_windows
1195                .into_iter()
1196                .filter(|window| component.contains(&window.id))
1197                .collect()
1198        } else {
1199            vec![dragged_window.node]
1200        };
1201
1202        self.active_drag = Some(WindowGraphDragSession {
1203            group,
1204            dragged,
1205            start_dragged_position: dragged_window.node.position,
1206            captured,
1207        });
1208    }
1209
1210    pub(crate) fn drag_to(
1211        &self,
1212        dragged: WindowId,
1213        target_position: Point,
1214    ) -> Vec<WindowGraphMove> {
1215        let Some(session) = &self.active_drag else {
1216            return vec![WindowGraphMove {
1217                id: dragged,
1218                position: target_position,
1219            }];
1220        };
1221        if session.dragged != dragged {
1222            return Vec::new();
1223        }
1224
1225        let delta = Point::new(
1226            target_position.x - session.start_dragged_position.x,
1227            target_position.y - session.start_dragged_position.y,
1228        );
1229        session
1230            .captured
1231            .iter()
1232            .map(|window| WindowGraphMove {
1233                id: window.id,
1234                position: Point::new(window.position.x + delta.x, window.position.y + delta.y),
1235            })
1236            .collect()
1237    }
1238
1239    pub(crate) fn cancel_drag(&mut self) {
1240        self.active_drag = None;
1241    }
1242
1243    pub(crate) fn finish_drag(
1244        &mut self,
1245        windows: &[WindowGraphPeerSnapshot],
1246    ) -> Vec<WindowGraphMove> {
1247        let Some(session) = self.active_drag.take() else {
1248            return Vec::new();
1249        };
1250        let Some(group) = &session.group else {
1251            return Vec::new();
1252        };
1253        let group_windows = group_windows(windows, group);
1254        if group_windows
1255            .iter()
1256            .all(|window| window.id != session.dragged)
1257        {
1258            return Vec::new();
1259        }
1260
1261        let moves_attached = group
1262            .policy
1263            .move_mode
1264            .moves_attached_component(session.dragged);
1265        let mut component = if moves_attached {
1266            session.captured.iter().map(|window| window.id).collect()
1267        } else {
1268            vec![session.dragged]
1269        };
1270        if let Some(snap) = closest_snap(&group_windows, &component, group.policy.snap_distance) {
1271            let mut moved = group_windows;
1272            translate_nodes(&mut moved, &component, snap.delta);
1273            if moves_attached {
1274                for id in attached_component(&moved, snap.target, group.policy.attach_epsilon) {
1275                    if !component.contains(&id) {
1276                        component.push(id);
1277                    }
1278                }
1279            }
1280            return moved
1281                .into_iter()
1282                .filter(|window| component.contains(&window.id))
1283                .map(|window| WindowGraphMove {
1284                    id: window.id,
1285                    position: window.position,
1286                })
1287                .collect();
1288        }
1289
1290        Vec::new()
1291    }
1292
1293    pub(crate) fn external_move(
1294        &self,
1295        windows: &[WindowGraphPeerSnapshot],
1296        moved: WindowId,
1297        new_position: Point,
1298    ) -> Vec<WindowGraphMove> {
1299        let Some(moved_window) = windows.iter().find(|window| window.node.id == moved) else {
1300            return Vec::new();
1301        };
1302        let Some(group) = &moved_window.group else {
1303            return Vec::new();
1304        };
1305        if !group.policy.move_mode.moves_attached_component(moved) {
1306            return Vec::new();
1307        }
1308
1309        let delta = Point::new(
1310            new_position.x - moved_window.node.position.x,
1311            new_position.y - moved_window.node.position.y,
1312        );
1313        if delta.x.abs() <= f32::EPSILON && delta.y.abs() <= f32::EPSILON {
1314            return Vec::new();
1315        }
1316        let group_windows = group_windows(windows, group);
1317        let component = attached_component(&group_windows, moved, group.policy.attach_epsilon);
1318        group_windows
1319            .into_iter()
1320            .filter(|window| component.contains(&window.id) && window.id != moved)
1321            .map(|window| WindowGraphMove {
1322                id: window.id,
1323                position: Point::new(window.position.x + delta.x, window.position.y + delta.y),
1324            })
1325            .collect()
1326    }
1327}
1328
1329#[cfg(all(
1330    feature = "desktop",
1331    feature = "renderer-wgpu",
1332    not(target_arch = "wasm32")
1333))]
1334fn group_windows(
1335    windows: &[WindowGraphPeerSnapshot],
1336    group: &NativeWindowGroupMembership,
1337) -> Vec<WindowGraphNodeSnapshot> {
1338    windows
1339        .iter()
1340        .filter(|window| {
1341            window
1342                .group
1343                .as_ref()
1344                .is_some_and(|candidate| candidate.id == group.id)
1345        })
1346        .map(|window| window.node)
1347        .collect()
1348}
1349
1350#[cfg(all(
1351    feature = "desktop",
1352    feature = "renderer-wgpu",
1353    not(target_arch = "wasm32")
1354))]
1355fn attached_component(
1356    windows: &[WindowGraphNodeSnapshot],
1357    dragged: WindowId,
1358    attach_epsilon: f32,
1359) -> Vec<WindowId> {
1360    let mut component = vec![dragged];
1361    let mut changed = true;
1362
1363    while changed {
1364        changed = false;
1365        for candidate in windows {
1366            if component.contains(&candidate.id) {
1367                continue;
1368            }
1369            let attached_to_component = windows
1370                .iter()
1371                .filter(|window| component.contains(&window.id))
1372                .any(|window| rects_attached(candidate, window, attach_epsilon));
1373            if attached_to_component {
1374                component.push(candidate.id);
1375                changed = true;
1376            }
1377        }
1378    }
1379
1380    component
1381}
1382
1383#[cfg(all(
1384    feature = "desktop",
1385    feature = "renderer-wgpu",
1386    not(target_arch = "wasm32")
1387))]
1388fn rects_attached(
1389    child: &WindowGraphNodeSnapshot,
1390    main: &WindowGraphNodeSnapshot,
1391    attach_epsilon: f32,
1392) -> bool {
1393    let child_right = child.position.x + child.size.width;
1394    let child_bottom = child.position.y + child.size.height;
1395    let main_right = main.position.x + main.size.width;
1396    let main_bottom = main.position.y + main.size.height;
1397
1398    let touches_horizontal = near(child.position.x, main_right, attach_epsilon)
1399        || near(child_right, main.position.x, attach_epsilon);
1400    let overlaps_vertical = ranges_overlap(
1401        child.position.y,
1402        child_bottom,
1403        main.position.y,
1404        main_bottom,
1405        attach_epsilon,
1406    );
1407    let touches_vertical = near(child.position.y, main_bottom, attach_epsilon)
1408        || near(child_bottom, main.position.y, attach_epsilon);
1409    let overlaps_horizontal = ranges_overlap(
1410        child.position.x,
1411        child_right,
1412        main.position.x,
1413        main_right,
1414        attach_epsilon,
1415    );
1416
1417    touches_horizontal && overlaps_vertical || touches_vertical && overlaps_horizontal
1418}
1419
1420#[cfg(all(
1421    feature = "desktop",
1422    feature = "renderer-wgpu",
1423    not(target_arch = "wasm32")
1424))]
1425#[derive(Clone, Copy, Debug, PartialEq)]
1426struct GraphSnap {
1427    target: WindowId,
1428    delta: Point,
1429    distance: f32,
1430    contact: f32,
1431}
1432
1433#[cfg(all(
1434    feature = "desktop",
1435    feature = "renderer-wgpu",
1436    not(target_arch = "wasm32")
1437))]
1438#[derive(Clone, Copy, Debug, PartialEq)]
1439struct GraphSnapCandidate {
1440    delta: Point,
1441    contact: f32,
1442}
1443
1444#[cfg(all(
1445    feature = "desktop",
1446    feature = "renderer-wgpu",
1447    not(target_arch = "wasm32")
1448))]
1449fn closest_snap(
1450    windows: &[WindowGraphNodeSnapshot],
1451    component: &[WindowId],
1452    snap_distance: f32,
1453) -> Option<GraphSnap> {
1454    let mut closest = None::<GraphSnap>;
1455
1456    for moving in windows
1457        .iter()
1458        .filter(|window| component.contains(&window.id))
1459    {
1460        for stationary in windows
1461            .iter()
1462            .filter(|window| !component.contains(&window.id))
1463        {
1464            for candidate in snap_candidates(moving, stationary, snap_distance) {
1465                let snap = GraphSnap {
1466                    target: stationary.id,
1467                    delta: candidate.delta,
1468                    distance: candidate.delta.x.abs() + candidate.delta.y.abs(),
1469                    contact: candidate.contact,
1470                };
1471                if closest.is_none_or(|current| {
1472                    snap.contact > current.contact
1473                        || snap.contact == current.contact && snap.distance < current.distance
1474                }) {
1475                    closest = Some(snap);
1476                }
1477            }
1478        }
1479    }
1480
1481    closest
1482}
1483
1484#[cfg(all(
1485    feature = "desktop",
1486    feature = "renderer-wgpu",
1487    not(target_arch = "wasm32")
1488))]
1489fn snap_candidates(
1490    moving: &WindowGraphNodeSnapshot,
1491    stationary: &WindowGraphNodeSnapshot,
1492    snap_distance: f32,
1493) -> Vec<GraphSnapCandidate> {
1494    let moving_left = moving.position.x;
1495    let moving_top = moving.position.y;
1496    let moving_right = moving.position.x + moving.size.width;
1497    let moving_bottom = moving.position.y + moving.size.height;
1498    let stationary_left = stationary.position.x;
1499    let stationary_top = stationary.position.y;
1500    let stationary_right = stationary.position.x + stationary.size.width;
1501    let stationary_bottom = stationary.position.y + stationary.size.height;
1502
1503    let mut candidates = Vec::new();
1504    if ranges_overlap_strict(moving_top, moving_bottom, stationary_top, stationary_bottom) {
1505        let contact =
1506            range_overlap_length(moving_top, moving_bottom, stationary_top, stationary_bottom);
1507        if near(moving_right, stationary_left, snap_distance) {
1508            candidates.push(GraphSnapCandidate {
1509                delta: Point::new(stationary_left - moving_right, 0.0),
1510                contact,
1511            });
1512        }
1513        if near(moving_left, stationary_right, snap_distance) {
1514            candidates.push(GraphSnapCandidate {
1515                delta: Point::new(stationary_right - moving_left, 0.0),
1516                contact,
1517            });
1518        }
1519    }
1520    if ranges_overlap_strict(moving_left, moving_right, stationary_left, stationary_right) {
1521        let contact =
1522            range_overlap_length(moving_left, moving_right, stationary_left, stationary_right);
1523        if near(moving_bottom, stationary_top, snap_distance) {
1524            candidates.push(GraphSnapCandidate {
1525                delta: Point::new(0.0, stationary_top - moving_bottom),
1526                contact,
1527            });
1528        }
1529        if near(moving_top, stationary_bottom, snap_distance) {
1530            candidates.push(GraphSnapCandidate {
1531                delta: Point::new(0.0, stationary_bottom - moving_top),
1532                contact,
1533            });
1534        }
1535    }
1536
1537    candidates
1538}
1539
1540#[cfg(all(
1541    feature = "desktop",
1542    feature = "renderer-wgpu",
1543    not(target_arch = "wasm32")
1544))]
1545fn translate_nodes(windows: &mut [WindowGraphNodeSnapshot], component: &[WindowId], delta: Point) {
1546    if delta.x.abs() <= f32::EPSILON && delta.y.abs() <= f32::EPSILON {
1547        return;
1548    }
1549    for window in windows {
1550        if component.contains(&window.id) {
1551            window.position.x += delta.x;
1552            window.position.y += delta.y;
1553        }
1554    }
1555}
1556
1557#[cfg(all(
1558    feature = "desktop",
1559    feature = "renderer-wgpu",
1560    not(target_arch = "wasm32")
1561))]
1562fn near(a: f32, b: f32, distance: f32) -> bool {
1563    (a - b).abs() <= distance
1564}
1565
1566#[cfg(all(
1567    feature = "desktop",
1568    feature = "renderer-wgpu",
1569    not(target_arch = "wasm32")
1570))]
1571fn ranges_overlap(a_start: f32, a_end: f32, b_start: f32, b_end: f32, attach_epsilon: f32) -> bool {
1572    a_start <= b_end + attach_epsilon && b_start <= a_end + attach_epsilon
1573}
1574
1575#[cfg(all(
1576    feature = "desktop",
1577    feature = "renderer-wgpu",
1578    not(target_arch = "wasm32")
1579))]
1580fn ranges_overlap_strict(a_start: f32, a_end: f32, b_start: f32, b_end: f32) -> bool {
1581    a_start < b_end && b_start < a_end
1582}
1583
1584#[cfg(all(
1585    feature = "desktop",
1586    feature = "renderer-wgpu",
1587    not(target_arch = "wasm32")
1588))]
1589fn range_overlap_length(a_start: f32, a_end: f32, b_start: f32, b_end: f32) -> f32 {
1590    (a_end.min(b_end) - a_start.max(b_start)).max(0.0)
1591}
1592
1593#[cfg(test)]
1594mod tests {
1595    use super::*;
1596    use std::sync::Arc;
1597
1598    fn test_window_state(
1599        width: f32,
1600        height: f32,
1601    ) -> (
1602        cranpose_core::Runtime,
1603        cranpose_core::OwnedMutableState<Option<Point>>,
1604        cranpose_core::OwnedMutableState<Size>,
1605        WindowState,
1606    ) {
1607        let runtime = cranpose_core::Runtime::new(Arc::new(cranpose_core::DefaultScheduler));
1608        let handle = runtime.handle();
1609        let position =
1610            cranpose_core::OwnedMutableState::with_runtime(None::<Point>, handle.clone());
1611        let size = cranpose_core::OwnedMutableState::with_runtime(Size::new(width, height), handle);
1612        let state = WindowState {
1613            position: position.handle(),
1614            size: size.handle(),
1615        };
1616        (runtime, position, size, state)
1617    }
1618
1619    #[cfg(all(
1620        feature = "desktop",
1621        feature = "renderer-wgpu",
1622        not(target_arch = "wasm32")
1623    ))]
1624    fn with_request_test_registry<R>(f: impl FnOnce(&Rc<NativeWindowRegistry>) -> R) -> R {
1625        let registry = Rc::new(NativeWindowRegistry::default());
1626        with_native_window_registry(&registry, || f(&registry))
1627    }
1628
1629    #[cfg(all(
1630        feature = "desktop",
1631        feature = "renderer-wgpu",
1632        not(target_arch = "wasm32")
1633    ))]
1634    fn reset_request_test_state(registry: &NativeWindowRegistry) {
1635        clear_native_window_requests(registry);
1636    }
1637
1638    #[cfg(all(
1639        feature = "desktop",
1640        feature = "renderer-wgpu",
1641        not(target_arch = "wasm32")
1642    ))]
1643    fn request_exists(registry: &NativeWindowRegistry, key: NativeWindowKey) -> bool {
1644        native_window_requests(registry)
1645            .into_iter()
1646            .any(|request| request.key == key)
1647    }
1648
1649    #[cfg(all(
1650        feature = "desktop",
1651        feature = "renderer-wgpu",
1652        not(target_arch = "wasm32")
1653    ))]
1654    #[test]
1655    fn native_window_requests_are_isolated_by_registry() {
1656        let first_registry = Rc::new(NativeWindowRegistry::default());
1657        let second_registry = Rc::new(NativeWindowRegistry::default());
1658        let first_key = WindowId::from_static("first-registry-window");
1659        let second_key = WindowId::from_static("second-registry-window");
1660        let first_owner = Rc::new(());
1661        let second_owner = Rc::new(());
1662        let first_content = Rc::new(RefCell::new(Box::new(|| {}) as Box<dyn FnMut()>));
1663        let second_content = Rc::new(RefCell::new(Box::new(|| {}) as Box<dyn FnMut()>));
1664
1665        with_native_window_registry(&first_registry, || {
1666            register_native_window(
1667                first_key,
1668                NativeWindowOptions::new("first", 80.0, 40.0),
1669                NativeWindowEvents::default(),
1670                None,
1671                None,
1672                first_content,
1673                first_owner,
1674            );
1675        });
1676        with_native_window_registry(&second_registry, || {
1677            register_native_window(
1678                second_key,
1679                NativeWindowOptions::new("second", 90.0, 45.0),
1680                NativeWindowEvents::default(),
1681                None,
1682                None,
1683                second_content,
1684                second_owner,
1685            );
1686        });
1687
1688        let first_requests = native_window_requests(&first_registry);
1689        let second_requests = native_window_requests(&second_registry);
1690
1691        assert_eq!(first_requests.len(), 1);
1692        assert_eq!(first_requests[0].key, first_key);
1693        assert_eq!(second_requests.len(), 1);
1694        assert_eq!(second_requests[0].key, second_key);
1695    }
1696
1697    #[cfg(all(
1698        feature = "desktop",
1699        feature = "renderer-wgpu",
1700        not(target_arch = "wasm32")
1701    ))]
1702    struct RequestTestComposition {
1703        _context: Rc<cranpose_ui::AppContext>,
1704        _scope: cranpose_ui::AppContextScope,
1705        runtime: cranpose_core::Runtime,
1706        composition: cranpose_core::Composition<cranpose_core::MemoryApplier>,
1707        registry: Rc<NativeWindowRegistry>,
1708    }
1709
1710    #[cfg(all(
1711        feature = "desktop",
1712        feature = "renderer-wgpu",
1713        not(target_arch = "wasm32")
1714    ))]
1715    impl RequestTestComposition {
1716        fn with_registry<R>(
1717            &mut self,
1718            f: impl FnOnce(&mut cranpose_core::Composition<cranpose_core::MemoryApplier>) -> R,
1719        ) -> R {
1720            let registry = Rc::clone(&self.registry);
1721            with_native_window_registry(&registry, || f(&mut self.composition))
1722        }
1723    }
1724
1725    #[cfg(all(
1726        feature = "desktop",
1727        feature = "renderer-wgpu",
1728        not(target_arch = "wasm32")
1729    ))]
1730    fn test_app_context_scope() -> (Rc<cranpose_ui::AppContext>, cranpose_ui::AppContextScope) {
1731        let context = cranpose_ui::AppContext::new();
1732        let scope = context.enter_scope();
1733        (context, scope)
1734    }
1735
1736    #[cfg(all(
1737        feature = "desktop",
1738        feature = "renderer-wgpu",
1739        not(target_arch = "wasm32")
1740    ))]
1741    fn request_test_composition() -> RequestTestComposition {
1742        let (context, scope) = test_app_context_scope();
1743        let runtime = cranpose_core::Runtime::new(Arc::new(cranpose_core::DefaultScheduler));
1744        let composition = cranpose_core::Composition::with_runtime(
1745            cranpose_core::MemoryApplier::new(),
1746            runtime.clone(),
1747        );
1748        RequestTestComposition {
1749            _context: context,
1750            _scope: scope,
1751            runtime,
1752            composition,
1753            registry: Rc::new(NativeWindowRegistry::default()),
1754        }
1755    }
1756
1757    #[cfg(all(
1758        feature = "desktop",
1759        feature = "renderer-wgpu",
1760        not(target_arch = "wasm32")
1761    ))]
1762    #[composable]
1763    #[allow(non_snake_case)]
1764    fn RequestCounterText(counter: cranpose_core::MutableState<i32>) {
1765        cranpose_ui::Text(
1766            format!("Counter {}", counter.get()),
1767            cranpose_ui::Modifier::empty(),
1768            cranpose_ui::TextStyle::default(),
1769        );
1770    }
1771
1772    #[cfg(all(
1773        feature = "desktop",
1774        feature = "renderer-wgpu",
1775        not(target_arch = "wasm32")
1776    ))]
1777    #[composable]
1778    #[allow(non_snake_case)]
1779    fn PersistentRequestRoot(counter: cranpose_core::MutableState<i32>) {
1780        RequestCounterText(counter);
1781        WindowNode(
1782            WindowId::from_static("persistent-request"),
1783            WindowConfig::new("Persistent request", 100.0, 50.0),
1784            || {},
1785        );
1786    }
1787
1788    #[cfg(all(
1789        feature = "desktop",
1790        feature = "renderer-wgpu",
1791        not(target_arch = "wasm32")
1792    ))]
1793    #[composable]
1794    #[allow(non_snake_case)]
1795    fn ConditionalRequestRoot(show: cranpose_core::MutableState<bool>) {
1796        if show.get() {
1797            WindowNode(
1798                WindowId::from_static("conditional-request"),
1799                WindowConfig::new("Conditional request", 100.0, 50.0),
1800                || {},
1801            );
1802        }
1803    }
1804
1805    #[cfg(all(
1806        feature = "desktop",
1807        feature = "renderer-wgpu",
1808        not(target_arch = "wasm32")
1809    ))]
1810    #[composable]
1811    #[allow(non_snake_case)]
1812    fn KeyedReplacementRequestRoot(show: cranpose_core::MutableState<bool>) {
1813        let active = show.get();
1814        cranpose_core::with_key(&active, || {
1815            if active {
1816                WindowNode(
1817                    WindowId::from_static("keyed-replacement-request"),
1818                    WindowConfig::new("Keyed replacement request", 100.0, 50.0),
1819                    || {},
1820                );
1821            } else {
1822                cranpose_ui::Text(
1823                    "Inactive branch",
1824                    cranpose_ui::Modifier::empty(),
1825                    cranpose_ui::TextStyle::default(),
1826                );
1827            }
1828        });
1829    }
1830
1831    #[cfg(all(
1832        feature = "desktop",
1833        feature = "renderer-wgpu",
1834        not(target_arch = "wasm32")
1835    ))]
1836    #[test]
1837    fn native_window_request_survives_unrelated_scoped_recompose() {
1838        let mut test = request_test_composition();
1839        reset_request_test_state(&test.registry);
1840        let counter = cranpose_core::MutableState::with_runtime(0i32, test.runtime.handle());
1841        let key = WindowId::from_static("persistent-request");
1842        let root_key = cranpose_core::location_key(file!(), line!(), column!());
1843        test.with_registry(|composition| {
1844            composition
1845                .render_stable(root_key, || PersistentRequestRoot(counter))
1846                .expect("initial persistent native-window request render");
1847        });
1848        assert!(request_exists(&test.registry, key));
1849
1850        counter.set(1);
1851        test.with_registry(|composition| {
1852            composition
1853                .reconcile(root_key, || PersistentRequestRoot(counter))
1854                .expect("persistent native-window request reconcile");
1855        });
1856
1857        assert!(
1858            request_exists(&test.registry, key),
1859            "unchanged native-window declarations must stay registered when only a sibling scope recomposes"
1860        );
1861        clear_native_window_requests(&test.registry);
1862    }
1863
1864    #[cfg(all(
1865        feature = "desktop",
1866        feature = "renderer-wgpu",
1867        not(target_arch = "wasm32")
1868    ))]
1869    #[test]
1870    fn native_window_request_unregisters_when_conditional_declaration_is_removed() {
1871        let mut test = request_test_composition();
1872        reset_request_test_state(&test.registry);
1873        let show = cranpose_core::MutableState::with_runtime(true, test.runtime.handle());
1874        let key = WindowId::from_static("conditional-request");
1875        let root_key = cranpose_core::location_key(file!(), line!(), column!());
1876        test.with_registry(|composition| {
1877            composition
1878                .render_stable(root_key, || ConditionalRequestRoot(show))
1879                .expect("initial conditional native-window request render");
1880        });
1881        assert!(request_exists(&test.registry, key));
1882
1883        show.set(false);
1884        test.with_registry(|composition| {
1885            composition
1886                .reconcile(root_key, || ConditionalRequestRoot(show))
1887                .expect("conditional native-window request reconcile");
1888        });
1889
1890        assert!(
1891            !request_exists(&test.registry, key),
1892            "removed native-window declarations must unregister through their disposable owner"
1893        );
1894        clear_native_window_requests(&test.registry);
1895    }
1896
1897    #[cfg(all(
1898        feature = "desktop",
1899        feature = "renderer-wgpu",
1900        not(target_arch = "wasm32")
1901    ))]
1902    #[test]
1903    fn native_window_request_unregisters_when_keyed_branch_is_replaced() {
1904        let mut test = request_test_composition();
1905        reset_request_test_state(&test.registry);
1906        let show = cranpose_core::MutableState::with_runtime(true, test.runtime.handle());
1907        let key = WindowId::from_static("keyed-replacement-request");
1908        let root_key = cranpose_core::location_key(file!(), line!(), column!());
1909        test.with_registry(|composition| {
1910            composition
1911                .render_stable(root_key, || KeyedReplacementRequestRoot(show))
1912                .expect("initial keyed native-window request render");
1913        });
1914        assert!(request_exists(&test.registry, key));
1915
1916        show.set(false);
1917        test.with_registry(|composition| {
1918            composition
1919                .reconcile(root_key, || KeyedReplacementRequestRoot(show))
1920                .expect("keyed native-window request reconcile");
1921        });
1922
1923        assert!(
1924            !request_exists(&test.registry, key),
1925            "keyed branch replacement must unregister native-window declarations from the inactive branch"
1926        );
1927        clear_native_window_requests(&test.registry);
1928    }
1929
1930    #[test]
1931    fn borderless_options_disable_decorations_and_resizing() {
1932        let options = NativeWindowOptions::borderless("Tool", 100.0, 50.0);
1933        assert_eq!(options.title, "Tool");
1934        assert_eq!(options.width, 100.0);
1935        assert_eq!(options.height, 50.0);
1936        assert_eq!(options.position_origin, NativeWindowPositionOrigin::Screen);
1937        assert!(!options.decorations);
1938        assert!(!options.resizable);
1939        assert!(options.visible);
1940    }
1941
1942    #[test]
1943    fn option_builders_update_specific_fields() {
1944        let options = NativeWindowOptions::new("Panel", 10.0, 20.0)
1945            .with_position(3.0, 4.0)
1946            .with_transparent(true)
1947            .with_resizable(false)
1948            .with_visible(false)
1949            .with_always_on_top(true)
1950            .with_min_size(5.0, 6.0)
1951            .with_max_size(50.0, 60.0);
1952        assert_eq!(options.x, Some(3.0));
1953        assert_eq!(options.y, Some(4.0));
1954        assert_eq!(options.position_origin, NativeWindowPositionOrigin::Screen);
1955        assert!(options.transparent);
1956        assert!(!options.resizable);
1957        assert!(!options.visible);
1958        assert!(options.always_on_top);
1959        assert_eq!(options.min_width, Some(5.0));
1960        assert_eq!(options.min_height, Some(6.0));
1961        assert_eq!(options.max_width, Some(50.0));
1962        assert_eq!(options.max_height, Some(60.0));
1963    }
1964
1965    #[test]
1966    fn host_window_position_records_origin() {
1967        let options =
1968            NativeWindowOptions::new("Panel", 10.0, 20.0).with_host_window_position(3.0, 4.0);
1969        assert_eq!(options.x, Some(3.0));
1970        assert_eq!(options.y, Some(4.0));
1971        assert_eq!(
1972            options.position_origin,
1973            NativeWindowPositionOrigin::HostWindow
1974        );
1975    }
1976
1977    #[test]
1978    fn events_builder_registers_move_callback() {
1979        let events = NativeWindowEvents::new().with_on_moved(|_, _| {});
1980        assert!(events.on_moved.is_some());
1981    }
1982
1983    #[test]
1984    fn window_state_accessors_update_position_and_size() {
1985        let (_runtime, _position_owner, _size_owner, state) = test_window_state(100.0, 50.0);
1986
1987        assert_eq!(state.position_non_reactive(), None);
1988        assert_eq!(state.size_non_reactive(), Size::new(100.0, 50.0));
1989
1990        state.set_position(Some(Point::new(4.0, 8.0)));
1991        assert_eq!(state.position_non_reactive(), Some(Point::new(4.0, 8.0)));
1992
1993        state.translate(3.0, -2.0);
1994        assert_eq!(state.position_non_reactive(), Some(Point::new(7.0, 6.0)));
1995
1996        state.set_size(Size::new(120.0, 64.0));
1997        assert_eq!(state.size_non_reactive(), Size::new(120.0, 64.0));
1998    }
1999
2000    #[test]
2001    fn window_config_collects_window_settings_and_callbacks() {
2002        let config = WindowConfig::borderless("Panel", 100.0, 50.0)
2003            .with_host_window_position(7.0, 9.0)
2004            .with_transparent(true)
2005            .with_resizable(false)
2006            .with_visible(false)
2007            .with_always_on_top(true)
2008            .with_min_size(20.0, 10.0)
2009            .with_max_size(400.0, 200.0)
2010            .on_moved(|_, _| {})
2011            .on_resized(|_, _| {})
2012            .on_close_requested(|| {});
2013
2014        let (options, callbacks, state) = config.into_parts();
2015        assert_eq!(options.title, "Panel");
2016        assert_eq!(options.width, 100.0);
2017        assert_eq!(options.height, 50.0);
2018        assert_eq!(options.x, Some(7.0));
2019        assert_eq!(options.y, Some(9.0));
2020        assert_eq!(
2021            options.position_origin,
2022            NativeWindowPositionOrigin::HostWindow
2023        );
2024        assert!(!options.decorations);
2025        assert!(options.transparent);
2026        assert!(!options.resizable);
2027        assert!(!options.visible);
2028        assert!(options.always_on_top);
2029        assert_eq!(options.min_width, Some(20.0));
2030        assert_eq!(options.min_height, Some(10.0));
2031        assert_eq!(options.max_width, Some(400.0));
2032        assert_eq!(options.max_height, Some(200.0));
2033        assert!(callbacks.on_moved.is_some());
2034        assert!(callbacks.on_resized.is_some());
2035        assert!(callbacks.on_close_requested.is_some());
2036        assert!(state.is_none());
2037    }
2038
2039    #[test]
2040    fn state_window_configs_bind_size_position() {
2041        let (_runtime, _position_owner, _size_owner, state) = test_window_state(100.0, 50.0);
2042        state.set_position(Some(Point::new(7.0, 9.0)));
2043
2044        let (options, callbacks, bound_state) =
2045            WindowConfig::borderless_for_state("Panel", state).into_parts();
2046        assert_eq!(options.title, "Panel");
2047        assert_eq!(options.width, 100.0);
2048        assert_eq!(options.height, 50.0);
2049        assert_eq!(options.x, Some(7.0));
2050        assert_eq!(options.y, Some(9.0));
2051        assert!(!options.decorations);
2052        assert!(!options.resizable);
2053        assert!(bound_state == Some(state));
2054        assert!(callbacks.on_moved.is_none());
2055        assert!(callbacks.on_resized.is_none());
2056
2057        state.set_size(Size::new(320.0, 200.0));
2058
2059        let (decorated_options, _, decorated_state) =
2060            WindowConfig::new_for_state("Decorated", state).into_parts();
2061        assert_eq!(decorated_options.width, 320.0);
2062        assert_eq!(decorated_options.height, 200.0);
2063        assert!(decorated_options.decorations);
2064        assert!(decorated_options.resizable);
2065        assert!(decorated_state == Some(state));
2066    }
2067
2068    #[test]
2069    fn drag_request_reports_missing_handler() {
2070        assert!(!request_native_window_drag());
2071    }
2072
2073    #[cfg(all(
2074        feature = "desktop",
2075        feature = "renderer-wgpu",
2076        not(target_arch = "wasm32")
2077    ))]
2078    #[test]
2079    fn drag_request_uses_handler_result() {
2080        let resize_handler: NativeWindowResizeHandler = Rc::new(|_| {});
2081
2082        with_native_window_drag_handler(Rc::new(|| true), Rc::clone(&resize_handler), || {
2083            assert!(request_native_window_drag());
2084        });
2085        with_native_window_drag_handler(Rc::new(|| false), resize_handler, || {
2086            assert!(!request_native_window_drag());
2087        });
2088
2089        assert!(!request_native_window_drag());
2090    }
2091
2092    #[cfg(all(
2093        feature = "desktop",
2094        feature = "renderer-wgpu",
2095        not(target_arch = "wasm32")
2096    ))]
2097    #[test]
2098    fn native_window_drag_handler_restores_outer_scope_after_nested_scope() {
2099        let outer_resize_calls = Rc::new(Cell::new(0));
2100        let inner_resize_calls = Rc::new(Cell::new(0));
2101        let outer_resize_handler: NativeWindowResizeHandler = {
2102            let outer_resize_calls = Rc::clone(&outer_resize_calls);
2103            Rc::new(move |_| outer_resize_calls.set(outer_resize_calls.get() + 1))
2104        };
2105        let inner_resize_handler: NativeWindowResizeHandler = {
2106            let inner_resize_calls = Rc::clone(&inner_resize_calls);
2107            Rc::new(move |_| inner_resize_calls.set(inner_resize_calls.get() + 1))
2108        };
2109
2110        with_native_window_drag_handler(Rc::new(|| true), outer_resize_handler, || {
2111            assert!(request_native_window_drag());
2112            assert!(request_native_window_resize(
2113                WindowResizeDirection::SouthEast
2114            ));
2115
2116            with_native_window_drag_handler(Rc::new(|| false), inner_resize_handler, || {
2117                assert!(!request_native_window_drag());
2118                assert!(request_native_window_resize(
2119                    WindowResizeDirection::NorthWest
2120                ));
2121            });
2122
2123            assert!(request_native_window_drag());
2124            assert!(request_native_window_resize(
2125                WindowResizeDirection::SouthEast
2126            ));
2127        });
2128
2129        assert!(!request_native_window_drag());
2130        assert_eq!(outer_resize_calls.get(), 2);
2131        assert_eq!(inner_resize_calls.get(), 1);
2132    }
2133
2134    #[cfg(all(
2135        feature = "desktop",
2136        feature = "renderer-wgpu",
2137        not(target_arch = "wasm32")
2138    ))]
2139    #[test]
2140    fn drag_area_callbacks_follow_accepted_native_drag_lifecycle() {
2141        use cranpose_ui::{collect_slices_from_modifier, PointerEvent};
2142        use std::cell::Cell;
2143
2144        let (_app_context, _app_context_scope) = test_app_context_scope();
2145        let started = Rc::new(Cell::new(0));
2146        let finished = Rc::new(Cell::new(0));
2147        let modifier = Modifier::empty().window_drag_area_with_callbacks(
2148            {
2149                let started = Rc::clone(&started);
2150                move || started.set(started.get() + 1)
2151            },
2152            {
2153                let finished = Rc::clone(&finished);
2154                move || finished.set(finished.get() + 1)
2155            },
2156        );
2157        let slices = collect_slices_from_modifier(&modifier);
2158        let handler = slices
2159            .pointer_inputs()
2160            .first()
2161            .expect("window drag pointer handler")
2162            .clone();
2163        let resize_handler: NativeWindowResizeHandler = Rc::new(|_| {});
2164
2165        with_native_window_drag_handler(Rc::new(|| true), resize_handler, || {
2166            let down = PointerEvent::new(
2167                PointerEventKind::Down,
2168                Point::new(4.0, 5.0),
2169                Point::new(4.0, 5.0),
2170            );
2171            handler(down.clone());
2172            assert!(down.is_consumed());
2173
2174            handler(PointerEvent::new(
2175                PointerEventKind::Up,
2176                Point::new(4.0, 5.0),
2177                Point::new(4.0, 5.0),
2178            ));
2179        });
2180
2181        assert_eq!(started.get(), 1);
2182        assert_eq!(finished.get(), 1);
2183    }
2184
2185    #[test]
2186    fn resize_request_reports_missing_handler() {
2187        assert!(!request_native_window_resize(
2188            WindowResizeDirection::SouthEast
2189        ));
2190    }
2191
2192    #[cfg(all(
2193        feature = "desktop",
2194        feature = "renderer-wgpu",
2195        not(target_arch = "wasm32")
2196    ))]
2197    #[test]
2198    fn native_window_surface_origin_is_scoped_to_dispatch() {
2199        assert_eq!(current_native_window_surface_origin(), None);
2200
2201        with_native_window_surface_origin(Some(Point::new(4.0, 8.0)), || {
2202            assert_eq!(
2203                current_native_window_surface_origin(),
2204                Some(Point::new(4.0, 8.0))
2205            );
2206            with_native_window_surface_origin(Some(Point::new(12.0, 16.0)), || {
2207                assert_eq!(
2208                    current_native_window_surface_origin(),
2209                    Some(Point::new(12.0, 16.0))
2210                );
2211            });
2212            assert_eq!(
2213                current_native_window_surface_origin(),
2214                Some(Point::new(4.0, 8.0))
2215            );
2216        });
2217
2218        assert_eq!(current_native_window_surface_origin(), None);
2219    }
2220
2221    #[cfg(all(
2222        feature = "desktop",
2223        feature = "renderer-wgpu",
2224        not(target_arch = "wasm32")
2225    ))]
2226    #[test]
2227    fn static_keys_are_stable() {
2228        assert_eq!(
2229            NativeWindowKey::from_static("stable"),
2230            NativeWindowKey::from_static("stable")
2231        );
2232        assert_ne!(
2233            NativeWindowKey::from_static("stable"),
2234            NativeWindowKey::from_static("other")
2235        );
2236    }
2237
2238    #[cfg(all(
2239        feature = "desktop",
2240        feature = "renderer-wgpu",
2241        not(target_arch = "wasm32")
2242    ))]
2243    fn graph_group(policy: WindowAttachPolicy) -> NativeWindowGroupMembership {
2244        NativeWindowGroupMembership {
2245            id: WindowGroupId::from_static("test-group"),
2246            policy,
2247        }
2248    }
2249
2250    #[cfg(all(
2251        feature = "desktop",
2252        feature = "renderer-wgpu",
2253        not(target_arch = "wasm32")
2254    ))]
2255    fn graph_node(
2256        id: &'static str,
2257        position: Point,
2258        size: Size,
2259        group: &NativeWindowGroupMembership,
2260    ) -> WindowGraphPeerSnapshot {
2261        WindowGraphPeerSnapshot {
2262            node: WindowGraphNodeSnapshot {
2263                id: WindowId::from_static(id),
2264                position,
2265                size,
2266            },
2267            group: Some(group.clone()),
2268        }
2269    }
2270
2271    #[cfg(all(
2272        feature = "desktop",
2273        feature = "renderer-wgpu",
2274        not(target_arch = "wasm32")
2275    ))]
2276    fn graph_position(moves: &[WindowGraphMove], id: WindowId) -> Option<Point> {
2277        moves
2278            .iter()
2279            .find(|window_move| window_move.id == id)
2280            .map(|window_move| window_move.position)
2281    }
2282
2283    #[cfg(all(
2284        feature = "desktop",
2285        feature = "renderer-wgpu",
2286        not(target_arch = "wasm32")
2287    ))]
2288    #[test]
2289    fn graph_drag_capture_freezes_attached_component() {
2290        let main = WindowId::from_static("main");
2291        let eq = WindowId::from_static("eq");
2292        let playlist = WindowId::from_static("playlist");
2293        let group = graph_group(WindowAttachPolicy::default());
2294        let windows = vec![
2295            graph_node(
2296                "main",
2297                Point::new(100.0, 100.0),
2298                Size::new(100.0, 50.0),
2299                &group,
2300            ),
2301            graph_node(
2302                "eq",
2303                Point::new(100.0, 150.0),
2304                Size::new(100.0, 50.0),
2305                &group,
2306            ),
2307            graph_node(
2308                "playlist",
2309                Point::new(240.0, 150.0),
2310                Size::new(100.0, 50.0),
2311                &group,
2312            ),
2313        ];
2314
2315        let mut graph = WindowGraphState::default();
2316        graph.start_drag(&windows, main);
2317        let moves = graph.drag_to(main, Point::new(120.0, 100.0));
2318
2319        assert_eq!(graph_position(&moves, main), Some(Point::new(120.0, 100.0)));
2320        assert_eq!(graph_position(&moves, eq), Some(Point::new(120.0, 150.0)));
2321        assert_eq!(graph_position(&moves, playlist), None);
2322    }
2323
2324    #[cfg(all(
2325        feature = "desktop",
2326        feature = "renderer-wgpu",
2327        not(target_arch = "wasm32")
2328    ))]
2329    #[test]
2330    fn graph_does_not_attach_new_window_during_drag() {
2331        let main = WindowId::from_static("main");
2332        let playlist = WindowId::from_static("playlist");
2333        let group = graph_group(WindowAttachPolicy::default());
2334        let windows = vec![
2335            graph_node(
2336                "main",
2337                Point::new(100.0, 100.0),
2338                Size::new(100.0, 50.0),
2339                &group,
2340            ),
2341            graph_node(
2342                "playlist",
2343                Point::new(216.0, 100.0),
2344                Size::new(100.0, 50.0),
2345                &group,
2346            ),
2347        ];
2348
2349        let mut graph = WindowGraphState::default();
2350        graph.start_drag(&windows, main);
2351        let moves = graph.drag_to(main, Point::new(112.0, 100.0));
2352
2353        assert_eq!(graph_position(&moves, main), Some(Point::new(112.0, 100.0)));
2354        assert_eq!(graph_position(&moves, playlist), None);
2355    }
2356
2357    #[cfg(all(
2358        feature = "desktop",
2359        feature = "renderer-wgpu",
2360        not(target_arch = "wasm32")
2361    ))]
2362    #[test]
2363    fn graph_does_not_detach_captured_component_during_fast_drag() {
2364        let main = WindowId::from_static("main");
2365        let eq = WindowId::from_static("eq");
2366        let group = graph_group(WindowAttachPolicy::default());
2367        let windows = vec![
2368            graph_node(
2369                "main",
2370                Point::new(100.0, 100.0),
2371                Size::new(100.0, 50.0),
2372                &group,
2373            ),
2374            graph_node(
2375                "eq",
2376                Point::new(100.0, 150.0),
2377                Size::new(100.0, 50.0),
2378                &group,
2379            ),
2380        ];
2381
2382        let mut graph = WindowGraphState::default();
2383        graph.start_drag(&windows, main);
2384        let moves = graph.drag_to(main, Point::new(400.0, 280.0));
2385
2386        assert_eq!(graph_position(&moves, main), Some(Point::new(400.0, 280.0)));
2387        assert_eq!(graph_position(&moves, eq), Some(Point::new(400.0, 330.0)));
2388    }
2389
2390    #[cfg(all(
2391        feature = "desktop",
2392        feature = "renderer-wgpu",
2393        not(target_arch = "wasm32")
2394    ))]
2395    #[test]
2396    fn graph_release_recomputes_attachment_once() {
2397        let main = WindowId::from_static("main");
2398        let playlist = WindowId::from_static("playlist");
2399        let group = graph_group(WindowAttachPolicy::default());
2400        let start = vec![
2401            graph_node(
2402                "main",
2403                Point::new(100.0, 100.0),
2404                Size::new(100.0, 50.0),
2405                &group,
2406            ),
2407            graph_node(
2408                "playlist",
2409                Point::new(216.0, 100.0),
2410                Size::new(100.0, 50.0),
2411                &group,
2412            ),
2413        ];
2414        let finish = vec![
2415            graph_node(
2416                "main",
2417                Point::new(112.0, 100.0),
2418                Size::new(100.0, 50.0),
2419                &group,
2420            ),
2421            graph_node(
2422                "playlist",
2423                Point::new(216.0, 100.0),
2424                Size::new(100.0, 50.0),
2425                &group,
2426            ),
2427        ];
2428
2429        let mut graph = WindowGraphState::default();
2430        graph.start_drag(&start, main);
2431        let release_moves = graph.finish_drag(&finish);
2432        let second_release_moves = graph.finish_drag(&finish);
2433
2434        assert_eq!(
2435            graph_position(&release_moves, main),
2436            Some(Point::new(116.0, 100.0))
2437        );
2438        assert_eq!(
2439            graph_position(&release_moves, playlist),
2440            Some(Point::new(216.0, 100.0))
2441        );
2442        assert!(second_release_moves.is_empty());
2443    }
2444
2445    #[cfg(all(
2446        feature = "desktop",
2447        feature = "renderer-wgpu",
2448        not(target_arch = "wasm32")
2449    ))]
2450    #[test]
2451    fn graph_release_uses_drag_start_component_for_attached_windows() {
2452        let main = WindowId::from_static("main");
2453        let group = graph_group(WindowAttachPolicy::default());
2454        let start = vec![
2455            graph_node(
2456                "main",
2457                Point::new(100.0, 100.0),
2458                Size::new(100.0, 50.0),
2459                &group,
2460            ),
2461            graph_node(
2462                "equalizer",
2463                Point::new(100.0, 150.0),
2464                Size::new(100.0, 50.0),
2465                &group,
2466            ),
2467            graph_node(
2468                "playlist",
2469                Point::new(200.0, 150.0),
2470                Size::new(100.0, 50.0),
2471                &group,
2472            ),
2473        ];
2474        let finish_with_peer_position_lag = vec![
2475            graph_node(
2476                "main",
2477                Point::new(112.0, 100.0),
2478                Size::new(100.0, 50.0),
2479                &group,
2480            ),
2481            graph_node(
2482                "equalizer",
2483                Point::new(112.0, 150.0),
2484                Size::new(100.0, 50.0),
2485                &group,
2486            ),
2487            graph_node(
2488                "playlist",
2489                Point::new(216.0, 150.0),
2490                Size::new(100.0, 50.0),
2491                &group,
2492            ),
2493        ];
2494
2495        let mut graph = WindowGraphState::default();
2496        graph.start_drag(&start, main);
2497
2498        assert!(
2499            graph
2500                .finish_drag(&finish_with_peer_position_lag)
2501                .is_empty(),
2502            "release snapping must not drop a start-captured peer from the moving component because OS move events arrived out of phase"
2503        );
2504    }
2505
2506    #[cfg(all(
2507        feature = "desktop",
2508        feature = "renderer-wgpu",
2509        not(target_arch = "wasm32")
2510    ))]
2511    #[test]
2512    fn graph_cancel_drag_discards_active_capture_without_release_moves() {
2513        let main = WindowId::from_static("main");
2514        let group = graph_group(WindowAttachPolicy::default());
2515        let windows = vec![
2516            graph_node(
2517                "main",
2518                Point::new(100.0, 100.0),
2519                Size::new(100.0, 50.0),
2520                &group,
2521            ),
2522            graph_node(
2523                "playlist",
2524                Point::new(216.0, 100.0),
2525                Size::new(100.0, 50.0),
2526                &group,
2527            ),
2528        ];
2529
2530        let mut graph = WindowGraphState::default();
2531        graph.start_drag(&windows, main);
2532        graph.cancel_drag();
2533
2534        assert!(graph.finish_drag(&windows).is_empty());
2535    }
2536
2537    #[cfg(all(
2538        feature = "desktop",
2539        feature = "renderer-wgpu",
2540        not(target_arch = "wasm32")
2541    ))]
2542    #[test]
2543    fn graph_drag_leader_only_moves_attached_component() {
2544        let main = WindowId::from_static("main");
2545        let eq = WindowId::from_static("eq");
2546        let group = graph_group(WindowAttachPolicy::new(
2547            8.0,
2548            3.0,
2549            WindowMoveMode::DragLeaderOnly(vec![main]),
2550        ));
2551        let windows = vec![
2552            graph_node(
2553                "main",
2554                Point::new(100.0, 100.0),
2555                Size::new(100.0, 50.0),
2556                &group,
2557            ),
2558            graph_node(
2559                "eq",
2560                Point::new(100.0, 150.0),
2561                Size::new(100.0, 50.0),
2562                &group,
2563            ),
2564        ];
2565
2566        let mut graph = WindowGraphState::default();
2567        graph.start_drag(&windows, eq);
2568        let eq_moves = graph.drag_to(eq, Point::new(130.0, 170.0));
2569        graph.start_drag(&windows, main);
2570        let main_moves = graph.drag_to(main, Point::new(130.0, 110.0));
2571
2572        assert_eq!(
2573            graph_position(&eq_moves, eq),
2574            Some(Point::new(130.0, 170.0))
2575        );
2576        assert_eq!(graph_position(&eq_moves, main), None);
2577        assert_eq!(
2578            graph_position(&main_moves, main),
2579            Some(Point::new(130.0, 110.0))
2580        );
2581        assert_eq!(
2582            graph_position(&main_moves, eq),
2583            Some(Point::new(130.0, 160.0))
2584        );
2585    }
2586
2587    #[cfg(all(
2588        feature = "desktop",
2589        feature = "renderer-wgpu",
2590        not(target_arch = "wasm32")
2591    ))]
2592    fn test_owner() -> NativeWindowOwner {
2593        Rc::new(())
2594    }
2595
2596    #[cfg(all(
2597        feature = "desktop",
2598        feature = "renderer-wgpu",
2599        not(target_arch = "wasm32")
2600    ))]
2601    #[test]
2602    fn registry_replaces_native_window_declarations_by_key() {
2603        with_request_test_registry(|registry| {
2604            clear_native_window_requests(registry);
2605
2606            let key = NativeWindowKey::from_static("visibility-update");
2607            let owner = test_owner();
2608            let content: NativeWindowContent =
2609                Rc::new(RefCell::new(Box::new(|| {}) as Box<dyn FnMut()>));
2610
2611            register_native_window(
2612                key,
2613                NativeWindowOptions::new("Panel", 100.0, 50.0).with_visible(true),
2614                NativeWindowEvents::new(),
2615                None,
2616                None,
2617                Rc::clone(&content),
2618                Rc::clone(&owner),
2619            );
2620            let initial_revision = native_window_requests(registry)
2621                .into_iter()
2622                .next()
2623                .expect("first native window request")
2624                .revision;
2625
2626            register_native_window(
2627                key,
2628                NativeWindowOptions::new("Panel", 100.0, 50.0).with_visible(false),
2629                NativeWindowEvents::new(),
2630                None,
2631                None,
2632                content,
2633                owner,
2634            );
2635
2636            let requests = native_window_requests(registry);
2637            assert_eq!(requests.len(), 1);
2638            assert_eq!(requests[0].key, key);
2639            assert_ne!(requests[0].revision, initial_revision);
2640            assert!(!requests[0].options.visible);
2641
2642            clear_native_window_requests(registry);
2643            assert!(native_window_requests(registry).is_empty());
2644        });
2645    }
2646
2647    #[cfg(all(
2648        feature = "desktop",
2649        feature = "renderer-wgpu",
2650        not(target_arch = "wasm32")
2651    ))]
2652    #[test]
2653    fn registry_revisions_change_on_each_declaration() {
2654        with_request_test_registry(|registry| {
2655            clear_native_window_requests(registry);
2656
2657            let key = NativeWindowKey::from_static("content-update");
2658            let owner = test_owner();
2659            let first_content: NativeWindowContent =
2660                Rc::new(RefCell::new(Box::new(|| {}) as Box<dyn FnMut()>));
2661            let second_content: NativeWindowContent =
2662                Rc::new(RefCell::new(Box::new(|| {}) as Box<dyn FnMut()>));
2663
2664            register_native_window(
2665                key,
2666                NativeWindowOptions::new("Panel", 100.0, 50.0),
2667                NativeWindowEvents::new(),
2668                None,
2669                None,
2670                first_content,
2671                Rc::clone(&owner),
2672            );
2673            let first_revision = native_window_requests(registry)
2674                .into_iter()
2675                .next()
2676                .expect("first native window request")
2677                .revision;
2678
2679            register_native_window(
2680                key,
2681                NativeWindowOptions::new("Panel", 100.0, 50.0),
2682                NativeWindowEvents::new(),
2683                None,
2684                None,
2685                second_content,
2686                owner,
2687            );
2688            let second_revision = native_window_requests(registry)
2689                .into_iter()
2690                .next()
2691                .expect("second native window request")
2692                .revision;
2693
2694            assert_ne!(first_revision, second_revision);
2695
2696            clear_native_window_requests(registry);
2697        });
2698    }
2699
2700    #[cfg(all(
2701        feature = "desktop",
2702        feature = "renderer-wgpu",
2703        not(target_arch = "wasm32")
2704    ))]
2705    #[test]
2706    fn registry_revisions_change_when_same_content_is_updated() {
2707        with_request_test_registry(|registry| {
2708            clear_native_window_requests(registry);
2709
2710            let key = NativeWindowKey::from_static("same-content-update");
2711            let owner = test_owner();
2712            let content: NativeWindowContent =
2713                Rc::new(RefCell::new(Box::new(|| {}) as Box<dyn FnMut()>));
2714
2715            register_native_window(
2716                key,
2717                NativeWindowOptions::new("Panel", 100.0, 50.0),
2718                NativeWindowEvents::new(),
2719                None,
2720                None,
2721                Rc::clone(&content),
2722                Rc::clone(&owner),
2723            );
2724            let first_revision = native_window_requests(registry)
2725                .into_iter()
2726                .next()
2727                .expect("first native window request")
2728                .revision;
2729
2730            register_native_window(
2731                key,
2732                NativeWindowOptions::new("Panel", 100.0, 50.0),
2733                NativeWindowEvents::new(),
2734                None,
2735                None,
2736                content,
2737                owner,
2738            );
2739            let second_revision = native_window_requests(registry)
2740                .into_iter()
2741                .next()
2742                .expect("second native window request")
2743                .revision;
2744
2745            assert_ne!(first_revision, second_revision);
2746
2747            clear_native_window_requests(registry);
2748        });
2749    }
2750
2751    #[cfg(all(
2752        feature = "desktop",
2753        feature = "renderer-wgpu",
2754        not(target_arch = "wasm32")
2755    ))]
2756    #[test]
2757    fn unregister_ignores_stale_owner_after_redeclaration() {
2758        with_request_test_registry(|registry| {
2759            clear_native_window_requests(registry);
2760
2761            let key = NativeWindowKey::from_static("reattach-window");
2762            let stale_owner = test_owner();
2763            let current_owner = test_owner();
2764            let first_content: NativeWindowContent =
2765                Rc::new(RefCell::new(Box::new(|| {}) as Box<dyn FnMut()>));
2766            let second_content: NativeWindowContent =
2767                Rc::new(RefCell::new(Box::new(|| {}) as Box<dyn FnMut()>));
2768
2769            register_native_window(
2770                key,
2771                NativeWindowOptions::new("Panel", 100.0, 50.0),
2772                NativeWindowEvents::new(),
2773                None,
2774                None,
2775                Rc::clone(&first_content),
2776                Rc::clone(&stale_owner),
2777            );
2778
2779            register_native_window(
2780                key,
2781                NativeWindowOptions::new("Panel", 100.0, 50.0),
2782                NativeWindowEvents::new(),
2783                None,
2784                None,
2785                Rc::clone(&second_content),
2786                Rc::clone(&current_owner),
2787            );
2788            unregister_native_window(key, stale_owner);
2789
2790            let requests = native_window_requests(registry);
2791            assert_eq!(requests.len(), 1);
2792            assert_eq!(requests[0].key, key);
2793            assert!(Rc::ptr_eq(&requests[0].content, &second_content));
2794
2795            unregister_native_window(key, current_owner);
2796            assert!(native_window_requests(registry).is_empty());
2797
2798            clear_native_window_requests(registry);
2799        });
2800    }
2801
2802    #[cfg(all(
2803        feature = "desktop",
2804        feature = "renderer-wgpu",
2805        not(target_arch = "wasm32")
2806    ))]
2807    #[test]
2808    fn clear_does_not_reuse_same_content_revision() {
2809        with_request_test_registry(|registry| {
2810            clear_native_window_requests(registry);
2811
2812            let key = NativeWindowKey::from_static("remove-window");
2813            let owner = test_owner();
2814            let content: NativeWindowContent =
2815                Rc::new(RefCell::new(Box::new(|| {}) as Box<dyn FnMut()>));
2816
2817            register_native_window(
2818                key,
2819                NativeWindowOptions::new("Panel", 100.0, 50.0),
2820                NativeWindowEvents::new(),
2821                None,
2822                None,
2823                Rc::clone(&content),
2824                Rc::clone(&owner),
2825            );
2826            let first_revision = native_window_requests(registry)
2827                .into_iter()
2828                .next()
2829                .expect("registered native window request")
2830                .revision;
2831
2832            clear_native_window_requests(registry);
2833            assert!(native_window_requests(registry).is_empty());
2834
2835            register_native_window(
2836                key,
2837                NativeWindowOptions::new("Panel", 100.0, 50.0),
2838                NativeWindowEvents::new(),
2839                None,
2840                None,
2841                content,
2842                owner,
2843            );
2844            let second_revision = native_window_requests(registry)
2845                .into_iter()
2846                .next()
2847                .expect("registered native window request after cleanup")
2848                .revision;
2849
2850            assert_ne!(first_revision, second_revision);
2851
2852            clear_native_window_requests(registry);
2853        });
2854    }
2855}