day-core 0.6.2

Declarative app development API using native UI toolkits
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
// Copyright © The Daybrite Project
// SPDX-License-Identifier: MPL-2.0

//! day-core: the Piece model, realized tree, mounter, layout engine, and event routing
//! (DESIGN.md §5, §7). Build-once: pieces are constructed exactly once; all dynamism flows
//! through reactive bindings (day-reactive) writing to the thread-local tree.

/// The linked Day runtime version, for diagnostics from independently versioned extensions.
pub const VERSION: &str = env!("CARGO_PKG_VERSION");

day_reactive::tls_root! {
    layout: crate::layout::TlsGroupSlots,
    ambient: crate::ambient::TlsGroupSlots,
    anim: crate::anim::TlsGroupSlots,
    conformance: crate::conformance::TlsGroupSlots,
    frame: crate::frame::TlsGroupSlots,
    image: crate::image::TlsGroupSlots,
    list: crate::list::TlsGroupSlots,
    lifecycle: crate::lifecycle::TlsGroupSlots,
    menu: crate::menu::TlsGroupSlots,
    nav: crate::nav::TlsGroupSlots,
    present: crate::present::TlsGroupSlots,
    root: crate::TlsGroupSlots,
    shield: crate::shield::TlsGroupSlots,
    shortcuts: crate::shortcuts::TlsGroupSlots,
    status: crate::status::TlsGroupSlots,
    toolbar: crate::toolbar::TlsGroupSlots,
    documents: crate::documents::TlsGroupSlots,
    urls: crate::urls::TlsGroupSlots,
    tree: crate::tree::TlsGroupSlots,
    windows: crate::windows::TlsGroupSlots,
}

/// The blocking pool (docs/async.md): `spawn_blocking` runs a job on a worker thread and
/// resolves on the awaiting side; std-only, so it lives in day-async and is re-exported here.
pub use day_async::{
    Blocking, BlockingError, blocking_inline, blocking_limit, blocking_threads, spawn_blocking,
};
#[cfg(target_arch = "wasm32")]
#[doc(hidden)]
pub use day_macros::retain_element_id as __retain_element_id;
/// Re-exported so every crate above day-core can reach it without taking a direct
/// day-reactive dependency (see day-reactive for what it does and why Android needs it).
pub use day_reactive::tls_group;
#[cfg(target_arch = "wasm32")]
#[doc(hidden)]
pub use inventory;
/// The link-time registry crate `#[day::test]` expands through (docs/testing.md).
pub use linkme;

mod ambient;
mod anim;
mod build;
pub mod conformance;
pub mod element_id;
pub mod frame;
pub use element_id::{ElementId, IntoElementKey};
mod frames;
pub mod image;
mod layout;
pub mod lifecycle;
pub mod list;
mod memory_profile;
mod modal_sheet;
pub use modal_sheet::ComposedSheet;
pub mod menu;
mod nav;
mod present;
pub mod shield;
pub mod shortcuts;
pub mod status;
pub mod testing;
pub mod toolbar;
mod tree;
pub mod tree_driver;
pub mod tween;
pub mod windows;

pub use ambient::{
    Ambient, app_environment, environment, focused_environment, override_size_class, reset_ambient,
    restore_reported_size_class, safe_area, seed_safe_area, set_safe_area, set_size_class,
    set_window_safe_area, set_window_size_class, size_class, window_safe_area, window_size_class,
    window_size_class_untracked, with_environment,
};
pub use anim::{current_anim, with_animation};
pub use build::*;
pub use frame::{
    FrameConsumer, add_frame_consumer, frame_consumer_count, install_frame_requester,
    remove_frame_consumer,
};
pub use image::{
    Bitmap, CanvasExports, RenderBitmap, RenderPdf, canvas_exports, decode as decode_image,
    decode_async as decode_image_async, image_decode_support, image_encode_formats,
    image_encode_support, render_bitmap, render_bitmap_async, render_pdf, render_pdf_async,
    resolve_bitmap_render, resolve_image_decode, resolve_image_encode, resolve_pdf_render,
};
pub use layout::*;
pub use lifecycle::{dispatch_lifecycle, lifecycle_supported, on_lifecycle};
pub use list::{
    BuiltRow, ListDeleteDriver, ListDriver, ListReorderDriver, ListSectionsDriver, ListSwipeDriver,
    install_list, list_reload, list_scroll_to_end, list_scroll_to_row, list_set_editing,
    list_set_selected, list_splice, list_try_delete, list_try_reorder, list_try_swipe,
    set_scripted_reorder,
};
pub use menu::{
    dispatch_menu_action, has_app_menu, register_menu_action, register_scoped_menu_action,
    set_app_menu, set_dock_menu,
};
pub use nav::*;
pub use shortcuts::set_launcher_shortcuts;
pub use status::{
    KeepRunning, remove_status_item, set_app_progress, set_dock_visible, set_keep_running,
    set_status_item, status_items,
};
pub mod documents;
#[doc(hidden)]
pub use documents::{forward_launch_args, launch_link, take_launch_files};
mod urls;
pub use documents::{on_open_files, request_open_files};
pub use present::*;
pub use toolbar::{
    Chrome, chrome_changed, current_chrome, current_page_column, current_page_gate,
    current_page_window, dispatch_toolbar_value, patch_chrome, patch_toolbar,
    register_contribution, register_contribution_gated, register_toolbar_value,
    relabel_contribution, toggle_sidebar, unregister_contribution, update_contribution,
    window_being_built, with_page, with_page_gated, with_page_in,
};
pub use tree_driver::{
    TreeBuiltRow, TreeDriver, TreeMovesDriver, install_tree, tree_driver, tree_reload, tree_reveal,
    tree_set_expanded, tree_set_selected, tree_try_move, tree_visible_rows,
};
pub use urls::{on_open_url, request_open_url};
// The resource types live in day-spec (backends depend only on day-spec); re-exported for the
// facade.
pub use day_spec::resource::{
    AssetDir, AssetName, FontFamily, ImageName, Resource, ResourceOpener, VectorName, resource,
    set_resource_opener,
};
pub use tree::*;
pub use windows::{
    WindowHandle, about_action_id, check_for_updates, check_for_updates_action_id, current_window,
    finish_window_open, focused_scope, focused_window, initial_window, launch_options, monitors,
    open_about, open_new_window, open_new_window_for_group, open_preferences, open_window, quit,
    register_about, register_about_window, register_check_for_updates, register_new_window,
    register_new_window_for, register_preferences, register_preferences_with, window_by_key,
    window_title,
};

/// The app-wide layout direction (docs/localization): mirrors every horizontal placement in
/// the place pass when [`day_geometry::LayoutDirection::Rtl`]. Resolved lazily from the
/// `DAY_LOCALE` launch environment (so toolkits can read it before any UI exists);
/// `set_layout_direction` (called by `install_locales` for the resolved locale) overrides.
/// Fixed for the life of the process; switching locale at runtime does not re-mirror.
pub fn layout_direction() -> day_geometry::LayoutDirection {
    DIRECTION.with(|d| {
        if let Some(dir) = d.get() {
            return dir;
        }
        let dir = std::env::var("DAY_LOCALE")
            .map(|l| direction_of_locale(&l))
            .unwrap_or_default();
        d.set(Some(dir));
        dir
    })
}

/// Override the layout direction (normally from `install_locales`). Must be called before the
/// first layout pass to take effect everywhere.
pub fn set_layout_direction(dir: day_geometry::LayoutDirection) {
    DIRECTION.with(|d| d.set(Some(dir)));
}

/// Whether the app is being rendered right-to-left (docs/localization), a convenience over
/// [`layout_direction`]. The layout engine already mirrors widget *placement* under an RTL locale,
/// but a `canvas` draws in its own coordinate space, so a custom drawing that has a reading
/// direction (a battery that drains one way, an arrow, a progress sweep) can call this to mirror
/// itself. Fixed for the life of the process, like [`layout_direction`].
pub fn is_rtl() -> bool {
    layout_direction() == day_geometry::LayoutDirection::Rtl
}

