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