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}