/// The writing direction a locale implies (language subtag match).
pub fn direction_of_locale(locale: &str) -> day_geometry::LayoutDirection {
    let lang = locale
        .split(['-', '_'])
        .next()
        .unwrap_or("")
        .to_ascii_lowercase();
    match lang.as_str() {
        "ar" | "he" | "iw" | "fa" | "ur" | "ps" | "sd" | "ug" | "yi" | "dv" | "ku" => {
            day_geometry::LayoutDirection::Rtl
        }
        _ => day_geometry::LayoutDirection::Ltr,
    }
}

day_reactive::tls_slots! {
    root;
    static SEARCH_HOSTS: std::cell::RefCell<Vec<(RNode, RNode)>> =
        const { std::cell::RefCell::new(Vec::new()) };
    static DIRECTION: std::cell::Cell<Option<day_geometry::LayoutDirection>> =
        const { std::cell::Cell::new(None) };

    static UNDO_INVOKE: std::cell::RefCell<Option<UndoInvoke>> =
        const { std::cell::RefCell::new(None) };

    /// The dayscript executor's stand-in for held modifiers (a synthetic tap cannot hold a
    /// real shift key); `None` = ask the toolkit.
    static MODIFIER_OVERRIDE: std::cell::Cell<Option<day_spec::Modifiers>> =
        const { std::cell::Cell::new(None) };

    static EDIT_INVOKE: std::cell::RefCell<Option<EditInvoke>> =
        const { std::cell::RefCell::new(None) };

    static UNDO_ACTION_IDS: std::cell::Cell<(u64, u64)> = const { std::cell::Cell::new((0, 0)) };
    static EDIT_ACTION_IDS: std::cell::Cell<(u64, u64, u64, u64)> =
        const { std::cell::Cell::new((0, 0, 0, 0)) };

    static DARK_SIGNAL: std::cell::OnceCell<day_reactive::Signal<bool>> =
        const { std::cell::OnceCell::new() };

    /// The system's reduce-motion answer as last read (docs/accessibility.md), kept beside the
    /// signal so the animation gates can ask without a tracked read or a tree borrow: they run
    /// inside the tree.
    static REDUCE_MOTION: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
    static REDUCE_MOTION_SIGNAL: std::cell::OnceCell<day_reactive::Signal<bool>> =
        const { std::cell::OnceCell::new() };

    static FONT_FAMILIES: std::cell::OnceCell<std::rc::Rc<[day_spec::FontFamilyInfo]>> =
        const { std::cell::OnceCell::new() };

    static TEXT_METRICS: std::cell::RefCell<TextMetricsCache> =
        std::cell::RefCell::new(TextMetricsCache::new());
}

use day_spec::{Platform, WindowOptions};

// ---- crash observation (§8.5) --------------------------------------------------------------

/// Observer called after day-core contains a panic at one of its trampoline boundaries
/// (`contain_posted_panic` here, `tree::pump_events`), on the panicking thread, after the
/// reactive-runtime reset. A crash reporter (day-piece-crash-reporter, docs/break.md) registers one to
/// downgrade the report its panic hook just wrote: the panic was caught, the process is not
/// dying. A plain `fn` (no closure) so the containment path allocates nothing.
static CONTAINED_PANIC_OBSERVER: std::sync::OnceLock<fn()> = std::sync::OnceLock::new();

/// Register the contained-panic observer. First registration wins; later calls are no-ops
/// (there is one crash reporter per process).
pub fn set_contained_panic_observer(f: fn()) {
    let _ = CONTAINED_PANIC_OBSERVER.set(f);
}

pub(crate) fn notify_contained_panic() {
    if let Some(f) = CONTAINED_PANIC_OBSERVER.get() {
        f();
    }
}

/// The compile-time target key of the running backend (`"macos-appkit"`, `"ios-uikit"`, …,
/// the `Platform::TARGET` string), recorded by [`launch_with`]. `None` before launch.
pub fn backend_name() -> Option<&'static str> {
    BACKEND_NAME.get().copied()
}

static BACKEND_NAME: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new();

/// The toolkit key of the running backend (`"appkit"`, `"gtk"`, …, the `Platform::TOOLKIT`
/// string), recorded by [`launch_with`]. `None` before launch.
pub fn toolkit_key() -> Option<&'static str> {
    TOOLKIT_KEY.get().copied()
}

static TOOLKIT_KEY: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new();

/// The development tag every window title carries in a debug build:
/// `(<version>/<toolkit>[/<script>])`, for example `(1.1.0/appkit)`, or
/// `(1.1.0/gtk/walkthrough.yaml)` while a dayscript is driving. With several apps, toolkits and
/// scripted runs open at once, the title bar is the only place that says which window is which.
///
/// `None` in a release build (this is a development aid and must never ship), and before
/// [`launch_with`] has named the backend. The version and the script name come from
/// `DAY_APP_VERSION` and `DAY_SCRIPT`, which the `day` CLI sets on every launch; run the binary
/// some other way and the tag carries the parts it knows.
pub fn debug_title_tag() -> Option<String> {
    if !cfg!(debug_assertions) {
        return None;
    }
    let toolkit = toolkit_key()?;
    // The mock backend has no window and no title bar, so there is nothing for a tag to
    // disambiguate; it would only corrupt what a headless test asserts about a title.
    if toolkit == "mock" {
        return None;
    }
    let mut parts = Vec::new();
    if let Ok(v) = std::env::var("DAY_APP_VERSION")
        && !v.is_empty()
    {
        parts.push(v);
    }
    parts.push(toolkit.to_string());
    if let Ok(s) = std::env::var("DAY_SCRIPT")
        && !s.is_empty()
    {
        parts.push(s);
    }
    Some(format!("({})", parts.join("/")))
}

/// Append [`debug_title_tag`] to a window title. Every title day sets goes through here: the
/// primary window's, each secondary window's, and every [`crate::windows::WindowHandle::set_title`].
///
/// An empty title stays empty: a window the app left untitled should not grow a title bar full
/// of build metadata. An already-tagged title is left alone, since the same window can be
/// retitled repeatedly.
pub(crate) fn decorate_window_title(title: &str) -> String {
    tag_title(title, debug_title_tag().as_deref())
}

/// The join rule, split out from the environment so it can be tested.
fn tag_title(title: &str, tag: Option<&str>) -> String {
    match tag {
        Some(tag) if !title.is_empty() && !title.ends_with(tag) => format!("{title} {tag}"),
        _ => title.to_string(),
    }
}

#[cfg(test)]
mod title_tag_tests {
    use super::tag_title;

    #[test]
    fn the_tag_is_appended_once_and_never_to_an_empty_title() {
        assert_eq!(
            tag_title("Day Sheets", Some("(0.1.0/appkit)")),
            "Day Sheets (0.1.0/appkit)"
        );
        // A release build has no tag, so the title is the app's own, untouched.
        assert_eq!(tag_title("Day Sheets", None), "Day Sheets");
        // An untitled window stays untitled rather than growing a bar of build metadata.
        assert_eq!(tag_title("", Some("(0.1.0/appkit)")), "");
        // Retitling an already-tagged window must not stack tags.
        assert_eq!(
            tag_title("Day Sheets (0.1.0/appkit)", Some("(0.1.0/appkit)")),
            "Day Sheets (0.1.0/appkit)"
        );
    }
}

// ---- logging (docs/logging.md) ---------------------------------------------------------------

/// Where a formatted line goes. Unset means the process's own stderr, which is right on every
/// native target: a terminal on the desktop, Xcode's console on Apple, and logcat on Android
/// (day-toolkit-material `dup2`s fd 2 into it). `wasm32-unknown-unknown` is the one target where that is
/// wrong rather than merely different (std's stdio there accepts the bytes and drops them, so
/// every line vanishes), and day-toolkit-dom installs a sink reaching the browser console instead.
static LOG_SINK: std::sync::OnceLock<fn(log::Level, &str)> = std::sync::OnceLock::new();

