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}