Skip to main content

teksilo_core/window/
config.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Configuration for creating a new window.
5//!
6//! Consumed by either the app builder's "initial window" at startup or
7//! [`EventContext::open_window`](crate::widget::EventContext) from
8//! handler code. The two paths share the same config and produce the
9//! same windows — there is no "initial vs runtime" split.
10
11use std::rc::Rc;
12
13use crate::signal::Prop;
14use crate::widget::EventContext;
15use crate::widget_id::WidgetId;
16use crate::widget_tree::WidgetTree;
17
18use super::decorations::DecorationsMode;
19use super::icon::WindowIcon;
20use super::id::TeksiloWindowId;
21use super::placement::WindowPlacement;
22use super::state::WindowState;
23
24/// Parent + focus wiring for a modal window.
25///
26/// Modal is an `Option<ModalConfig>` on [`WindowConfig`]; the type
27/// system enforces that a modal always names a parent, something a
28/// `modal: bool` + `parent: Option<...>` split could not express.
29#[derive(Debug, Clone)]
30pub struct ModalConfig {
31    /// Window whose input is blocked while this modal is open. Also
32    /// the window the modal is transient for (Z-order parent on every
33    /// OS).
34    pub parent: TeksiloWindowId,
35    /// Explicit initial-focus target inside the modal's root subtree.
36    /// When `None` the framework falls back to the root widget's
37    /// `initial_focus_hint`, then `first_focusable_descendant`.
38    pub focus_target: Option<WidgetId>,
39}
40
41/// Signature of a window's root-builder closure.
42///
43/// Receives a mutable [`WidgetTree`] and a cloned [`WindowState`] so
44/// the builder can register widgets that bind against window-level
45/// signals (placement, title, size, …).
46pub type RootBuilder = Box<dyn FnOnce(&mut WidgetTree, WindowState) -> WidgetId>;
47
48/// Per-window post-root hook. Runs after the user's `root_builder`
49/// returns, with the resulting `WidgetId`. The hook may wrap the user
50/// root in another widget and return the wrapper's id, or simply return
51/// the original id unchanged. Used by the debug inspector to splice an
52/// inspector shell around every window's root in debug builds.
53pub type PostRootBuilder = Box<dyn FnOnce(&mut WidgetTree, WidgetId) -> WidgetId>;
54
55/// Verdict returned by a window's [close guard](WindowConfig::on_close_requested)
56/// when the user (or the app) asks to close the window.
57///
58/// The guard runs *before* the window's tree is torn down. Returning
59/// [`Veto`](CloseResponse::Veto) cancels that one close attempt and
60/// leaves the window open — the idiomatic place to pop a
61/// "you have unsaved changes" confirmation, then re-issue the close via
62/// [`EventContext::close_window_forced`](crate::widget::EventContext::close_window_forced)
63/// once the user confirms.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum CloseResponse {
66    /// Proceed with closing the window.
67    Close,
68    /// Cancel this close attempt; the window stays open.
69    Veto,
70}
71
72/// Signature of a window's close-request guard.
73///
74/// Invoked with a real [`EventContext`] for the window's own tree, so
75/// the guard can show a confirmation dialog, open a modal child, set
76/// signals, or fire intents before deciding. It is consulted on every
77/// user-initiated close attempt (OS close button / `Alt+F4` / `Cmd+W`,
78/// a custom-chrome close button, and
79/// [`EventContext::close_window`](crate::widget::EventContext::close_window))
80/// and may run many times over a window's lifetime, so it is an `Fn`,
81/// not an `FnOnce`.
82///
83/// It is **not** consulted for a
84/// [`close_window_forced`](crate::widget::EventContext::close_window_forced),
85/// nor for framework-internal teardown (modal cleanup, the last-window
86/// shutdown drain).
87pub type CloseGuard = Rc<dyn Fn(&mut EventContext) -> CloseResponse>;
88
89/// Signature of the [`on_close_blocked`](WindowConfig::on_close_blocked)
90/// callback — the `Fn`-shaped notification fired when the
91/// [`can_close`](WindowConfig::can_close) sugar signal vetoes a close.
92/// Runs with the window's [`EventContext`] so it can present the
93/// confirmation UI.
94pub type CloseBlockedCallback = Rc<dyn Fn(&mut EventContext)>;
95
96/// Snapshot handed to a [`WindowConfig::on_removed`] callback once its
97/// window has finished tearing down.
98///
99/// There is no [`EventContext`] here, unlike [`CloseGuard`] /
100/// [`CloseBlockedCallback`]: those run *before* teardown, while the
101/// window's own tree is still alive to build a context from; `on_removed`
102/// runs *after* — the tree, platform window, and every framework
103/// registry entry for this window are already gone (see `on_removed`'s
104/// doc comment for exactly where in teardown it fires).
105#[derive(Debug, Clone)]
106pub struct WindowRemovedEvent {
107    /// Identity of the window that was just removed. Redundant with
108    /// whatever the closure already captured — a `WindowConfig` callback
109    /// is inherently per-window — but useful when one closure is shared
110    /// across several windows (e.g. `Rc<dyn Fn>` cloned onto every window
111    /// opened for the same document).
112    pub id: TeksiloWindowId,
113    /// The window's `string_id`, if it had one (persistence key / stable
114    /// handle apps use to correlate a window with their own bookkeeping).
115    pub string_id: Option<String>,
116    /// How many windows remain across the whole app, counted AFTER this
117    /// one's removal. `0` means this was the last window standing. The
118    /// framework has no notion of "this app's Work/document" grouping —
119    /// an app that needs a *scoped* last-window answer (e.g. "last window
120    /// for this particular Work") combines this fact with its own
121    /// window-to-Work bookkeeping; this field only answers "last window,
122    /// full stop".
123    pub remaining_windows: usize,
124}
125
126/// Signature of the [`on_removed`](WindowConfig::on_removed) callback —
127/// the framework's window-teardown hook. See [`WindowRemovedEvent`] and
128/// [`WindowConfig::on_removed`] for exactly when it runs and what it
129/// receives.
130pub type WindowRemovedCallback = Rc<dyn Fn(&WindowRemovedEvent)>;
131
132/// Configuration for creating a new window.
133pub struct WindowConfig {
134    pub title: String,
135    pub string_id: Option<String>,
136    /// The application id the desktop matches this window against, on the
137    /// platforms that have one.
138    ///
139    /// Set it to the basename of the installed desktop entry, without the
140    /// `.desktop` suffix, and matching the reverse-DNS convention that entry
141    /// already follows: `"eu.example.MyApp"`.
142    ///
143    /// **Wayland**: this becomes the toplevel's `xdg_toplevel.set_app_id`. A
144    /// toplevel that sends none cannot be tied to its desktop entry at all, so
145    /// the shell shows the window as a separate unnamed entry carrying the
146    /// generic fallback icon, however many icons the application installed.
147    /// `StartupWMClass` in the desktop entry does not rescue it: a Wayland
148    /// compositor has no `WM_CLASS` to put there, and GNOME reads that key for
149    /// X11 windows only. Portals attribute notifications by the same id.
150    ///
151    /// **X11**: it becomes `WM_CLASS`. Without it winit falls back to the
152    /// executable's own name, which is right often enough that the Wayland
153    /// half of this gap goes unnoticed on an X11 session.
154    ///
155    /// **Windows and macOS**: ignored. Neither identifies a window this way.
156    ///
157    /// `None` by default, which leaves winit's own behaviour untouched.
158    pub app_id: Option<String>,
159    pub size: (u32, u32),
160    pub position: Option<(i32, i32)>,
161    pub min_size: Option<(u32, u32)>,
162    pub max_size: Option<(u32, u32)>,
163    /// Whether this window's geometry is **restored** from the persisted
164    /// window state at creation. Default `true`.
165    ///
166    /// Persisting and restoring are usually the same decision, so
167    /// [`string_id`](Self::string_id) normally governs both. They come apart in
168    /// one common case: a **multi-window (or multi-process) app where every
169    /// window shares one geometry slot.** Restoring the saved geometry into
170    /// *every* window would stack them exactly on top of each other; you want
171    /// the first window to land where the user left it, and any window opened
172    /// alongside it to be placed by the OS (which cascades). But you still want
173    /// every window to *save* its geometry, so whichever the user moved or
174    /// closed last is what reopens next time — the behaviour of Word, Firefox
175    /// and most document apps.
176    ///
177    /// Set `false` for those later windows: they still persist under their
178    /// `string_id`, they simply don't read the saved value back. With
179    /// [`position`](Self::position) left `None`, the window manager picks the
180    /// spot.
181    pub restore_geometry: bool,
182    pub initial_placement: WindowPlacement,
183    pub decorations: DecorationsMode,
184    pub resizable: bool,
185    /// Whether the OS window resizes itself to fit its content's intrinsic
186    /// size. See [`SizeToContent`]. Default [`SizeToContent::Off`].
187    pub size_to_content: SizeToContent,
188    pub always_on_top: bool,
189    pub skip_taskbar: bool,
190    /// When set, this window consumes an `xdg_activation_v1` startup token from
191    /// the environment at creation so it comes up focused on Wayland (the
192    /// launching process set it via `set_child_activation_env`). No effect off
193    /// Wayland/X11.
194    pub activate_from_env: bool,
195    pub icon: Option<WindowIcon>,
196    pub modal: Option<ModalConfig>,
197    pub root_builder: Option<RootBuilder>,
198    /// Optional post-root wrapper. When set, the framework calls it
199    /// after `root_builder` and uses the returned id as the window's
200    /// effective root. See [`PostRootBuilder`].
201    pub post_root_builder: Option<PostRootBuilder>,
202    /// Optional close guard. Consulted before this window closes in
203    /// response to a user gesture; returning [`CloseResponse::Veto`]
204    /// cancels the close. See [`WindowConfig::on_close_requested`].
205    pub on_close_requested: Option<CloseGuard>,
206    /// Optional reactive "may this window close?" signal. Sugar over
207    /// `on_close_requested`: when present and `false`, a close attempt
208    /// is vetoed and [`on_close_blocked`](Self::on_close_blocked) fires
209    /// (if set). See [`WindowConfig::can_close`].
210    pub can_close: Option<Prop<bool>>,
211    /// Optional notification fired when the [`can_close`](Self::can_close)
212    /// signal blocks a close — the hook that presents the confirmation
213    /// UI. See [`WindowConfig::on_close_blocked`].
214    pub on_close_blocked: Option<CloseBlockedCallback>,
215    /// Optional teardown hook, fired once this window has been fully
216    /// removed from the window manager. See [`WindowConfig::on_removed`].
217    pub on_removed: Option<WindowRemovedCallback>,
218}
219
220impl std::fmt::Debug for WindowConfig {
221    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
222        f.debug_struct("WindowConfig")
223            .field("title", &self.title)
224            .field("string_id", &self.string_id)
225            .field("size", &self.size)
226            .field("position", &self.position)
227            .field("min_size", &self.min_size)
228            .field("max_size", &self.max_size)
229            .field("initial_placement", &self.initial_placement)
230            .field("decorations", &self.decorations)
231            .field("resizable", &self.resizable)
232            .field("size_to_content", &self.size_to_content)
233            .field("always_on_top", &self.always_on_top)
234            .field("skip_taskbar", &self.skip_taskbar)
235            .field("activate_from_env", &self.activate_from_env)
236            .field("icon", &self.icon.as_ref().map(|i| (i.width, i.height)))
237            .field("modal", &self.modal)
238            .field(
239                "root_builder",
240                &self.root_builder.as_ref().map(|_| "<closure>"),
241            )
242            .field(
243                "post_root_builder",
244                &self.post_root_builder.as_ref().map(|_| "<closure>"),
245            )
246            .field(
247                "on_close_requested",
248                &self.on_close_requested.as_ref().map(|_| "<closure>"),
249            )
250            .field("can_close", &self.can_close.as_ref().map(|_| "<signal>"))
251            .field(
252                "on_close_blocked",
253                &self.on_close_blocked.as_ref().map(|_| "<closure>"),
254            )
255            .field("on_removed", &self.on_removed.as_ref().map(|_| "<closure>"))
256            .finish()
257    }
258}
259
260/// Whether an OS window resizes itself to fit its content's intrinsic height.
261///
262/// `Off` (default) keeps the window at its configured
263/// [`size`](WindowConfig::size). `Height` fixes the width and grows or shrinks
264/// the height to the content's natural height — the modal-dialog case, e.g. a
265/// `MessageBox` whose "Show details" expander adds text.
266///
267/// The window never shrinks below its [`min_size`](WindowConfig::min_size)
268/// floor, and its width is left untouched. Intended for a window with a single
269/// primary content root (a dialog). The content's height must NOT depend on the
270/// window's own height (e.g. a signal bound to the window size), or the
271/// measure → resize loop may fail to converge. On Wayland only the size
272/// round-trips (position is compositor-owned), which is fine — size-to-content
273/// changes only size.
274///
275/// (A width / both-axes mode is intentionally not offered: no consumer needs
276/// it, and a half-wired variant would silently behave like `Height`.)
277#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
278pub enum SizeToContent {
279    /// The window keeps its configured size (the default).
280    #[default]
281    Off,
282    /// Width is fixed; height follows the content's intrinsic height.
283    Height,
284}
285
286impl SizeToContent {
287    /// The window sizes its height to the content.
288    pub fn sizes_height(self) -> bool {
289        matches!(self, Self::Height)
290    }
291}
292
293impl WindowConfig {
294    /// Start a config with sensible defaults.
295    ///
296    /// Defaults: title `"Teksilo"`, size `800x600`,
297    /// `WindowPlacement::Floating`, `DecorationsMode::Native`,
298    /// resizable, no parent, no `id`, no root builder.
299    pub fn new() -> Self {
300        Self {
301            title: "Teksilo".to_string(),
302            string_id: None,
303            app_id: None,
304            size: (800, 600),
305            position: None,
306            min_size: None,
307            max_size: None,
308            restore_geometry: true,
309            initial_placement: WindowPlacement::Floating,
310            decorations: DecorationsMode::Native,
311            resizable: true,
312            size_to_content: SizeToContent::Off,
313            always_on_top: false,
314            skip_taskbar: false,
315            activate_from_env: false,
316            icon: None,
317            modal: None,
318            root_builder: None,
319            post_root_builder: None,
320            on_close_requested: None,
321            can_close: None,
322            on_close_blocked: None,
323            on_removed: None,
324        }
325    }
326
327    /// User-visible title. Also becomes the initial value of
328    /// [`WindowState::title`].
329    pub fn title(mut self, title: impl Into<String>) -> Self {
330        self.title = title.into();
331        self
332    }
333
334    /// The application id the desktop matches this window against. See
335    /// [`app_id`](Self::app_id) for what each platform does with it, and why a
336    /// Wayland session shows the wrong icon without it.
337    pub fn app_id(mut self, app_id: impl Into<String>) -> Self {
338        self.app_id = Some(app_id.into());
339        self
340    }
341
342    /// Restored size in logical pixels. This is the size the window
343    /// returns to when leaving `Maximized` or `Fullscreen`, and the
344    /// current size when placement is `Floating`.
345    pub fn size(mut self, width: u32, height: u32) -> Self {
346        self.size = (width, height);
347        self
348    }
349
350    /// Restored on-screen position in logical pixels. `None` lets the
351    /// window manager pick.
352    pub fn position(mut self, x: i32, y: i32) -> Self {
353        self.position = Some((x, y));
354        self
355    }
356
357    /// Lower bound on the floating size. The OS prevents the user
358    /// from resizing the window below this.
359    pub fn min_size(mut self, width: u32, height: u32) -> Self {
360        self.min_size = Some((width, height));
361        self
362    }
363
364    /// Upper bound on the floating size.
365    pub fn max_size(mut self, width: u32, height: u32) -> Self {
366        self.max_size = Some((width, height));
367        self
368    }
369
370    /// Whether to restore this window's persisted geometry at creation
371    /// (default `true`). See [`WindowConfig::restore_geometry`].
372    ///
373    /// Pass `false` for a window that should still *save* its geometry but be
374    /// placed by the OS rather than reopened at the remembered spot — the
375    /// second and later windows of an app whose windows share one geometry
376    /// slot, which would otherwise all land exactly on top of each other.
377    pub fn restore_geometry(mut self, restore: bool) -> Self {
378        self.restore_geometry = restore;
379        self
380    }
381
382    /// Stable string identifier for later lookup via
383    /// [`EventContext::find_window`](crate::widget::EventContext).
384    /// Optional — omit for "open a fresh window every time."
385    pub fn id(mut self, id: impl Into<String>) -> Self {
386        self.string_id = Some(id.into());
387        self
388    }
389
390    /// Initial placement. Defaults to `Floating`; pass
391    /// `WindowPlacement::Fullscreen` / `Maximized` to start in that
392    /// state.
393    pub fn initial_placement(mut self, placement: WindowPlacement) -> Self {
394        self.initial_placement = placement;
395        self
396    }
397
398    /// Chrome mode. `Native` draws OS decorations; `CustomChrome`
399    /// constructs a [`PlatformTitleBarHost`](crate::PlatformTitleBarHost)
400    /// (on X11, falls back to `Native` when the window manager lacks
401    /// `_NET_WM_MOVERESIZE`); `None` is borderless.
402    pub fn decorations(mut self, mode: DecorationsMode) -> Self {
403        self.decorations = mode;
404        self
405    }
406
407    /// Whether the user can resize the window interactively. Also
408    /// affects whether maximize gestures are accepted on some
409    /// platforms.
410    pub fn resizable(mut self, resizable: bool) -> Self {
411        self.resizable = resizable;
412        self
413    }
414
415    /// Make this window resize itself to fit its content's intrinsic size.
416    /// See [`SizeToContent`]. The configured [`size`](Self::size) /
417    /// [`min_size`](Self::min_size) act as a floor. Used for native modal dialogs
418    /// (e.g. `MessageBox`) so the OS window grows when the content does —
419    /// matching the in-tree overlay path.
420    ///
421    /// Do NOT also call `.resizable(false)`: winit encodes non-resizable as
422    /// equal min/max size hints (notably on X11), which would clamp away the
423    /// programmatic growth this relies on.
424    pub fn size_to_content(mut self, mode: SizeToContent) -> Self {
425        self.size_to_content = mode;
426        self
427    }
428
429    /// Keep this window above all others regardless of focus.
430    pub fn always_on_top(mut self, on_top: bool) -> Self {
431        self.always_on_top = on_top;
432        self
433    }
434
435    /// Hide this window from the taskbar / dock. Useful for tool
436    /// palettes and secondary overlays.
437    pub fn skip_taskbar(mut self, skip: bool) -> Self {
438        self.skip_taskbar = skip;
439        self
440    }
441
442    /// Consume an `xdg_activation_v1` startup token from the environment at
443    /// creation so this window comes up focused on Wayland. Set on the initial
444    /// window of a process spawned by another instance's "open in new window".
445    pub fn activate_from_env(mut self, on: bool) -> Self {
446        self.activate_from_env = on;
447        self
448    }
449
450    /// Set the window's icon from a raw RGBA8 buffer. The icon is
451    /// used by the taskbar / dock and the window's title bar on
452    /// platforms where it applies.
453    ///
454    /// Invalid buffers (`rgba.len() != width * height * 4`) are
455    /// logged and dropped at creation time — the window still opens,
456    /// just with the platform default icon.
457    pub fn icon(mut self, icon: WindowIcon) -> Self {
458        self.icon = Some(icon);
459        self
460    }
461
462    /// Make this window modal to the given parent, with no explicit
463    /// focus target. Prefer this over constructing [`ModalConfig`]
464    /// yourself when you already have the parent id handy.
465    pub fn modal_to(mut self, parent: TeksiloWindowId) -> Self {
466        self.modal = Some(ModalConfig {
467            parent,
468            focus_target: None,
469        });
470        self
471    }
472
473    /// Make this window modal using a caller-built [`ModalConfig`].
474    /// Use this form when you need to specify an explicit
475    /// `focus_target`.
476    pub fn modal(mut self, config: ModalConfig) -> Self {
477        self.modal = Some(config);
478        self
479    }
480
481    /// Root-widget builder. Called once during window creation with
482    /// the new window's [`WidgetTree`] and a cloned [`WindowState`]
483    /// so widgets can bind against window-level signals.
484    pub fn root(
485        mut self,
486        builder: impl FnOnce(&mut WidgetTree, WindowState) -> WidgetId + 'static,
487    ) -> Self {
488        self.root_builder = Some(Box::new(builder));
489        self
490    }
491
492    // ----- Query helpers used by the app-level window manager -------
493
494    /// Take the root builder out of the config, leaving `None` in its
495    /// place. Consumed by the window manager exactly once during
496    /// `create_window`.
497    pub fn take_root_builder(&mut self) -> Option<RootBuilder> {
498        self.root_builder.take()
499    }
500
501    /// Attach a per-window post-root hook. Runs after the user's
502    /// `root_builder` returns; receives the user's root id and may
503    /// return either the same id or a wrapper's id. The framework uses
504    /// the returned id as the window's effective root.
505    ///
506    /// Typically used by the debug inspector. Apps that want to
507    /// install a default wrapper across all windows should use the
508    /// app-level mechanism instead of setting this per-config.
509    pub fn post_root(
510        mut self,
511        builder: impl FnOnce(&mut WidgetTree, WidgetId) -> WidgetId + 'static,
512    ) -> Self {
513        self.post_root_builder = Some(Box::new(builder));
514        self
515    }
516
517    /// Take the post-root builder out of the config.
518    pub fn take_post_root_builder(&mut self) -> Option<PostRootBuilder> {
519        self.post_root_builder.take()
520    }
521
522    /// Install a **close guard** consulted before this window closes in
523    /// response to a user gesture — the OS close button / `Alt+F4` /
524    /// `Cmd+W`, a custom-chrome close button, or
525    /// [`EventContext::close_window`](crate::widget::EventContext::close_window).
526    ///
527    /// The guard runs with a real [`EventContext`] for this window's
528    /// tree. Return [`CloseResponse::Close`] to let the close proceed,
529    /// or [`CloseResponse::Veto`] to cancel it. The canonical pattern is
530    /// veto-then-reissue:
531    ///
532    /// ```ignore
533    /// WindowConfig::new()
534    ///     .on_close_requested(move |ctx| {
535    ///         if has_unsaved_changes() {
536    ///             ctx.show_message_box(/* "Save before closing?" */);
537    ///             CloseResponse::Veto
538    ///         } else {
539    ///             CloseResponse::Close
540    ///         }
541    ///     });
542    ///
543    /// // …and from the confirmation dialog's "Discard & Close" button:
544    /// ctx.close_window_forced();
545    /// ```
546    ///
547    /// [`close_window_forced`](crate::widget::EventContext::close_window_forced)
548    /// bypasses the guard, so the second close actually goes through.
549    /// The guard is **not** consulted for framework-internal teardown
550    /// (modal cleanup, the final-window shutdown drain).
551    pub fn on_close_requested(
552        mut self,
553        guard: impl Fn(&mut EventContext) -> CloseResponse + 'static,
554    ) -> Self {
555        self.on_close_requested = Some(Rc::new(guard));
556        self
557    }
558
559    /// Reactive sugar over [`on_close_requested`](Self::on_close_requested):
560    /// bind a `Signal<bool>` that answers "may this window close right
561    /// now?". While the signal reads `false`, every user-initiated close
562    /// attempt is vetoed and [`on_close_blocked`](Self::on_close_blocked)
563    /// (if set) fires so the app can surface a confirmation.
564    ///
565    /// `can_close` is evaluated *before* the `on_close_requested` guard:
566    /// a `false` signal short-circuits to a veto; a `true` signal (or no
567    /// signal) falls through to the guard, then to closing.
568    pub fn can_close(mut self, may_close: impl Into<Prop<bool>>) -> Self {
569        self.can_close = Some(may_close.into());
570        self
571    }
572
573    /// Notification fired when the [`can_close`](Self::can_close) signal
574    /// blocks a close attempt. Runs with this window's [`EventContext`];
575    /// use it to open the confirmation dialog / modal that, on confirm,
576    /// calls
577    /// [`close_window_forced`](crate::widget::EventContext::close_window_forced).
578    /// No-op unless a `can_close` signal is also set.
579    pub fn on_close_blocked(mut self, on_blocked: impl Fn(&mut EventContext) + 'static) -> Self {
580        self.on_close_blocked = Some(Rc::new(on_blocked));
581        self
582    }
583
584    /// Take the close guard out of the config. Consumed by the window
585    /// manager once during `create_window`, which stores it on the
586    /// managed window for the window's lifetime.
587    pub fn take_close_guard(&mut self) -> Option<CloseGuard> {
588        self.on_close_requested.take()
589    }
590
591    /// Take the `can_close` prop out of the config.
592    pub fn take_can_close(&mut self) -> Option<Prop<bool>> {
593        self.can_close.take()
594    }
595
596    /// Take the `on_close_blocked` callback out of the config.
597    pub fn take_close_blocked(&mut self) -> Option<CloseBlockedCallback> {
598        self.on_close_blocked.take()
599    }
600
601    /// Register a teardown hook for this window: an `Fn`, not `FnOnce` or
602    /// `FnMut`, because a shared closure (`Rc`-cloned config, or a
603    /// closure built once and attached to several windows opened for the
604    /// same document) may run once per window it's attached to.
605    ///
606    /// Fires exactly once, no matter which of the two ways this window
607    /// closes:
608    /// - a *guarded* close ([`EventContext::close_window`](crate::widget::EventContext::close_window)
609    ///   / the OS close button / `Alt+F4` / `Cmd+W`), once
610    ///   [`can_close`](Self::can_close) / [`on_close_requested`](Self::on_close_requested)
611    ///   let it through;
612    /// - a *forced* close ([`EventContext::close_window_forced`](crate::widget::EventContext::close_window_forced) /
613    ///   [`EventContext::close_window_by_id`](crate::widget::EventContext::close_window_by_id)),
614    ///   which bypasses the guard entirely;
615    ///
616    /// because the window manager funnels both through the same, single
617    /// teardown routine.
618    ///
619    /// Runs **after** the window is gone: its tree has been dropped, its
620    /// platform window destroyed, and every framework-internal
621    /// registration for it (native menu, drag-and-drop target, pending
622    /// async completions, …) purged. This is the deliberate choice — it
623    /// is what lets [`WindowRemovedEvent::remaining_windows`] already
624    /// exclude the window being removed, so a handler that wants to know
625    /// "was this the last window [for my Work]" gets an unambiguous
626    /// answer rather than having to remember to subtract one. The
627    /// trade-off is that the callback cannot reach into the removed
628    /// window's own widget tree — by the time it runs, there isn't one.
629    /// If a hook needs to read tree state before it's torn down, that has
630    /// to happen earlier, in [`on_close_requested`](Self::on_close_requested)
631    /// or [`on_close_blocked`](Self::on_close_blocked).
632    ///
633    /// The intended use is releasing whatever an app keeps keyed by a
634    /// window's [`TeksiloWindowId`] — a shared-document refcount, an
635    /// entry in the app's own "windows open for this Work" map — so that
636    /// bookkeeping is decremented exactly when the framework agrees the
637    /// window is really gone, instead of a hand-maintained registry that
638    /// only ever grows.
639    pub fn on_removed(mut self, hook: impl Fn(&WindowRemovedEvent) + 'static) -> Self {
640        self.on_removed = Some(Rc::new(hook));
641        self
642    }
643
644    /// Take the `on_removed` teardown hook out of the config.
645    pub fn take_on_removed(&mut self) -> Option<WindowRemovedCallback> {
646        self.on_removed.take()
647    }
648
649    pub fn is_modal(&self) -> bool {
650        self.modal.is_some()
651    }
652
653    pub fn modal_parent(&self) -> Option<TeksiloWindowId> {
654        self.modal.as_ref().map(|m| m.parent)
655    }
656
657    pub fn modal_focus_target(&self) -> Option<WidgetId> {
658        self.modal.as_ref().and_then(|m| m.focus_target)
659    }
660}
661
662impl Default for WindowConfig {
663    fn default() -> Self {
664        Self::new()
665    }
666}
667
668#[cfg(test)]
669mod tests {
670    use super::*;
671    use crate::signal::Signal;
672
673    #[test]
674    fn window_config_defaults() {
675        let config = WindowConfig::new();
676        assert_eq!(config.title, "Teksilo");
677        assert_eq!(config.size, (800, 600));
678        assert_eq!(config.initial_placement, WindowPlacement::Floating);
679        assert_eq!(config.decorations, DecorationsMode::Native);
680        assert!(config.resizable);
681        assert!(!config.always_on_top);
682        assert!(!config.skip_taskbar);
683        assert!(config.modal.is_none());
684        assert!(config.string_id.is_none());
685        assert!(config.app_id.is_none());
686        assert!(config.position.is_none());
687        assert!(config.min_size.is_none());
688        assert!(config.max_size.is_none());
689        assert!(config.on_close_requested.is_none());
690        assert!(config.can_close.is_none());
691        assert!(config.on_close_blocked.is_none());
692        assert!(config.on_removed.is_none());
693        // Geometry is restored unless an app explicitly opts out.
694        assert!(config.restore_geometry);
695    }
696
697    /// Persisting and restoring geometry are separate decisions.
698    ///
699    /// An app whose windows share one geometry slot wants the *first* window to
700    /// reopen where the user left it and any window opened alongside it to be
701    /// placed by the window manager — otherwise they all land on the same pixel.
702    /// But those later windows must still *save* their geometry, so whichever
703    /// the user moved or closed last is the one that reopens. That is
704    /// `id(..)` + `restore_geometry(false)`: persist, don't restore.
705    #[test]
706    fn restore_geometry_can_be_opted_out_of_without_giving_up_persistence() {
707        let config = WindowConfig::new().id("main").restore_geometry(false);
708
709        assert!(!config.restore_geometry, "this window must not be restored");
710        assert_eq!(
711            config.string_id.as_deref(),
712            Some("main"),
713            "...but it keeps its id, so it still persists into that slot"
714        );
715        // And with no explicit position, the window manager picks the spot.
716        assert!(config.position.is_none());
717    }
718
719    #[test]
720    fn close_guard_builders_set_and_take() {
721        let may_close = Signal::new(false);
722        let mut config = WindowConfig::new()
723            .on_close_requested(|_ctx| CloseResponse::Veto)
724            .can_close(may_close.clone())
725            .on_close_blocked(|_ctx| {});
726
727        assert!(config.on_close_requested.is_some());
728        assert!(config.can_close.is_some());
729        assert!(config.on_close_blocked.is_some());
730
731        // The window manager drains the guard fields exactly once at
732        // create_window time; after that the config no longer carries them.
733        let guard = config.take_close_guard();
734        let signal = config.take_can_close();
735        let blocked = config.take_close_blocked();
736        assert!(guard.is_some());
737        assert!(signal.is_some());
738        assert!(blocked.is_some());
739        assert!(config.on_close_requested.is_none());
740        assert!(config.can_close.is_none());
741        assert!(config.on_close_blocked.is_none());
742
743        // The taken signal is the same handle the caller passed in.
744        signal.unwrap().as_signal().set(true);
745        assert!(may_close.get());
746    }
747
748    #[test]
749    fn on_removed_builder_sets_and_takes() {
750        use std::cell::RefCell;
751        use std::rc::Rc;
752
753        let seen: Rc<RefCell<Vec<WindowRemovedEvent>>> = Rc::new(RefCell::new(Vec::new()));
754        let log = seen.clone();
755        let mut config =
756            WindowConfig::new().on_removed(move |ev| log.borrow_mut().push(ev.clone()));
757
758        assert!(config.on_removed.is_some());
759
760        // The window manager drains this exactly once at create_window
761        // time, same discipline as the close-guard fields above.
762        let hook = config.take_on_removed();
763        assert!(hook.is_some());
764        assert!(config.on_removed.is_none());
765
766        // The taken callback is the same closure the caller passed in,
767        // and it receives exactly the event it's handed — no field is
768        // dropped or reordered on the way through the `Rc<dyn Fn>`.
769        let id = TeksiloWindowId::new(7);
770        hook.unwrap()(&WindowRemovedEvent {
771            id,
772            string_id: Some("main".to_string()),
773            remaining_windows: 0,
774        });
775        let logged = seen.borrow();
776        assert_eq!(logged.len(), 1);
777        assert_eq!(logged[0].id, id);
778        assert_eq!(logged[0].string_id.as_deref(), Some("main"));
779        assert_eq!(logged[0].remaining_windows, 0);
780    }
781
782    #[test]
783    fn builder_sets_fields() {
784        let config = WindowConfig::new()
785            .title("Test")
786            .size(400, 300)
787            .id("test-window")
788            .initial_placement(WindowPlacement::Fullscreen)
789            .decorations(DecorationsMode::CustomChrome)
790            .min_size(200, 150)
791            .resizable(false)
792            .always_on_top(true)
793            .skip_taskbar(true)
794            .position(100, 50);
795
796        assert_eq!(config.title, "Test");
797        assert_eq!(config.size, (400, 300));
798        assert_eq!(config.string_id, Some("test-window".to_string()));
799        assert_eq!(config.initial_placement, WindowPlacement::Fullscreen);
800        assert_eq!(config.decorations, DecorationsMode::CustomChrome);
801        assert_eq!(config.min_size, Some((200, 150)));
802        assert_eq!(config.position, Some((100, 50)));
803        assert!(!config.resizable);
804        assert!(config.always_on_top);
805        assert!(config.skip_taskbar);
806    }
807
808    #[test]
809    fn modal_to_sets_parent() {
810        let parent = TeksiloWindowId::new(3);
811        let config = WindowConfig::new().modal_to(parent);
812        assert!(config.is_modal());
813        assert_eq!(config.modal_parent(), Some(parent));
814        assert_eq!(config.modal_focus_target(), None);
815    }
816
817    #[test]
818    fn modal_with_focus_target() {
819        let parent = TeksiloWindowId::new(3);
820        let target = WidgetId::default();
821        let config = WindowConfig::new().modal(ModalConfig {
822            parent,
823            focus_target: Some(target),
824        });
825        assert!(config.is_modal());
826        assert_eq!(config.modal_parent(), Some(parent));
827        assert_eq!(config.modal_focus_target(), Some(target));
828    }
829}