/// Install the line sink. First registration wins (one host per process). Called before launch;
/// `day::web::start` does it for the browser.
pub fn set_log_sink(f: fn(log::Level, &str)) {
    let _ = LOG_SINK.set(f);
}

/// The width the level column is padded to. Shared by the formatter and the coloring below, which
/// splits the finished line at exactly this offset.
const LEVEL_WIDTH: usize = 5;

/// The terminal color for a level: `env_logger`'s palette, so a Day app's output reads like any
/// other Rust app's, and the same one `day-cli` uses when it re-emits these lines under
/// `day launch` (day-cli `src/term.rs`).
#[cfg(not(target_arch = "wasm32"))]
fn level_style(level: log::Level) -> anstyle::Style {
    let color = match level {
        log::Level::Error => anstyle::AnsiColor::Red,
        log::Level::Warn => anstyle::AnsiColor::Yellow,
        log::Level::Info => anstyle::AnsiColor::Green,
        log::Level::Debug => anstyle::AnsiColor::Blue,
        log::Level::Trace => anstyle::AnsiColor::Cyan,
    };
    anstyle::Style::new().fg_color(Some(anstyle::Color::Ansi(color)))
}

/// Write one already-formatted line to stderr, never panicking. That matters because the
/// `*println!` macros panic on a failed write, a closed stderr pipe is routine when `day launch`
/// tears the app down, and a panic raised inside a native trampoline aborts the process.
///
/// Only the level column is colored: a fully colored line is noise at `INFO`, and leaving the
/// message plain keeps it greppable. `anstream` decides whether the escapes survive: they are
/// stripped when stderr is not a color terminal, which covers the pipe `day launch` gives us
/// (day-cli re-colors from its own end), logcat, Xcode's console, `NO_COLOR`, and `TERM=dumb`.
#[cfg(not(target_arch = "wasm32"))]
fn write_line(level: log::Level, line: &str) {
    use std::io::Write as _;
    let style = level_style(level);
    // The column is ASCII and padded to `LEVEL_WIDTH` by the caller, so this never lands inside a
    // character; `split_at_checked` keeps even a malformed line from panicking.
    let (lvl, rest) = line.split_at_checked(LEVEL_WIDTH).unwrap_or((line, ""));
    let _ = writeln!(anstream::stderr(), "{style}{lvl}{style:#}{rest}");
}

/// The wasm fallback, used only if no sink was installed; `day::web::start` installs one before
/// an app's first line. std's stdio on `wasm32-unknown-unknown` accepts these bytes and drops
/// them, so this writes into nothing; it exists so the logger has a total function.
#[cfg(target_arch = "wasm32")]
fn write_line(_level: log::Level, line: &str) {
    use std::io::Write as _;
    let _ = writeln!(std::io::stderr(), "{line}");
}

/// Day's default [`log::Log`].
///
/// Not `env_logger` or another off-the-shelf backend, for the reason above: they print through
/// `*println!`, and a panic on that path is an abort rather than a lost line. An app that wants
/// one installs it itself; see [`init_logging`].
struct DayLogger;

impl log::Log for DayLogger {
    fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
        // The level filter is `log::set_max_level`'s job, checked by the macros before they even
        // format; answering `true` here keeps `log_enabled!` consistent with them for anything
        // that asks.
        metadata.level() <= log::max_level()
    }

    fn log(&self, record: &log::Record<'_>) {
        if !self.enabled(record.metadata()) {
            return;
        }
        // `WARN day_toolkit_gtk: could not register bundled font …`: the level first so a scan down
        // the column finds problems, then the emitting crate, which is what says which backend
        // or piece is unhappy. The old hand-written `day: ` prefixes said only that it was us.
        let sink = LOG_SINK.get();
        // Formatted plain: a sink is a structured destination (the browser console, a test
        // collector, an app's own transport) and wants the text, not terminal escapes. Color is
        // `write_line`'s business, applied only on the way to a real stderr.
        let line = format!(
            "{:<LEVEL_WIDTH$} {}: {}",
            record.level(),
            record.target(),
            record.args()
        );
        match sink {
            Some(f) => f(record.level(), &line),
            None => write_line(record.level(), &line),
        }
    }

    fn flush(&self) {}
}

/// Install Day's default logger and level, unless the app already installed its own.
///
/// Called from [`launch_with`], so logging works with no ceremony at all: an app writes
/// `log::info!(…)` (or `day::info!`, the same macro re-exported through the prelude) and the line
/// comes out on every platform. `set_logger` answers `Err` when a logger is already registered;
/// that is all the customization there is, and why the error is ignored: an app that
/// called `env_logger::init()` (or installed `tracing`, or its own `log::Log`) before
/// `day::launch` keeps it, and Day does not fight for the slot.
///
/// The default level is `Debug` in a debug build and `Info` in a release one; `DAY_LOG` overrides
/// it with a level name (`off`/`error`/`warn`/`info`/`debug`/`trace`). Reading an environment
/// variable is a native-only affordance; on the web there is no process environment, so the
/// launch path sets the level explicitly from the page's query string instead.
pub fn init_logging() {
    let level = std::env::var("DAY_LOG")
        .ok()
        .and_then(|v| v.parse::<log::LevelFilter>().ok())
        .unwrap_or(if cfg!(debug_assertions) {
            log::LevelFilter::Debug
        } else {
            log::LevelFilter::Info
        });
    set_log_level(level);
    // Err = the app got there first, which is exactly the intended escape hatch.
    let _ = log::set_logger(&DayLogger);
}

/// Set the maximum level that will be emitted. Separate from [`init_logging`] because the web
/// launch path has to supply the level from the page URL rather than the environment, and because
/// an app may want to raise or lower it at runtime.
pub fn set_log_level(level: log::LevelFilter) {
    log::set_max_level(level);
}

/// Run a posted main-thread task, containing any panic (the `pump_events` twin for the poster /
/// scheduler doors): log the cause and reset the reactive runtime so the app keeps running
/// (degraded) instead of aborting across the native trampoline's non-unwind boundary.
pub(crate) fn contain_posted_panic(f: Box<dyn FnOnce() + Send>) {
    if let Err(payload) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
        let msg = payload
            .downcast_ref::<&str>()
            .map(|s| (*s).to_string())
            .or_else(|| payload.downcast_ref::<String>().cloned())
            .unwrap_or_else(|| "unknown panic".to_string());
        log::warn!(
            "a posted main-thread task panicked and was contained — the app continues, but \
             reactive/UI state may be inconsistent until the next interaction. Cause: {msg}"
        );
        day_reactive::recover_from_panic();
        notify_contained_panic();
    }
}

/// The app's undo/redo entry point as the platform front calls it; `true` means redo.
/// `Rc` because the invocation clones it out of the cell before running, so the borrow is
/// released before app code (which may install a new bridge) gets control.
type UndoInvoke = std::rc::Rc<dyn Fn(bool)>;

/// Wire an undo history to the platform (docs/model.md): the four signals mirror into the
/// toolkit's native front where one exists (`Cap::UndoBridge`: the stock Edit menu retitles
/// and enables itself, the platform's gestures land), and every invocation the platform
/// delivers comes back through `on_invoke(redo)`. On a toolkit without a native undo system
/// the state goes nowhere and the app's own affordances call the stack directly; installing
/// the bridge is still harmless. Call once, after launch; installing again replaces the wiring.
pub fn install_undo_bridge(
    can_undo: day_reactive::Signal<bool>,
    can_redo: day_reactive::Signal<bool>,
    undo_label: day_reactive::Signal<String>,
    redo_label: day_reactive::Signal<String>,
    on_invoke: impl Fn(bool) + 'static,
) {
    UNDO_INVOKE.with(|u| *u.borrow_mut() = Some(std::rc::Rc::new(on_invoke)));
    day_reactive::bind(
        move || day_spec::UndoState {
            can_undo: can_undo.get(),
            can_redo: can_redo.get(),
            undo_label: undo_label.get(),
            redo_label: redo_label.get(),
        },
        |state: &day_spec::UndoState| {
            let state = state.clone();
            with_tree(|t| t.set_undo_state(&state));
        },
    );
}

