day_core/lib.rs
1// Copyright © The Daybrite Project
2// SPDX-License-Identifier: MPL-2.0
3
4//! day-core: the Piece model, realized tree, mounter, layout engine, and event routing
5//! (DESIGN.md §5, §7). Build-once: pieces are constructed exactly once; all dynamism flows
6//! through reactive bindings (day-reactive) writing to the thread-local tree.
7
8/// The linked Day runtime version, for diagnostics from independently versioned extensions.
9pub const VERSION: &str = env!("CARGO_PKG_VERSION");
10
11day_reactive::tls_root! {
12 layout: crate::layout::TlsGroupSlots,
13 ambient: crate::ambient::TlsGroupSlots,
14 anim: crate::anim::TlsGroupSlots,
15 conformance: crate::conformance::TlsGroupSlots,
16 frame: crate::frame::TlsGroupSlots,
17 image: crate::image::TlsGroupSlots,
18 list: crate::list::TlsGroupSlots,
19 lifecycle: crate::lifecycle::TlsGroupSlots,
20 menu: crate::menu::TlsGroupSlots,
21 nav: crate::nav::TlsGroupSlots,
22 present: crate::present::TlsGroupSlots,
23 root: crate::TlsGroupSlots,
24 shield: crate::shield::TlsGroupSlots,
25 shortcuts: crate::shortcuts::TlsGroupSlots,
26 status: crate::status::TlsGroupSlots,
27 toolbar: crate::toolbar::TlsGroupSlots,
28 documents: crate::documents::TlsGroupSlots,
29 urls: crate::urls::TlsGroupSlots,
30 tree: crate::tree::TlsGroupSlots,
31 windows: crate::windows::TlsGroupSlots,
32}
33
34/// The blocking pool (docs/async.md): `spawn_blocking` runs a job on a worker thread and
35/// resolves on the awaiting side; std-only, so it lives in day-async and is re-exported here.
36pub use day_async::{
37 Blocking, BlockingError, blocking_inline, blocking_limit, blocking_threads, spawn_blocking,
38};
39#[cfg(target_arch = "wasm32")]
40#[doc(hidden)]
41pub use day_macros::retain_element_id as __retain_element_id;
42/// Re-exported so every crate above day-core can reach it without taking a direct
43/// day-reactive dependency (see day-reactive for what it does and why Android needs it).
44pub use day_reactive::tls_group;
45#[cfg(target_arch = "wasm32")]
46#[doc(hidden)]
47pub use inventory;
48/// The link-time registry crate `#[day::test]` expands through (docs/testing.md).
49pub use linkme;
50
51mod ambient;
52mod anim;
53mod build;
54pub mod conformance;
55pub mod element_id;
56pub mod frame;
57pub use element_id::{ElementId, IntoElementKey};
58mod frames;
59pub mod image;
60mod layout;
61pub mod lifecycle;
62pub mod list;
63mod memory_profile;
64mod modal_sheet;
65pub use modal_sheet::ComposedSheet;
66pub mod menu;
67mod nav;
68mod present;
69pub mod shield;
70pub mod shortcuts;
71pub mod status;
72pub mod testing;
73pub mod toolbar;
74mod tree;
75pub mod tree_driver;
76pub mod tween;
77pub mod windows;
78
79pub use ambient::{
80 Ambient, app_environment, environment, focused_environment, override_size_class, reset_ambient,
81 restore_reported_size_class, safe_area, seed_safe_area, set_safe_area, set_size_class,
82 set_window_safe_area, set_window_size_class, size_class, window_safe_area, window_size_class,
83 window_size_class_untracked, with_environment,
84};
85pub use anim::{current_anim, with_animation};
86pub use build::*;
87pub use frame::{
88 FrameConsumer, add_frame_consumer, frame_consumer_count, install_frame_requester,
89 remove_frame_consumer,
90};
91pub use image::{
92 Bitmap, CanvasExports, RenderBitmap, RenderPdf, canvas_exports, decode as decode_image,
93 decode_async as decode_image_async, image_decode_support, image_encode_formats,
94 image_encode_support, render_bitmap, render_bitmap_async, render_pdf, render_pdf_async,
95 resolve_bitmap_render, resolve_image_decode, resolve_image_encode, resolve_pdf_render,
96};
97pub use layout::*;
98pub use lifecycle::{dispatch_lifecycle, lifecycle_supported, on_lifecycle};
99pub use list::{
100 BuiltRow, ListDeleteDriver, ListDriver, ListReorderDriver, ListSectionsDriver, ListSwipeDriver,
101 install_list, list_reload, list_scroll_to_end, list_scroll_to_row, list_set_editing,
102 list_set_selected, list_splice, list_try_delete, list_try_reorder, list_try_swipe,
103 set_scripted_reorder,
104};
105pub use menu::{
106 dispatch_menu_action, has_app_menu, register_menu_action, register_scoped_menu_action,
107 set_app_menu, set_dock_menu,
108};
109pub use nav::*;
110pub use shortcuts::set_launcher_shortcuts;
111pub use status::{
112 KeepRunning, remove_status_item, set_app_progress, set_dock_visible, set_keep_running,
113 set_status_item, status_items,
114};
115pub mod documents;
116#[doc(hidden)]
117pub use documents::{forward_launch_args, launch_link, take_launch_files};
118mod urls;
119pub use documents::{on_open_files, request_open_files};
120pub use present::*;
121pub use toolbar::{
122 Chrome, chrome_changed, current_chrome, current_page_column, current_page_gate,
123 current_page_window, dispatch_toolbar_value, patch_chrome, patch_toolbar,
124 register_contribution, register_contribution_gated, register_toolbar_value,
125 relabel_contribution, toggle_sidebar, unregister_contribution, update_contribution,
126 window_being_built, with_page, with_page_gated, with_page_in,
127};
128pub use tree_driver::{
129 TreeBuiltRow, TreeDriver, TreeMovesDriver, install_tree, tree_driver, tree_reload, tree_reveal,
130 tree_set_expanded, tree_set_selected, tree_try_move, tree_visible_rows,
131};
132pub use urls::{on_open_url, request_open_url};
133// The resource types live in day-spec (backends depend only on day-spec); re-exported for the
134// facade.
135pub use day_spec::resource::{
136 AssetDir, AssetName, FontFamily, ImageName, Resource, ResourceOpener, VectorName, resource,
137 set_resource_opener,
138};
139pub use tree::*;
140pub use windows::{
141 WindowHandle, about_action_id, check_for_updates, check_for_updates_action_id, current_window,
142 finish_window_open, focused_scope, focused_window, initial_window, launch_options, monitors,
143 open_about, open_new_window, open_new_window_for_group, open_preferences, open_window, quit,
144 register_about, register_about_window, register_check_for_updates, register_new_window,
145 register_new_window_for, register_preferences, register_preferences_with, window_by_key,
146 window_title,
147};
148
149/// The app-wide layout direction (docs/localization): mirrors every horizontal placement in
150/// the place pass when [`day_geometry::LayoutDirection::Rtl`]. Resolved lazily from the
151/// `DAY_LOCALE` launch environment (so toolkits can read it before any UI exists);
152/// `set_layout_direction` (called by `install_locales` for the resolved locale) overrides.
153/// Fixed for the life of the process; switching locale at runtime does not re-mirror.
154pub fn layout_direction() -> day_geometry::LayoutDirection {
155 DIRECTION.with(|d| {
156 if let Some(dir) = d.get() {
157 return dir;
158 }
159 let dir = std::env::var("DAY_LOCALE")
160 .map(|l| direction_of_locale(&l))
161 .unwrap_or_default();
162 d.set(Some(dir));
163 dir
164 })
165}
166
167/// Override the layout direction (normally from `install_locales`). Must be called before the
168/// first layout pass to take effect everywhere.
169pub fn set_layout_direction(dir: day_geometry::LayoutDirection) {
170 DIRECTION.with(|d| d.set(Some(dir)));
171}
172
173/// Whether the app is being rendered right-to-left (docs/localization), a convenience over
174/// [`layout_direction`]. The layout engine already mirrors widget *placement* under an RTL locale,
175/// but a `canvas` draws in its own coordinate space, so a custom drawing that has a reading
176/// direction (a battery that drains one way, an arrow, a progress sweep) can call this to mirror
177/// itself. Fixed for the life of the process, like [`layout_direction`].
178pub fn is_rtl() -> bool {
179 layout_direction() == day_geometry::LayoutDirection::Rtl
180}
181
182/// The writing direction a locale implies (language subtag match).
183pub fn direction_of_locale(locale: &str) -> day_geometry::LayoutDirection {
184 let lang = locale
185 .split(['-', '_'])
186 .next()
187 .unwrap_or("")
188 .to_ascii_lowercase();
189 match lang.as_str() {
190 "ar" | "he" | "iw" | "fa" | "ur" | "ps" | "sd" | "ug" | "yi" | "dv" | "ku" => {
191 day_geometry::LayoutDirection::Rtl
192 }
193 _ => day_geometry::LayoutDirection::Ltr,
194 }
195}
196
197day_reactive::tls_slots! {
198 root;
199 static SEARCH_HOSTS: std::cell::RefCell<Vec<(RNode, RNode)>> =
200 const { std::cell::RefCell::new(Vec::new()) };
201 static DIRECTION: std::cell::Cell<Option<day_geometry::LayoutDirection>> =
202 const { std::cell::Cell::new(None) };
203
204 static UNDO_INVOKE: std::cell::RefCell<Option<UndoInvoke>> =
205 const { std::cell::RefCell::new(None) };
206
207 /// The dayscript executor's stand-in for held modifiers (a synthetic tap cannot hold a
208 /// real shift key); `None` = ask the toolkit.
209 static MODIFIER_OVERRIDE: std::cell::Cell<Option<day_spec::Modifiers>> =
210 const { std::cell::Cell::new(None) };
211
212 static EDIT_INVOKE: std::cell::RefCell<Option<EditInvoke>> =
213 const { std::cell::RefCell::new(None) };
214
215 static UNDO_ACTION_IDS: std::cell::Cell<(u64, u64)> = const { std::cell::Cell::new((0, 0)) };
216 static EDIT_ACTION_IDS: std::cell::Cell<(u64, u64, u64, u64)> =
217 const { std::cell::Cell::new((0, 0, 0, 0)) };
218
219 static DARK_SIGNAL: std::cell::OnceCell<day_reactive::Signal<bool>> =
220 const { std::cell::OnceCell::new() };
221
222 /// The system's reduce-motion answer as last read (docs/accessibility.md), kept beside the
223 /// signal so the animation gates can ask without a tracked read or a tree borrow: they run
224 /// inside the tree.
225 static REDUCE_MOTION: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
226 static REDUCE_MOTION_SIGNAL: std::cell::OnceCell<day_reactive::Signal<bool>> =
227 const { std::cell::OnceCell::new() };
228
229 static FONT_FAMILIES: std::cell::OnceCell<std::rc::Rc<[day_spec::FontFamilyInfo]>> =
230 const { std::cell::OnceCell::new() };
231
232 static TEXT_METRICS: std::cell::RefCell<TextMetricsCache> =
233 std::cell::RefCell::new(TextMetricsCache::new());
234}
235
236use day_spec::{Platform, WindowOptions};
237
238// ---- crash observation (§8.5) --------------------------------------------------------------
239
240/// Observer called after day-core contains a panic at one of its trampoline boundaries
241/// (`contain_posted_panic` here, `tree::pump_events`), on the panicking thread, after the
242/// reactive-runtime reset. A crash reporter (day-piece-crash-reporter, docs/break.md) registers one to
243/// downgrade the report its panic hook just wrote: the panic was caught, the process is not
244/// dying. A plain `fn` (no closure) so the containment path allocates nothing.
245static CONTAINED_PANIC_OBSERVER: std::sync::OnceLock<fn()> = std::sync::OnceLock::new();
246
247/// Register the contained-panic observer. First registration wins; later calls are no-ops
248/// (there is one crash reporter per process).
249pub fn set_contained_panic_observer(f: fn()) {
250 let _ = CONTAINED_PANIC_OBSERVER.set(f);
251}
252
253pub(crate) fn notify_contained_panic() {
254 if let Some(f) = CONTAINED_PANIC_OBSERVER.get() {
255 f();
256 }
257}
258
259/// The compile-time target key of the running backend (`"macos-appkit"`, `"ios-uikit"`, …,
260/// the `Platform::TARGET` string), recorded by [`launch_with`]. `None` before launch.
261pub fn backend_name() -> Option<&'static str> {
262 BACKEND_NAME.get().copied()
263}
264
265static BACKEND_NAME: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new();
266
267/// The toolkit key of the running backend (`"appkit"`, `"gtk"`, …, the `Platform::TOOLKIT`
268/// string), recorded by [`launch_with`]. `None` before launch.
269pub fn toolkit_key() -> Option<&'static str> {
270 TOOLKIT_KEY.get().copied()
271}
272
273static TOOLKIT_KEY: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new();
274
275/// The development tag every window title carries in a debug build:
276/// `(<version>/<toolkit>[/<script>])`, for example `(1.1.0/appkit)`, or
277/// `(1.1.0/gtk/walkthrough.yaml)` while a dayscript is driving. With several apps, toolkits and
278/// scripted runs open at once, the title bar is the only place that says which window is which.
279///
280/// `None` in a release build (this is a development aid and must never ship), and before
281/// [`launch_with`] has named the backend. The version and the script name come from
282/// `DAY_APP_VERSION` and `DAY_SCRIPT`, which the `day` CLI sets on every launch; run the binary
283/// some other way and the tag carries the parts it knows.
284pub fn debug_title_tag() -> Option<String> {
285 if !cfg!(debug_assertions) {
286 return None;
287 }
288 let toolkit = toolkit_key()?;
289 // The mock backend has no window and no title bar, so there is nothing for a tag to
290 // disambiguate; it would only corrupt what a headless test asserts about a title.
291 if toolkit == "mock" {
292 return None;
293 }
294 let mut parts = Vec::new();
295 if let Ok(v) = std::env::var("DAY_APP_VERSION")
296 && !v.is_empty()
297 {
298 parts.push(v);
299 }
300 parts.push(toolkit.to_string());
301 if let Ok(s) = std::env::var("DAY_SCRIPT")
302 && !s.is_empty()
303 {
304 parts.push(s);
305 }
306 Some(format!("({})", parts.join("/")))
307}
308
309/// Append [`debug_title_tag`] to a window title. Every title day sets goes through here: the
310/// primary window's, each secondary window's, and every [`crate::windows::WindowHandle::set_title`].
311///
312/// An empty title stays empty: a window the app left untitled should not grow a title bar full
313/// of build metadata. An already-tagged title is left alone, since the same window can be
314/// retitled repeatedly.
315pub(crate) fn decorate_window_title(title: &str) -> String {
316 tag_title(title, debug_title_tag().as_deref())
317}
318
319/// The join rule, split out from the environment so it can be tested.
320fn tag_title(title: &str, tag: Option<&str>) -> String {
321 match tag {
322 Some(tag) if !title.is_empty() && !title.ends_with(tag) => format!("{title} {tag}"),
323 _ => title.to_string(),
324 }
325}
326
327#[cfg(test)]
328mod title_tag_tests {
329 use super::tag_title;
330
331 #[test]
332 fn the_tag_is_appended_once_and_never_to_an_empty_title() {
333 assert_eq!(
334 tag_title("Day Sheets", Some("(0.1.0/appkit)")),
335 "Day Sheets (0.1.0/appkit)"
336 );
337 // A release build has no tag, so the title is the app's own, untouched.
338 assert_eq!(tag_title("Day Sheets", None), "Day Sheets");
339 // An untitled window stays untitled rather than growing a bar of build metadata.
340 assert_eq!(tag_title("", Some("(0.1.0/appkit)")), "");
341 // Retitling an already-tagged window must not stack tags.
342 assert_eq!(
343 tag_title("Day Sheets (0.1.0/appkit)", Some("(0.1.0/appkit)")),
344 "Day Sheets (0.1.0/appkit)"
345 );
346 }
347}
348
349// ---- logging (docs/logging.md) ---------------------------------------------------------------
350
351/// Where a formatted line goes. Unset means the process's own stderr, which is right on every
352/// native target: a terminal on the desktop, Xcode's console on Apple, and logcat on Android
353/// (day-toolkit-material `dup2`s fd 2 into it). `wasm32-unknown-unknown` is the one target where that is
354/// wrong rather than merely different (std's stdio there accepts the bytes and drops them, so
355/// every line vanishes), and day-toolkit-dom installs a sink reaching the browser console instead.
356static LOG_SINK: std::sync::OnceLock<fn(log::Level, &str)> = std::sync::OnceLock::new();
357
358/// Install the line sink. First registration wins (one host per process). Called before launch;
359/// `day::web::start` does it for the browser.
360pub fn set_log_sink(f: fn(log::Level, &str)) {
361 let _ = LOG_SINK.set(f);
362}
363
364/// The width the level column is padded to. Shared by the formatter and the coloring below, which
365/// splits the finished line at exactly this offset.
366const LEVEL_WIDTH: usize = 5;
367
368/// The terminal color for a level: `env_logger`'s palette, so a Day app's output reads like any
369/// other Rust app's, and the same one `day-cli` uses when it re-emits these lines under
370/// `day launch` (day-cli `src/term.rs`).
371#[cfg(not(target_arch = "wasm32"))]
372fn level_style(level: log::Level) -> anstyle::Style {
373 let color = match level {
374 log::Level::Error => anstyle::AnsiColor::Red,
375 log::Level::Warn => anstyle::AnsiColor::Yellow,
376 log::Level::Info => anstyle::AnsiColor::Green,
377 log::Level::Debug => anstyle::AnsiColor::Blue,
378 log::Level::Trace => anstyle::AnsiColor::Cyan,
379 };
380 anstyle::Style::new().fg_color(Some(anstyle::Color::Ansi(color)))
381}
382
383/// Write one already-formatted line to stderr, never panicking. That matters because the
384/// `*println!` macros panic on a failed write, a closed stderr pipe is routine when `day launch`
385/// tears the app down, and a panic raised inside a native trampoline aborts the process.
386///
387/// Only the level column is colored: a fully colored line is noise at `INFO`, and leaving the
388/// message plain keeps it greppable. `anstream` decides whether the escapes survive: they are
389/// stripped when stderr is not a color terminal, which covers the pipe `day launch` gives us
390/// (day-cli re-colors from its own end), logcat, Xcode's console, `NO_COLOR`, and `TERM=dumb`.
391#[cfg(not(target_arch = "wasm32"))]
392fn write_line(level: log::Level, line: &str) {
393 use std::io::Write as _;
394 let style = level_style(level);
395 // The column is ASCII and padded to `LEVEL_WIDTH` by the caller, so this never lands inside a
396 // character; `split_at_checked` keeps even a malformed line from panicking.
397 let (lvl, rest) = line.split_at_checked(LEVEL_WIDTH).unwrap_or((line, ""));
398 let _ = writeln!(anstream::stderr(), "{style}{lvl}{style:#}{rest}");
399}
400
401/// The wasm fallback, used only if no sink was installed; `day::web::start` installs one before
402/// an app's first line. std's stdio on `wasm32-unknown-unknown` accepts these bytes and drops
403/// them, so this writes into nothing; it exists so the logger has a total function.
404#[cfg(target_arch = "wasm32")]
405fn write_line(_level: log::Level, line: &str) {
406 use std::io::Write as _;
407 let _ = writeln!(std::io::stderr(), "{line}");
408}
409
410/// Day's default [`log::Log`].
411///
412/// Not `env_logger` or another off-the-shelf backend, for the reason above: they print through
413/// `*println!`, and a panic on that path is an abort rather than a lost line. An app that wants
414/// one installs it itself; see [`init_logging`].
415struct DayLogger;
416
417impl log::Log for DayLogger {
418 fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
419 // The level filter is `log::set_max_level`'s job, checked by the macros before they even
420 // format; answering `true` here keeps `log_enabled!` consistent with them for anything
421 // that asks.
422 metadata.level() <= log::max_level()
423 }
424
425 fn log(&self, record: &log::Record<'_>) {
426 if !self.enabled(record.metadata()) {
427 return;
428 }
429 // `WARN day_toolkit_gtk: could not register bundled font …`: the level first so a scan down
430 // the column finds problems, then the emitting crate, which is what says which backend
431 // or piece is unhappy. The old hand-written `day: ` prefixes said only that it was us.
432 let sink = LOG_SINK.get();
433 // Formatted plain: a sink is a structured destination (the browser console, a test
434 // collector, an app's own transport) and wants the text, not terminal escapes. Color is
435 // `write_line`'s business, applied only on the way to a real stderr.
436 let line = format!(
437 "{:<LEVEL_WIDTH$} {}: {}",
438 record.level(),
439 record.target(),
440 record.args()
441 );
442 match sink {
443 Some(f) => f(record.level(), &line),
444 None => write_line(record.level(), &line),
445 }
446 }
447
448 fn flush(&self) {}
449}
450
451/// Install Day's default logger and level, unless the app already installed its own.
452///
453/// Called from [`launch_with`], so logging works with no ceremony at all: an app writes
454/// `log::info!(…)` (or `day::info!`, the same macro re-exported through the prelude) and the line
455/// comes out on every platform. `set_logger` answers `Err` when a logger is already registered;
456/// that is all the customization there is, and why the error is ignored: an app that
457/// called `env_logger::init()` (or installed `tracing`, or its own `log::Log`) before
458/// `day::launch` keeps it, and Day does not fight for the slot.
459///
460/// The default level is `Debug` in a debug build and `Info` in a release one; `DAY_LOG` overrides
461/// it with a level name (`off`/`error`/`warn`/`info`/`debug`/`trace`). Reading an environment
462/// variable is a native-only affordance; on the web there is no process environment, so the
463/// launch path sets the level explicitly from the page's query string instead.
464pub fn init_logging() {
465 let level = std::env::var("DAY_LOG")
466 .ok()
467 .and_then(|v| v.parse::<log::LevelFilter>().ok())
468 .unwrap_or(if cfg!(debug_assertions) {
469 log::LevelFilter::Debug
470 } else {
471 log::LevelFilter::Info
472 });
473 set_log_level(level);
474 // Err = the app got there first, which is exactly the intended escape hatch.
475 let _ = log::set_logger(&DayLogger);
476}
477
478/// Set the maximum level that will be emitted. Separate from [`init_logging`] because the web
479/// launch path has to supply the level from the page URL rather than the environment, and because
480/// an app may want to raise or lower it at runtime.
481pub fn set_log_level(level: log::LevelFilter) {
482 log::set_max_level(level);
483}
484
485/// Run a posted main-thread task, containing any panic (the `pump_events` twin for the poster /
486/// scheduler doors): log the cause and reset the reactive runtime so the app keeps running
487/// (degraded) instead of aborting across the native trampoline's non-unwind boundary.
488pub(crate) fn contain_posted_panic(f: Box<dyn FnOnce() + Send>) {
489 if let Err(payload) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
490 let msg = payload
491 .downcast_ref::<&str>()
492 .map(|s| (*s).to_string())
493 .or_else(|| payload.downcast_ref::<String>().cloned())
494 .unwrap_or_else(|| "unknown panic".to_string());
495 log::warn!(
496 "a posted main-thread task panicked and was contained — the app continues, but \
497 reactive/UI state may be inconsistent until the next interaction. Cause: {msg}"
498 );
499 day_reactive::recover_from_panic();
500 notify_contained_panic();
501 }
502}
503
504/// The app's undo/redo entry point as the platform front calls it; `true` means redo.
505/// `Rc` because the invocation clones it out of the cell before running, so the borrow is
506/// released before app code (which may install a new bridge) gets control.
507type UndoInvoke = std::rc::Rc<dyn Fn(bool)>;
508
509/// Wire an undo history to the platform (docs/model.md): the four signals mirror into the
510/// toolkit's native front where one exists (`Cap::UndoBridge`: the stock Edit menu retitles
511/// and enables itself, the platform's gestures land), and every invocation the platform
512/// delivers comes back through `on_invoke(redo)`. On a toolkit without a native undo system
513/// the state goes nowhere and the app's own affordances call the stack directly; installing
514/// the bridge is still harmless. Call once, after launch; installing again replaces the wiring.
515pub fn install_undo_bridge(
516 can_undo: day_reactive::Signal<bool>,
517 can_redo: day_reactive::Signal<bool>,
518 undo_label: day_reactive::Signal<String>,
519 redo_label: day_reactive::Signal<String>,
520 on_invoke: impl Fn(bool) + 'static,
521) {
522 UNDO_INVOKE.with(|u| *u.borrow_mut() = Some(std::rc::Rc::new(on_invoke)));
523 day_reactive::bind(
524 move || day_spec::UndoState {
525 can_undo: can_undo.get(),
526 can_redo: can_redo.get(),
527 undo_label: undo_label.get(),
528 redo_label: redo_label.get(),
529 },
530 |state: &day_spec::UndoState| {
531 let state = state.clone();
532 with_tree(|t| t.set_undo_state(&state));
533 },
534 );
535}
536
537/// The standing dispatch id behind a `MenuRole::Cut`/`Copy`/`Paste` item on a toolkit whose
538/// role items come back as plain menu actions (docs/menus.md): the closure invokes the
539/// installed edit bridge. Durable across menu installs, like the undo pair.
540pub fn edit_action_id(op: day_spec::EditOp) -> u64 {
541 use day_spec::EditOp as E;
542 let (c, y, p, a) = EDIT_ACTION_IDS.with(|s| s.get());
543 let have = match op {
544 E::Cut => c,
545 E::Copy => y,
546 E::Paste => p,
547 E::SelectAll => a,
548 };
549 if have != 0 {
550 return have;
551 }
552 let id = menu::register_menu_action(std::rc::Rc::new(move || dispatch_edit_invoke(op)));
553 EDIT_ACTION_IDS.with(|s| {
554 let (c0, y0, p0, a0) = s.get();
555 s.set(match op {
556 E::Cut => (id, y0, p0, a0),
557 E::Copy => (c0, id, p0, a0),
558 E::Paste => (c0, y0, id, a0),
559 E::SelectAll => (c0, y0, p0, id),
560 });
561 });
562 id
563}
564
565fn dispatch_undo_invoke(redo: bool) {
566 let _ = invoke_undo(redo);
567}
568
569/// Invoke the installed undo bridge (the same handler a native front reaches: ⌘Z, the Edit
570/// menu, a three-finger swipe), returning whether one is installed. The dayscript
571/// `undo:`/`redo:` steps drive history through this, so a walkthrough needs no app-provided
572/// undo button on any target.
573pub fn invoke_undo(redo: bool) -> bool {
574 let f = UNDO_INVOKE.with(|u| u.borrow().clone());
575 match f {
576 Some(f) => {
577 day_reactive::batch(|| f(redo));
578 true
579 }
580 None => false,
581 }
582}
583
584type EditInvoke = std::rc::Rc<dyn Fn(day_spec::EditOp)>;
585
586/// The keyboard modifiers held right now, for interactions whose meaning they change
587/// (shift-click adds to a selection). Touch backends answer all-false; a dayscript step's
588/// declared modifiers take precedence while it dispatches.
589pub fn modifiers() -> day_spec::Modifiers {
590 if let Some(m) = MODIFIER_OVERRIDE.with(|o| o.get()) {
591 return m;
592 }
593 with_tree(|t| t.modifiers())
594}
595
596/// Scoped modifier stand-in for the dayscript executor: `Some` while a step with declared
597/// modifiers dispatches, back to `None` after.
598pub fn set_modifier_override(m: Option<day_spec::Modifiers>) {
599 MODIFIER_OVERRIDE.with(|o| o.set(m));
600}
601
602/// Wire the app's standard-edit handlers to the platform (docs/menus.md): `state` is a
603/// tracked read whose value mirrors into the toolkit (`Cap::EditBridge`: native menu
604/// validation enables the stock Cut/Copy/Paste exactly as it does for text widgets), and
605/// every invocation a platform route delivers comes back through `on_invoke`. On a toolkit
606/// with no native route, the `menu_role(Cut/Copy/Paste)` items dispatch here instead (the
607/// same standing-id fallback the undo pair uses). Call once, after launch; installing again
608/// replaces the wiring. Most apps want [`day::install_edit_commands`](/api/day/fn.install_edit_commands.html), which adds the
609/// clipboard transport.
610pub fn install_edit_bridge(
611 state: impl Fn() -> day_spec::EditState + 'static,
612 on_invoke: impl Fn(day_spec::EditOp) + 'static,
613) {
614 EDIT_INVOKE.with(|u| *u.borrow_mut() = Some(std::rc::Rc::new(on_invoke)));
615 day_reactive::bind(state, |state: &day_spec::EditState| {
616 let state = *state;
617 with_tree(|t| t.set_edit_state(&state));
618 });
619}
620
621fn dispatch_edit_invoke(op: day_spec::EditOp) {
622 let _ = invoke_edit(op);
623}
624
625/// Invoke the installed edit bridge (the same handler the platform's Cut/Copy/Paste
626/// route reaches, clipboard transport included), returning whether one is installed. An
627/// app's own affordances (a context menu's Cut/Copy) call this rather than duplicating the
628/// clipboard plumbing (docs/menus.md).
629pub fn invoke_edit(op: day_spec::EditOp) -> bool {
630 let f = EDIT_INVOKE.with(|u| u.borrow().clone());
631 match f {
632 Some(f) => {
633 day_reactive::batch(|| f(op));
634 true
635 }
636 None => false,
637 }
638}
639
640/// The standing dispatch id behind a `MenuRole::Undo`/`Redo` item on a toolkit with no native
641/// undo responder (docs/menus.md): activating the item comes back as a plain menu action, and
642/// this id's closure invokes the installed undo bridge. Registered once and durable across
643/// menu installs, like the preferences id. Toolkits with a native undo system (appkit) keep
644/// their responder-chain selector instead, so a focused text field's own undo stays ahead of
645/// the app stack there.
646pub fn undo_action_id(redo: bool) -> u64 {
647 let (u, r) = UNDO_ACTION_IDS.with(|c| c.get());
648 let have = if redo { r } else { u };
649 if have != 0 {
650 return have;
651 }
652 let id = menu::register_menu_action(std::rc::Rc::new(move || dispatch_undo_invoke(redo)));
653 UNDO_ACTION_IDS.with(|c| {
654 let (u0, r0) = c.get();
655 c.set(if redo { (u0, id) } else { (id, r0) });
656 });
657 id
658}
659
660/// A runtime route request from the backend (`Event::RouteRequested`: web-dom's URL hash
661/// changing via browser back/forward or a hand-edited hash). Echoes of our own `set_route`
662/// match the current route and are dropped.
663fn handle_route_request(route: &str) {
664 nav::apply_route_request(route);
665}
666
667/// Launch a Day app on the given platform backend: sets up the reactive scheduler and the
668/// cross-thread poster, mounts the root piece into the window's content container, runs the
669/// initial layout, and installs the turn-end layout callback (§3.3). The backend then owns
670/// the native main loop.
671pub fn launch_with<P: Platform>(
672 backend: P,
673 mut options: WindowOptions,
674 root_piece: impl FnOnce() -> AnyPiece + 'static,
675) {
676 // Before anything else that might want to report: from here on `log::warn!` and friends come
677 // out, on every backend, with no app-side setup. An app that installed its own logger first
678 // keeps it (docs/logging.md).
679 init_logging();
680 // Record the backend identity for runtime introspection (crash reports, diagnostics).
681 let _ = BACKEND_NAME.set(P::TARGET);
682 let _ = TOOLKIT_KEY.set(P::TOOLKIT);
683 // Tag the window title with version/toolkit/script in debug builds. Pin the app's display
684 // name to the undecorated title first: backends fall back to `title` for the macOS App menu
685 // and the About panel, which must keep reading "Day Sheets", not "Day Sheets (0.1.0/appkit)".
686 if options.app_name.is_none() && !options.title.is_empty() {
687 options.app_name = Some(options.title.clone());
688 }
689 options.title = decorate_window_title(&options.title);
690 // `DAY_WINDOW=900x700` overrides the app's initial window size, for responsive-layout testing
691 // (scripted runs can exercise a narrow window without a resize gesture). Desktop only in
692 // effect; mobile/web backends size to the screen and ignore `options.size` anyway.
693 if let Ok(v) = std::env::var("DAY_WINDOW")
694 && let Some((w, h)) = v.split_once('x')
695 && let (Ok(w), Ok(h)) = (w.trim().parse::<f64>(), h.trim().parse::<f64>())
696 && w > 0.0
697 && h > 0.0
698 {
699 options.size = day_spec::Size::new(w, h);
700 }
701 // Remember what the app asked for, so File ▸ New Window can open another window of the same
702 // app rather than an untitled one the platform cannot list (docs/windows.md).
703 windows::set_launch_options(&options);
704 // Reactive plumbing rides the platform's main-loop poster. Both doors contain panics (the
705 // `pump_events` rationale, tree.rs): posted closures run inside native main-loop trampolines
706 // (a glib idle, a GCD block) that abort the process on unwind (`panic_cannot_unwind`), so a
707 // panic in a `Setter` write's drain or a scheduled `flush_sync` would SIGABRT the app instead
708 // of surfacing. Contain at this single backend-agnostic boundary and reset the runtime.
709 // Backend FFI trampolines (JNI up-calls, C callbacks, posted closures) contain panics
710 // through day-spec's `ffi_guard`; hand it the recovery hook here (the one layer that
711 // knows day-reactive) so a contained panic can't strand the observer stack or leave a
712 // half-open batch behind. Each door also defers its closure when the tree is borrowed
713 // (`tree::run_posted_or_defer`): a backend pumping its native loop from inside a tree
714 // method would otherwise run the closure on the borrowed tree.
715 day_spec::ffi_guard::set_recovery(day_reactive::recover_from_panic);
716 day_reactive::install_main_poster(|f| {
717 P::post(Box::new(move || tree::run_posted_or_defer(f)));
718 });
719 // The timer door (docs/async.md `day::sleep`): same containment and deferral as the poster.
720 day_reactive::install_delayed_poster(|ms, f| {
721 P::post_delayed(ms, Box::new(move || tree::run_posted_or_defer(f)));
722 });
723 day_reactive::install_scheduler(|| {
724 P::post(Box::new(|| {
725 tree::run_posted_or_defer(Box::new(day_reactive::flush_sync));
726 }))
727 });
728 // The async-spawn door (docs/async.md): day-reactive's `Resource` runs its fetch futures on
729 // this executor; the returned closure aborts (a no-op once the task completed, the contract
730 // Resource's eager-poll ordering relies on).
731 day_reactive::install_spawner(|fut| {
732 let handle = present::task(fut);
733 Box::new(move || handle.abort())
734 });
735 // The frame clock (§8.4): the animation driver re-arms the platform's vsync callback while any
736 // frame consumer (game loop / self-driven animation) is live.
737 frame::install_frame_requester(|root, cb| tree::with_tree(|t| t.request_frame(root, cb)));
738
739 // What the exit line names (docs/lifecycle.md), and when the clock started; and the exit
740 // itself, for a loop that ends the process without returning.
741 #[cfg(not(target_arch = "wasm32"))]
742 if toolkit_key() != Some("mock") {
743 deliver_exit_at_process_exit();
744 }
745 lifecycle::note_launch(
746 options.app_name.clone().unwrap_or_default(),
747 options.version.clone(),
748 );
749
750 // WillLaunch: before the window/UI exists (docs/lifecycle.md). Fired uniformly by day-core so
751 // it is reliable on every backend; handlers must not touch the tree (there isn't one yet).
752 lifecycle::dispatch_lifecycle(day_spec::Lifecycle::WillLaunch);
753
754 P::run(
755 backend,
756 options,
757 Box::new(move |mut toolkit, root_handle, size| {
758 day_spec::Toolkit::set_event_sink(&mut toolkit, Box::new(tree::enqueue_native_event));
759 let tree = Tree::new(toolkit, root_handle, size);
760 let root = tree.root();
761 tree::install_tree(Box::new(tree));
762 // The launch read of the user's motion setting (docs/accessibility.md), before the
763 // root piece builds: its first transitions already land where the user asked.
764 note_motion_changed();
765
766 // Seed the window's size class from the size the backend just reported
767 // (docs/size-classes.md), before the root piece builds below: a nav host resolving
768 // an automatic presentation reads it during its own build. Backends push later
769 // changes themselves; one that never does keeps this launch value.
770 ambient::set_window_size_class(
771 root,
772 day_spec::SizeClass::from_size(size.width, size.height),
773 );
774
775 // The backend is now known: warn about any lifecycle handlers already registered for
776 // phases this platform doesn't deliver (docs/lifecycle.md).
777 lifecycle::warn_unsupported_registrations();
778
779 // Window resize → relayout. Route requests (runtime deep links: web-dom's URL
780 // hash changing under the app via browser back/forward or a hand-edited hash) →
781 // navigate, guarded against echo: the request the backend reflects back after
782 // our own `set_route` matches the current route and is dropped here.
783 with_tree(|t| {
784 let rn = root;
785 t.on_event(
786 rn,
787 std::rc::Rc::new(move |ev| match ev {
788 day_spec::Event::WindowResized(size) => {
789 let s = *size;
790 with_tree(|t| t.set_window_size(s));
791 // Re-bucket the window (docs/size-classes.md). Derived here, from the
792 // geometry every backend already reports, so there is one breakpoint
793 // table rather than one per toolkit, and only a class change
794 // notifies, so dragging an edge within a bucket costs nothing.
795 ambient::set_window_size_class(
796 rn,
797 day_spec::SizeClass::from_size(s.width, s.height),
798 );
799 }
800 day_spec::Event::RouteRequested(route) => handle_route_request(route),
801 // A native undo front's invocation (⌘Z through the stock Edit menu, a
802 // three-finger swipe): route to whatever stack the app installed.
803 day_spec::Event::Undo { redo } => dispatch_undo_invoke(*redo),
804 // A platform edit-command route (Edit ▸ Cut/Copy/Paste, the
805 // browser's clipboard events) with no text widget claiming it.
806 day_spec::Event::Edit(op) => dispatch_edit_invoke(*op),
807 _ => {}
808 }),
809 );
810 });
811
812 // The first window is an ordinary primary window (docs/windows.md close policy):
813 // it goes in the registry like any other, so closing it runs the same teardown and
814 // the same "was that the last primary?" question. Its content builds in a child of
815 // the root scope; disposing that on close takes this window's tree and nothing
816 // else, while `Signal::global` state stays on the root scope above it.
817 let win_scope = day_reactive::Scope::root().enter(day_reactive::Scope::child);
818 windows::adopt_initial_window(root, win_scope);
819
820 // Build the root piece under the window container.
821 let piece = root_piece();
822 win_scope.enter(|| {
823 let mut cx = BuildCx::new(root);
824 let _ = piece.build(&mut cx);
825 });
826
827 // Initial layout, then keep laying out at every turn boundary.
828 with_tree(|t| {
829 t.mark_layout_dirty();
830 t.layout_if_needed();
831 });
832 day_reactive::on_turn_end(|| with_tree(|t| t.layout_if_needed()));
833 // The Dock menu lists the app's launcher shortcuts from the start (docs/menus.md),
834 // ahead of whatever `dock_menu(..)` the root just installed.
835 menu::install_dock_menu();
836 // A keep-running policy or a status item set before the tree existed reaches the
837 // backend now (docs/windows.md "Keeping the app running").
838 status::boot_sync();
839
840 #[cfg(not(any(
841 target_arch = "wasm32",
842 target_os = "ios",
843 target_os = "android",
844 target_env = "ohos"
845 )))]
846 documents::request_open_files(documents::take_launch_files());
847 day_reactive::on_main(documents::drain_open_files);
848 if let Ok(url) = std::env::var("DAY_OPEN_URL") {
849 urls::request_open_url(&url);
850 }
851 day_reactive::on_main(urls::drain);
852
853 // DidLaunch: the UI is mounted and laid out, the app is about to run (docs/lifecycle.md).
854 lifecycle::dispatch_lifecycle(day_spec::Lifecycle::DidLaunch);
855
856 // Startup deep link (docs/navigation.md): uniform across platforms; desktop
857 // sets `DAY_DEEPLINK` directly, mobile shells forward the launch URL/intent into
858 // it, and web-dom (no process environment) records the URL hash via
859 // `set_launch_deeplink`. Deferred one turn so the first frame mounts before the
860 // destination pushes. The turn-end route sync (`Toolkit::set_route` on change;
861 // web-dom mirrors it into the URL hash) installs in the same deferred closure,
862 // after the deep link resolves, so the launch route is never clobbered by a sync
863 // of the pre-navigation state.
864 day_reactive::on_main(move || {
865 if let Some(route) = nav::launch_deeplink()
866 && !nav::navigate(&route)
867 {
868 log::warn!("launch deep link {route:?} did not match a route");
869 }
870 // Consume the `request_route` buffer the line above may have read: a tap that
871 // cold-started the process has now been applied, and leaving it set would
872 // re-navigate on the next request.
873 let _ = nav::take_requested_route();
874 // First reflection runs eagerly: a launch with no deep link ends no turn.
875 let route = nav::current_route().unwrap_or_default();
876 with_tree(|t| t.set_route(&route));
877 let last = std::cell::RefCell::new(Some(route));
878 day_reactive::on_turn_end(move || {
879 let route = nav::current_route().unwrap_or_default();
880 if last.borrow().as_deref() != Some(route.as_str()) {
881 *last.borrow_mut() = Some(route.clone());
882 with_tree(|t| t.set_route(&route));
883 }
884 });
885 });
886
887 // Verification hook (headless CI / no-input environments): drive the app through
888 // Day's own event path once the native loop starts (delayed past first allocation
889 // so snapshots see a laid-out window). Precursor of dayscript (§14).
890 if let Ok(spec) = std::env::var("DAY_AUTODRIVE") {
891 day_reactive::on_main_delayed(800, move || autodrive(&spec));
892 }
893 }),
894 );
895 // DidExit: the native loop returned, which is the last thing the app runs on the desktop
896 // backends whose loop returns (GTK, Qt, XAML). The backends whose process ends inside the
897 // loop (AppKit's terminate exits) emit it themselves, after WillTerminate, and
898 // `dispatch_lifecycle` delivers it once whichever comes first. Where the host owns the
899 // loop and `run` hands back at once with the app still to come (ArkUI, Android, the web,
900 // the mock's test), the return is no exit at all: DidExit there is the host's own last
901 // callback, and delivering it here would run the exit handlers at launch and leave the
902 // real exit undelivered (`RUN_ENDS_APP`, docs/lifecycle.md).
903 if P::RUN_ENDS_APP {
904 lifecycle::dispatch_lifecycle(day_spec::Lifecycle::DidExit);
905 }
906}
907
908/// Deliver `DidExit` from the C runtime's exit, for the loop that never returns: Qt's macOS
909/// Quit menu item ends the process through `exit()` from inside `exec()`, and any backend or
910/// app that calls `std::process::exit` does the same. `atexit` runs on the exiting thread,
911/// the main one for every such path, with the thread-locals still alive; delivery is once, so
912/// a loop that did return costs nothing here. Registered by `launch_with` on every backend but
913/// the mock and wasm, which have no process exit of their own.
914#[cfg(not(target_arch = "wasm32"))]
915fn deliver_exit_at_process_exit() {
916 unsafe extern "C" {
917 fn atexit(f: extern "C" fn()) -> i32;
918 }
919 extern "C" fn at_exit() {
920 lifecycle::dispatch_lifecycle(day_spec::Lifecycle::DidExit);
921 }
922 // SAFETY: `atexit` takes a plain C function pointer and keeps it for the process's life.
923 unsafe {
924 atexit(at_exit);
925 }
926}
927
928/// `DAY_AUTODRIVE="<id>:press;<id>:text:Ada;<id>:value:80;<id>:toggle:true;<id>:tap;
929/// <id>:drag:40:60;shot:/tmp/x.png"`: synthesized Day events by element id, plus snapshots.
930fn autodrive(spec: &str) {
931 use day_spec::{DragPhase, Event, Point};
932 for step in spec.split(';').filter(|s| !s.is_empty()) {
933 let parts: Vec<&str> = step.splitn(3, ':').collect();
934 if parts[0] == "shot" {
935 let path = parts[1..].join(":");
936 let png = with_tree(|t| t.snapshot());
937 match png {
938 Ok(bytes) => {
939 let _ = std::fs::write(&path, bytes);
940 }
941 Err(e) => log::warn!("day autodrive: snapshot failed: {e}"),
942 }
943 continue;
944 }
945 let node = with_tree(|t| t.find_by_id(parts[0]));
946 let Some(node) = node else {
947 log::warn!("day autodrive: id {:?} not found", parts[0]);
948 continue;
949 };
950 // Gesture drivers (docs/shapes.md): tap fires at the node's local center; drag runs a
951 // Began→Changed→Ended sequence translated by dx,dy, exercising `.on_tap`/`.on_drag`
952 // hit-testing through Day's event path (the native recognizers deliver the same events).
953 if parts.get(1) == Some(&"tap") {
954 if let Some(f) = with_tree(|t| t.node_frame(node)) {
955 let c = Point::new(f.size.width / 2.0, f.size.height / 2.0);
956 tree::enqueue_event(tree::rnode_to_id(node), Event::Tap(c));
957 }
958 continue;
959 }
960 if parts.get(1) == Some(&"drag") {
961 if let Some(f) = with_tree(|t| t.node_frame(node)) {
962 let c = Point::new(f.size.width / 2.0, f.size.height / 2.0);
963 let (dx, dy) = parts
964 .get(2)
965 .and_then(|s| s.split_once(':'))
966 .and_then(|(a, b)| Some((a.parse::<f64>().ok()?, b.parse::<f64>().ok()?)))
967 .unwrap_or((0.0, 0.0));
968 let end = Point::new(c.x + dx, c.y + dy);
969 let id = tree::rnode_to_id(node);
970 tree::enqueue_event(
971 id,
972 Event::Drag {
973 phase: DragPhase::Began,
974 location: c,
975 translation: Point::ZERO,
976 },
977 );
978 tree::enqueue_event(
979 id,
980 Event::Drag {
981 phase: DragPhase::Changed,
982 location: end,
983 translation: Point::new(dx, dy),
984 },
985 );
986 tree::enqueue_event(
987 id,
988 Event::Drag {
989 phase: DragPhase::Ended,
990 location: end,
991 translation: Point::new(dx, dy),
992 },
993 );
994 }
995 continue;
996 }
997 if let (Some("text"), Some(v)) = (parts.get(1).copied(), parts.get(2).copied()) {
998 synthesize_text(node, v.to_string());
999 continue;
1000 }
1001 let ev = match (parts.get(1).copied(), parts.get(2).copied()) {
1002 (Some("press"), _) => Event::Pressed,
1003 (Some("toggle"), Some(v)) => Event::ToggleChanged(v == "true"),
1004 (Some("value"), Some(v)) => Event::ValueChanged(v.parse().unwrap_or(0.0)),
1005 (Some("select"), Some(v)) => Event::SelectionChanged(v.parse().unwrap_or(-1)),
1006 _ => continue,
1007 };
1008 tree::enqueue_event(tree::rnode_to_id(node), ev);
1009 }
1010}
1011
1012/// Whether the platform is rendering in dark appearance (see `Toolkit::dark_mode`): the
1013/// branch apps take when painting custom opaque surfaces so fills track the theme that the
1014/// default text colors already follow.
1015pub fn dark_mode() -> bool {
1016 dark_signal().get()
1017}
1018
1019/// The platform's font families with their faces (docs/fonts.md), enumerated once per
1020/// process through `Toolkit::font_families` and cached: the answer is stable for the process
1021/// (bundled fonts register before the tree exists, and nothing tracks a system font install
1022/// under a running app; the OS font panels cache too), enumeration is expensive, and the
1023/// query is synchronous on the UI thread. Sorted case-insensitively by family, deduplicated,
1024/// and unioned with the bundled fonts (`day_spec::fonts::bundled_fonts`) the toolkit's own
1025/// database did not report, which is how a `res/font/` (Android), `ms-appx` (XAML) or
1026/// `registerFont` (HarmonyOS) family reaches a font menu without each shim parsing sfnt.
1027/// Empty where the toolkit answers `Cap::FontList` = `Unsupported`.
1028pub fn font_families() -> std::rc::Rc<[day_spec::FontFamilyInfo]> {
1029 // Headless (no tree on this thread, i.e. a model unit test): nothing to enumerate, and
1030 // nothing to cache, so the first call with a tree still fills the cache.
1031 if !tree::has_tree() {
1032 return std::rc::Rc::from(Vec::new());
1033 }
1034 FONT_FAMILIES.with(|c| {
1035 c.get_or_init(|| {
1036 let mut list = tree::with_tree(|t| t.font_families());
1037 for path in day_spec::fonts::bundled_fonts() {
1038 let Some(names) = std::fs::read(&path)
1039 .ok()
1040 .and_then(|bytes| day_spec::fonts::parse_font_names(&bytes))
1041 else {
1042 continue;
1043 };
1044 if list
1045 .iter()
1046 .any(|f| f.family.eq_ignore_ascii_case(&names.family))
1047 {
1048 continue;
1049 }
1050 list.push(day_spec::FontFamilyInfo {
1051 family: names.family,
1052 faces: vec![day_spec::FontFace {
1053 name: "Regular".to_string(),
1054 weight: day_spec::FontWeight::Regular,
1055 italic: false,
1056 }],
1057 });
1058 }
1059 list.sort_by_key(|f| f.family.to_lowercase());
1060 list.dedup_by(|a, b| a.family.eq_ignore_ascii_case(&b.family));
1061 list.into()
1062 })
1063 .clone()
1064 })
1065}
1066
1067/// Distinct measurements held per generation. Two generations are live at once, so the cache
1068/// holds up to twice this before the older one is dropped whole.
1069///
1070/// 512 is far more than a drawing measures per frame (a chart with two axes, their titles and a
1071/// legend is around twenty) and small enough that the worst case (a text tool measuring a
1072/// different string every keystroke) costs tens of kilobytes rather than growing without end.
1073const TEXT_METRICS_CAP: usize = 512;
1074
1075/// The `measure_text` cache: a **pure** memo, because every backend's measurement is a function
1076/// of `(text, size, font)` and nothing else. Nothing in a running app changes the answer: canvas
1077/// text takes absolute points, so it carries neither the reader's font-scale setting nor the
1078/// window's scale factor (docs/canvas.md "Text"), which is what makes caching it correct rather
1079/// than merely fast.
1080///
1081/// Two generations rather than an LRU list: a hit in `previous` is promoted into `current`, and
1082/// when `current` fills, `previous` is replaced by it wholesale. That approximates least-recently-
1083/// used closely enough for a working set that repeats every frame, is O(1) amortized with no
1084/// eviction scan, and needs no ordering structure beside the maps.
1085struct TextMetricsCache {
1086 /// Reused so a hit allocates nothing at all: the key is rebuilt into this buffer, compared,
1087 /// and only copied into the map on a miss.
1088 key: String,
1089 current: std::collections::HashMap<String, day_spec::TextMetrics>,
1090 previous: std::collections::HashMap<String, day_spec::TextMetrics>,
1091 hits: u64,
1092 misses: u64,
1093}
1094
1095impl TextMetricsCache {
1096 fn new() -> Self {
1097 TextMetricsCache {
1098 key: String::new(),
1099 current: std::collections::HashMap::new(),
1100 previous: std::collections::HashMap::new(),
1101 hits: 0,
1102 misses: 0,
1103 }
1104 }
1105
1106 /// Write the key for one measurement into the scratch buffer.
1107 ///
1108 /// The family's length is written before the family, so the encoding is injective whatever a
1109 /// family name contains; the alternative, a separator, is only injective while no font is
1110 /// ever named with it in it. `size` goes in as its bit pattern: exact, and two sizes that
1111 /// differ only in representation would measure the same anyway.
1112 fn build_key(&mut self, text: &str, size: f64, font: &day_spec::CanvasFont) {
1113 use std::fmt::Write;
1114 let family = font.family_str();
1115 self.key.clear();
1116 let _ = write!(
1117 self.key,
1118 "{:x}:{}:{}:{}:{family}{text}",
1119 size.to_bits(),
1120 font.css_weight(),
1121 u8::from(font.italic),
1122 family.len(),
1123 );
1124 }
1125
1126 /// A hit promotes an older-generation entry into the current one, so a working set that
1127 /// survives a generation flip is not measured again.
1128 fn lookup(&mut self) -> Option<day_spec::TextMetrics> {
1129 if let Some(m) = self.current.get(&self.key) {
1130 self.hits += 1;
1131 return Some(*m);
1132 }
1133 let m = *self.previous.get(&self.key)?;
1134 self.hits += 1;
1135 self.current.insert(self.key.clone(), m);
1136 Some(m)
1137 }
1138
1139 fn insert(&mut self, m: day_spec::TextMetrics) {
1140 self.misses += 1;
1141 if self.current.len() >= TEXT_METRICS_CAP {
1142 self.previous = std::mem::take(&mut self.current);
1143 }
1144 self.current.insert(self.key.clone(), m);
1145 }
1146}
1147
1148/// What [`measure_text`]'s cache has done on this thread: a diagnostic, for an app that wants to
1149/// see whether its drawing is measuring the same strings over and over (it usually is).
1150#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1151pub struct TextMetricsCacheStats {
1152 /// Measurements answered without asking the toolkit.
1153 pub hits: u64,
1154 /// Measurements that had to cross into the toolkit.
1155 pub misses: u64,
1156 /// Distinct measurements currently held, across both generations.
1157 pub entries: usize,
1158}
1159
1160/// Read the [`measure_text`] cache's counters (docs/fonts.md).
1161pub fn text_metrics_cache_stats() -> TextMetricsCacheStats {
1162 TEXT_METRICS.with(|c| {
1163 let c = c.borrow();
1164 TextMetricsCacheStats {
1165 hits: c.hits,
1166 misses: c.misses,
1167 entries: c.current.len() + c.previous.len(),
1168 }
1169 })
1170}
1171
1172/// Drop every cached measurement (the counters keep running).
1173///
1174/// Nothing in day needs this: a measurement is a pure function of its key, and the font set is
1175/// fixed for the process (see [`font_families`]). It exists for a toolkit or app that registers a
1176/// face at runtime and so does change what a family name measures to.
1177pub fn clear_text_metrics_cache() {
1178 TEXT_METRICS.with(|c| {
1179 let mut c = c.borrow_mut();
1180 c.current.clear();
1181 c.previous.clear();
1182 });
1183}
1184
1185/// Measure one line of canvas text at `size` points in `font` (docs/fonts.md), in the engine
1186/// that draws `DrawOp::Text`; a toolkit that cannot measure answers
1187/// `TextMetrics::approximate`, so a caller always gets a usable box.
1188///
1189/// **Memoized** (`TextMetricsCache`): measuring crosses into the toolkit and lays the text out
1190/// there (around 29 µs a call on AppKit, which builds an `NSString` and an attribute dictionary
1191/// every time), and a drawing measures the same handful of strings on every frame it records. A
1192/// repeat costs a hash of the key and nothing else.
1193pub fn measure_text(text: &str, size: f64, font: &day_spec::CanvasFont) -> day_spec::TextMetrics {
1194 if let Some(m) = TEXT_METRICS.with(|c| {
1195 let mut c = c.borrow_mut();
1196 c.build_key(text, size, font);
1197 c.lookup()
1198 }) {
1199 return m;
1200 }
1201 // The toolkit call happens with no borrow of the cache held: `replay` and a piece's own
1202 // measurement can reach back into day-core, and a borrow spanning the call would panic.
1203 // Headless (no tree on this thread) answers the approximation too, so a model unit test
1204 // that frames text runs without a toolkit.
1205 let Some(m) = tree::try_with_tree(|t| t.measure_text(text, size, font)).flatten() else {
1206 // Not cached. "The toolkit could not answer" is a fact about this moment (Android's
1207 // returns nothing until its VM is up), not about the text, and caching it would
1208 // pin a guess for the life of the process.
1209 return day_spec::TextMetrics::approximate(text, size);
1210 };
1211 TEXT_METRICS.with(|c| {
1212 let mut c = c.borrow_mut();
1213 c.build_key(text, size, font);
1214 c.insert(m);
1215 });
1216 m
1217}
1218
1219/// Whether motion should be reduced (docs/accessibility.md): the user's system setting
1220/// (`Toolkit::reduce_motion`), or a launch that forced it (`DAY_REDUCE_MOTION=1`, and
1221/// `day launch --fast`, whose fast mode is this setting forced on). Day already applies every
1222/// finite animation at its destination under it and keeps only indeterminate motion; this read
1223/// is for the motion an app drives itself, such as a `day::frame` simulation or a decorative
1224/// loop, which the app opts down. Reactive: a closure reading it re-runs when the setting flips.
1225pub fn reduce_motion() -> bool {
1226 reduce_motion_signal().get()
1227}
1228
1229/// [`reduce_motion`] untracked and borrow-free, for the animation gates inside the tree.
1230pub fn reduce_motion_now() -> bool {
1231 testing::forced_reduce_motion() || REDUCE_MOTION.with(|c| c.get())
1232}
1233
1234/// The reactive backing for [`reduce_motion`], seeded from the current answer.
1235fn reduce_motion_signal() -> day_reactive::Signal<bool> {
1236 REDUCE_MOTION_SIGNAL.with(|c| {
1237 *c.get_or_init(|| {
1238 let seed = reduce_motion_now();
1239 day_reactive::Scope::detached().enter(|| day_reactive::Signal::new(seed))
1240 })
1241 })
1242}
1243
1244/// Re-read the toolkit's reduce-motion setting into [`reduce_motion`]. Backends call this
1245/// when the user flips the setting under a running app; [`launch_with`] calls it once the tree
1246/// stands, which is the launch read. Like [`note_appearance_changed`], it may fire before the
1247/// tree is installed and then does nothing.
1248pub fn note_motion_changed() {
1249 if let Some(system) = tree::try_with_tree(|t| t.reduce_motion()) {
1250 REDUCE_MOTION.with(|c| c.set(system));
1251 let now = reduce_motion_now();
1252 REDUCE_MOTION_SIGNAL.with(|c| {
1253 if let Some(s) = c.get() {
1254 s.set(now);
1255 }
1256 });
1257 }
1258}
1259
1260/// The reactive backing for [`dark_mode`], lazily seeded from the toolkit's answer.
1261fn dark_signal() -> day_reactive::Signal<bool> {
1262 DARK_SIGNAL.with(|c| {
1263 *c.get_or_init(|| {
1264 let seed = tree::with_tree(|t| t.dark_mode());
1265 day_reactive::Scope::detached().enter(|| day_reactive::Signal::new(seed))
1266 })
1267 })
1268}
1269
1270/// Re-read the toolkit's appearance into the reactive [`dark_mode`] signal. Backends call
1271/// this when the system appearance changes under a running app (macOS theme switch, GTK
1272/// style-manager change), and [`set_appearance`] calls it after applying an override, so
1273/// closures reading `dark_mode()` recolor live instead of going stale until a rebuild.
1274pub fn note_appearance_changed() {
1275 // May fire before the tree is installed (GTK's StyleManager emits `dark` notifies while
1276 // `startup` applies a forced DAY_THEME scheme; AppKit's observer dispatches async): no
1277 // tree means no palette closures exist yet, and the signal seeds from the tree on first
1278 // use; skip rather than panic inside a native callback.
1279 if let Some(d) = tree::try_with_tree(|t| t.dark_mode()) {
1280 dark_signal().set(d);
1281 }
1282}
1283
1284/// The system text size changed under a running app (iOS Dynamic Type, a desktop's text
1285/// scaling): every text measurement Day holds is stale. Backends call this from the platform's
1286/// notification, once the native text has taken the new size. Day re-measures every node and
1287/// re-lays every bound list row, so a content-sized row (docs/list.md "Row heights") takes its
1288/// new height through `ListPatch::RowSizeInvalidated`. Skipped when the tree is busy or not
1289/// installed yet: a tree built after the change measures at the new size anyway.
1290pub fn note_text_size_changed() {
1291 let _ = tree::try_with_tree(|t| {
1292 t.invalidate_all_measurements();
1293 t.layout_if_needed();
1294 });
1295}
1296
1297// The `.searchable()` hosts whose field is INLINE (docs/search.md), newest last: each has no
1298// `day::search` bar item, so a caller holding that reserved id (dayscript's `toolbar:` step)
1299// resolves it here and delivers `Event::SearchChanged` to the host, as the native field would.
1300/// Record `host` as showing an inline search field. Registered by the host's builder for the
1301/// life of its scope; see [`inline_search_host`].
1302pub fn register_search_host(host: RNode) {
1303 let window = toolbar::current_page_window();
1304 SEARCH_HOSTS.with(|h| {
1305 let mut h = h.borrow_mut();
1306 h.retain(|(_, node)| *node != host);
1307 h.push((window, host));
1308 });
1309}
1310
1311/// Forget `host`, once its scope is disposed.
1312pub fn unregister_search_host(host: RNode) {
1313 SEARCH_HOSTS.with(|h| h.borrow_mut().retain(|(_, node)| *node != host));
1314}
1315
1316/// The inline search host the reserved `day::search` id names when no bar item carries it: the
1317/// most recently shown one in the focused window.
1318pub fn inline_search_host() -> Option<RNode> {
1319 let window = windows::focused_root();
1320 SEARCH_HOSTS.with(|h| {
1321 h.borrow()
1322 .iter()
1323 .rev()
1324 .find(|(root, _)| *root == window)
1325 .map(|(_, host)| *host)
1326 })
1327}
1328
1329/// Activate the focused window's searchable navigation surface (for a Find command).
1330pub fn focus_search() {
1331 if toolbar::focus_toolbar_search() {
1332 return;
1333 }
1334 if let Some(host) = inline_search_host() {
1335 tree::with_tree(|t| t.patch(host, Box::new(day_spec::props::SearchPatch::Focus), false));
1336 }
1337}
1338
1339/// Deliver synthetic typing to a text control (the dayscript `input` step and the autodrive
1340/// string commands both route here): paint the widget via the ordinary app-write patch, then
1341/// enqueue the `TextChanged` event. Both halves are needed: when a real user types, the text
1342/// is already in the native field by the time its change event fires, so the two-way
1343/// binding's echo guard suppresses the write-back (§4.4); a synthesized event
1344/// alone would drive the app's signal while the widget kept showing its old text.
1345pub fn synthesize_text(node: RNode, text: String) {
1346 tree::with_tree(|t| match t.node_kind(node) {
1347 Some(day_spec::kinds::TEXT_FIELD) => t.patch(
1348 node,
1349 Box::new(day_spec::props::TextFieldPatch::Text {
1350 text: text.clone(),
1351 from_native: false,
1352 }),
1353 false,
1354 ),
1355 Some(day_spec::kinds::TEXT_AREA) => t.patch(
1356 node,
1357 Box::new(day_spec::props::TextAreaPatch::SetText(text.clone())),
1358 false,
1359 ),
1360 // Other kinds (external pieces like the combobox) own their display, and hear about the
1361 // text first through [`SYNTHESIZED_TEXT`]: a piece that treats `TextChanged` as the
1362 // view's own report (the styled editor does) paints from that one instead, since the
1363 // view has not seen this text.
1364 _ => tree::enqueue_event(
1365 tree::rnode_to_id(node),
1366 day_spec::Event::custom(SYNTHESIZED_TEXT, text.clone()),
1367 ),
1368 });
1369 tree::enqueue_event(tree::rnode_to_id(node), day_spec::Event::TextChanged(text));
1370}
1371
1372/// The tag of the `Event::Custom` [`synthesize_text`] sends to a piece of a kind day-core does not
1373/// paint itself, carrying the text, just before the `TextChanged` that follows it. A piece whose
1374/// native view owns the characters shows the text on this event and then sees the `TextChanged`
1375/// as the echo it already handles.
1376pub const SYNTHESIZED_TEXT: &str = "day:synthesized-text";
1377
1378mod piece_ops;
1379pub use piece_ops::{
1380 PieceOperationDone, PieceOperationFn, piece_operation, register_piece_operation,
1381};
1382
1383/// Override the app's appearance: `Some(true)` dark, `Some(false)` light, `None` follow the
1384/// system again. On backends reporting `Cap::Appearance` the native widgets restyle in place
1385/// and [`dark_mode`] answers the override; app-painted surfaces pick it up on their next
1386/// rebuild. Other backends ignore the call; probe before offering a theme picker.
1387pub fn set_appearance(dark: Option<bool>) {
1388 tree::with_tree(|t| t.set_appearance(dark));
1389 note_appearance_changed();
1390}
1391
1392/// Put a badge on the app's icon: the Dock, launcher, home screen, or taskbar (docs/badge.md).
1393///
1394/// Fire-and-forget: a payload the running toolkit cannot render is ignored, so probe
1395/// `capability(Cap::AppBadgeCount | AppBadgeText | AppBadgeDot)` first and choose. Nothing is ever
1396/// substituted, because a wrong number on a user's icon is worse than no badge.
1397///
1398/// ```no_run
1399/// # use day_core::{set_app_badge};
1400/// # use day_spec::AppBadge;
1401/// set_app_badge(&AppBadge::Count(7));
1402/// set_app_badge(&AppBadge::None); // clear
1403/// ```
1404///
1405/// An iOS badge belongs to the installed app and survives termination, so an app that sets one
1406/// usually clears it from a `WillTerminate` handler (docs/lifecycle.md); a macOS Dock badge dies
1407/// with the process.
1408pub fn set_app_badge(badge: &day_spec::AppBadge) {
1409 tree::with_tree(|t| t.set_app_badge(badge));
1410}
1411
1412#[cfg(test)]
1413mod posted_panic_tests {
1414 /// A panic inside a posted main-thread task must be contained (logged + runtime reset), never
1415 /// unwind into the native trampoline that posted it (`panic_cannot_unwind` → SIGABRT). This
1416 /// is the poster/scheduler twin of `pump_events`' containment.
1417 #[test]
1418 fn posted_panic_is_contained() {
1419 super::contain_posted_panic(Box::new(|| panic!("boom in a posted task")));
1420 // Reaching here is the assertion: the panic did not propagate. The runtime was reset, so
1421 // subsequent reactive work still runs.
1422 let s = day_reactive::Signal::new(1i32);
1423 s.set(2);
1424 assert_eq!(s.get_untracked(), 2);
1425 }
1426}