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}