/// The standing dispatch id behind a `MenuRole::Cut`/`Copy`/`Paste` item on a toolkit whose
/// role items come back as plain menu actions (docs/menus.md): the closure invokes the
/// installed edit bridge. Durable across menu installs, like the undo pair.
pub fn edit_action_id(op: day_spec::EditOp) -> u64 {
    use day_spec::EditOp as E;
    let (c, y, p, a) = EDIT_ACTION_IDS.with(|s| s.get());
    let have = match op {
        E::Cut => c,
        E::Copy => y,
        E::Paste => p,
        E::SelectAll => a,
    };
    if have != 0 {
        return have;
    }
    let id = menu::register_menu_action(std::rc::Rc::new(move || dispatch_edit_invoke(op)));
    EDIT_ACTION_IDS.with(|s| {
        let (c0, y0, p0, a0) = s.get();
        s.set(match op {
            E::Cut => (id, y0, p0, a0),
            E::Copy => (c0, id, p0, a0),
            E::Paste => (c0, y0, id, a0),
            E::SelectAll => (c0, y0, p0, id),
        });
    });
    id
}

fn dispatch_undo_invoke(redo: bool) {
    let _ = invoke_undo(redo);
}

/// Invoke the installed undo bridge (the same handler a native front reaches: ⌘Z, the Edit
/// menu, a three-finger swipe), returning whether one is installed. The dayscript
/// `undo:`/`redo:` steps drive history through this, so a walkthrough needs no app-provided
/// undo button on any target.
pub fn invoke_undo(redo: bool) -> bool {
    let f = UNDO_INVOKE.with(|u| u.borrow().clone());
    match f {
        Some(f) => {
            day_reactive::batch(|| f(redo));
            true
        }
        None => false,
    }
}

type EditInvoke = std::rc::Rc<dyn Fn(day_spec::EditOp)>;

/// The keyboard modifiers held right now, for interactions whose meaning they change
/// (shift-click adds to a selection). Touch backends answer all-false; a dayscript step's
/// declared modifiers take precedence while it dispatches.
pub fn modifiers() -> day_spec::Modifiers {
    if let Some(m) = MODIFIER_OVERRIDE.with(|o| o.get()) {
        return m;
    }
    with_tree(|t| t.modifiers())
}

/// Scoped modifier stand-in for the dayscript executor: `Some` while a step with declared
/// modifiers dispatches, back to `None` after.
pub fn set_modifier_override(m: Option<day_spec::Modifiers>) {
    MODIFIER_OVERRIDE.with(|o| o.set(m));
}

/// Wire the app's standard-edit handlers to the platform (docs/menus.md): `state` is a
/// tracked read whose value mirrors into the toolkit (`Cap::EditBridge`: native menu
/// validation enables the stock Cut/Copy/Paste exactly as it does for text widgets), and
/// every invocation a platform route delivers comes back through `on_invoke`. On a toolkit
/// with no native route, the `menu_role(Cut/Copy/Paste)` items dispatch here instead (the
/// same standing-id fallback the undo pair uses). Call once, after launch; installing again
/// replaces the wiring. Most apps want [`day::install_edit_commands`](/api/day/fn.install_edit_commands.html), which adds the
/// clipboard transport.
pub fn install_edit_bridge(
    state: impl Fn() -> day_spec::EditState + 'static,
    on_invoke: impl Fn(day_spec::EditOp) + 'static,
) {
    EDIT_INVOKE.with(|u| *u.borrow_mut() = Some(std::rc::Rc::new(on_invoke)));
    day_reactive::bind(state, |state: &day_spec::EditState| {
        let state = *state;
        with_tree(|t| t.set_edit_state(&state));
    });
}

fn dispatch_edit_invoke(op: day_spec::EditOp) {
    let _ = invoke_edit(op);
}

/// Invoke the installed edit bridge (the same handler the platform's Cut/Copy/Paste
/// route reaches, clipboard transport included), returning whether one is installed. An
/// app's own affordances (a context menu's Cut/Copy) call this rather than duplicating the
/// clipboard plumbing (docs/menus.md).
pub fn invoke_edit(op: day_spec::EditOp) -> bool {
    let f = EDIT_INVOKE.with(|u| u.borrow().clone());
    match f {
        Some(f) => {
            day_reactive::batch(|| f(op));
            true
        }
        None => false,
    }
}

/// The standing dispatch id behind a `MenuRole::Undo`/`Redo` item on a toolkit with no native
/// undo responder (docs/menus.md): activating the item comes back as a plain menu action, and
/// this id's closure invokes the installed undo bridge. Registered once and durable across
/// menu installs, like the preferences id. Toolkits with a native undo system (appkit) keep
/// their responder-chain selector instead, so a focused text field's own undo stays ahead of
/// the app stack there.
pub fn undo_action_id(redo: bool) -> u64 {
    let (u, r) = UNDO_ACTION_IDS.with(|c| c.get());
    let have = if redo { r } else { u };
    if have != 0 {
        return have;
    }
    let id = menu::register_menu_action(std::rc::Rc::new(move || dispatch_undo_invoke(redo)));
    UNDO_ACTION_IDS.with(|c| {
        let (u0, r0) = c.get();
        c.set(if redo { (u0, id) } else { (id, r0) });
    });
    id
}

/// A runtime route request from the backend (`Event::RouteRequested`: web-dom's URL hash
/// changing via browser back/forward or a hand-edited hash). Echoes of our own `set_route`
/// match the current route and are dropped.
fn handle_route_request(route: &str) {
    nav::apply_route_request(route);
}

