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