Skip to main content

day_core/
windows.rs

1// Copyright © The Daybrite Project
2// SPDX-License-Identifier: MPL-2.0
3
4//! Secondary windows (docs/windows.md): the app-facing `open_window` API, the per-window
5//! registry, and the fallback that presents window content as a fullscreen cover where the
6//! toolkit cannot open windows (`Cap::MultiWindow` = `Unsupported`).
7//!
8//! Windows live in the one thread-local tree as additional boundary roots (the same
9//! "wrap an externally-owned handle" record as the primary root and the list cell
10//! anchors), so bindings, `find_by_id`, and dayscript work across windows unchanged.
11//! Close is asynchronous everywhere: the platform (or the cover's hide transition)
12//! confirms, and teardown runs then: one path for native and programmatic closes.
13
14use std::cell::{Cell, OnceCell, RefCell};
15use std::rc::Rc;
16
17use day_reactive::{Scope, Signal};
18use day_spec::props::{CoverPatch, CoverProps};
19use day_spec::{
20    Attention, Event, NodeId, Point, Rect, Size, WindowChange, WindowKind, WindowLevel,
21    WindowOptions, WindowRole, WindowState, WindowTabbing, kinds,
22};
23
24use crate::AnyPiece;
25use crate::build::{Boundary, BuildCx, Piece};
26use crate::tree::{RNode, WindowRootReply, id_to_rnode, rnode_to_id, with_tree};
27
28fn focus_revision() -> Signal<u64> {
29    FOCUS_REVISION.with(|revision| *revision.get_or_init(|| Signal::global(0)))
30}
31
32/// How a window record is realized.
33enum Tier {
34    /// A live native OS window; the root's handle is its content container.
35    Native { closing: Cell<bool> },
36    /// The toolkit answered `Pending`: native creation is in flight; the content builds
37    /// when the backend calls [`finish_window_open`].
38    PendingNative {
39        build: Option<Box<dyn FnOnce() -> AnyPiece>>,
40        title: String,
41    },
42    /// No native window: the content presents as a fullscreen cover in the primary
43    /// window; `cover` is the cover node, `closing` gates the dismiss transition.
44    Cover {
45        cover: RNode,
46        closing: Rc<Cell<bool>>,
47    },
48}
49
50/// Close callbacks, shared so they outlive the registry entry during teardown.
51type OnCloseList = Rc<RefCell<Vec<Box<dyn Fn()>>>>;
52
53struct WindowRecord {
54    root: RNode,
55    key: Option<String>,
56    kind: WindowKind,
57    /// Whether this window keeps the app alive (docs/windows.md close policy). Derived from
58    /// `kind` today; a per-window override is the natural next step.
59    role: WindowRole,
60    scope: Scope,
61    content_scope: Option<Scope>,
62    tier: Tier,
63    focused: bool,
64    on_close: OnCloseList,
65    close_request: Option<Rc<dyn Fn()>>,
66    /// What the window's `state()` signal holds: written by the app to ask, and by
67    /// `Event::WindowStateChanged` to report.
68    state: Signal<WindowState>,
69    /// The last state the platform reported, so a report written into `state` is not sent back
70    /// to the platform as a request.
71    reported: Rc<Cell<WindowState>>,
72    /// The `WindowOptions::remember_frame` key this window saves its frame under.
73    remember: Option<String>,
74}
75
76impl WindowRecord {
77    /// Whether dismissal has been requested and not yet confirmed by the platform.
78    fn closing(&self) -> bool {
79        match &self.tier {
80            Tier::Native { closing } => closing.get(),
81            Tier::Cover { closing, .. } => closing.get(),
82            Tier::PendingNative { .. } => false,
83        }
84    }
85}
86
87day_reactive::tls_slots! {
88    windows;
89    static WINDOWS: RefCell<Vec<WindowRecord>> = const { RefCell::new(Vec::new()) };
90
91    /// Invalidate reactive focused ambient lookups after command routing changes.
92    // Keep the core TLS initializer independent of the reactive runtime. Bitmap cleanup
93    // can touch an empty core root while reactive TLS is being destroyed (Windows GNU
94    // can even recreate the root after its destructor has finished).
95    static FOCUS_REVISION: OnceCell<Signal<u64>> = const { OnceCell::new() };
96
97    /// The app's first window, so [`initial_window`] can name it. Nothing about the close
98    /// policy consults this: that window is an ordinary registry record and counts exactly
99    /// like the ones opened after it.
100    static INITIAL_WINDOW: Cell<Option<RNode>> = const { Cell::new(None) };
101
102    /// Set while [`quit`] disposes the windows, so the last one closing does not start a second
103    /// quit.
104    static QUITTING: Cell<bool> = const { Cell::new(false) };
105
106    static PREFS: RefCell<Option<PrefsRegistration>> = const { RefCell::new(None) };
107    static PREFS_ACTION: Cell<u64> = const { Cell::new(0) };
108    /// What About does (docs/settings.md): opens a Day window, or the toolkit's own dialog.
109    static ABOUT: RefCell<Option<Rc<dyn Fn()>>> = const { RefCell::new(None) };
110    static ABOUT_ACTION: Cell<u64> = const { Cell::new(0) };
111    /// The updater's Check for Updates… command (docs/settings.md).
112    static UPDATES: RefCell<Option<Rc<dyn Fn()>>> = const { RefCell::new(None) };
113    static UPDATES_ACTION: Cell<u64> = const { Cell::new(0) };
114    static NEW_WINDOW: RefCell<Option<Rc<dyn Fn() -> AnyPiece>>> = const { RefCell::new(None) };
115    /// Builders behind the macOS tab bar's "+" for windows of one tab group
116    /// (`register_new_window_for`, docs/windows.md).
117    static TAB_NEW: RefCell<Vec<(String, Rc<dyn Fn()>)>> = const { RefCell::new(Vec::new()) };
118    static GROUP_WINDOWS: RefCell<Vec<(String, Rc<dyn Fn() -> AnyPiece>)>> = const { RefCell::new(Vec::new()) };
119    static NEW_WINDOW_ACTION: Cell<u64> = const { Cell::new(0) };
120
121    /// What the app asked `launch` for, so a window opened later can describe itself the same
122    /// way (docs/windows.md). Title above all: every platform's automatic window management
123    /// keys on it (the macOS Window menu and tab bar, the iPad app switcher, the Android
124    /// recents card), and an untitled window is absent from all of them.
125    static LAUNCH_OPTIONS: RefCell<Option<WindowOptions>> = const { RefCell::new(None) };
126}
127
128/// Record what the app handed `launch`, for [`open_new_window`] to inherit. Called once by
129/// `launch_with` with the options it is about to open the primary window with, already
130/// title-decorated, which is idempotent (`tag_title`).
131pub fn set_launch_options(options: &WindowOptions) {
132    LAUNCH_OPTIONS.with(|o| *o.borrow_mut() = Some(options.clone()));
133}
134
135/// What the app handed `launch` (title, app name, version), for surfaces that describe the
136/// app (an About page). `None` before launch.
137pub fn launch_options() -> Option<WindowOptions> {
138    LAUNCH_OPTIONS.with(|o| o.borrow().clone())
139}
140
141/// A live secondary window (docs/windows.md). Cheap to clone; inert after close.
142#[derive(Clone)]
143pub struct WindowHandle {
144    root: RNode,
145}
146
147impl WindowHandle {
148    /// Bind command/ambient lookup to retained document state without transferring scope ownership.
149    pub fn set_content_scope(&self, scope: Scope) {
150        WINDOWS.with(|w| {
151            if let Some(r) = w.borrow_mut().iter_mut().find(|r| r.root == self.root) {
152                r.content_scope = Some(scope);
153            }
154        });
155        focus_revision().update(|n| *n += 1);
156    }
157
158    /// Route native user close requests to the document owner. `close()` still closes
159    /// unconditionally after the owner has accepted and removed its content.
160    pub fn on_close_request(&self, f: impl Fn() + 'static) {
161        WINDOWS.with(|w| {
162            if let Some(r) = w.borrow_mut().iter_mut().find(|r| r.root == self.root) {
163                r.close_request = Some(Rc::new(f));
164            }
165        });
166    }
167    /// Observe native activation (including selection of an OS window tab).
168    pub fn on_focus(&self, f: impl Fn() + 'static) {
169        with_tree(|t| {
170            t.on_event(
171                self.root,
172                Rc::new(move |ev| {
173                    if matches!(ev, Event::WindowFocused(true)) {
174                        f();
175                    }
176                }),
177            )
178        });
179    }
180    /// Native order of this window's tab group. Empty where unsupported.
181    pub fn tab_order(&self) -> Vec<NodeId> {
182        with_tree(|t| t.window_tab_order(self.root))
183    }
184
185    /// Close the window. Asynchronous: the platform (or the cover's hide transition)
186    /// confirms, then the content is disposed and `on_close` callbacks run. Focused-window
187    /// lookup stops targeting this window as soon as close is requested. Idempotent.
188    pub fn close(&self) {
189        let root = self.root;
190        let action = WINDOWS.with(|w| {
191            w.borrow()
192                .iter()
193                .find(|r| r.root == root)
194                .map(|r| match &r.tier {
195                    Tier::Native { closing } => {
196                        if closing.replace(true) {
197                            CloseAction::None
198                        } else {
199                            CloseAction::Native
200                        }
201                    }
202                    Tier::PendingNative { .. } => CloseAction::Immediate,
203                    Tier::Cover { cover, closing } => {
204                        if closing.get() {
205                            CloseAction::None
206                        } else {
207                            closing.set(true);
208                            CloseAction::Cover(*cover)
209                        }
210                    }
211                })
212        });
213        focus_revision().update(|n| *n += 1);
214        match action {
215            Some(CloseAction::Native) => with_tree(|t| t.close_native_window(root)),
216            // Parked pending: nothing native to wait for, so tear down now; the backend's
217            // later `finish_window_open` finds the record gone and drops its window.
218            Some(CloseAction::Immediate) => teardown(root),
219            Some(CloseAction::Cover(cover)) => {
220                with_tree(|t| t.patch(cover, Box::new(CoverPatch::Dismiss), false));
221            }
222            Some(CloseAction::None) | None => {}
223        }
224    }
225
226    /// Bring the window to front and make it key. No-op on the cover tier (the cover is
227    /// already frontmost in the primary window) and while a `Pending` open is in flight.
228    pub fn focus(&self) {
229        let root = self.root;
230        if WINDOWS.with(|w| {
231            w.borrow()
232                .iter()
233                .any(|r| r.root == root && !r.closing() && matches!(r.tier, Tier::Native { .. }))
234        }) {
235            // Record explicit activation as well as native notifications. An inactive
236            // AppKit application can accept makeKeyAndOrderFront without sending a key
237            // notification; commands must still follow the window we just selected.
238            WINDOWS.with(|windows| {
239                for window in windows.borrow_mut().iter_mut() {
240                    window.focused = window.root == root;
241                }
242            });
243            focus_revision().update(|n| *n += 1);
244            with_tree(|t| t.focus_native_window(root));
245        }
246    }
247
248    /// Retitle the window. Applies to live native windows; a `Pending` window picks the
249    /// new title up at completion; the cover tier has no title bar (no-op).
250    pub fn set_title(&self, title: &str) {
251        let root = self.root;
252        let title = &crate::decorate_window_title(title);
253        let live = WINDOWS.with(|w| {
254            let mut windows = w.borrow_mut();
255            match windows.iter_mut().find(|r| r.root == root) {
256                Some(r) => match &mut r.tier {
257                    Tier::Native { .. } => true,
258                    Tier::PendingNative { title: t, .. } => {
259                        *t = title.to_string();
260                        false
261                    }
262                    Tier::Cover { .. } => false,
263                },
264                None => false,
265            }
266        });
267        if live {
268            let title = title.to_string();
269            with_tree(|t| t.set_native_window_title(root, &title));
270        }
271    }
272
273    /// A frame source tied to this window, usable outside a piece build.
274    pub fn frame_clock(&self) -> crate::frame::FrameClock {
275        crate::frame::FrameClock::for_root(self.root)
276    }
277
278    /// Whether the window is still open (its record exists; a `Pending` open counts).
279    pub fn is_open(&self) -> bool {
280        let root = self.root;
281        WINDOWS.with(|w| w.borrow().iter().any(|r| r.root == root))
282    }
283
284    /// Run `f` after the window has closed and its content is disposed, on any close path
285    /// (title-bar, platform gesture, [`WindowHandle::close`]). No-op if already closed.
286    pub fn on_close(&self, f: impl Fn() + 'static) {
287        let root = self.root;
288        WINDOWS.with(|w| {
289            if let Some(r) = w.borrow().iter().find(|r| r.root == root) {
290                r.on_close.borrow_mut().push(Box::new(f));
291            }
292        });
293    }
294
295    /// The window's display state, two ways (docs/windows.md "Window properties"): read it to
296    /// follow what the user does (minimize, zoom, fullscreen), write it to ask for a state.
297    ///
298    /// The platform has the last word. A write is a request; the signal settles on whatever the
299    /// window actually became once the platform reports it, so a request the platform declines
300    /// (a fullscreen the browser refuses outside a user gesture) reads back as the state the
301    /// window kept. Probe `Cap::WindowStates` and `Cap::WindowFullscreen` before offering the
302    /// controls. A cover-tier window (`Cap::MultiWindow` = `Unsupported`) stays `Normal`.
303    pub fn state(&self) -> Signal<WindowState> {
304        let root = self.root;
305        WINDOWS
306            .with(|w| w.borrow().iter().find(|r| r.root == root).map(|r| r.state))
307            .unwrap_or_else(|| Signal::new(WindowState::Normal))
308    }
309
310    /// This window's safe-area insets, in points (tracked): what lies under the window's own
311    /// chrome. Nonzero for a [`WindowChrome::Overlay`] window, whose title bar sits over the
312    /// content (docs/window-chrome.md), and on a phone running edge-to-edge. Unlike
313    /// [`crate::safe_area`], which resolves the window being built and otherwise the primary,
314    /// this keeps answering for this window when a binding re-runs later.
315    ///
316    /// [`WindowChrome::Overlay`]: day_spec::WindowChrome::Overlay
317    pub fn safe_area(&self) -> day_geometry::Insets {
318        crate::ambient::window_safe_area(self.root)
319    }
320
321    /// Ask for a display state: `state().set(s)`, spelled as an action.
322    pub fn set_state(&self, state: WindowState) {
323        self.state().set(state);
324    }
325
326    /// Keep this window's content out of screenshots, screen recordings and screen sharing
327    /// (`Cap::ContentProtection`, docs/windows.md): for passwords, payment details, private
328    /// messages. On iOS the content is covered while the screen is recorded or mirrored, but a
329    /// screenshot still captures it.
330    pub fn set_content_protected(&self, protected: bool) {
331        self.apply(WindowChange::ContentProtected(protected));
332    }
333
334    /// Move and/or resize the window (docs/windows.md "Window properties"): `origin` is the outer
335    /// frame's top-left corner on the desktop (`Cap::WindowPosition`), `size` the content size
336    /// (`Cap::WindowGeometry`). `None` leaves that part alone.
337    pub fn set_frame(&self, origin: Option<Point>, size: Option<Size>) {
338        self.apply(WindowChange::Frame { origin, size });
339    }
340
341    /// The window's outer frame on the desktop, in points, where the platform says
342    /// (`None` on a phone, under Wayland, and for a cover-tier window).
343    pub fn frame(&self) -> Option<Rect> {
344        if !self.is_native() {
345            return None;
346        }
347        let root = self.root;
348        with_tree(|t| t.native_window_frame(root))
349    }
350
351    /// The content sizes the user can resize the window between (`Cap::WindowGeometry`).
352    pub fn set_limits(&self, min: Option<Size>, max: Option<Size>) {
353        self.apply(WindowChange::Limits { min, max });
354    }
355
356    /// Whether the user can resize the window (`Cap::WindowGeometry`).
357    pub fn set_resizable(&self, resizable: bool) {
358        self.apply(WindowChange::Resizable(resizable));
359    }
360
361    /// Keep the window above other apps' windows, or let it stack normally (`Cap::WindowLevel`).
362    pub fn set_level(&self, level: WindowLevel) {
363        self.apply(WindowChange::Level(level));
364    }
365
366    /// Show the window on every virtual desktop / Space (`Cap::WindowLevel`).
367    pub fn set_on_all_workspaces(&self, on: bool) {
368        self.apply(WindowChange::OnAllWorkspaces(on));
369    }
370
371    /// Leave the window out of the taskbar and the window switcher (`Cap::WindowLevel`).
372    pub fn set_skip_taskbar(&self, skip: bool) {
373        self.apply(WindowChange::SkipTaskbar(skip));
374    }
375
376    /// Offer or withhold the window's minimize control (`Cap::WindowStates`).
377    pub fn set_minimizable(&self, on: bool) {
378        self.apply(WindowChange::Minimizable(on));
379    }
380
381    /// Offer or withhold the window's maximize / zoom control (`Cap::WindowStates`).
382    pub fn set_maximizable(&self, on: bool) {
383        self.apply(WindowChange::Maximizable(on));
384    }
385
386    /// Offer or withhold the window's close control (`Cap::WindowStates`). The app can still
387    /// close it with [`close`](Self::close).
388    pub fn set_closable(&self, on: bool) {
389        self.apply(WindowChange::Closable(on));
390    }
391
392    /// Hide the window without closing it, or show it again (`Cap::WindowStates`): a palette
393    /// that comes and goes, a window a status item toggles.
394    pub fn set_visible(&self, visible: bool) {
395        self.apply(WindowChange::Visible(visible));
396    }
397
398    /// Give this window its own light (`Some(false)`) or dark (`Some(true)`) appearance, or
399    /// follow the app's again (`None`). `Cap::WindowAppearance`; where unsupported the app-wide
400    /// `day::set_appearance` still applies.
401    pub fn set_appearance(&self, dark: Option<bool>) {
402        self.apply(WindowChange::Appearance(dark));
403    }
404
405    /// Ask for the user's attention: a Dock bounce, a flashing taskbar button
406    /// (`Cap::RequestAttention`). The platform does nothing while the app is already in front.
407    pub fn request_attention(&self, attention: Attention) {
408        self.apply(WindowChange::RequestAttention(attention));
409    }
410
411    /// Hand one change to the toolkit, for a window that has a native side. A cover-tier window
412    /// lives inside the primary one and has nothing of its own to change.
413    fn apply(&self, change: WindowChange) {
414        if self.is_native() {
415            let root = self.root;
416            with_tree(|t| t.apply_window(root, &change));
417        }
418    }
419
420    fn is_native(&self) -> bool {
421        let root = self.root;
422        WINDOWS.with(|w| {
423            w.borrow()
424                .iter()
425                .any(|r| r.root == root && matches!(r.tier, Tier::Native { .. }))
426        })
427    }
428}
429
430enum CloseAction {
431    Native,
432    Immediate,
433    Cover(RNode),
434    None,
435}
436
437/// Open a secondary window (docs/windows.md): `open_window(res::window_options().key("city").size(…), build)`.
438/// `options.key` names the logical window: when a window with this key is already open it is
439/// focused and returned instead of duplicated (the preferences pattern); no key always opens a
440/// new one. `options.kind` says whether it is a regular window or a preferences/About panel.
441/// Where the toolkit cannot open windows (`Cap::MultiWindow` = `Unsupported`; probe it to adapt
442/// chrome) the content presents as a fullscreen cover in the primary window instead, closable
443/// the same way.
444pub fn open_window<P: Piece>(
445    mut options: WindowOptions,
446    build: impl FnOnce() -> P + 'static,
447) -> WindowHandle {
448    let key = options.key.take();
449    let key = key.as_deref();
450    let kind = options.kind;
451    if let Some(k) = key
452        && let Some(existing) = window_by_key(k)
453    {
454        revive_if_dismissing(existing.root);
455        existing.focus();
456        return existing;
457    }
458    // A secondary window carries the same debug tag as the primary: with several windows of
459    // several builds open, that is what tells them apart.
460    options.title = crate::decorate_window_title(&options.title);
461
462    // The window's lifetime is app-owned, not caller-owned: a window opened from a button
463    // must survive that page popping, so its scope hangs off the root scope.
464    let scope = Scope::root().enter(Scope::child);
465
466    match with_tree(|t| t.open_window_root(&options, kind)) {
467        WindowRootReply::Open(root) => {
468            register(
469                root,
470                key,
471                kind,
472                scope,
473                Tier::Native {
474                    closing: Cell::new(false),
475                },
476            );
477            wire_window_events(root);
478            if let Some(k) = &options.remember_frame {
479                remember_frame(root, k);
480            }
481            scope.enter(|| {
482                // Name this window for the duration of its build, so a `toolbar(...)` inside a
483                // shared window builder installs on this window (docs/toolbars.md).
484                crate::toolbar::with_window(root, || {
485                    let piece = build();
486                    let mut cx = BuildCx::new(root);
487                    let _ = piece.build(&mut cx);
488                })
489            });
490            with_tree(|t| {
491                t.mark_layout_dirty();
492                t.layout_if_needed();
493            });
494            if options.size_to_fit {
495                // Measured after the first layout, because that is the only point where the
496                // content's real height is known; it depends on the user's text size, their
497                // language, and which rows the app decided to show.
498                //
499                // The content root has exactly one child (the piece the builder returned), and
500                // its laid-out frame is the content's natural height.
501                let fitted = with_tree(|t| {
502                    t.first_child(root)
503                        .and_then(|c| t.node_frame(c))
504                        .map(|f| f.origin.y + f.size.height)
505                        .unwrap_or(0.0)
506                });
507                // Never grow past what the caller asked for: `size` is the ceiling, so a panel
508                // with more content than fits scrolls rather than running off the screen.
509                if fitted > 0.0 && fitted < options.size.height {
510                    with_tree(|t| {
511                        t.fit_window(root, Size::new(options.size.width, fitted));
512                        t.mark_layout_dirty();
513                        t.layout_if_needed();
514                    });
515                }
516            }
517            focus_revision().update(|n| *n += 1);
518            WindowHandle { root }
519        }
520        WindowRootReply::Pending(root) => {
521            register(
522                root,
523                key,
524                kind,
525                scope,
526                Tier::PendingNative {
527                    build: Some(Box::new(move || AnyPiece::new(build()))),
528                    title: options.title.clone(),
529                },
530            );
531            wire_window_events(root);
532            WindowHandle { root }
533        }
534        WindowRootReply::Unsupported => {
535            open_as_cover(key, kind, scope, Box::new(move || AnyPiece::new(build())))
536        }
537    }
538}
539
540/// The open window registered under `key`, if any (the dayscript `screenshot` step's
541/// `window:` target resolves here). Native windows being closed are excluded; a dismissing
542/// fallback cover remains available so reopening its key can reverse the transition.
543pub fn window_by_key(key: &str) -> Option<WindowHandle> {
544    WINDOWS.with(|w| {
545        w.borrow()
546            .iter()
547            .find(|r| {
548                r.key.as_deref() == Some(key)
549                    && !matches!(&r.tier, Tier::Native { closing } if closing.get())
550            })
551            .map(|r| WindowHandle { root: r.root })
552    })
553}
554
555/// The most recently focused open secondary window, if any. `None` ⇒ the primary window
556/// is key (or no secondary window exists).
557pub fn focused_window() -> Option<WindowHandle> {
558    WINDOWS.with(|w| {
559        w.borrow()
560            .iter()
561            // The same rule as `focused_scope`: a dismissing window is already behind us.
562            .find(|r| r.focused && !r.closing())
563            .map(|r| WindowHandle { root: r.root })
564    })
565}
566
567pub(crate) fn focused_root() -> RNode {
568    focused_window()
569        .map(|w| w.root)
570        .unwrap_or_else(|| with_tree(|t| t.root_node()))
571}
572
573/// The scope owning the content of the window that currently has focus: the scope an
574/// app-wide command should resolve per-window state through (docs/state.md).
575///
576/// Falls back to the app's primary window, which is the same rule [`focused_window`] states:
577/// a backend reports focus for the windows it opened, and "no record is focused" means the
578/// primary is key (on AppKit the primary's delegate carries no node and never emits
579/// `WindowFocused` at all, so that IS the primary's steady state). `None` only before boot
580/// and after the last window is gone.
581pub fn focused_scope() -> Option<Scope> {
582    focus_revision().get();
583    WINDOWS.with(|w| {
584        let windows = w.borrow();
585        // A dismissing window is no longer the command target, even while its content stays
586        // registered until native confirmation. GTK dialogs animate their close just as
587        // cover sheets do; resolving through Settings during that gap loses document state.
588        let focused = windows.iter().find(|r| r.focused && !r.closing());
589        let initial = INITIAL_WINDOW.with(|c| c.get());
590        focused
591            .or_else(|| windows.iter().find(|r| Some(r.root) == initial))
592            .map(|r| r.content_scope.unwrap_or(r.scope))
593    })
594}
595
596/// The scopes a focused-window lookup consults, in order: the focused window's own, then,
597/// when that window is a cover, the primary window's. A cover renders inside the primary
598/// window, so state the cover does not carry — an editor's per-window `Scene` while its
599/// settings cover is up — is still the primary's: a handler of the content underneath that
600/// fires during the presentation (a focus change, a redraw) resolves to the window it belongs
601/// to rather than to a sheet that never had one (docs/state.md).
602pub(crate) fn focused_scope_chain() -> Vec<Scope> {
603    focus_revision().get();
604    WINDOWS.with(|w| {
605        let windows = w.borrow();
606        let initial = INITIAL_WINDOW.with(|c| c.get());
607        let primary = || {
608            windows
609                .iter()
610                .find(|r| Some(r.root) == initial)
611                .map(|r| r.content_scope.unwrap_or(r.scope))
612        };
613        match windows.iter().find(|r| r.focused && !r.closing()) {
614            Some(front) => {
615                let own = front.content_scope.unwrap_or(front.scope);
616                let mut chain = vec![own];
617                if matches!(front.tier, Tier::Cover { .. }) {
618                    chain.extend(primary().filter(|scope| *scope != own));
619                }
620                chain
621            }
622            None => primary().into_iter().collect(),
623        }
624    })
625}
626
627/// The tree root of the window registered under `key`: day-script's snapshot target.
628pub fn window_root_by_key(key: &str) -> Option<RNode> {
629    WINDOWS.with(|w| {
630        w.borrow()
631            .iter()
632            .find(|r| {
633                r.key.as_deref() == Some(key)
634                    && !matches!(&r.tier, Tier::Native { closing } if closing.get())
635            })
636            .map(|r| match &r.tier {
637                // The cover renders inside the primary window: its pixels are the
638                // primary's pixels.
639                Tier::Cover { .. } => with_tree(|t| t.root_node()),
640                _ => r.root,
641            })
642    })
643}
644
645/// Backend-facing: complete a `Pending` window open (docs/windows.md). `id` is the node
646/// the backend's `open_window` received; `raw` is the new window's content container;
647/// `size` its content size in points. `false` ⇒ the window was closed before completion,
648/// and the backend should drop the native window it just created.
649pub fn finish_window_open(id: NodeId, raw: day_spec::RawHandle, size: Size) -> bool {
650    let root = id_to_rnode(id);
651    let pending = WINDOWS.with(|w| {
652        let mut windows = w.borrow_mut();
653        let r = windows.iter_mut().find(|r| r.root == root)?;
654        match &mut r.tier {
655            Tier::PendingNative { build, title } => {
656                let b = build.take();
657                let title = title.clone();
658                r.tier = Tier::Native {
659                    closing: Cell::new(false),
660                };
661                Some((b, title, r.scope))
662            }
663            _ => None,
664        }
665    });
666    let Some((build, title, scope)) = pending else {
667        return false;
668    };
669    if !with_tree(|t| t.adopt_window_root(root, raw, size)) {
670        return false;
671    }
672    with_tree(|t| t.set_native_window_title(root, &title));
673    // This window's size class, before its content builds (docs/size-classes.md): a second
674    // window can sit in a different class from the first, which is why the signal is per-window.
675    crate::ambient::set_window_size_class(
676        root,
677        day_spec::SizeClass::from_size(size.width, size.height),
678    );
679    if let Some(build) = build {
680        scope.enter(|| {
681            crate::toolbar::with_window(root, || {
682                let piece = build();
683                let mut cx = BuildCx::new(root);
684                let _ = piece.build(&mut cx);
685            })
686        });
687    }
688    with_tree(|t| {
689        t.mark_layout_dirty();
690        t.layout_if_needed();
691    });
692    // The window has a native root only now, so a toolbar edit composed before this could not
693    // reach it (docs/toolbars.md). Owe the windows a recompose: the bar goes out whole.
694    crate::toolbar::chrome_changed();
695    focus_revision().update(|n| *n += 1);
696    true
697}
698
699fn register(root: RNode, key: Option<&str>, kind: WindowKind, scope: Scope, tier: Tier) {
700    WINDOWS.with(|w| {
701        let mut windows = w.borrow_mut();
702        // A window that just opened is the key window; every platform orders it front. Seeding
703        // that here rather than waiting for `Event::WindowFocused` is what makes it true for the
704        // first one: the toolkit makes the window key while creating it, which on AppKit fires
705        // `windowDidBecomeKey` before `wire_window_events` has installed a handler to hear it.
706        // Without this, a window opened by File ▸ New Window is never marked focused, and
707        // `focused_scope` (how an app-wide menu command finds the front window's state,
708        // docs/state.md) resolves to the primary until the user clicks away and back.
709        for r in windows.iter_mut() {
710            r.focused = false;
711        }
712        let (state, reported) = state_rail(root, scope);
713        windows.push(WindowRecord {
714            root,
715            key: key.map(str::to_string),
716            kind,
717            role: WindowRole::from(kind),
718            scope,
719            content_scope: None,
720            tier,
721            focused: true,
722            on_close: Rc::default(),
723            close_request: None,
724            state,
725            reported,
726            remember: None,
727        })
728    });
729    focus_revision().update(|n| *n += 1);
730}
731
732/// Start remembering `root`'s frame under `key`, and put the window where it was last time
733/// (docs/windows.md "Remembered frames"). A saved position no attached display shows any more is
734/// dropped; the size is kept.
735fn remember_frame(root: RNode, key: &str) {
736    WINDOWS.with(|w| {
737        if let Some(r) = w.borrow_mut().iter_mut().find(|r| r.root == root) {
738            r.remember = Some(key.to_owned());
739        }
740    });
741    let Some(saved) = crate::frames::load(key) else {
742        return;
743    };
744    let areas: Vec<Rect> = monitors().iter().map(|m| m.work_area).collect();
745    let origin = crate::frames::on_screen(saved.frame, &areas).then_some(saved.frame.origin);
746    let handle = WindowHandle { root };
747    handle.set_frame(origin, Some(saved.frame.size));
748    if saved.maximized {
749        handle.set_state(WindowState::Maximized);
750    }
751}
752
753/// [`save_frame`] for a window already out of the registry (its state signal is still alive:
754/// the scope is disposed after this).
755fn save_frame_of_record(root: RNode, key: &str, state: WindowState) {
756    if matches!(state, WindowState::Minimized | WindowState::Fullscreen) {
757        return;
758    }
759    if let Some(frame) = remembered_frame(root) {
760        crate::frames::save(
761            key,
762            crate::frames::Remembered {
763                frame,
764                maximized: state == WindowState::Maximized,
765            },
766        );
767    }
768}
769
770/// Save the frame of every window that remembers one (window close, app quit).
771pub(crate) fn save_remembered_frames() {
772    let keyed: Vec<(RNode, String)> = WINDOWS
773        .try_with(|w| {
774            w.borrow()
775                .iter()
776                .filter(|r| matches!(r.tier, Tier::Native { .. }))
777                .filter_map(|r| r.remember.clone().map(|k| (r.root, k)))
778                .collect()
779        })
780        .unwrap_or_default();
781    for (root, key) in keyed {
782        save_frame(root, &key);
783    }
784}
785
786fn save_frame(root: RNode, key: &str) {
787    let handle = WindowHandle { root };
788    let state = handle.state().get_untracked();
789    // A minimized or fullscreen window's frame is not one to reopen at; keep the last normal
790    // one and only note the zoom.
791    if matches!(state, WindowState::Minimized | WindowState::Fullscreen) {
792        return;
793    }
794    if let Some(frame) = remembered_frame(root) {
795        crate::frames::save(
796            key,
797            crate::frames::Remembered {
798                frame,
799                maximized: state == WindowState::Maximized,
800            },
801        );
802    }
803}
804
805/// What a remembered frame records: the outer frame's position, and the size Day lays the
806/// content out at, which is the size `WindowChange::Frame` restores. Recording the outer size
807/// would grow the window by its title bar on every launch.
808fn remembered_frame(root: RNode) -> Option<Rect> {
809    let outer = with_tree(|t| t.native_window_frame(root))?;
810    let laid_out = with_tree(|t| t.node_frame(root)).map_or(outer.size, |f| f.size);
811    Some(Rect::new(
812        outer.origin.x,
813        outer.origin.y,
814        laid_out.width,
815        laid_out.height,
816    ))
817}
818
819/// The window's two-way state signal: an app write becomes a [`WindowChange::State`] request,
820/// a platform report (`Event::WindowStateChanged`, handled in `wire_window_events`) is written
821/// into the signal without being sent back. Owned by the window's scope, so it goes with the
822/// window.
823fn state_rail(root: RNode, scope: Scope) -> (Signal<WindowState>, Rc<Cell<WindowState>>) {
824    let reported: Rc<Cell<WindowState>> = Rc::default();
825    let state = scope.enter(|| {
826        let state = Signal::new(WindowState::Normal);
827        let rep = reported.clone();
828        day_reactive::bind_seeded(
829            WindowState::Normal,
830            move || state.get(),
831            move |s| {
832                if *s != rep.get() {
833                    WindowHandle { root }.apply(WindowChange::State(*s));
834                }
835            },
836        );
837        state
838    });
839    (state, reported)
840}
841
842/// The per-window event rail (native + pending tiers): resize relayouts that window,
843/// focus updates the registry, close tears down, deferred one main-loop hop so the
844/// disposal never runs inside the platform's close callback (a released view inside
845/// `windowWillClose`/`close-request`/`closeEvent` is the top reentrancy hazard).
846fn wire_window_events(root: RNode) {
847    with_tree(|t| {
848        t.on_event(
849            root,
850            Rc::new(move |ev| match ev {
851                Event::WindowResized(size) => {
852                    let s = *size;
853                    with_tree(|t| t.set_root_size(root, s));
854                    // …and re-bucket this window (docs/size-classes.md). The primary's rail does
855                    // the same in `launch_with`; without it here a secondary window relayouts at
856                    // its new size but keeps the class it opened at, so dragging the second
857                    // window from narrow to wide never re-presented its navigation. Invisible
858                    // until a window could be resized at all, which on the phones it now can.
859                    crate::ambient::set_window_size_class(
860                        root,
861                        day_spec::SizeClass::from_size(s.width, s.height),
862                    );
863                }
864                Event::WindowFocused(f) => {
865                    let f = *f;
866                    WINDOWS.with(|w| {
867                        for r in w.borrow_mut().iter_mut() {
868                            if r.root == root {
869                                r.focused = f;
870                            } else if f {
871                                r.focused = false;
872                            }
873                        }
874                    });
875                    focus_revision().update(|n| *n += 1);
876                }
877                Event::WindowClosed => {
878                    day_reactive::on_main(move || teardown(root));
879                }
880                Event::WindowStateChanged(s) => {
881                    let s = *s;
882                    let signal = WINDOWS.with(|w| {
883                        w.borrow().iter().find(|r| r.root == root).map(|r| {
884                            r.reported.set(s);
885                            r.state
886                        })
887                    });
888                    if let Some(signal) = signal {
889                        signal.set_if_changed(s);
890                    }
891                }
892                _ => {}
893            }),
894        );
895    });
896}
897
898/// How many non-closing primary windows keep the app alive (docs/windows.md close policy).
899/// This includes the initial window while it remains registered and open.
900pub fn primary_window_count() -> usize {
901    WINDOWS.with(|w| {
902        w.borrow()
903            .iter()
904            .filter(|r| r.role == WindowRole::Primary)
905            .count()
906    })
907}
908
909/// Whether any window is still holding the app open.
910fn app_has_primary_window() -> bool {
911    primary_window_count() > 0
912}
913
914/// Whether closing the last primary window ends the process on this platform.
915///
916/// macOS says no, and means it: an app with no windows stays running with its menu bar live,
917/// and ⌘N reopens one; `applicationShouldTerminateAfterLastWindowClosed` defaults to false
918/// for exactly this. Quitting there would be the framework overriding a platform convention
919/// its users rely on. Every other desktop treats the last window as the app.
920fn last_primary_close_quits() -> bool {
921    match crate::status::keep_running_policy() {
922        // A status item (Automatic) or the app's own choice keeps the process up.
923        _ if crate::status::keeps_running() => false,
924        crate::status::KeepRunning::Never => true,
925        _ => !cfg!(target_os = "macos"),
926    }
927}
928
929/// End the app now (docs/windows.md "Keeping the app running"): the Quit item of a status
930/// item's menu, a background helper's own decision. Every window is disposed and
931/// `Lifecycle::WillTerminate` delivered first, as when the last window closes.
932pub fn quit() {
933    let remaining: Vec<RNode> = WINDOWS.with(|w| w.borrow().iter().map(|r| r.root).collect());
934    for root in remaining {
935        teardown_quietly(root);
936    }
937    crate::lifecycle::dispatch_lifecycle(day_spec::Lifecycle::WillTerminate);
938    with_tree(|t| t.quit_app());
939}
940
941/// [`teardown`] without the "was that the last primary?" check: the caller is ending the app
942/// itself.
943fn teardown_quietly(root: RNode) {
944    QUITTING.with(|q| q.set(true));
945    teardown(root);
946    QUITTING.with(|q| q.set(false));
947}
948
949/// End the app because its last [`WindowRole::Primary`] window has closed.
950///
951/// One place, for every backend: dispose whatever is still open (a settings panel does not
952/// keep an app alive, however long it has been up), deliver `WillTerminate` once, and then
953/// ask the toolkit for the platform's exit.
954fn quit_after_last_primary() {
955    if !last_primary_close_quits() {
956        return;
957    }
958    // Secondary windows go with the app. Collected first: teardown mutates the registry.
959    let remaining: Vec<RNode> = WINDOWS.with(|w| w.borrow().iter().map(|r| r.root).collect());
960    for root in remaining {
961        teardown(root);
962    }
963    crate::lifecycle::dispatch_lifecycle(day_spec::Lifecycle::WillTerminate);
964    with_tree(|t| t.quit_app());
965}
966
967/// The single teardown path (any close route lands here). Idempotent via registry
968/// membership: registry-remove → scope dispose → content removal → root removal →
969/// `on_close` callbacks.
970fn teardown(root: RNode) {
971    let Some(record) = WINDOWS.with(|w| {
972        let mut windows = w.borrow_mut();
973        windows
974            .iter()
975            .position(|r| r.root == root)
976            .map(|i| windows.remove(i))
977    }) else {
978        return;
979    };
980    focus_revision().update(|n| *n += 1);
981    // The toolbar forgets the window FIRST: its contributions' cleanups run inside the
982    // dispose below and would otherwise re-compose a bar through gates whose signals the same
983    // dispose has already dropped (a contained panic that left the next New Window blank).
984    // The frame, while the native window still answers for it.
985    if let (Some(key), Tier::Native { .. }) = (&record.remember, &record.tier) {
986        save_frame_of_record(root, key, record.state.get_untracked());
987    }
988    crate::frame::forget_window(root);
989    crate::toolbar::forget_window(root);
990    record.scope.dispose();
991    crate::ambient::forget_window(root);
992    match record.tier {
993        Tier::Native { .. } | Tier::PendingNative { .. } => {
994            while let Some(c) = with_tree(|t| t.first_child(root)) {
995                with_tree(|t| t.remove_subtree(c));
996            }
997            with_tree(|t| t.remove_window_root(root));
998        }
999        Tier::Cover { cover, .. } => {
1000            // The cover node lives under the primary root: drop its independent layout
1001            // entry, then the ordinary subtree removal takes its content with it.
1002            with_tree(|t| {
1003                t.drop_extra_layout_root(cover);
1004                t.remove_subtree(cover);
1005            });
1006        }
1007    }
1008    with_tree(|t| {
1009        t.mark_layout_dirty();
1010        t.layout_if_needed();
1011    });
1012    for f in record.on_close.borrow().iter() {
1013        f();
1014    }
1015    // The app's life is the life of its primary windows, not of the first one opened. Checked
1016    // after the callbacks so a handler that opens a replacement window is counted.
1017    if record.role == WindowRole::Primary
1018        && !app_has_primary_window()
1019        && !QUITTING.with(|q| q.get())
1020    {
1021        quit_after_last_primary();
1022    }
1023}
1024
1025/// Reverse a cover dismissal that has not yet been confirmed. Close is asynchronous on this
1026/// tier (the hide transition runs for a quarter second, and the record stays registered until
1027/// `CoverHidden` comes back), so a keyed window reopened inside that window is the same window
1028/// arriving again, not a second one. Without this the reopen returns a handle to content the
1029/// pending confirmation is about to dispose, and the surface the caller asked for goes blank
1030/// (docs/windows.md). Clearing `closing` is also what makes that confirmation a no-op when it
1031/// lands: the cover's handler already gates on it.
1032fn revive_if_dismissing(root: RNode) {
1033    let cover = WINDOWS.with(|w| {
1034        w.borrow()
1035            .iter()
1036            .find(|r| r.root == root)
1037            .and_then(|r| match &r.tier {
1038                Tier::Cover { cover, closing } if closing.get() => {
1039                    closing.set(false);
1040                    Some(*cover)
1041                }
1042                _ => None,
1043            })
1044    });
1045    let Some(cover) = cover else { return };
1046    with_tree(|t| {
1047        t.patch(
1048            cover,
1049            Box::new(CoverPatch::Present {
1050                background: None,
1051                dismiss_disabled: false,
1052            }),
1053            false,
1054        )
1055    });
1056}
1057
1058/// The fallback tier: present window content modally in the primary window. Preferences
1059/// prefer a sheet on regular-width windows and remain fullscreen on compact windows.
1060/// NavBack dismisses; `Event::CoverHidden` confirms (docs/cover.md).
1061fn open_as_cover(
1062    key: Option<&str>,
1063    kind: WindowKind,
1064    scope: Scope,
1065    build: Box<dyn FnOnce() -> AnyPiece>,
1066) -> WindowHandle {
1067    let size: Rc<RefCell<Option<Size>>> = Rc::default();
1068    let primary = with_tree(|t| t.root_node());
1069    // A settings or About panel is a sheet where there is room for one; content windows cover.
1070    let sheet = matches!(kind, WindowKind::Preferences | WindowKind::About)
1071        && crate::window_size_class_untracked(primary)
1072            .is_some_and(|size| size.width != day_spec::WidthClass::Compact);
1073    let composed_sheet =
1074        sheet && crate::capability(day_spec::Cap::CoverSheet) == day_spec::Support::Unsupported;
1075    let cover = {
1076        let mut cx = BuildCx::new(primary);
1077        cx.native(
1078            kinds::COVER,
1079            &CoverProps { sheet, half: false },
1080            Rc::new(crate::CoverLayout { size: size.clone() }),
1081            crate::Flex::default(),
1082            Boundary::Yes,
1083        )
1084    };
1085    with_tree(|t| t.add_extra_layout_root(cover, Size::new(0.0, 0.0)));
1086    let closing: Rc<Cell<bool>> = Rc::default();
1087    // The window root is the cover node on this tier: one id for close/teardown.
1088    register(
1089        cover,
1090        key,
1091        kind,
1092        scope,
1093        Tier::Cover {
1094            cover,
1095            closing: closing.clone(),
1096        },
1097    );
1098
1099    {
1100        let (size, closing) = (size.clone(), closing.clone());
1101        with_tree(|t| {
1102            t.on_event(
1103                cover,
1104                Rc::new(move |ev| match ev {
1105                    Event::FrameChanged(sz) => {
1106                        if *size.borrow() != Some(*sz) {
1107                            *size.borrow_mut() = Some(*sz);
1108                            with_tree(|t| {
1109                                t.set_root_size(cover, *sz);
1110                                t.mark_layout_dirty();
1111                                t.layout_if_needed();
1112                            });
1113                        }
1114                    }
1115                    // Native dismissal request (Android system back): a fallback "window"
1116                    // closes like a window, not like a page.
1117                    Event::NavBack { .. } => {
1118                        if !closing.get() {
1119                            closing.set(true);
1120                            with_tree(|t| t.patch(cover, Box::new(CoverPatch::Dismiss), false));
1121                        }
1122                    }
1123                    // The hide transition finished; now the content can go.
1124                    Event::CoverHidden if closing.get() => {
1125                        day_reactive::on_main(move || teardown(cover));
1126                    }
1127                    _ => {}
1128                }),
1129            );
1130        });
1131    }
1132
1133    scope.enter(|| {
1134        crate::toolbar::with_window(cover, || {
1135            let piece = build();
1136            let mut cx = BuildCx::new(cover);
1137            if composed_sheet {
1138                let _ = crate::ComposedSheet { content: piece }.build(&mut cx);
1139            } else {
1140                let _ = piece.build(&mut cx);
1141            }
1142        })
1143    });
1144    with_tree(|t| {
1145        t.patch(
1146            cover,
1147            Box::new(CoverPatch::Present {
1148                background: composed_sheet.then_some(day_spec::Color::CLEAR),
1149                dismiss_disabled: false,
1150            }),
1151            false,
1152        );
1153        t.mark_needs_measure(cover);
1154        t.mark_layout_dirty();
1155        t.layout_if_needed();
1156    });
1157    focus_revision().update(|n| *n += 1);
1158    WindowHandle { root: cover }
1159}
1160
1161// ---------------------------------------------------------------------------
1162// Preferences + New Window registration (docs/windows.md)
1163// ---------------------------------------------------------------------------
1164
1165/// The registered preferences piece: its builder plus the window options it opens with.
1166type PrefsRegistration = (Rc<dyn Fn() -> AnyPiece>, WindowOptions);
1167
1168/// The singleton key every preferences window opens under.
1169pub const PREFERENCES_KEY: &str = "day.preferences";
1170
1171/// Declare the app's preferences piece (docs/windows.md): once, in `root()`, ideally
1172/// before `app_menu`. Enables the desktop Preferences window (singleton, primary+`,`),
1173/// the auto Settings…/Preferences menu item, and [`open_preferences`] everywhere (cover
1174/// fallback where the toolkit cannot open windows). The window titles itself with
1175/// `options.title`; use [`register_preferences_with`] to localize it or change the size.
1176pub fn register_preferences<P: Piece>(build: impl Fn() -> P + 'static) {
1177    register_preferences_with(
1178        WindowOptions {
1179            title: day_localization::t(day_localization::key::day_settings),
1180            version: None,
1181            // The width a settings panel wants, and a ceiling for the height rather than the
1182            // height itself: `size_to_fit` shrinks the window to whatever the rows actually
1183            // measure. A fixed 640 either clips the last row or leaves a band of empty panel
1184            // under it, and which one depends on the user's text size, so nobody can pick a
1185            // number that is right for everyone.
1186            size: Size::new(520.0, 640.0),
1187            min_size: None,
1188            size_to_fit: true,
1189            app_name: None,
1190            // Secondary windows: the app-launch ceremony belongs to `launch` alone.
1191            locales: None,
1192            title_fn: None,
1193            // A settings panel never becomes a tab; `WindowKind::Preferences` says so already.
1194            tabbing: WindowTabbing::Automatic,
1195            ..WindowOptions::default()
1196        },
1197        build,
1198    );
1199}
1200
1201/// [`register_preferences`] with explicit window options (localized title, size).
1202pub fn register_preferences_with<P: Piece>(
1203    options: WindowOptions,
1204    build: impl Fn() -> P + 'static,
1205) {
1206    PREFS.with(|p| *p.borrow_mut() = Some((Rc::new(move || AnyPiece::new(build())), options)));
1207    if PREFS_ACTION.with(|c| c.get()) == 0 {
1208        PREFS_ACTION.with(|c| {
1209            c.set(crate::menu::register_menu_action(Rc::new(|| {
1210                open_preferences();
1211            })))
1212        });
1213    }
1214    // Self-heal for registration after `app_menu`: re-forward the retained model so the
1215    // injection pass sees the now-registered action.
1216    crate::menu::reinstall_app_menu();
1217}
1218
1219/// Open-or-focus the preferences surface (docs/windows.md): a `WindowKind::Preferences`
1220/// singleton window on desktop, the cover fallback elsewhere: one call for menu items and
1221/// toolbar gears alike. `false` = no preferences piece is registered (logged).
1222pub fn open_preferences() -> bool {
1223    let Some((build, options)) = PREFS.with(|p| p.borrow().clone()) else {
1224        log::warn!("open_preferences without register_preferences — ignored");
1225        return false;
1226    };
1227    open_window(
1228        options.key(PREFERENCES_KEY).kind(WindowKind::Preferences),
1229        move || build(),
1230    );
1231    true
1232}
1233
1234// ---------------------------------------------------------------------------
1235// About + Check for Updates registration (docs/settings.md)
1236// ---------------------------------------------------------------------------
1237
1238/// The singleton key the About window opens under.
1239pub const ABOUT_KEY: &str = "day.about";
1240
1241/// Declare what the app's About command does (docs/settings.md): once, in `root()`. Enables
1242/// the About menu item everywhere a desktop has one (the macOS App menu, Help elsewhere; both
1243/// the default menu and an installed `app_menu`) and [`open_about`]. The action is the whole
1244/// contract, so a toolkit-native About dialog (libadwaita's) and a Day window
1245/// ([`register_about_window`]) register the same way.
1246pub fn register_about(open: impl Fn() + 'static) {
1247    ABOUT.with(|a| *a.borrow_mut() = Some(Rc::new(open)));
1248    if ABOUT_ACTION.with(|c| c.get()) == 0 {
1249        ABOUT_ACTION.with(|c| {
1250            c.set(crate::menu::register_menu_action(Rc::new(|| {
1251                open_about();
1252            })))
1253        });
1254    }
1255    crate::menu::reinstall_app_menu();
1256}
1257
1258/// [`register_about`] with a Day-rendered About window: opened under the singleton key
1259/// [`ABOUT_KEY`] as a `WindowKind::About` panel (fixed, centered, out of the Window menu),
1260/// the cover fallback where the toolkit cannot open windows. The window titles itself with
1261/// `options.title`.
1262pub fn register_about_window<P: Piece>(options: WindowOptions, build: impl Fn() -> P + 'static) {
1263    let build: Rc<dyn Fn() -> AnyPiece> = Rc::new(move || AnyPiece::new(build()));
1264    register_about(move || {
1265        let build = build.clone();
1266        open_window(
1267            options.clone().key(ABOUT_KEY).kind(WindowKind::About),
1268            move || build(),
1269        );
1270    });
1271}
1272
1273/// Run the registered About action (docs/settings.md): the menu item's dispatch, and what a
1274/// toolbar button or a settings row calls. `false` = nothing registered (logged).
1275pub fn open_about() -> bool {
1276    let Some(open) = ABOUT.with(|a| a.borrow().clone()) else {
1277        log::warn!("open_about without register_about — ignored");
1278        return false;
1279    };
1280    open();
1281    true
1282}
1283
1284/// The dispatch id of the About menu action (0 = unregistered). Backends wire their stock
1285/// About item to it; `MenuRole::About` lowering and the injection pass use it.
1286pub fn about_action_id() -> u64 {
1287    ABOUT_ACTION.with(|c| c.get())
1288}
1289
1290/// Declare the updater's Check for Updates… command (docs/settings.md): the item appears
1291/// under About in the macOS App menu and in Help elsewhere, and `MenuRole::CheckForUpdates`
1292/// items lower to it. Registered by an update provider, not by the app.
1293pub fn register_check_for_updates(check: impl Fn() + 'static) {
1294    UPDATES.with(|u| *u.borrow_mut() = Some(Rc::new(check)));
1295    if UPDATES_ACTION.with(|c| c.get()) == 0 {
1296        UPDATES_ACTION.with(|c| {
1297            c.set(crate::menu::register_menu_action(Rc::new(|| {
1298                check_for_updates();
1299            })))
1300        });
1301    }
1302    crate::menu::reinstall_app_menu();
1303}
1304
1305/// Run the registered Check for Updates… command. `false` = none registered.
1306pub fn check_for_updates() -> bool {
1307    let Some(check) = UPDATES.with(|u| u.borrow().clone()) else {
1308        return false;
1309    };
1310    check();
1311    true
1312}
1313
1314/// The dispatch id of the Check for Updates… action (0 = unregistered).
1315pub fn check_for_updates_action_id() -> u64 {
1316    UPDATES_ACTION.with(|c| c.get())
1317}
1318
1319/// Register the builder behind File ▸ New Window and the macOS tab-bar "+" (docs/windows.md):
1320/// each call opens another `WindowKind::Normal` window. A `menu_role(MenuRole::NewWindow)`
1321/// item lowers to this action; without a registration it lowers disabled.
1322pub fn register_new_window<P: Piece>(build: impl Fn() -> P + 'static) {
1323    NEW_WINDOW.with(|p| *p.borrow_mut() = Some(Rc::new(move || AnyPiece::new(build()))));
1324    if NEW_WINDOW_ACTION.with(|c| c.get()) == 0 {
1325        NEW_WINDOW_ACTION.with(|c| {
1326            c.set(crate::menu::register_menu_action(Rc::new(|| {
1327                open_new_window();
1328            })))
1329        });
1330    }
1331    crate::menu::reinstall_app_menu();
1332}
1333
1334/// Open a window through the registered new-window builder (the `newWindowForTab:` /
1335/// File ▸ New Window path). `None` = no builder registered.
1336pub fn open_new_window() -> Option<WindowHandle> {
1337    let build = NEW_WINDOW.with(|p| p.borrow().clone())?;
1338    // Another window of this app, so it describes itself the way the app described its first
1339    // one: same title, same minimum size, same display name. A window with no title is missing
1340    // from the macOS Window menu and shows a blank tab, so inheriting is what makes File ▸ New
1341    // Window produce something the platform can manage (docs/windows.md). Content that wants a
1342    // title of its own says so with `window_title` from inside the window.
1343    let launch = LAUNCH_OPTIONS.with(|o| o.borrow().clone());
1344    // Size mirrors the primary's current content size so a "duplicate window" lands familiar.
1345    let size = with_tree(|t| {
1346        let root = t.root_node();
1347        t.node_frame(root).map(|f| f.size)
1348    })
1349    .unwrap_or(Size::new(800.0, 600.0));
1350    Some(open_window(
1351        WindowOptions {
1352            title: launch.as_ref().map(|o| o.title.clone()).unwrap_or_default(),
1353            size,
1354            min_size: launch.as_ref().and_then(|o| o.min_size),
1355            size_to_fit: false,
1356            app_name: launch.as_ref().and_then(|o| o.app_name.clone()),
1357            version: launch.as_ref().and_then(|o| o.version.clone()),
1358            // Secondary windows: the app-launch ceremony belongs to `launch` alone.
1359            locales: None,
1360            // Already resolved into `title` above; calling it again would re-run app code
1361            // outside the launch sequence it was written for.
1362            title_fn: None,
1363            tabbing: WindowTabbing::Automatic,
1364            ..WindowOptions::default()
1365        },
1366        move || build(),
1367    ))
1368}
1369
1370/// Register the builder behind the macOS tab bar's "+" for windows whose
1371/// [`WindowOptions::tabbing`] names `group` (docs/windows.md): each call opens another window of
1372/// that group, as a tab of the window the user clicked "+" in. Groups without a registration
1373/// fall back to [`register_new_window`]'s builder.
1374pub fn register_new_window_for<P: Piece>(group: &str, build: impl Fn() -> P + 'static) {
1375    let build: Rc<dyn Fn() -> AnyPiece> = Rc::new(move || AnyPiece::new(build()));
1376    GROUP_WINDOWS.with(|g| {
1377        let mut groups = g.borrow_mut();
1378        groups.retain(|(name, _)| name != group);
1379        groups.push((group.to_string(), build));
1380    });
1381}
1382
1383/// Open another window of tab group `group` (the backend's `newWindowForTab:` path for a window
1384/// whose tabbing names a group). Uses the group's registered builder, else the app's New Window
1385/// builder; `None` when neither exists.
1386pub fn open_new_window_for_group(group: &str) -> Option<WindowHandle> {
1387    let action = TAB_NEW.with(|g| {
1388        g.borrow()
1389            .iter()
1390            .find(|(name, _)| name == group)
1391            .map(|(_, f)| f.clone())
1392    });
1393    if action.is_some() {
1394        let group = group.to_string();
1395        day_reactive::on_main(move || {
1396            let action = TAB_NEW.with(|g| {
1397                g.borrow()
1398                    .iter()
1399                    .find(|(name, _)| name == &group)
1400                    .map(|(_, f)| f.clone())
1401            });
1402            if let Some(f) = action {
1403                f();
1404            }
1405        });
1406        return None;
1407    }
1408
1409    let Some(build) = GROUP_WINDOWS.with(|g| {
1410        g.borrow()
1411            .iter()
1412            .find(|(name, _)| name == group)
1413            .map(|(_, b)| b.clone())
1414    }) else {
1415        return open_new_window();
1416    };
1417    let launch = LAUNCH_OPTIONS.with(|o| o.borrow().clone());
1418    let size = with_tree(|t| {
1419        let root = t.root_node();
1420        t.node_frame(root).map(|f| f.size)
1421    })
1422    .unwrap_or(Size::new(800.0, 600.0));
1423    Some(open_window(
1424        WindowOptions {
1425            title: launch.as_ref().map(|o| o.title.clone()).unwrap_or_default(),
1426            size,
1427            min_size: launch.as_ref().and_then(|o| o.min_size),
1428            app_name: launch.as_ref().and_then(|o| o.app_name.clone()),
1429            version: launch.as_ref().and_then(|o| o.version.clone()),
1430            tabbing: WindowTabbing::Group(group.to_string()),
1431            ..WindowOptions::default()
1432        },
1433        move || build(),
1434    ))
1435}
1436
1437/// The window the caller belongs to (docs/windows.md): the one whose content is being built
1438/// right now, else the focused window, else the app's first window. What a piece uses to change
1439/// "its" window (`current_window()?.set_content_protected(true)`) without threading a handle
1440/// through. `None` before boot.
1441pub fn current_window() -> Option<WindowHandle> {
1442    let building = crate::toolbar::window_being_built();
1443    let known = WINDOWS.with(|w| w.borrow().iter().any(|r| r.root == building));
1444    if known {
1445        return Some(WindowHandle { root: building });
1446    }
1447    focused_window().or_else(initial_window)
1448}
1449
1450/// Bind the title of the window this piece is building into to a reactive closure
1451/// (docs/windows.md). This is how a window comes to be named after what it shows.
1452///
1453/// The window-level counterpart to a navigation title. It matters more than it looks: the macOS
1454/// Window menu, the tab bar, Mission Control, the iPad app switcher and the Android recents card
1455/// all label a window by its title, so two windows that share one title are two windows the user
1456/// cannot tell apart anywhere the system lists them.
1457///
1458/// ```ignore
1459/// // inside a window's shell, so each window titles itself:
1460/// day::window_title(move || match scene.selected.get() {
1461///     Some(id) => scene.name_of(id),
1462///     None => app_title(),
1463/// });
1464/// ```
1465///
1466/// Reactive like any binding: the title follows what the closure reads. Which window it targets
1467/// is resolved once, here, for the same reason `toolbar_reactive` captures it: the binding
1468/// re-runs long after this build, when "the window being built" is no longer this one.
1469pub fn window_title(f: impl Fn() -> String + 'static) {
1470    let root = crate::toolbar::window_being_built();
1471    day_reactive::bind(f, move |title| {
1472        let title = crate::decorate_window_title(title);
1473        with_tree(|t| t.set_native_window_title(root, &title));
1474    });
1475}
1476
1477/// The dispatch id of the auto Preferences menu action (0 = unregistered). Backends use it
1478/// to wire their default-menu Settings item; the injection pass uses it for `app_menu`.
1479pub fn preferences_action_id() -> u64 {
1480    PREFS_ACTION.with(|c| c.get())
1481}
1482
1483/// The dispatch id of the New Window action (0 = unregistered), used by `MenuRole::NewWindow`
1484/// lowering and the backends' tab-bar "+" wiring.
1485pub fn new_window_action_id() -> u64 {
1486    NEW_WINDOW_ACTION.with(|c| c.get())
1487}
1488
1489/// Test/diagnostic surface: the number of open secondary windows.
1490pub fn open_window_count() -> usize {
1491    WINDOWS.with(|w| w.borrow().len())
1492}
1493
1494/// The `WindowKind` of the open window at `root`, for backends and tests.
1495pub fn window_kind_of(handle: &WindowHandle) -> Option<WindowKind> {
1496    WINDOWS.with(|w| {
1497        w.borrow()
1498            .iter()
1499            .find(|r| r.root == handle.root)
1500            .map(|r| r.kind)
1501    })
1502}
1503
1504/// Reset the registry + registrations (tests; pairs with `uninstall_tree`).
1505pub fn reset_windows() {
1506    // Does not dispose each record's content scope, which is a change that was
1507    // made and then reverted after it broke navigation (docs/settings.md "Appearance on Android").
1508    //
1509    // Disposing looks right: the reactive graph a window built otherwise outlives it, and on an
1510    // Android re-mount that showed up as the previous window's navigation host re-registering
1511    // over the new one's ("two routed one-of-N surfaces … at the same navigation level"). But
1512    // the disposal cascade runs app cleanup that reads signals the same cascade has already
1513    // disposed; the panic is contained, the disposal stops half-way, and the rebuild that
1514    // follows registers no routes at all. The symptom is an app whose tab bar draws and whose
1515    // tabs do nothing.
1516    //
1517    // Leaking the old graph is the lesser fault: it warns, and the app works. Disposing safely
1518    // needs the cascade to tear down in dependency order, which is its own piece of work.
1519    WINDOWS.with(|w| w.borrow_mut().clear());
1520    INITIAL_WINDOW.with(|c| c.set(None));
1521    crate::toolbar::reset_toolbars();
1522    PREFS.with(|p| *p.borrow_mut() = None);
1523    ABOUT.with(|a| *a.borrow_mut() = None);
1524    UPDATES.with(|u| *u.borrow_mut() = None);
1525    NEW_WINDOW.with(|p| *p.borrow_mut() = None);
1526    GROUP_WINDOWS.with(|g| g.borrow_mut().clear());
1527    // Action ids stay registered (the closures are inert without a builder); that is cheap, and
1528    // re-registration reuses them.
1529}
1530
1531/// Adopt the app's first window into the registry, so it is an ordinary primary window rather
1532/// than a privileged one (docs/windows.md close policy).
1533///
1534/// Called once at boot with the root container the backend handed back. `scope` owns the root
1535/// content, so closing this window disposes exactly its tree and nothing else, which is
1536/// why the caller builds that content in a child of the root scope rather than in the root
1537/// scope itself. State an app wants to outlive its windows still does: `Signal::global` lives
1538/// on the root scope, above this one.
1539pub fn adopt_initial_window(root: RNode, scope: Scope) {
1540    register(
1541        root,
1542        None,
1543        WindowKind::Normal,
1544        scope,
1545        Tier::Native {
1546            closing: Cell::new(false),
1547        },
1548    );
1549    wire_window_events(root);
1550    INITIAL_WINDOW.with(|c| c.set(Some(root)));
1551    let key = LAUNCH_OPTIONS.with(|o| o.borrow().as_ref().and_then(|o| o.remember_frame.clone()));
1552    if let Some(key) = key {
1553        remember_frame(root, &key);
1554    }
1555}
1556
1557/// The displays attached now (docs/windows.md "Monitors"). A toolkit that cannot list them
1558/// (`Cap::Monitors` unsupported) answers with one display the size of the primary window, so a
1559/// placement computed from the answer stays on screen.
1560pub fn monitors() -> Vec<day_spec::Monitor> {
1561    let listed = with_tree(|t| t.monitors());
1562    if !listed.is_empty() {
1563        return listed;
1564    }
1565    let size = with_tree(|t| {
1566        let root = t.root_node();
1567        t.node_frame(root).map(|f| f.size)
1568    })
1569    .unwrap_or(Size::new(0.0, 0.0));
1570    let frame = Rect::new(0.0, 0.0, size.width, size.height);
1571    vec![day_spec::Monitor {
1572        id: "0".into(),
1573        name: String::new(),
1574        frame,
1575        work_area: frame,
1576        scale: 1.0,
1577        primary: true,
1578    }]
1579}
1580
1581/// A handle to the app's first window, once adopted. `None` before boot completes, and after
1582/// that window closes; it is an ordinary window and can be closed like any other.
1583pub fn initial_window() -> Option<WindowHandle> {
1584    let root = INITIAL_WINDOW.with(|c| c.get())?;
1585    WINDOWS.with(|w| {
1586        w.borrow()
1587            .iter()
1588            .any(|r| r.root == root)
1589            .then_some(WindowHandle { root })
1590    })
1591}
1592
1593/// The window root's spec-boundary id (backends key their per-window maps by it).
1594pub fn window_node_id(handle: &WindowHandle) -> NodeId {
1595    rnode_to_id(handle.root)
1596}
1597
1598/// Whether this backend can group actual OS windows as tabs.
1599pub fn native_window_tabs() -> bool {
1600    with_tree(|t| t.native_window_tabs())
1601}
1602
1603/// Explicit grouping, independent of the user's automatic-tabbing preference.
1604pub fn group_windows(windows: &[WindowHandle]) {
1605    with_tree(|t| t.group_windows(&windows.iter().map(|w| w.root).collect::<Vec<_>>()));
1606}
1607
1608/// Native close delegate hook. Queue document code outside the toolkit callback.
1609/// Returns false when the owner will decide whether to close.
1610pub fn native_close_requested(id: NodeId) -> bool {
1611    let handler = WINDOWS.with(|w| {
1612        w.borrow()
1613            .iter()
1614            .find(|r| r.root == id_to_rnode(id))
1615            .and_then(|r| r.close_request.clone())
1616    });
1617    if handler.is_some() {
1618        day_reactive::on_main(move || {
1619            let handler = WINDOWS.with(|w| {
1620                w.borrow()
1621                    .iter()
1622                    .find(|r| r.root == id_to_rnode(id))
1623                    .and_then(|r| r.close_request.clone())
1624            });
1625            if let Some(f) = handler {
1626                f();
1627            }
1628        });
1629        false
1630    } else {
1631        true
1632    }
1633}
1634
1635/// Route the native group add button to a document collection rather than a window builder.
1636/// Registration belongs to the current scope.
1637pub fn register_tab_new(group: String, action: Rc<dyn Fn()>) {
1638    TAB_NEW.with(|g| g.borrow_mut().push((group.clone(), action)));
1639    Scope::current()
1640        .on_cleanup(move || TAB_NEW.with(|g| g.borrow_mut().retain(|(name, _)| *name != group)));
1641}