/// Launch a Day app on the given platform backend: sets up the reactive scheduler and the
/// cross-thread poster, mounts the root piece into the window's content container, runs the
/// initial layout, and installs the turn-end layout callback (§3.3). The backend then owns
/// the native main loop.
pub fn launch_with<P: Platform>(
    backend: P,
    mut options: WindowOptions,
    root_piece: impl FnOnce() -> AnyPiece + 'static,
) {
    // Before anything else that might want to report: from here on `log::warn!` and friends come
    // out, on every backend, with no app-side setup. An app that installed its own logger first
    // keeps it (docs/logging.md).
    init_logging();
    // Record the backend identity for runtime introspection (crash reports, diagnostics).
    let _ = BACKEND_NAME.set(P::TARGET);
    let _ = TOOLKIT_KEY.set(P::TOOLKIT);
    // Tag the window title with version/toolkit/script in debug builds. Pin the app's display
    // name to the undecorated title first: backends fall back to `title` for the macOS App menu
    // and the About panel, which must keep reading "Day Sheets", not "Day Sheets (0.1.0/appkit)".
    if options.app_name.is_none() && !options.title.is_empty() {
        options.app_name = Some(options.title.clone());
    }
    options.title = decorate_window_title(&options.title);
    // `DAY_WINDOW=900x700` overrides the app's initial window size, for responsive-layout testing
    // (scripted runs can exercise a narrow window without a resize gesture). Desktop only in
    // effect; mobile/web backends size to the screen and ignore `options.size` anyway.
    if let Ok(v) = std::env::var("DAY_WINDOW")
        && let Some((w, h)) = v.split_once('x')
        && let (Ok(w), Ok(h)) = (w.trim().parse::<f64>(), h.trim().parse::<f64>())
        && w > 0.0
        && h > 0.0
    {
        options.size = day_spec::Size::new(w, h);
    }
    // Remember what the app asked for, so File ▸ New Window can open another window of the same
    // app rather than an untitled one the platform cannot list (docs/windows.md).
    windows::set_launch_options(&options);
    // Reactive plumbing rides the platform's main-loop poster. Both doors contain panics (the
    // `pump_events` rationale, tree.rs): posted closures run inside native main-loop trampolines
    // (a glib idle, a GCD block) that abort the process on unwind (`panic_cannot_unwind`), so a
    // panic in a `Setter` write's drain or a scheduled `flush_sync` would SIGABRT the app instead
    // of surfacing. Contain at this single backend-agnostic boundary and reset the runtime.
    // Backend FFI trampolines (JNI up-calls, C callbacks, posted closures) contain panics
    // through day-spec's `ffi_guard`; hand it the recovery hook here (the one layer that
    // knows day-reactive) so a contained panic can't strand the observer stack or leave a
    // half-open batch behind. Each door also defers its closure when the tree is borrowed
    // (`tree::run_posted_or_defer`): a backend pumping its native loop from inside a tree
    // method would otherwise run the closure on the borrowed tree.
    day_spec::ffi_guard::set_recovery(day_reactive::recover_from_panic);
    day_reactive::install_main_poster(|f| {
        P::post(Box::new(move || tree::run_posted_or_defer(f)));
    });
    // The timer door (docs/async.md `day::sleep`): same containment and deferral as the poster.
    day_reactive::install_delayed_poster(|ms, f| {
        P::post_delayed(ms, Box::new(move || tree::run_posted_or_defer(f)));
    });
    day_reactive::install_scheduler(|| {
        P::post(Box::new(|| {
            tree::run_posted_or_defer(Box::new(day_reactive::flush_sync));
        }))
    });
    // The async-spawn door (docs/async.md): day-reactive's `Resource` runs its fetch futures on
    // this executor; the returned closure aborts (a no-op once the task completed, the contract
    // Resource's eager-poll ordering relies on).
    day_reactive::install_spawner(|fut| {
        let handle = present::task(fut);
        Box::new(move || handle.abort())
    });
    // The frame clock (§8.4): the animation driver re-arms the platform's vsync callback while any
    // frame consumer (game loop / self-driven animation) is live.
    frame::install_frame_requester(|root, cb| tree::with_tree(|t| t.request_frame(root, cb)));

    // What the exit line names (docs/lifecycle.md), and when the clock started; and the exit
    // itself, for a loop that ends the process without returning.
    #[cfg(not(target_arch = "wasm32"))]
    if toolkit_key() != Some("mock") {
        deliver_exit_at_process_exit();
    }
    lifecycle::note_launch(
        options.app_name.clone().unwrap_or_default(),
        options.version.clone(),
    );

    // WillLaunch: before the window/UI exists (docs/lifecycle.md). Fired uniformly by day-core so
    // it is reliable on every backend; handlers must not touch the tree (there isn't one yet).
    lifecycle::dispatch_lifecycle(day_spec::Lifecycle::WillLaunch);

    P::run(
        backend,
        options,
        Box::new(move |mut toolkit, root_handle, size| {
            day_spec::Toolkit::set_event_sink(&mut toolkit, Box::new(tree::enqueue_native_event));
            let tree = Tree::new(toolkit, root_handle, size);
            let root = tree.root();
            tree::install_tree(Box::new(tree));
            // The launch read of the user's motion setting (docs/accessibility.md), before the
            // root piece builds: its first transitions already land where the user asked.
            note_motion_changed();

            // Seed the window's size class from the size the backend just reported
            // (docs/size-classes.md), before the root piece builds below: a nav host resolving
            // an automatic presentation reads it during its own build. Backends push later
            // changes themselves; one that never does keeps this launch value.
            ambient::set_window_size_class(
                root,
                day_spec::SizeClass::from_size(size.width, size.height),
            );

            // The backend is now known: warn about any lifecycle handlers already registered for
            // phases this platform doesn't deliver (docs/lifecycle.md).
            lifecycle::warn_unsupported_registrations();

            // Window resize → relayout. Route requests (runtime deep links: web-dom's URL
            // hash changing under the app via browser back/forward or a hand-edited hash) →
            // navigate, guarded against echo: the request the backend reflects back after
            // our own `set_route` matches the current route and is dropped here.
            with_tree(|t| {
                let rn = root;
                t.on_event(
                    rn,
                    std::rc::Rc::new(move |ev| match ev {
                        day_spec::Event::WindowResized(size) => {
                            let s = *size;
                            with_tree(|t| t.set_window_size(s));
                            // Re-bucket the window (docs/size-classes.md). Derived here, from the
                            // geometry every backend already reports, so there is one breakpoint
                            // table rather than one per toolkit, and only a class change
                            // notifies, so dragging an edge within a bucket costs nothing.
                            ambient::set_window_size_class(
                                rn,
                                day_spec::SizeClass::from_size(s.width, s.height),
                            );
                        }
                        day_spec::Event::RouteRequested(route) => handle_route_request(route),
                        // A native undo front's invocation (⌘Z through the stock Edit menu, a
                        // three-finger swipe): route to whatever stack the app installed.
                        day_spec::Event::Undo { redo } => dispatch_undo_invoke(*redo),
                        // A platform edit-command route (Edit ▸ Cut/Copy/Paste, the
                        // browser's clipboard events) with no text widget claiming it.
                        day_spec::Event::Edit(op) => dispatch_edit_invoke(*op),
                        _ => {}
                    }),
                );
            });

            // The first window is an ordinary primary window (docs/windows.md close policy):
            // it goes in the registry like any other, so closing it runs the same teardown and
            // the same "was that the last primary?" question. Its content builds in a child of
            // the root scope; disposing that on close takes this window's tree and nothing
            // else, while `Signal::global` state stays on the root scope above it.
            let win_scope = day_reactive::Scope::root().enter(day_reactive::Scope::child);
            windows::adopt_initial_window(root, win_scope);

            // Build the root piece under the window container.
            let piece = root_piece();
            win_scope.enter(|| {
                let mut cx = BuildCx::new(root);
                let _ = piece.build(&mut cx);
            });

            // Initial layout, then keep laying out at every turn boundary.
            with_tree(|t| {
                t.mark_layout_dirty();
                t.layout_if_needed();
            });
            day_reactive::on_turn_end(|| with_tree(|t| t.layout_if_needed()));
            // The Dock menu lists the app's launcher shortcuts from the start (docs/menus.md),
            // ahead of whatever `dock_menu(..)` the root just installed.
            menu::install_dock_menu();
            // A keep-running policy or a status item set before the tree existed reaches the
            // backend now (docs/windows.md "Keeping the app running").
            status::boot_sync();

            #[cfg(not(any(
                target_arch = "wasm32",
                target_os = "ios",
                target_os = "android",
                target_env = "ohos"
            )))]
            documents::request_open_files(documents::take_launch_files());
            day_reactive::on_main(documents::drain_open_files);
            if let Ok(url) = std::env::var("DAY_OPEN_URL") {
                urls::request_open_url(&url);
            }
            day_reactive::on_main(urls::drain);

            // DidLaunch: the UI is mounted and laid out, the app is about to run (docs/lifecycle.md).
            lifecycle::dispatch_lifecycle(day_spec::Lifecycle::DidLaunch);

            // Startup deep link (docs/navigation.md): uniform across platforms; desktop
            // sets `DAY_DEEPLINK` directly, mobile shells forward the launch URL/intent into
            // it, and web-dom (no process environment) records the URL hash via
            // `set_launch_deeplink`. Deferred one turn so the first frame mounts before the
            // destination pushes. The turn-end route sync (`Toolkit::set_route` on change;
            // web-dom mirrors it into the URL hash) installs in the same deferred closure,
            // after the deep link resolves, so the launch route is never clobbered by a sync
            // of the pre-navigation state.
            day_reactive::on_main(move || {
                if let Some(route) = nav::launch_deeplink()
                    && !nav::navigate(&route)
                {
                    log::warn!("launch deep link {route:?} did not match a route");
                }
                // Consume the `request_route` buffer the line above may have read: a tap that
                // cold-started the process has now been applied, and leaving it set would
                // re-navigate on the next request.
                let _ = nav::take_requested_route();
                // First reflection runs eagerly: a launch with no deep link ends no turn.
                let route = nav::current_route().unwrap_or_default();
                with_tree(|t| t.set_route(&route));
                let last = std::cell::RefCell::new(Some(route));
                day_reactive::on_turn_end(move || {
                    let route = nav::current_route().unwrap_or_default();
                    if last.borrow().as_deref() != Some(route.as_str()) {
                        *last.borrow_mut() = Some(route.clone());
                        with_tree(|t| t.set_route(&route));
                    }
                });
            });

            // Verification hook (headless CI / no-input environments): drive the app through
            // Day's own event path once the native loop starts (delayed past first allocation
            // so snapshots see a laid-out window). Precursor of dayscript (§14).
            if let Ok(spec) = std::env::var("DAY_AUTODRIVE") {
                day_reactive::on_main_delayed(800, move || autodrive(&spec));
            }
        }),
    );
    // DidExit: the native loop returned, which is the last thing the app runs on the desktop
    // backends whose loop returns (GTK, Qt, XAML). The backends whose process ends inside the
    // loop (AppKit's terminate exits) emit it themselves, after WillTerminate, and
    // `dispatch_lifecycle` delivers it once whichever comes first. Where the host owns the
    // loop and `run` hands back at once with the app still to come (ArkUI, Android, the web,
    // the mock's test), the return is no exit at all: DidExit there is the host's own last
    // callback, and delivering it here would run the exit handlers at launch and leave the
    // real exit undelivered (`RUN_ENDS_APP`, docs/lifecycle.md).
    if P::RUN_ENDS_APP {
        lifecycle::dispatch_lifecycle(day_spec::Lifecycle::DidExit);
    }
}

