kui_core/window.rs
1//! Windows as data. A node can declare a chrome role (drag handle,
2//! close/minimize/maximize button); interacting with it produces
3//! [`WindowCommand`]s that the frame driver drains and applies to the real
4//! window. A frame can declare that a *window exists* (`Core::declare_window`,
5//! `docs/adr/0004-multi-window.md`): the core diffs the declared set and the
6//! same queue carries the [`WindowCommand::Open`] / [`WindowCommand::Close`]
7//! the diff produces. A declared window is a [`WindowKind::Normal`] one or
8//! a [`WindowKind::Popup`] — borderless, owned, anchored, non-activating —
9//! and a driver reports a popup dismissed the way it reports one closed
10//! ([`DismissReason`]). Host window facts flow back in through [`WindowEnv`]
11//! on `Env`. The core never touches a window — headless drivers just never
12//! drain.
13
14use crate::geom::{Rect, Size};
15use crate::tree::OriginId;
16
17/// Which OS window something belongs to: an opaque integer the core's
18/// declaration diff assigns when it opens a window, not a handle an app
19/// builds. [`WindowId::MAIN`] is 0 — the window the launcher opens, which is
20/// always live. Apps name windows with a stable string
21/// (`Core::declare_window`); the id is how the driver and the events refer
22/// to the surface that string opened.
23///
24/// It crosses every transport as a plain integer — `UiEvent::window` in
25/// Rust, `window` on a JSX `UiEvent`, `KuiEvent.window` in C — so nothing
26/// has to model window identity twice.
27#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
28pub struct WindowId(pub u32);
29
30impl WindowId {
31 /// The window the app starts in, and the answer everywhere until a
32 /// driver opens a second one.
33 pub const MAIN: WindowId = WindowId(0);
34}
35
36/// Role a node plays in window chrome (set via `NodeSpec::window_drag` /
37/// `NodeSpec::window_button`). Chrome nodes never emit `UiEvent`s — their
38/// interactions become [`WindowCommand`]s for the driver instead.
39#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
40pub enum WindowRole {
41 /// Mouse-down here asks the driver to start an OS window drag (drivers
42 /// conventionally promote a quick second press to a maximize toggle).
43 Drag,
44 /// Clicking here emits the button's window command.
45 Button(WindowButton),
46}
47
48#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
49pub enum WindowButton {
50 Close,
51 Minimize,
52 Maximize,
53}
54
55impl WindowButton {
56 /// The command a click on this button issues, for the window the
57 /// button was drawn in.
58 pub fn command(self, window: WindowId) -> WindowCommand {
59 match self {
60 WindowButton::Close => WindowCommand::Close(window),
61 WindowButton::Minimize => WindowCommand::Minimize(window),
62 WindowButton::Maximize => WindowCommand::ToggleMaximize(window),
63 }
64 }
65}
66
67/// What kind of OS surface a declared window is
68/// (`docs/adr/0004-multi-window.md`, decision 9).
69#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
70pub enum WindowKind {
71 /// A regular top-level window with the launcher's chrome.
72 #[default]
73 Normal,
74 /// A menu surface: borderless, absent from the taskbar, owned by the
75 /// window that declared it and closed when that window closes, placed
76 /// in screen coordinates against [`WindowConfig::anchor`] rather than
77 /// clamped into a viewport, and **non-activating** by default — it
78 /// must not take OS focus, or opening a combobox would blur the field
79 /// that opened it. While a non-activating popup is up the driver
80 /// routes its owner's keyboard input to it and the owner's
81 /// `env.focused` stays true, so the field still draws focused while
82 /// the arrow keys walk the list.
83 ///
84 /// Reach for it only for the three placements a float cannot make: a
85 /// list taller than the window, a menu near an edge with nowhere
86 /// in-window to go, and a panel the user wants beside the app.
87 /// Everything else is cheaper as a float — see
88 /// [`crate::spec::FloatConfig::fit`] — because a float costs one tree
89 /// and one draw call where this costs an OS surface, a swapchain, a
90 /// `Core` and an accessibility adapter.
91 Popup,
92}
93
94impl WindowKind {
95 /// Every kind, in wire order: the index a binding that spells kinds
96 /// as numbers sends. Append-only.
97 pub const ALL: [WindowKind; 2] = [WindowKind::Normal, WindowKind::Popup];
98
99 /// The wire name, for the bindings and the report.
100 pub fn name(self) -> &'static str {
101 match self {
102 WindowKind::Normal => "normal",
103 WindowKind::Popup => "popup",
104 }
105 }
106
107 /// The kind a wire name spells: the inverse of [`Self::name`].
108 pub fn from_name(name: &str) -> Option<WindowKind> {
109 Self::ALL.into_iter().find(|k| k.name() == name)
110 }
111}
112
113/// What a frame says about a window it declares (`Core::declare_window`).
114/// Plain data by ADR 0004 decision 5 — no title, no callbacks — so a
115/// `WindowCommand` stays `Copy` and equality is derived, which is how the
116/// diff tells two declarations of one name apart.
117///
118/// **Read on the opening edge only.** The config that reaches
119/// [`WindowCommand::Open`] is the one the declaration carried on the frame
120/// it started; a live window's config is never looked at again, so
121/// re-declaring `"palette"` at a new size does not resize it. The user owns
122/// a window's geometry once it exists.
123#[derive(Clone, Copy, Debug, PartialEq)]
124pub struct WindowConfig {
125 pub kind: WindowKind,
126 /// Initial inner size, logical px.
127 pub size: Size,
128 /// Whether opening it takes OS focus. True for a normal window;
129 /// [`WindowConfig::popup`] turns it off, so the field that opened a
130 /// dropdown keeps the ring while the list is up.
131 pub activates: bool,
132 /// [`WindowKind::Popup`] only: what the popup is placed against, as a
133 /// rect in the **declaring window's** logical viewport coordinates —
134 /// which is exactly the rect an `onLayout` node reports, so an app
135 /// needs no new geometry query to fill it. The driver resolves it to
136 /// screen coordinates against the owner's own position and puts the
137 /// popup below it, flipping above when the display's bottom edge is
138 /// nearer than the popup is tall.
139 ///
140 /// Read on the opening edge with the rest of the config and never
141 /// again: a popup that has to follow a moving anchor stops being
142 /// declared and is declared again, which is what a dropdown does when
143 /// its field scrolls away anyway. Ignored by a `Normal` window.
144 pub anchor: Rect,
145}
146
147impl WindowConfig {
148 /// `{kind, width, height, activates, anchor: {x, y, w, h}}`.
149 pub fn to_value(&self) -> crate::value::Value {
150 use crate::value::Value;
151 Value::map([
152 ("kind", Value::str(self.kind.name())),
153 ("width", Value::float(self.size.w)),
154 ("height", Value::float(self.size.h)),
155 ("activates", Value::Bool(self.activates)),
156 ("anchor", self.anchor.to_value()),
157 ])
158 }
159
160 /// The size a declaration that names none gets.
161 pub const DEFAULT_SIZE: Size = Size { w: 640.0, h: 480.0 };
162
163 /// A normal, activating window of `w` x `h` logical px.
164 pub fn sized(w: f32, h: f32) -> Self {
165 Self {
166 size: Size::new(w, h),
167 ..Self::default()
168 }
169 }
170
171 /// A [`WindowKind::Popup`] of `w` x `h` logical px placed against
172 /// `anchor` — the rect, in the declaring window's viewport
173 /// coordinates, that an `onLayout` handler reported for the field or
174 /// button the menu belongs to.
175 ///
176 /// Non-activating, which is the default a popup wants and the reason
177 /// this is a constructor rather than a `kind` you set: a popup that
178 /// takes OS focus blurs whatever opened it. Set `activates` back to
179 /// true afterwards for the rare surface that should steal focus.
180 pub fn popup(anchor: Rect, w: f32, h: f32) -> Self {
181 Self {
182 size: Size::new(w, h),
183 anchor,
184 ..Self::of_kind(WindowKind::Popup)
185 }
186 }
187
188 /// The defaults for a window of `kind`: the one decision the kind
189 /// makes on its own is whether opening it takes OS focus — a popup
190 /// that did would blur the field that opened it, so it does not unless
191 /// asked. Every binding's window entry starts from this.
192 pub fn of_kind(kind: WindowKind) -> Self {
193 Self {
194 kind,
195 activates: kind == WindowKind::Normal,
196 ..Self::default()
197 }
198 }
199
200 /// A declaration from plain data: a bare name (the defaults), or a map
201 /// with `name`, `kind` (`"normal"` | `"popup"`), `width` and `height`
202 /// (both and positive, or the default size), `activates`, and the `anchor` rect
203 /// (`{x, y, w, h}`) a popup is placed against. Returns the name with
204 /// the config. An entry is plain data with a fixed shape, not a node's
205 /// loose prop bag, so a value that does nothing is refused rather than
206 /// dropped: a kind kui does not have would otherwise open a normal
207 /// window and read as the popup having worked.
208 pub fn from_value(v: &crate::value::Value) -> Result<(String, Self), String> {
209 use crate::value::Value;
210 match v {
211 Value::Str(name) => Ok((name.clone(), Self::default())),
212 Value::Map(_) => {
213 let name = v
214 .get_str("name")
215 .ok_or("a windows entry needs a name")?
216 .to_string();
217 let kind = match v.get_str("kind") {
218 None => WindowKind::Normal,
219 Some(k) => WindowKind::from_name(k).ok_or_else(|| {
220 format!(
221 "windows entry `{name}` has kind {k:?}; the kinds are {}",
222 WindowKind::ALL
223 .iter()
224 .map(|k| format!("{:?}", k.name()))
225 .collect::<Vec<_>>()
226 .join(" and ")
227 )
228 })?,
229 };
230 let mut cfg = Self::of_kind(kind);
231 let num = |key: &str| v.get(key).and_then(Value::as_float).map(|n| n as f32);
232 // Both, and positive: a zero is the default size, as C's
233 // `kui_window_declare` reads it and the runner opens it —
234 // Node's own decoder said so and this did not (AR43).
235 if let (Some(w), Some(h)) = (num("width"), num("height"))
236 && w > 0.0
237 && h > 0.0
238 {
239 cfg.size = Size::new(w, h);
240 }
241 if let Some(a) = v.get_bool("activates") {
242 cfg.activates = a;
243 }
244 if let Some(a) = v.get("anchor") {
245 let f = |key: &str| a.get(key).and_then(Value::as_float).unwrap_or(0.0) as f32;
246 cfg.anchor = Rect::new(f("x"), f("y"), f("w"), f("h"));
247 }
248 Ok((name, cfg))
249 }
250 other => Err(format!(
251 "a windows entry is a name or a table, not {}",
252 other.type_name()
253 )),
254 }
255 }
256}
257
258impl Default for WindowConfig {
259 fn default() -> Self {
260 Self {
261 kind: WindowKind::Normal,
262 size: Self::DEFAULT_SIZE,
263 activates: true,
264 anchor: Rect::new(0.0, 0.0, 0.0, 0.0),
265 }
266 }
267}
268
269/// A window-level intent for the frame driver, drained via
270/// `Core::take_window_commands` after each input dispatch and each frame.
271/// Three things produce one: input on a chrome node, the declared set's
272/// diff, and an app asking directly (`Core::set_window_size`,
273/// `Core::focus_window`, `Core::push_window_command`). A headless driver
274/// never drains, which is the whole of "the core never touches a window".
275///
276/// Every variant says which window it is about, and every variant is
277/// `Copy` and pointer-free — an `Open` carries no title (the new window's
278/// own first frame declares one through `window_title`) and a `SetSize`
279/// carries two floats — so nothing borrowed ever enters a driver's drain
280/// loop.
281#[derive(Clone, Copy, Debug, PartialEq)]
282pub enum WindowCommand {
283 /// Begin an interactive OS move (the press landed on a `Drag` node).
284 StartDrag(WindowId),
285 /// Close this window: from its chrome close button, or because no
286 /// frame declares it any more. For the main window a driver exits.
287 Close(WindowId),
288 Minimize(WindowId),
289 ToggleMaximize(WindowId),
290 /// Open a window the declared set gained. `id` is the one the core
291 /// assigned and will stamp on the window's events; `origin` is the
292 /// frontend whose declaration won (a host may refuse an extension's);
293 /// `owner` is the window whose frame that declaration came from — what
294 /// a [`WindowKind::Popup`] is anchored against and owned by, and what
295 /// closing takes the popup with it (the owner's declarations leave the
296 /// declared set when the owner does, so the diff closes the child in
297 /// the same pass).
298 Open {
299 id: WindowId,
300 owner: WindowId,
301 origin: OriginId,
302 config: WindowConfig,
303 },
304 /// Resize `window` to `size` (logical px), asked for by the app
305 /// (`Core::set_window_size`). A command and not part of a declaration,
306 /// because the user owns a window's size once it exists — a declared
307 /// size would fight every drag of the window's edge, which is why
308 /// `WindowConfig::size` is read on the opening edge only. The OS may
309 /// answer with a different size (a minimum, a tiling manager); the
310 /// frame that follows posts a `resize` event with whatever it actually
311 /// became, the way every resize already does.
312 SetSize {
313 window: WindowId,
314 size: Size,
315 },
316 /// Give `window` keyboard focus (`Core::focus_window`). Advisory, like
317 /// every focus request an app makes of a window manager.
318 Focus(WindowId),
319 /// Draw `window` again: something another window's frame or input
320 /// changed is shown there (`docs/adr/0024`, decision 7 — the devtools
321 /// window's row hover outlines a node in the main window, and an event
322 /// the main window logs moves the stream in the devtools window). A
323 /// driver that redraws every window on every event may ignore it.
324 Redraw(WindowId),
325}
326
327impl WindowCommand {
328 /// The command's wire name: `startDrag`, `close`, `minimize`,
329 /// `toggleMaximize`, `open`, `setSize`, `focus`, `redraw`.
330 pub fn kind_name(&self) -> &'static str {
331 match self {
332 WindowCommand::StartDrag(_) => "startDrag",
333 WindowCommand::Close(_) => "close",
334 WindowCommand::Minimize(_) => "minimize",
335 WindowCommand::ToggleMaximize(_) => "toggleMaximize",
336 WindowCommand::Open { .. } => "open",
337 WindowCommand::SetSize { .. } => "setSize",
338 WindowCommand::Focus(_) => "focus",
339 WindowCommand::Redraw(_) => "redraw",
340 }
341 }
342
343 /// `{kind, window}` plus what the variant carries: `width`/`height`
344 /// for `setSize`; `owner`, `origin` and `config` for `open`.
345 pub fn to_value(&self) -> crate::value::Value {
346 use crate::value::Value;
347 let mut out = vec![
348 ("kind".to_string(), Value::str(self.kind_name())),
349 ("window".to_string(), Value::Int(self.window().0 as i64)),
350 ];
351 match self {
352 WindowCommand::SetSize { size, .. } => {
353 out.push(("width".into(), Value::float(size.w)));
354 out.push(("height".into(), Value::float(size.h)));
355 }
356 WindowCommand::Open {
357 owner,
358 origin,
359 config,
360 ..
361 } => {
362 out.push(("owner".into(), Value::Int(owner.0 as i64)));
363 out.push(("origin".into(), Value::Int(origin.0 as i64)));
364 out.push(("config".into(), config.to_value()));
365 }
366 _ => {}
367 }
368 Value::Map(out)
369 }
370
371 /// The window the command is about.
372 pub fn window(&self) -> WindowId {
373 match *self {
374 WindowCommand::StartDrag(w)
375 | WindowCommand::Close(w)
376 | WindowCommand::Minimize(w)
377 | WindowCommand::ToggleMaximize(w)
378 | WindowCommand::Focus(w)
379 | WindowCommand::Redraw(w) => w,
380 WindowCommand::Open { id, .. } => id,
381 WindowCommand::SetSize { window, .. } => window,
382 }
383 }
384}
385
386/// Why a window was asked to go away (`Core::dismiss_window`): the same
387/// two reasons ADR 0003 gave a modal node, one level up.
388///
389/// A [`WindowKind::Popup`] is dismissed by the driver, because both facts
390/// are the OS's and not the frame's: a press outside a window lands in
391/// another surface, and Escape reaches a non-activating popup only through
392/// whichever window the OS gave the keyboard to. What the core does with
393/// either is what ADR 0003 decided — raise `{kind:"dismiss", reason}` and
394/// close nothing. The app stops declaring the window on the frame it
395/// decides to, so a dropdown that graduates from a `modal` float to a
396/// popup window changes its declaration and not its handler.
397#[derive(Clone, Copy, Debug, PartialEq, Eq)]
398pub enum DismissReason {
399 /// A press landed outside the window.
400 Outside,
401 /// Escape, wherever the OS delivered it.
402 Escape,
403}
404
405impl DismissReason {
406 /// The string the `dismiss` payload carries, the same spelling a
407 /// modal's `reason` uses.
408 pub fn as_str(self) -> &'static str {
409 match self {
410 DismissReason::Outside => "outside",
411 DismissReason::Escape => "escape",
412 }
413 }
414}
415
416/// What the host knows about its window, pushed into `Env` by the frame
417/// driver. Views (e.g. `widgets::titlebar`) read this to adapt: reserve
418/// space for native controls, pick the maximize/restore glyph, or render
419/// nothing at all under native decorations.
420#[derive(Clone, Copy, Debug, Default, PartialEq)]
421pub struct WindowEnv {
422 /// Which window this core is drawing: the id the `Open` that created
423 /// it carried, written here by the driver (`MAIN` for the launcher's).
424 /// A view reads it here rather than through a query, and it is what the
425 /// core stamps onto every `UiEvent` it hands out — so a driver that
426 /// pushes the rest of these facts has already said where its events
427 /// came from. The window's *name* is `Core::window_name`.
428 pub id: WindowId,
429 /// The host asked the app to draw its own chrome (no native titlebar).
430 pub custom_chrome: bool,
431 pub maximized: bool,
432 pub fullscreen: bool,
433 /// The window is above every other app's: the level the driver set
434 /// after the frame asked (`Core::set_always_on_top`, backlog C30), on
435 /// a platform that has one — where winit has no call for it (Wayland)
436 /// a driver reports false however often the app asks, which is what
437 /// a pin button draws its state from. It is the driver's record of
438 /// what it set and not a query: winit has no level getter, so a level
439 /// the OS dropped afterwards (a fullscreen space, a tiling manager)
440 /// is not seen here (backlog AR49 names the gap).
441 pub always_on_top: bool,
442 /// Area (logical px, window coords) covered by controls the OS still
443 /// draws over our content — macOS traffic lights under custom chrome.
444 /// Views keep out of it; `None` means the OS draws nothing over us.
445 pub native_controls: Option<Rect>,
446}