Secondary windows (§8.1)
One tree has many roots: a secondary window’s content container is adopted as an additional
boundary root of the same thread-local tree (the create_cell_anchor trick the primary root
and list cells already use), laid out at that window’s own size. Bindings, find_by_id,
navigation state, and dayscript therefore work across windows with no window parameter;
element ids stay globally unique, and a control in one window can drive a label in another
through ordinary signals.
Titles: what the platform manages windows by
Every platform’s automatic window management identifies a window by its title, and supplies no fallback when there isn’t one. On macOS an untitled window is skipped when AppKit builds the Window menu and shows a blank tab in a tab group; on iPad it is an unlabeled card in the app switcher; on Android an unlabeled recents entry; on GTK/Qt/Windows an unlabeled entry in the window list or taskbar. So a window with no title is missing from every place the system lists windows.
These rules keep that from happening:
- A window opened by File ▸ New Window inherits the app’s launch
WindowOptions(the title, minimum size, and display name the app handedlaunch). An app needs no code for this; a new window is another window of the same app and describes itself that way. day::window_title(|| …)binds the title of the window the calling piece is building into, so a window names itself after what it shows. It is reactive like any binding, and window-scoped: the target is resolved once, at build, exactly as a toolbar contribution resolves its own.
fn window_shell() -> impl Piece {
Scene::scoped(|scene| {
day::window_title(move || match scene.selected.get() {
Some(id) => scene.name_of(id),
None => app_title(),
});
my_ui(scene)
})
}
Two windows sharing one title are two windows the user cannot tell apart in the Window menu, the
tab bar, Mission Control, or the app switcher, so title them by content wherever there is content
to name. WindowHandle::set_title remains the imperative form for a window you hold a handle to.
The platform places windows. macOS staggers each new window from the last
(cascadeTopLeftFromPoint:) rather than centering it, since two centered windows would hide each
other, and remembers the primary window’s frame between launches under an autosave name. That restore is
turned off while DAY_SCRIPT or DAY_WINDOW is set, so captured screenshots keep the size the
script asked for instead of the size the developer last dragged. Every other desktop leaves
placement to its window manager, which is that platform’s convention.
WindowKind::Preferences is centered and kept out of the macOS Window menu, the convention stock
apps follow.
Opening windows
let win = day::open_window(
Some("detail:AAPL"), // key: open-or-focus singleton; None = always new
WindowOptions { title: "AAPL".into(), size: Size::new(720.0, 640.0), ..Default::default() },
WindowKind::Normal, // or WindowKind::Preferences
|| detail_page("AAPL"), // built under the new window's root
);
win.set_title("AAPL — live");
win.on_close(|| println!("gone"));
win.close(); // async: confirmed by the platform, THEN torn down
keynames the logical window: opening an already-open key focuses it instead of duplicating, which is howday.preferencesstays a singleton.window_by_keyfinds it later. A key reopened while its window is still closing is the same window arriving again: on the cover tier that reverses the hide transition and keeps the content that is already there, rather than handing back a surface the pending confirmation is about to dispose.WindowKindshapes the chrome:Normalis resizable/miniaturizable and joins the platform’s tabbing group;Preferencesdrops resize/minimize and never tabs (macOS convention; other platforms map as fits).- The window’s lifetime is app-owned: it survives the page that opened it. Its content builds in a fresh scope disposed at close.
- Close is async everywhere: the title-bar close, a platform gesture, and
WindowHandle::close()all route through the platform’s confirm (Event::WindowClosedon the window’s root), and day-core tears the subtree down on a deferred hop, never inside the native close callback.on_closeruns after disposal. - Closing the last primary window quits the app, taking secondary windows with it; a settings
panel does not keep an app alive, however long it has been up. macOS is the exception:
applicationShouldTerminateAfterLastWindowCloseddefaults to false there, an app with no windows keeps its menu bar live, and ⌘N reopens one. So on macOS the app stays up and its secondary windows stay with it; every other desktop treats the last primary as the app. A window’s role comes from itsWindowKind(Preferences⇒ secondary). - Probe
Cap::MultiWindowto adapt chrome: onUnsupportedbackends the surface is a fullscreen cover with no native title bar or close button; content that needs a close affordance should carry its own (system back closes it on Android). - Dialogs (
docs/dialogs.md) attach to the key window at present time, falling back to the primary.
The preferences paradigm
// root(), once, before app_menu:
day::register_preferences_with(
WindowOptions { title: tr("day-preferences").format(), size: Size::new(520.0, 640.0), ..Default::default() },
|| my_prefs_page(),
);
// anywhere (menu items get this wired automatically; toolbar gears call it directly):
day::open_preferences();
Registering a preferences piece enables the following, without any menu code in the app:
- macOS: “Settings…” + ⌘, in the App menu, directly under About, in both the default
menu (apps that never call
app_menu) and an installed one (the item is hoisted out of the model into its standard position). - GTK/Qt/XAML: a Preferences item with the platform accelerator (Ctrl+comma; Qt’s
menu-role relocates it into the app menu on macOS, and macOS-gtk additionally enables the
stock GTK app menu’s Settings… through an
app.preferencesaction). day-core injects it into the first (File) menu when the app didn’t place amenu_role(MenuRole::Preferences)itself. - The window opens under the singleton key
day.preferences(WindowKind::Preferences); reopening focuses. On cover-tier backendsopen_preferencespresents the same piece fullscreen; mobile apps typically keep their in-nav settings route as the visible entry point and gate onCap::MultiWindow(Day-Matrix’ssettings::show()is the pattern).
pieces/day-piece-settings supplies the shared theme/language rows most preferences
surfaces need: appearance_picker(key) (Light/Dark/System, id theme-picker, gated on
Cap::Appearance), language_picker(key, res::locales::ALL) (System + autonyms, id
language-picker), settings_sections(..), and apply_startup(theme_key, locale_key),
which applies persisted overrides at boot with the env-wins rule: DAY_THEME /
DAY_LOCALE launches keep their forced values regardless of persistence (CI variant loops
stay deterministic), while live picker changes always apply.
New Window + the macOS Window menu
day::register_new_window(|| shell()) names the builder behind menu_role(MenuRole::NewWindow)
(File ▸ New Window, ⌘N/Ctrl+N; lowers disabled when unregistered) and the macOS tab-bar ”+”
(newWindowForTab:). Each call opens an independent Normal window, and mark secondary shells’
routed navs .local() so navigate() stays unambiguous (the showcase’s window_root(primary)
is the pattern; so is the scaffold’s window_shell(primary)).
Because the builder runs again per window, whatever state that shell reaches for decides whether
the windows are independent; a thread_local! gives all of them one selection.
docs/state.md covers this: T::scoped(…) for per-window state, T::ambient()
to read it back anywhere below, and T::focused() for the app-wide menu bar, whose items belong
to no window and have to resolve the front one when they run. The same shell should call
day::window_title (above), so the windows it builds are distinguishable everywhere the system
lists them.
On macOS, day-appkit also auto-installs the standard Window menu (Minimize ⌘M, Zoom,
Bring All to Front) registered as NSApp.windowsMenu, so AppKit appends the open-window
list and, while automatic tabbing is live, the tab commands (Show Next/Previous Tab,
Merge All Windows). Normal Day windows share the day.normal tabbing identifier and
group as native tabs per the system “prefer tabs” setting. When no new-window builder is
registered, automatic tabbing is turned off entirely, so there is no tab bar and the menu carries no tab
commands. An
app that places MenuRole::Minimize in its own model owns window management and skips the
auto menu.
The debug title tag
A debug build appends (<version>/<toolkit>[/<script>]) to every window title it sets
(the primary window’s, each secondary window’s, and every WindowHandle::set_title):
Day Showcase (1.1.0/gtk/walkthrough.yaml)
Day News (0.1.0/appkit)
With several apps, several toolkits and a scripted run open at once, the title bar is the only
place that says which window is which. Release builds get none of it: day_core::debug_title_tag
returns None outside debug_assertions, so the decoration can never ship.
The version and script name arrive as DAY_APP_VERSION and DAY_SCRIPT, which day launch sets
from the project manifest and the --script arguments (docs/environment.md). Run the binary
another way and the tag carries only what it knows: (gtk). Apps do nothing: do not put the
toolkit in your own title, or it will be there twice.
The join follows two rules. An empty title stays empty, so a window the app left untitled does not grow a bar of build metadata. And an already-tagged title is left alone, since the same window can be retitled many times.
The tag is on the window title only. The macOS App menu, the About panel and the process name
read the app’s name, so launch_with pins WindowOptions::app_name to the undecorated title
before tagging; an app that sets only title still shows “Day News” in its App menu.
dayscript
- menu: { key: day-preferences } # invoke a menu action (menus.md)
- wait_for: { id: prefs-title } # ids are tree-global — no window scoping
- screenshot: { name: prefs, window: day.preferences } # capture a window by its open key
- close_window: { window: day.preferences } # async confirm → teardown, like the title bar
screenshot.window resolves the key through the registry; on the cover tier it captures the
primary window, whose fullscreen cover is the content, so the same pixels come back with no
special case. (XAML
currently also answers the primary for per-window captures, a noted follow-up.)
The Toolkit duties (backends)
Toolkit::open_window(id, options, kind) -> WindowOpenReply<Handle> creates and shows the
native window, wires its events to id (WindowResized in content points, WindowClosed
after the platform committed the close, WindowFocused on key changes), and answers the
content container handle, the same contract as ready’s root.
WindowResized must carry id, not the primary’s node. It is what re-buckets that window’s
size class (docs/size-classes.md), so a backend that reports every window’s
geometry against the primary re-lays-out the wrong window, which is what day-uikit’s holder view
did, invisibly, until iPadOS made two windows at two sizes an ordinary thing. day-core relayouts
and re-buckets on receipt, so a secondary window dragged from narrow to wide re-presents its
navigation exactly as the primary does. Backends whose window
creation is asynchronous (a scene, an activity, an ability) answer Pending and complete
later through day_core::finish_window_open(id, raw, size), the type-erased RawHandle
adoption path list cells use; day-core parks the record (build deferred) and a close before
completion cancels it (finish_window_open answers false; the backend drops its window).
A window the platform opens by itself takes the same path in reverse. iPadOS 26 offers
Window ▸ New Window for any app that supports multiple scenes, an app icon dragged into Split
View connects a scene, and a relaunch reconnects the sessions from the previous run — none of
them carry the node id a Toolkit::open_window request does. day-uikit builds the window and
then calls day_core::open_new_window(), so the content is whatever the app registered with
register_new_window, the same builder behind File ▸ New Window on
desktop; an app that registered none has nothing to put in the window, and the backend hands the
scene back. Until 2026-09 those scenes were destroyed on sight, which is what made the system’s
New Window flash a window and lose it.
close_window/focus_window/set_window_title/snapshot_window_of round out the duties;
day-core releases the content handle after teardown, which is each backend’s signal to
destroy the native window (Qt/XAML defer destruction to exactly this point so child-widget
releases stay sound).
| Backend | Tier | Mechanism |
|---|---|---|
| AppKit | Native | per-window NSWindow + delegate (retained; windows are not released-when-closed); tabbing groups; Window menu |
| GTK | Native | additional AdwApplicationWindows on the shared GtkApplication; app-level active-state debounced across windows |
| Qt | Native | shim DayWindow carrying its node id; explicit quit policy (quitOnLastWindowClosed(false)) |
| XAML | Native (CI-verified) | second Win32 host + its own DesktopWindowXamlSource island; accelerators in secondary islands are a noted v1 limit |
| iOS | Native on iPad (UIScene request/connect; the whole backend runs the scene lifecycle, and a scene the system opens on its own is filled from register_new_window) | iPhone answers Unsupported → cover; iPad runtime check pends a day launch device flag |
| Android | Native | document-style DayWindowActivity per window (own recents entry; split-screen/freeform); Preferences → cover |
| HarmonyOS | Native (when the ArkTS host registers the launchers) | multiton DayWindowAbility per window; Preferences → cover. Pre-existing backend quirk: presented covers pass asserts and receive taps but device captures show the page beneath (affects the cover piece identically — follow-up) |
| web | Unsupported → cover fallback | a second browser window cannot share the wasm instance |
| mock | Native | recorded windows + synthesized confirms; the e2e suite that pins the window duties |