/// Deliver `DidExit` from the C runtime's exit, for the loop that never returns: Qt's macOS
/// Quit menu item ends the process through `exit()` from inside `exec()`, and any backend or
/// app that calls `std::process::exit` does the same. `atexit` runs on the exiting thread,
/// the main one for every such path, with the thread-locals still alive; delivery is once, so
/// a loop that did return costs nothing here. Registered by `launch_with` on every backend but
/// the mock and wasm, which have no process exit of their own.
#[cfg(not(target_arch = "wasm32"))]
fn deliver_exit_at_process_exit() {
    unsafe extern "C" {
        fn atexit(f: extern "C" fn()) -> i32;
    }
    extern "C" fn at_exit() {
        lifecycle::dispatch_lifecycle(day_spec::Lifecycle::DidExit);
    }
    // SAFETY: `atexit` takes a plain C function pointer and keeps it for the process's life.
    unsafe {
        atexit(at_exit);
    }
}

/// `DAY_AUTODRIVE="<id>:press;<id>:text:Ada;<id>:value:80;<id>:toggle:true;<id>:tap;
/// <id>:drag:40:60;shot:/tmp/x.png"`: synthesized Day events by element id, plus snapshots.
fn autodrive(spec: &str) {
    use day_spec::{DragPhase, Event, Point};
    for step in spec.split(';').filter(|s| !s.is_empty()) {
        let parts: Vec<&str> = step.splitn(3, ':').collect();
        if parts[0] == "shot" {
            let path = parts[1..].join(":");
            let png = with_tree(|t| t.snapshot());
            match png {
                Ok(bytes) => {
                    let _ = std::fs::write(&path, bytes);
                }
                Err(e) => log::warn!("day autodrive: snapshot failed: {e}"),
            }
            continue;
        }
        let node = with_tree(|t| t.find_by_id(parts[0]));
        let Some(node) = node else {
            log::warn!("day autodrive: id {:?} not found", parts[0]);
            continue;
        };
        // Gesture drivers (docs/shapes.md): tap fires at the node's local center; drag runs a
        // Began→Changed→Ended sequence translated by dx,dy, exercising `.on_tap`/`.on_drag`
        // hit-testing through Day's event path (the native recognizers deliver the same events).
        if parts.get(1) == Some(&"tap") {
            if let Some(f) = with_tree(|t| t.node_frame(node)) {
                let c = Point::new(f.size.width / 2.0, f.size.height / 2.0);
                tree::enqueue_event(tree::rnode_to_id(node), Event::Tap(c));
            }
            continue;
        }
        if parts.get(1) == Some(&"drag") {
            if let Some(f) = with_tree(|t| t.node_frame(node)) {
                let c = Point::new(f.size.width / 2.0, f.size.height / 2.0);
                let (dx, dy) = parts
                    .get(2)
                    .and_then(|s| s.split_once(':'))
                    .and_then(|(a, b)| Some((a.parse::<f64>().ok()?, b.parse::<f64>().ok()?)))
                    .unwrap_or((0.0, 0.0));
                let end = Point::new(c.x + dx, c.y + dy);
                let id = tree::rnode_to_id(node);
                tree::enqueue_event(
                    id,
                    Event::Drag {
                        phase: DragPhase::Began,
                        location: c,
                        translation: Point::ZERO,
                    },
                );
                tree::enqueue_event(
                    id,
                    Event::Drag {
                        phase: DragPhase::Changed,
                        location: end,
                        translation: Point::new(dx, dy),
                    },
                );
                tree::enqueue_event(
                    id,
                    Event::Drag {
                        phase: DragPhase::Ended,
                        location: end,
                        translation: Point::new(dx, dy),
                    },
                );
            }
            continue;
        }
        if let (Some("text"), Some(v)) = (parts.get(1).copied(), parts.get(2).copied()) {
            synthesize_text(node, v.to_string());
            continue;
        }
        let ev = match (parts.get(1).copied(), parts.get(2).copied()) {
            (Some("press"), _) => Event::Pressed,
            (Some("toggle"), Some(v)) => Event::ToggleChanged(v == "true"),
            (Some("value"), Some(v)) => Event::ValueChanged(v.parse().unwrap_or(0.0)),
            (Some("select"), Some(v)) => Event::SelectionChanged(v.parse().unwrap_or(-1)),
            _ => continue,
        };
        tree::enqueue_event(tree::rnode_to_id(node), ev);
    }
}

/// Whether the platform is rendering in dark appearance (see `Toolkit::dark_mode`): the
/// branch apps take when painting custom opaque surfaces so fills track the theme that the
/// default text colors already follow.
pub fn dark_mode() -> bool {
    dark_signal().get()
}

/// The platform's font families with their faces (docs/fonts.md), enumerated once per
/// process through `Toolkit::font_families` and cached: the answer is stable for the process
/// (bundled fonts register before the tree exists, and nothing tracks a system font install
/// under a running app; the OS font panels cache too), enumeration is expensive, and the
/// query is synchronous on the UI thread. Sorted case-insensitively by family, deduplicated,
/// and unioned with the bundled fonts (`day_spec::fonts::bundled_fonts`) the toolkit's own
/// database did not report, which is how a `res/font/` (Android), `ms-appx` (XAML) or
/// `registerFont` (HarmonyOS) family reaches a font menu without each shim parsing sfnt.
/// Empty where the toolkit answers `Cap::FontList` = `Unsupported`.
pub fn font_families() -> std::rc::Rc<[day_spec::FontFamilyInfo]> {
    // Headless (no tree on this thread, i.e. a model unit test): nothing to enumerate, and
    // nothing to cache, so the first call with a tree still fills the cache.
    if !tree::has_tree() {
        return std::rc::Rc::from(Vec::new());
    }
    FONT_FAMILIES.with(|c| {
        c.get_or_init(|| {
            let mut list = tree::with_tree(|t| t.font_families());
            for path in day_spec::fonts::bundled_fonts() {
                let Some(names) = std::fs::read(&path)
                    .ok()
                    .and_then(|bytes| day_spec::fonts::parse_font_names(&bytes))
                else {
                    continue;
                };
                if list
                    .iter()
                    .any(|f| f.family.eq_ignore_ascii_case(&names.family))
                {
                    continue;
                }
                list.push(day_spec::FontFamilyInfo {
                    family: names.family,
                    faces: vec![day_spec::FontFace {
                        name: "Regular".to_string(),
                        weight: day_spec::FontWeight::Regular,
                        italic: false,
                    }],
                });
            }
            list.sort_by_key(|f| f.family.to_lowercase());
            list.dedup_by(|a, b| a.family.eq_ignore_ascii_case(&b.family));
            list.into()
        })
        .clone()
    })
}

