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}