/// Distinct measurements held per generation. Two generations are live at once, so the cache
/// holds up to twice this before the older one is dropped whole.
///
/// 512 is far more than a drawing measures per frame (a chart with two axes, their titles and a
/// legend is around twenty) and small enough that the worst case (a text tool measuring a
/// different string every keystroke) costs tens of kilobytes rather than growing without end.
const TEXT_METRICS_CAP: usize = 512;

/// The `measure_text` cache: a **pure** memo, because every backend's measurement is a function
/// of `(text, size, font)` and nothing else. Nothing in a running app changes the answer: canvas
/// text takes absolute points, so it carries neither the reader's font-scale setting nor the
/// window's scale factor (docs/canvas.md "Text"), which is what makes caching it correct rather
/// than merely fast.
///
/// Two generations rather than an LRU list: a hit in `previous` is promoted into `current`, and
/// when `current` fills, `previous` is replaced by it wholesale. That approximates least-recently-
/// used closely enough for a working set that repeats every frame, is O(1) amortized with no
/// eviction scan, and needs no ordering structure beside the maps.
struct TextMetricsCache {
    /// Reused so a hit allocates nothing at all: the key is rebuilt into this buffer, compared,
    /// and only copied into the map on a miss.
    key: String,
    current: std::collections::HashMap<String, day_spec::TextMetrics>,
    previous: std::collections::HashMap<String, day_spec::TextMetrics>,
    hits: u64,
    misses: u64,
}

impl TextMetricsCache {
    fn new() -> Self {
        TextMetricsCache {
            key: String::new(),
            current: std::collections::HashMap::new(),
            previous: std::collections::HashMap::new(),
            hits: 0,
            misses: 0,
        }
    }

    /// Write the key for one measurement into the scratch buffer.
    ///
    /// The family's length is written before the family, so the encoding is injective whatever a
    /// family name contains; the alternative, a separator, is only injective while no font is
    /// ever named with it in it. `size` goes in as its bit pattern: exact, and two sizes that
    /// differ only in representation would measure the same anyway.
    fn build_key(&mut self, text: &str, size: f64, font: &day_spec::CanvasFont) {
        use std::fmt::Write;
        let family = font.family_str();
        self.key.clear();
        let _ = write!(
            self.key,
            "{:x}:{}:{}:{}:{family}{text}",
            size.to_bits(),
            font.css_weight(),
            u8::from(font.italic),
            family.len(),
        );
    }

    /// A hit promotes an older-generation entry into the current one, so a working set that
    /// survives a generation flip is not measured again.
    fn lookup(&mut self) -> Option<day_spec::TextMetrics> {
        if let Some(m) = self.current.get(&self.key) {
            self.hits += 1;
            return Some(*m);
        }
        let m = *self.previous.get(&self.key)?;
        self.hits += 1;
        self.current.insert(self.key.clone(), m);
        Some(m)
    }

    fn insert(&mut self, m: day_spec::TextMetrics) {
        self.misses += 1;
        if self.current.len() >= TEXT_METRICS_CAP {
            self.previous = std::mem::take(&mut self.current);
        }
        self.current.insert(self.key.clone(), m);
    }
}

/// What [`measure_text`]'s cache has done on this thread: a diagnostic, for an app that wants to
/// see whether its drawing is measuring the same strings over and over (it usually is).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct TextMetricsCacheStats {
    /// Measurements answered without asking the toolkit.
    pub hits: u64,
    /// Measurements that had to cross into the toolkit.
    pub misses: u64,
    /// Distinct measurements currently held, across both generations.
    pub entries: usize,
}

/// Read the [`measure_text`] cache's counters (docs/fonts.md).
pub fn text_metrics_cache_stats() -> TextMetricsCacheStats {
    TEXT_METRICS.with(|c| {
        let c = c.borrow();
        TextMetricsCacheStats {
            hits: c.hits,
            misses: c.misses,
            entries: c.current.len() + c.previous.len(),
        }
    })
}

/// Drop every cached measurement (the counters keep running).
///
/// Nothing in day needs this: a measurement is a pure function of its key, and the font set is
/// fixed for the process (see [`font_families`]). It exists for a toolkit or app that registers a
/// face at runtime and so does change what a family name measures to.
pub fn clear_text_metrics_cache() {
    TEXT_METRICS.with(|c| {
        let mut c = c.borrow_mut();
        c.current.clear();
        c.previous.clear();
    });
}

/// Measure one line of canvas text at `size` points in `font` (docs/fonts.md), in the engine
/// that draws `DrawOp::Text`; a toolkit that cannot measure answers
/// `TextMetrics::approximate`, so a caller always gets a usable box.
///
/// **Memoized** (`TextMetricsCache`): measuring crosses into the toolkit and lays the text out
/// there (around 29 µs a call on AppKit, which builds an `NSString` and an attribute dictionary
/// every time), and a drawing measures the same handful of strings on every frame it records. A
/// repeat costs a hash of the key and nothing else.
pub fn measure_text(text: &str, size: f64, font: &day_spec::CanvasFont) -> day_spec::TextMetrics {
    if let Some(m) = TEXT_METRICS.with(|c| {
        let mut c = c.borrow_mut();
        c.build_key(text, size, font);
        c.lookup()
    }) {
        return m;
    }
    // The toolkit call happens with no borrow of the cache held: `replay` and a piece's own
    // measurement can reach back into day-core, and a borrow spanning the call would panic.
    // Headless (no tree on this thread) answers the approximation too, so a model unit test
    // that frames text runs without a toolkit.
    let Some(m) = tree::try_with_tree(|t| t.measure_text(text, size, font)).flatten() else {
        // Not cached. "The toolkit could not answer" is a fact about this moment (Android's
        // returns nothing until its VM is up), not about the text, and caching it would
        // pin a guess for the life of the process.
        return day_spec::TextMetrics::approximate(text, size);
    };
    TEXT_METRICS.with(|c| {
        let mut c = c.borrow_mut();
        c.build_key(text, size, font);
        c.insert(m);
    });
    m
}

/// Whether motion should be reduced (docs/accessibility.md): the user's system setting
/// (`Toolkit::reduce_motion`), or a launch that forced it (`DAY_REDUCE_MOTION=1`, and
/// `day launch --fast`, whose fast mode is this setting forced on). Day already applies every
/// finite animation at its destination under it and keeps only indeterminate motion; this read
/// is for the motion an app drives itself, such as a `day::frame` simulation or a decorative
/// loop, which the app opts down. Reactive: a closure reading it re-runs when the setting flips.
pub fn reduce_motion() -> bool {
    reduce_motion_signal().get()
}

/// [`reduce_motion`] untracked and borrow-free, for the animation gates inside the tree.
pub fn reduce_motion_now() -> bool {
    testing::forced_reduce_motion() || REDUCE_MOTION.with(|c| c.get())
}

/// The reactive backing for [`reduce_motion`], seeded from the current answer.
fn reduce_motion_signal() -> day_reactive::Signal<bool> {
    REDUCE_MOTION_SIGNAL.with(|c| {
        *c.get_or_init(|| {
            let seed = reduce_motion_now();
            day_reactive::Scope::detached().enter(|| day_reactive::Signal::new(seed))
        })
    })
}

/// Re-read the toolkit's reduce-motion setting into [`reduce_motion`]. Backends call this
/// when the user flips the setting under a running app; [`launch_with`] calls it once the tree
/// stands, which is the launch read. Like [`note_appearance_changed`], it may fire before the
/// tree is installed and then does nothing.
pub fn note_motion_changed() {
    if let Some(system) = tree::try_with_tree(|t| t.reduce_motion()) {
        REDUCE_MOTION.with(|c| c.set(system));
        let now = reduce_motion_now();
        REDUCE_MOTION_SIGNAL.with(|c| {
            if let Some(s) = c.get() {
                s.set(now);
            }
        });
    }
}

/// The reactive backing for [`dark_mode`], lazily seeded from the toolkit's answer.
fn dark_signal() -> day_reactive::Signal<bool> {
    DARK_SIGNAL.with(|c| {
        *c.get_or_init(|| {
            let seed = tree::with_tree(|t| t.dark_mode());
            day_reactive::Scope::detached().enter(|| day_reactive::Signal::new(seed))
        })
    })
}

/// Re-read the toolkit's appearance into the reactive [`dark_mode`] signal. Backends call
/// this when the system appearance changes under a running app (macOS theme switch, GTK
/// style-manager change), and [`set_appearance`] calls it after applying an override, so
/// closures reading `dark_mode()` recolor live instead of going stale until a rebuild.
pub fn note_appearance_changed() {
    // May fire before the tree is installed (GTK's StyleManager emits `dark` notifies while
    // `startup` applies a forced DAY_THEME scheme; AppKit's observer dispatches async): no
    // tree means no palette closures exist yet, and the signal seeds from the tree on first
    // use; skip rather than panic inside a native callback.
    if let Some(d) = tree::try_with_tree(|t| t.dark_mode()) {
        dark_signal().set(d);
    }
}

/// The system text size changed under a running app (iOS Dynamic Type, a desktop's text
/// scaling): every text measurement Day holds is stale. Backends call this from the platform's
/// notification, once the native text has taken the new size. Day re-measures every node and
/// re-lays every bound list row, so a content-sized row (docs/list.md "Row heights") takes its
/// new height through `ListPatch::RowSizeInvalidated`. Skipped when the tree is busy or not
/// installed yet: a tree built after the change measures at the new size anyway.
pub fn note_text_size_changed() {
    let _ = tree::try_with_tree(|t| {
        t.invalidate_all_measurements();
        t.layout_if_needed();
    });
}

// The `.searchable()` hosts whose field is INLINE (docs/search.md), newest last: each has no
// `day::search` bar item, so a caller holding that reserved id (dayscript's `toolbar:` step)
// resolves it here and delivers `Event::SearchChanged` to the host, as the native field would.
/// Record `host` as showing an inline search field. Registered by the host's builder for the
/// life of its scope; see [`inline_search_host`].
pub fn register_search_host(host: RNode) {
    let window = toolbar::current_page_window();
    SEARCH_HOSTS.with(|h| {
        let mut h = h.borrow_mut();
        h.retain(|(_, node)| *node != host);
        h.push((window, host));
    });
}

/// Forget `host`, once its scope is disposed.
pub fn unregister_search_host(host: RNode) {
    SEARCH_HOSTS.with(|h| h.borrow_mut().retain(|(_, node)| *node != host));
}

/// The inline search host the reserved `day::search` id names when no bar item carries it: the
/// most recently shown one in the focused window.
pub fn inline_search_host() -> Option<RNode> {
    let window = windows::focused_root();
    SEARCH_HOSTS.with(|h| {
        h.borrow()
            .iter()
            .rev()
            .find(|(root, _)| *root == window)
            .map(|(_, host)| *host)
    })
}

/// Activate the focused window's searchable navigation surface (for a Find command).
pub fn focus_search() {
    if toolbar::focus_toolbar_search() {
        return;
    }
    if let Some(host) = inline_search_host() {
        tree::with_tree(|t| t.patch(host, Box::new(day_spec::props::SearchPatch::Focus), false));
    }
}

/// Deliver synthetic typing to a text control (the dayscript `input` step and the autodrive
/// string commands both route here): paint the widget via the ordinary app-write patch, then
/// enqueue the `TextChanged` event. Both halves are needed: when a real user types, the text
/// is already in the native field by the time its change event fires, so the two-way
/// binding's echo guard suppresses the write-back (§4.4); a synthesized event
/// alone would drive the app's signal while the widget kept showing its old text.
pub fn synthesize_text(node: RNode, text: String) {
    tree::with_tree(|t| match t.node_kind(node) {
        Some(day_spec::kinds::TEXT_FIELD) => t.patch(
            node,
            Box::new(day_spec::props::TextFieldPatch::Text {
                text: text.clone(),
                from_native: false,
            }),
            false,
        ),
        Some(day_spec::kinds::TEXT_AREA) => t.patch(
            node,
            Box::new(day_spec::props::TextAreaPatch::SetText(text.clone())),
            false,
        ),
        // Other kinds (external pieces like the combobox) own their display, and hear about the
        // text first through [`SYNTHESIZED_TEXT`]: a piece that treats `TextChanged` as the
        // view's own report (the styled editor does) paints from that one instead, since the
        // view has not seen this text.
        _ => tree::enqueue_event(
            tree::rnode_to_id(node),
            day_spec::Event::custom(SYNTHESIZED_TEXT, text.clone()),
        ),
    });
    tree::enqueue_event(tree::rnode_to_id(node), day_spec::Event::TextChanged(text));
}

/// The tag of the `Event::Custom` [`synthesize_text`] sends to a piece of a kind day-core does not
/// paint itself, carrying the text, just before the `TextChanged` that follows it. A piece whose
/// native view owns the characters shows the text on this event and then sees the `TextChanged`
/// as the echo it already handles.
pub const SYNTHESIZED_TEXT: &str = "day:synthesized-text";

mod piece_ops;
pub use piece_ops::{
    PieceOperationDone, PieceOperationFn, piece_operation, register_piece_operation,
};

/// Override the app's appearance: `Some(true)` dark, `Some(false)` light, `None` follow the
/// system again. On backends reporting `Cap::Appearance` the native widgets restyle in place
/// and [`dark_mode`] answers the override; app-painted surfaces pick it up on their next
/// rebuild. Other backends ignore the call; probe before offering a theme picker.
pub fn set_appearance(dark: Option<bool>) {
    tree::with_tree(|t| t.set_appearance(dark));
    note_appearance_changed();
}

/// Put a badge on the app's icon: the Dock, launcher, home screen, or taskbar (docs/badge.md).
///
/// Fire-and-forget: a payload the running toolkit cannot render is ignored, so probe
/// `capability(Cap::AppBadgeCount | AppBadgeText | AppBadgeDot)` first and choose. Nothing is ever
/// substituted, because a wrong number on a user's icon is worse than no badge.
///
/// ```no_run
/// # use day_core::{set_app_badge};
/// # use day_spec::AppBadge;
/// set_app_badge(&AppBadge::Count(7));
/// set_app_badge(&AppBadge::None); // clear
/// ```
///
/// An iOS badge belongs to the installed app and survives termination, so an app that sets one
/// usually clears it from a `WillTerminate` handler (docs/lifecycle.md); a macOS Dock badge dies
/// with the process.
pub fn set_app_badge(badge: &day_spec::AppBadge) {
    tree::with_tree(|t| t.set_app_badge(badge));
}

#[cfg(test)]
mod posted_panic_tests {
    /// A panic inside a posted main-thread task must be contained (logged + runtime reset), never
    /// unwind into the native trampoline that posted it (`panic_cannot_unwind` → SIGABRT). This
    /// is the poster/scheduler twin of `pump_events`' containment.
    #[test]
    fn posted_panic_is_contained() {
        super::contain_posted_panic(Box::new(|| panic!("boom in a posted task")));
        // Reaching here is the assertion: the panic did not propagate. The runtime was reset, so
        // subsequent reactive work still runs.
        let s = day_reactive::Signal::new(1i32);
        s.set(2);
        assert_eq!(s.get_untracked(), 2);
    }
}