Skip to main content

day_core/
toolbar.rs

1// Copyright © The Daybrite Project
2// SPDX-License-Identifier: MPL-2.0
3
4//! Toolbar contributions (docs/toolbars.md). The model ([`day_spec::ToolbarItem`]) is
5//! toolkit-neutral and carries only ids for its commands; the closures live here, keyed by
6//! id, the same shape as [`crate::menu`] and the same id space, so one closure can
7//! back both a toolbar button and its menu-bar twin.
8//!
9//! Any piece can declare items. Where it sits decides which chrome carries them (the window's
10//! own, or one navigation page's), and its scope decides how long they stay: a contribution is
11//! withdrawn when the piece that registered it is disposed, so a command leaves with the content
12//! it acts on. Several pieces may contribute to one chrome; this module merges them in
13//! registration order and hands the result to the toolkit as one model.
14
15use std::cell::{Cell, RefCell};
16use std::collections::HashMap;
17use std::rc::Rc;
18
19use day_spec::{ToolbarItem, ToolbarPatch, ToolbarValue};
20
21use crate::tree::{RNode, with_tree};
22
23/// A toolbar item's value callback: what a search field's text or a toggle's state runs.
24type ValueAction = Rc<dyn Fn(&ToolbarValue)>;
25
26/// Which chrome a contribution lands on.
27///
28/// Not a placement; [`day_spec::ToolbarPlacement`] says where on a chrome an item sits. This
29/// says which chrome, and it is never written by an app: it follows from the piece that declared
30/// the items, which is what the design is built around (docs/toolbars.md).
31#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
32pub enum Chrome {
33    /// The window's own chrome, shown on every page of that window.
34    Window(RNode),
35    /// One navigation page's chrome. Which surface draws it follows from the page's
36    /// [`day_spec::props::Pane`].
37    Page(RNode),
38}
39
40/// One navigation page being built: the node contributions land on, whether it is on screen, and
41/// which column of the window it is.
42struct PageFrame {
43    page: RNode,
44    active: Option<Rc<dyn Fn() -> bool>>,
45    column: day_spec::ToolbarColumn,
46    /// The window this page belongs to, captured when its host was built. A destination page is
47    /// built lazily, on the first selection, long after its window's build has returned, and
48    /// `window_being_built()` then answers the primary window, so a second window's pages
49    /// would contribute to the first one's bar.
50    window: RNode,
51}
52
53/// One piece's declared items, alive as long as that piece is.
54struct Contribution {
55    chrome: Chrome,
56    /// Registration order within its chrome. Stable across an update, so re-deriving a list does
57    /// not move it past a neighbor that was declared later.
58    seq: u64,
59    items: Vec<ToolbarItem>,
60    /// The window this contribution's chrome belongs to.
61    window: RNode,
62    document_window: Option<DocumentWindow>,
63    /// Whether the page carrying it is on screen. One bar serves the window, so a pane that is
64    /// collapsed or a destination that is not the one showing must not leave its commands on it;
65    /// that is what made a sidebar row's chrome look one level out of step (docs/toolbars.md).
66    /// `None` for a window's items, which are always showing.
67    active: Option<Rc<dyn Fn() -> bool>>,
68}
69
70/// A resident document's mutable presentation window. Contributions inherit this
71/// context so moving the document also moves its commands without rebuilding them.
72#[derive(Clone)]
73pub struct DocumentWindow(Rc<Cell<RNode>>);
74impl DocumentWindow {
75    pub fn new(root: RNode) -> Self {
76        Self(Rc::new(Cell::new(root)))
77    }
78    pub fn set(&self, root: RNode) {
79        if self.0.replace(root) != root {
80            schedule_recompose();
81        }
82    }
83}
84impl Contribution {
85    fn window(&self) -> RNode {
86        self.document_window
87            .as_ref()
88            .map_or(self.window, |w| w.0.get())
89    }
90}
91
92day_reactive::tls_slots! {
93    toolbar;
94    /// Value callbacks (search text, toggle state) by dispatch id. Plain buttons don't appear
95    /// here; they register with [`crate::menu::register_menu_action`] and arrive as
96    /// `Event::MenuAction`.
97    static VALUE_ACTIONS: RefCell<HashMap<u64, ValueAction>> = RefCell::new(HashMap::new());
98    /// Live contributions by token.
99    static CONTRIBUTIONS: RefCell<HashMap<u64, Contribution>> = RefCell::new(HashMap::new());
100    /// Each chrome's merged model as last lowered; dayscript resolves an item's action here,
101    /// and a recompose compares against it to skip an install that would change nothing. Items
102    /// carry slot ids here, never closure ids (the toolbar slot model).
103    static MODELS: RefCell<Vec<(Chrome, Vec<ToolbarItem>)>> = const { RefCell::new(Vec::new()) };
104    /// Each window's dispatch slots: (window, item key) → the id the toolkit holds for that item
105    /// (the toolbar slot model).
106    static SLOT_IDS: RefCell<HashMap<(RNode, String), u64>> = RefCell::new(HashMap::new());
107    /// Slot id → the closure id it dispatches to right now.
108    static SLOT_TARGETS: RefCell<HashMap<u64, u64>> = RefCell::new(HashMap::new());
109    /// Each window's bar as its toolkit holds it: what an edit is computed against. Separate
110    /// from [`MODELS`], which is the bar the window should carry (and what dayscript reads), so
111    /// an edit the toolkit never received is not mistaken for one it did: the next recompose
112    /// sends it again, the way a whole-bar install used to heal on the next change.
113    static DELIVERED: RefCell<HashMap<RNode, Vec<ToolbarItem>>> = RefCell::new(HashMap::new());
114    /// A recompose is owed at the end of this turn ([`schedule_recompose`]).
115    static RECOMPOSE_PENDING: Cell<bool> = const { Cell::new(false) };
116    static RECOMPOSING: Cell<bool> = const { Cell::new(false) };
117    static RECOMPOSE_AGAIN: Cell<bool> = const { Cell::new(false) };
118    /// Next contribution token, and the registration counter behind `Contribution::seq`.
119    static NEXT_TOKEN: Cell<u64> = const { Cell::new(1) };
120    /// The window whose content is being built right now (see [`with_window`]).
121    static BUILDING: Cell<Option<RNode>> = const { Cell::new(None) };
122    /// The navigation pages this build is inside, innermost last, each with the predicate that
123    /// says whether it is on screen. A contribution registered with a non-empty stack belongs to
124    /// the innermost page; one registered with an empty stack belongs to the window.
125    static PAGE_STACK: RefCell<Vec<PageFrame>> = RefCell::new(Vec::new());
126}
127
128/// Run `f` with `root` as the window contributions inside it belong to. day-core wraps each
129/// window's content build in this; nesting restores the previous window on the way out.
130pub(crate) fn with_window<R>(root: RNode, f: impl FnOnce() -> R) -> R {
131    // Restored on unwind too (the anim.rs `Restore` rule): a contained panic during a
132    // secondary window's build would otherwise leave `BUILDING` pointing at a dead window,
133    // silently redirecting every later contribution.
134    struct Restore(Option<RNode>);
135    impl Drop for Restore {
136        fn drop(&mut self) {
137            BUILDING.with(|b| b.set(self.0));
138        }
139    }
140    let _restore = Restore(BUILDING.with(|b| b.replace(Some(root))));
141    f()
142}
143
144/// Run `f` with `page` as the navigation page contributions inside it belong to. The pieces layer
145/// wraps each destination, sidebar and content-list build in this.
146pub fn with_page<R>(page: RNode, f: impl FnOnce() -> R) -> R {
147    with_page_in(
148        page,
149        None,
150        day_spec::ToolbarColumn::Detail,
151        window_being_built(),
152        f,
153    )
154}
155
156/// [`with_page_gated`] naming the window explicitly. Callers that build pages lazily (every
157/// navigation host) pass the window they were built in.
158pub fn with_page_in<R>(
159    page: RNode,
160    active: Option<Rc<dyn Fn() -> bool>>,
161    column: day_spec::ToolbarColumn,
162    window: RNode,
163    f: impl FnOnce() -> R,
164) -> R {
165    struct Restore;
166    impl Drop for Restore {
167        fn drop(&mut self) {
168            PAGE_STACK.with(|s| {
169                s.borrow_mut().pop();
170            });
171        }
172    }
173    PAGE_STACK.with(|s| {
174        s.borrow_mut().push(PageFrame {
175            page,
176            active,
177            column,
178            window,
179        })
180    });
181    let _restore = Restore;
182    f()
183}
184
185/// [`with_page`] with the predicate that says whether this page is on screen (a collapsed
186/// content-list pane, a destination that is not the one showing, a page covered by a push). The
187/// window's one bar carries only the chromes that predicate admits (docs/toolbars.md).
188pub fn with_page_gated<R>(
189    page: RNode,
190    active: Option<Rc<dyn Fn() -> bool>>,
191    column: day_spec::ToolbarColumn,
192    f: impl FnOnce() -> R,
193) -> R {
194    with_page_in(page, active, column, window_being_built(), f)
195}
196
197/// The window the page being built belongs to, else the one being built.
198pub fn current_page_window() -> RNode {
199    PAGE_STACK
200        .with(|s| s.borrow().last().map(|f| f.window))
201        .unwrap_or_else(window_being_built)
202}
203
204/// The predicate for the page being built, if it has one.
205pub fn current_page_gate() -> Option<Rc<dyn Fn() -> bool>> {
206    PAGE_STACK.with(|s| s.borrow().last().and_then(|f| f.active.clone()))
207}
208
209/// Which column the page being built is; [`day_spec::ToolbarColumn::Window`] at the window root.
210pub fn current_page_column() -> day_spec::ToolbarColumn {
211    PAGE_STACK.with(|s| {
212        s.borrow()
213            .last()
214            .map(|f| f.column)
215            .unwrap_or(day_spec::ToolbarColumn::Window)
216    })
217}
218
219/// The window being built, else the primary root. Shared with the ambient environment lookup, which scopes
220/// the same way for the same reason: an app's one `size_class()` call inside a shared
221/// `build_shell` must mean "this window".
222pub fn window_being_built() -> RNode {
223    BUILDING
224        .with(|b| b.get())
225        .unwrap_or_else(|| with_tree(|t| t.root_node()))
226}
227
228/// The chrome a contribution registered right now belongs to: the innermost navigation page
229/// being built, else the window being built, else the primary window.
230///
231/// Captured once, at registration. A derived contribution re-runs long after its build, when
232/// neither stack says anything, so reading this later would send its items to the primary
233/// window's chrome.
234pub fn current_chrome() -> Chrome {
235    match PAGE_STACK.with(|s| s.borrow().last().map(|f| f.page)) {
236        Some(page) => Chrome::Page(page),
237        None => Chrome::Window(window_being_built()),
238    }
239}
240
241/// Register a value callback for a search or toggle item and return its dispatch id (nonzero).
242/// The id comes from the menu action counter, so toolbar and menu ids never collide.
243pub fn register_toolbar_value(f: Rc<dyn Fn(&ToolbarValue)>) -> u64 {
244    let id = crate::menu::next_action_id();
245    VALUE_ACTIONS.with(|m| m.borrow_mut().insert(id, f));
246    id
247}
248
249/// Run the value callback registered for `action` (no-op if none). Called by the event pump on
250/// `Event::ToolbarChanged`, inside a reactive batch so multiple signal writes coalesce. `action`
251/// is the slot id the toolkit holds (the toolbar slot model); a raw closure id works too.
252pub fn dispatch_toolbar_value(action: u64, value: &ToolbarValue) {
253    mirror_value(action, value);
254    let target = resolve_slot(action);
255    let f = VALUE_ACTIONS.with(|m| m.borrow().get(&target).cloned());
256    if let Some(f) = f {
257        day_reactive::batch(|| f(value));
258    }
259}
260
261/// Synthetic input to a bar item (dayscript's `toolbar:` step): paint the widget the way an app
262/// write would, then dispatch the value as the native control would. Both halves are needed, as
263/// for a text field (`crate::synthesize_text`): a real keystroke reaches Day with the field
264/// already showing it, so the dispatch alone records the text without touching the widget, and a
265/// synthesized one left the field empty while the app acted on the query.
266pub fn synthesize_toolbar_value(action: u64, value: &ToolbarValue) {
267    if let ToolbarValue::Text(text) = value {
268        let target = MODELS.with(|m| {
269            m.borrow().iter().find_map(|(c, items)| match c {
270                Chrome::Window(root) => items
271                    .iter()
272                    .find(|i| i.action == action)
273                    .map(|i| (*root, i.id.clone())),
274                Chrome::Page(_) => None,
275            })
276        });
277        if let Some((root, item)) = target {
278            let patch = ToolbarPatch::Text {
279                item,
280                text: text.clone(),
281            };
282            if with_tree(|t| t.patch_window_toolbar(root, patch.clone())) {
283                deliver_value(root, &patch);
284            }
285        }
286    }
287    dispatch_toolbar_value(action, value);
288}
289
290/// The closure id a toolkit-held slot id dispatches to now; any other id is returned as is.
291/// The menu rail resolves through here too, since toolbar buttons ride it.
292pub(crate) fn resolve_slot(id: u64) -> u64 {
293    SLOT_TARGETS.with(|m| m.borrow().get(&id).copied().unwrap_or(id))
294}
295
296/// Add one piece's items to `chrome` and return the token that owns them. The caller withdraws
297/// them with [`unregister_contribution`], normally from its own scope cleanup.
298pub fn register_contribution(chrome: Chrome, items: Vec<ToolbarItem>) -> u64 {
299    register_contribution_gated(chrome, items, None)
300}
301
302/// [`register_contribution`] for a page, with the predicate that says whether that page is on
303/// screen. The caller also re-reads it inside an effect, which is what re-composes the bar when
304/// a pane collapses or the destination changes.
305pub fn register_contribution_gated(
306    chrome: Chrome,
307    items: Vec<ToolbarItem>,
308    active: Option<Rc<dyn Fn() -> bool>>,
309) -> u64 {
310    let token = NEXT_TOKEN.with(|n| {
311        let t = n.get();
312        n.set(t + 1);
313        t
314    });
315    let window = current_page_window();
316    CONTRIBUTIONS.with(|m| {
317        m.borrow_mut().insert(
318            token,
319            Contribution {
320                chrome,
321                seq: token,
322                items,
323                window,
324                document_window: day_reactive::Scope::current().use_context::<DocumentWindow>(),
325                active,
326            },
327        )
328    });
329    let _ = chrome;
330    schedule_recompose();
331    token
332}
333
334/// One window's whole bar for a toolkit that draws only one: its items, then every page
335/// chrome on screen under it, in registration order (docs/toolbars.md).
336fn merged_window(root: RNode) -> Vec<ToolbarItem> {
337    let mut live: Vec<(u64, Vec<ToolbarItem>)> = CONTRIBUTIONS.with(|m| {
338        m.borrow()
339            .values()
340            .filter(|c| c.window() == root)
341            .filter(|c| {
342                c.active
343                    .as_ref()
344                    .is_none_or(|f| day_reactive::untrack(|| f()))
345            })
346            .map(|c| (c.seq, c.items.clone()))
347            .collect()
348    });
349    live.sort_by_key(|(seq, _)| *seq);
350    let mut out: Vec<ToolbarItem> = live.into_iter().flat_map(|(_, items)| items).collect();
351    out.sort_by_key(|i| placement_rank(i.placement));
352    out
353}
354
355/// Replace one contribution's items in place, keeping its position among its neighbors.
356pub fn update_contribution(token: u64, items: Vec<ToolbarItem>) {
357    let old = CONTRIBUTIONS.with(|m| {
358        let mut m = m.borrow_mut();
359        let c = m.get_mut(&token)?;
360        Some(std::mem::replace(&mut c.items, items))
361    });
362    if let Some(old) = old {
363        forget_closures(&old);
364        schedule_recompose();
365    }
366}
367
368/// Give a contribution's items new labels and tooltips, one `(label, tooltip)` per item in
369/// order, keeping everything else as it now stands (actions, a search field's typed text): a
370/// language change relabeling a fixed toolbar (docs/toolbars.md).
371pub fn relabel_contribution(token: u64, texts: &[(String, Option<String>)]) {
372    let Some(mut items) = CONTRIBUTIONS.with(|m| m.borrow().get(&token).map(|c| c.items.clone()))
373    else {
374        return;
375    };
376    for (item, (label, tooltip)) in items.iter_mut().zip(texts) {
377        item.label = label.clone();
378        item.tooltip = tooltip.clone();
379    }
380    update_contribution(token, items);
381}
382
383/// Withdraw a contribution. Its items leave the chrome, and the closures only it owned go with
384/// them.
385pub fn unregister_contribution(token: u64) {
386    let old = CONTRIBUTIONS.with(|m| m.borrow_mut().remove(&token).map(|c| c.items));
387    if let Some(old) = old {
388        forget_closures(&old);
389        schedule_recompose();
390    }
391}
392
393/// Drop the closures `items` registered that no live contribution still carries, so a page
394/// visited a hundred times does not hold a hundred generations of its commands.
395///
396/// Only the item-level ids: a button's or a toggle's own closure, registered fresh by each
397/// lowering. A pull-down's entries can name Day's standing dispatchers (a new-window item), so
398/// they stay registered, as they did before.
399fn forget_closures(items: &[ToolbarItem]) {
400    let live: std::collections::HashSet<u64> = CONTRIBUTIONS.with(|m| {
401        m.borrow()
402            .values()
403            .flat_map(|c| c.items.iter().map(|i| i.action))
404            .collect()
405    });
406    for item in items
407        .iter()
408        .filter(|i| i.action != 0 && !live.contains(&i.action))
409    {
410        VALUE_ACTIONS.with(|m| m.borrow_mut().remove(&item.action));
411        if matches!(
412            item.kind,
413            day_spec::ToolbarItemKind::Button | day_spec::ToolbarItemKind::Menu { .. }
414        ) {
415            crate::menu::forget_action(item.action);
416        }
417    }
418}
419
420/// The order the buckets draw in, leading to trailing. `Bottom` sorts with `Secondary`: a chrome
421/// with no bottom bar draws it there, and one with a bottom bar takes it out of this list before
422/// ordering matters.
423fn placement_rank(p: day_spec::ToolbarPlacement) -> u8 {
424    use day_spec::ToolbarPlacement as P;
425    match p {
426        P::Navigation => 0,
427        P::Principal => 1,
428        P::Automatic => 2,
429        P::Primary => 3,
430        P::Secondary | P::Bottom => 4,
431    }
432}
433
434/// Record a value the user put into a live item, without touching the widget that reported it.
435///
436/// The widget already shows it; what is stale is Day's copy. Both copies (the owning
437/// contribution's, which a re-compose merges from, and the window's model, which the toolkit
438/// drew and dayscript reads) must learn it, or the next rebuild seeds the item with whatever Day
439/// last pushed. That is how a search field typed into came back empty after a page change, with
440/// the list still filtered by the text it had lost, and how clearing it afterwards reported
441/// nothing new (the field held the stale seed already).
442fn mirror_value(action: u64, value: &ToolbarValue) {
443    // A page's own bar (a phone's navigation item) is a model of its own; the desktops merge
444    // the showing page into the window's. Either way the item's chrome is where Day's copy lives.
445    // A window's bar first: where a page merges into it, that is the bar the toolkit drew.
446    let Some((chrome, id)) = MODELS.with(|m| {
447        let m = m.borrow();
448        let found = |window: bool| {
449            m.iter().find_map(|(c, items)| {
450                (matches!(c, Chrome::Window(_)) == window)
451                    .then(|| items.iter().find(|i| i.action == action))
452                    .flatten()
453                    .map(|i| (*c, i.id.clone()))
454            })
455        };
456        found(true).or_else(|| found(false))
457    }) else {
458        return;
459    };
460    let patch = match value {
461        ToolbarValue::Text(text) => ToolbarPatch::Text {
462            item: id,
463            text: text.clone(),
464        },
465        ToolbarValue::On(on) => ToolbarPatch::On { item: id, on: *on },
466        ToolbarValue::Selected(index) => ToolbarPatch::Selected {
467            item: id,
468            index: *index,
469        },
470        // A submission leaves the field's text as the last `Text` set it.
471        ToolbarValue::Submit(_) => return,
472    };
473    CONTRIBUTIONS.with(|m| {
474        for c in m.borrow_mut().values_mut().filter(|c| match chrome {
475            Chrome::Window(root) => c.window() == root,
476            Chrome::Page(_) => c.chrome == chrome,
477        }) {
478            apply_to_model(&mut c.items, &patch);
479        }
480    });
481    MODELS.with(|m| {
482        if let Some((_, items)) = m.borrow_mut().iter_mut().find(|(c, _)| *c == chrome) {
483            apply_to_model(items, &patch);
484        }
485    });
486    if let Chrome::Window(root) = chrome {
487        deliver_value(root, &patch);
488    }
489}
490
491/// Owe the windows a recompose, paid once when the current turn settles.
492///
493/// Every change to what a bar carries lands here: a contribution registered, re-derived or
494/// withdrawn, a page gate flipping. A single user action makes several of them. Swapping the
495/// page withdraws the old page's items and contributes the new page's, and handing each step to
496/// the toolkit rebuilt the bar twice, through an intermediate bar nobody should see, when the
497/// bar before and after was the same. Deferring to the end of the turn composes only the settled
498/// result, and [`recompose_windows`] then finds nothing to install. Outside a turn (a window's
499/// first build) it runs right away.
500fn schedule_recompose() {
501    if RECOMPOSE_PENDING.with(|p| p.replace(true)) {
502        return;
503    }
504    day_reactive::at_turn_end(recompose_windows);
505}
506
507/// Hand every window whose composed bar changed its new model, and nothing to the rest.
508///
509/// A bar is re-installed only when something the toolkit draws changed: an item added,
510/// removed or reordered, a label, an icon, a kind. What changes on every derivation, the
511/// closures, never reaches the model: each item is handed to the toolkit under a stable slot
512/// id (the toolbar slot model), so a page re-built with the same commands composes a model equal to
513/// the installed one. Values the user or the app change (search text, a toggle) reach the model
514/// through the same patches that update the widget, so they never differ either.
515fn recompose_windows() {
516    recompose_serially(recompose_windows_inner);
517}
518
519// Native toolbar edits can enqueue layout/focus events. `with_tree` drains those events
520// before returning, and their handlers can request another toolbar composition. Finish
521// recording DELIVERED first: nested diffs against the old baseline insert an item twice,
522// which raises an Objective-C exception in NSToolbar.
523fn recompose_serially(mut apply: impl FnMut()) {
524    if RECOMPOSING.with(|busy| busy.replace(true)) {
525        RECOMPOSE_AGAIN.with(|again| again.set(true));
526        return;
527    }
528    struct Reset;
529    impl Drop for Reset {
530        fn drop(&mut self) {
531            RECOMPOSING.with(|busy| busy.set(false));
532            RECOMPOSE_AGAIN.with(|again| again.set(false));
533        }
534    }
535    let _reset = Reset;
536    loop {
537        RECOMPOSE_AGAIN.with(|again| again.set(false));
538        apply();
539        if !RECOMPOSE_AGAIN.with(|again| again.get()) {
540            break;
541        }
542    }
543}
544
545fn recompose_windows_inner() {
546    RECOMPOSE_PENDING.with(|p| p.set(false));
547    let roots: Vec<RNode> = {
548        let mut v: Vec<RNode> =
549            CONTRIBUTIONS.with(|m| m.borrow().values().map(|c| c.window()).collect());
550        // A window whose last contribution left still has a bar to take down.
551        v.extend(MODELS.with(|m| {
552            m.borrow()
553                .iter()
554                .filter_map(|(c, _)| match c {
555                    Chrome::Window(r) => Some(*r),
556                    Chrome::Page(_) => None,
557                })
558                .collect::<Vec<_>>()
559        }));
560        v.sort();
561        v.dedup();
562        v
563    };
564    for root in roots {
565        let items = slot_model(root, unique_ids(merged_window(root)));
566        MODELS.with(|m| {
567            let mut m = m.borrow_mut();
568            match m.iter_mut().find(|(c, _)| *c == Chrome::Window(root)) {
569                Some(entry) => entry.1 = items.clone(),
570                None => m.push((Chrome::Window(root), items.clone())),
571            }
572        });
573        let prev = DELIVERED.with(|d| d.borrow().get(&root).cloned().unwrap_or_default());
574        let (ops, patches) = diff_bar(&prev, &items);
575        if ops.is_empty() && patches.is_empty() {
576            continue;
577        }
578        debug_assert!(edit_is_ordered(&ops), "toolbar edit out of order: {ops:?}");
579        // Recorded as the toolkit's only once it has the edit. A window whose root has no native
580        // handle yet takes none; its bar goes out whole on the next recompose instead.
581        let delivered = ops.is_empty() || with_tree(|t| t.edit_window_toolbar(root, ops));
582        if !delivered {
583            continue;
584        }
585        for patch in patches {
586            with_tree(|t| t.patch_window_toolbar(root, patch));
587        }
588        DELIVERED.with(|d| d.borrow_mut().insert(root, items));
589    }
590}
591
592/// The order backends rely on (`Toolkit::edit_toolbar`): every removal, then the insertions in
593/// ascending position.
594fn edit_is_ordered(ops: &[day_spec::ToolbarOp]) -> bool {
595    use day_spec::ToolbarOp as O;
596    let first_insert = ops
597        .iter()
598        .position(|o| matches!(o, O::Insert { .. }))
599        .unwrap_or(ops.len());
600    let inserts: Vec<usize> = ops[first_insert..]
601        .iter()
602        .filter_map(|o| match o {
603            O::Insert { index, .. } => Some(*index),
604            O::Remove { .. } => None,
605        })
606        .collect();
607    inserts.len() == ops.len() - first_insert && inserts.windows(2).all(|w| w[0] < w[1])
608}
609
610/// Record a value the toolkit already shows (a patch it took, or input it reported) in its copy
611/// of the bar, so the next edit is computed against what is on screen.
612fn deliver_value(root: RNode, patch: &ToolbarPatch) {
613    DELIVERED.with(|d| {
614        if let Some(items) = d.borrow_mut().get_mut(&root) {
615            patch.apply_to(items);
616        }
617    });
618}
619
620/// The bar without the items whose id an earlier one already has.
621///
622/// An id is an item's identity on the bar (the native identifier, the key an edit and a patch
623/// address), so it must be unique, and two showing pages declaring the same one is an app bug.
624/// Keeping the first, and saying so once, leaves the bar coherent rather than editing two
625/// natives under one name.
626fn unique_ids(items: Vec<ToolbarItem>) -> Vec<ToolbarItem> {
627    let mut seen = std::collections::HashSet::new();
628    items
629        .into_iter()
630        .filter(|i| {
631            let fresh = seen.insert(i.id.clone());
632            if !fresh {
633                log::warn!(
634                    "toolbar item id {:?} appears twice in one window's bar; only the first is \
635                     shown (docs/toolbars.md: ids are unique within a bar)",
636                    i.id
637                );
638            }
639            fresh
640        })
641        .collect()
642}
643
644/// The edit that turns bar `prev` into bar `next`, and the value patches for the items it keeps
645/// (docs/toolbars.md).
646///
647/// Items are matched by id. One the toolkit already draws is kept when it is drawn the same way
648/// (everything but its values: words, search text, prompt and completions, toggle state, selection,
649/// enablement) and when keeping it does not reorder it past another kept item; the kept set is
650/// the longest run of matched items already in order, so a reshuffle costs the fewest items.
651/// Everything else is removed and inserted. A kept item whose values moved gets the patches that
652/// bring it current, the same patches a bound signal would have sent.
653fn diff_bar(
654    prev: &[ToolbarItem],
655    next: &[ToolbarItem],
656) -> (Vec<day_spec::ToolbarOp>, Vec<ToolbarPatch>) {
657    use day_spec::ToolbarOp;
658    // For each item of `next`, the position of its unchanged counterpart in `prev`.
659    let matched: Vec<Option<usize>> = next
660        .iter()
661        .map(|n| {
662            prev.iter()
663                .position(|p| p.id == n.id)
664                .filter(|&at| shape_of(&prev[at]) == shape_of(n))
665        })
666        .collect();
667    let kept = longest_increasing(&matched);
668
669    let mut ops: Vec<ToolbarOp> = prev
670        .iter()
671        .enumerate()
672        .filter(|(at, _)| !kept.iter().any(|&k| matched[k] == Some(*at)))
673        .map(|(_, p)| ToolbarOp::Remove { id: p.id.clone() })
674        .collect();
675    let mut patches = Vec::new();
676    for (index, item) in next.iter().enumerate() {
677        if kept.contains(&index) {
678            let before = &prev[matched[index].unwrap_or_default()];
679            patches.extend(value_patches(before, item));
680        } else {
681            ops.push(ToolbarOp::Insert {
682                index,
683                item: item.clone(),
684            });
685        }
686    }
687    (ops, patches)
688}
689
690/// The indices into `seq` of a longest strictly increasing subsequence of its `Some` values.
691fn longest_increasing(seq: &[Option<usize>]) -> Vec<usize> {
692    // Patience sorting: `tails[k]` is the index of the smallest tail of an increasing run of
693    // length k + 1, and `back` links each element to its predecessor in the run.
694    let mut tails: Vec<usize> = Vec::new();
695    let mut back: Vec<Option<usize>> = vec![None; seq.len()];
696    for (i, v) in seq.iter().enumerate() {
697        let Some(v) = *v else { continue };
698        let k = tails.partition_point(|&t| seq[t].unwrap_or_default() < v);
699        back[i] = k.checked_sub(1).map(|p| tails[p]);
700        if k == tails.len() {
701            tails.push(i);
702        } else {
703            tails[k] = i;
704        }
705    }
706    let mut out = Vec::new();
707    let mut at = tails.last().copied();
708    while let Some(i) = at {
709        out.push(i);
710        at = back[i];
711    }
712    out.reverse();
713    out
714}
715
716/// The patches that bring a kept item's values from `was` to `now`.
717fn value_patches(was: &ToolbarItem, now: &ToolbarItem) -> Vec<ToolbarPatch> {
718    use day_spec::ToolbarItemKind as K;
719    let item = now.id.clone();
720    let mut out = Vec::new();
721    match (&was.kind, &now.kind) {
722        (
723            K::Search {
724                text: t0,
725                suggestions: s0,
726                placeholder: p0,
727            },
728            K::Search {
729                text: t1,
730                suggestions: s1,
731                placeholder: p1,
732            },
733        ) => {
734            if t0 != t1 {
735                out.push(ToolbarPatch::Text {
736                    item: item.clone(),
737                    text: t1.clone(),
738                });
739            }
740            if s0 != s1 {
741                out.push(ToolbarPatch::Suggestions {
742                    item: item.clone(),
743                    list: s1.clone(),
744                });
745            }
746            if p0 != p1 {
747                out.push(ToolbarPatch::Placeholder {
748                    item: item.clone(),
749                    text: p1.clone(),
750                });
751            }
752        }
753        (K::Toggle { on: a }, K::Toggle { on: b }) if a != b => out.push(ToolbarPatch::On {
754            item: item.clone(),
755            on: *b,
756        }),
757        (K::Segmented { selected: a, .. }, K::Segmented { selected: b, .. }) if a != b => {
758            out.push(ToolbarPatch::Selected {
759                item: item.clone(),
760                index: *b,
761            })
762        }
763        _ => {}
764    }
765    if was.label != now.label || was.tooltip != now.tooltip {
766        out.push(ToolbarPatch::Label {
767            item: item.clone(),
768            label: now.label.clone(),
769            tooltip: now.tooltip.clone(),
770        });
771    }
772    if was.enabled != now.enabled {
773        out.push(ToolbarPatch::Enabled {
774            item,
775            on: now.enabled,
776        });
777    }
778    out
779}
780
781/// Swap each item's closure id for the window's stable slot id for that item, and point the
782/// slot at the closure.
783///
784/// An item's identity is its id, so the same command on the next page, or the same page built
785/// again, gets the slot it had. The toolkit keeps dispatching the id it was given, and the id keeps reaching the
786/// current closure: no install to repoint it, and nothing for a toolkit to diff. A pull-down's
787/// entries get slots the same way, by their path under the item.
788fn slot_model(root: RNode, mut items: Vec<ToolbarItem>) -> Vec<ToolbarItem> {
789    let mut live: Vec<u64> = Vec::new();
790    for item in &mut items {
791        let key = item.id.clone();
792        item.action = slot_for(root, &key, item.action, &mut live);
793        if let day_spec::ToolbarItemKind::Menu { items: entries } = &mut item.kind {
794            slot_menu(root, &key, entries, &mut live);
795        }
796    }
797    // Slots this window no longer shows dispatch nothing; the ids stay reserved for the item's
798    // return.
799    let keep: std::collections::HashSet<u64> = live.into_iter().collect();
800    SLOT_IDS.with(|ids| {
801        SLOT_TARGETS.with(|t| {
802            let mut t = t.borrow_mut();
803            for ((r, _), slot) in ids.borrow().iter() {
804                if *r == root && !keep.contains(slot) {
805                    t.remove(slot);
806                }
807            }
808        })
809    });
810    items
811}
812
813fn slot_menu(root: RNode, parent: &str, entries: &mut [day_spec::MenuItem], live: &mut Vec<u64>) {
814    for (i, entry) in entries.iter_mut().enumerate() {
815        let key = format!("{parent}/{i}");
816        match entry {
817            day_spec::MenuItem::Action { action, .. } => {
818                *action = slot_for(root, &key, *action, live);
819            }
820            day_spec::MenuItem::Submenu { items, .. } => slot_menu(root, &key, items, live),
821            day_spec::MenuItem::Separator => {}
822        }
823    }
824}
825
826/// The slot for `key` in `root`'s bar, now dispatching to `target`. `0` stays `0`: an item with
827/// nothing to run has nothing to address.
828fn slot_for(root: RNode, key: &str, target: u64, live: &mut Vec<u64>) -> u64 {
829    if target == 0 {
830        return 0;
831    }
832    let slot = SLOT_IDS.with(|ids| {
833        *ids.borrow_mut()
834            .entry((root, key.to_string()))
835            .or_insert_with(crate::menu::next_action_id)
836    });
837    SLOT_TARGETS.with(|t| t.borrow_mut().insert(slot, target));
838    live.push(slot);
839    slot
840}
841
842/// The pieces layer, after a change to what is on screen (a push, a pop, a tab switch), so a
843/// one-bar-per-window toolkit is handed the showing pages' items. No-op where every page has a
844/// bar of its own.
845pub fn chrome_changed() {
846    schedule_recompose();
847}
848
849/// Apply a targeted item update wherever the item lives: the path a bound signal writes through,
850/// so a search field keeps its focus and its insertion point.
851pub fn patch_toolbar(patch: ToolbarPatch) {
852    let owner = MODELS.with(|m| {
853        m.borrow()
854            .iter()
855            .find(|(_, items)| items.iter().any(|i| i.id == patch.item()))
856            .map(|(c, _)| *c)
857    });
858    if let Some(chrome) = owner {
859        patch_chrome(chrome, patch);
860    }
861}
862
863/// [`patch_toolbar`] against an explicit chrome. Also updates the retained model, so a later
864/// full replace does not resurrect the stale value.
865pub fn patch_chrome(chrome: Chrome, patch: ToolbarPatch) {
866    // The contribution that owns the item keeps its own copy, so a later re-compose rebuilds
867    // from the current value rather than the one the item was declared with.
868    let window = CONTRIBUTIONS.with(|m| {
869        let mut m = m.borrow_mut();
870        let mut window = None;
871        for c in m.values_mut() {
872            if c.chrome == chrome {
873                apply_to_model(&mut c.items, &patch);
874                window = Some(c.window());
875            }
876        }
877        window
878    });
879    // …and so does the window's model, which is the one the toolkit draws and dayscript reads.
880    //
881    // A page's chrome has no model of its own: everything is composed into the window's before
882    // it crosses (see `lower`). Patching a per-chrome copy left the drawn bar carrying the value
883    // the item was built with, which is how a page command declared while nothing was selected
884    // stayed disabled on a window that never re-composed afterwards, and did nothing when tapped.
885    let Some(root) = window else { return };
886    MODELS.with(|m| {
887        let mut m = m.borrow_mut();
888        if let Some((_, items)) = m.iter_mut().find(|(c, _)| *c == Chrome::Window(root)) {
889            apply_to_model(items, &patch);
890        }
891    });
892    if with_tree(|t| t.patch_window_toolbar(root, patch.clone())) {
893        deliver_value(root, &patch);
894    }
895}
896
897/// Mirror a patch into the retained model.
898fn apply_to_model(items: &mut [ToolbarItem], patch: &ToolbarPatch) {
899    patch.apply_to(items);
900}
901
902/// An item as the toolkit draws it: everything but its values, which travel as patches, and its
903/// action, whose slot id never changes while the item is on the bar.
904fn shape_of(item: &ToolbarItem) -> ToolbarItem {
905    use day_spec::ToolbarItemKind as K;
906    let mut i = item.clone();
907    i.enabled = true;
908    // Words are values too: a language change patches them on the live item.
909    i.label.clear();
910    i.tooltip = None;
911    match &mut i.kind {
912        K::Search {
913            text,
914            suggestions,
915            placeholder,
916        } => {
917            text.clear();
918            suggestions.clear();
919            placeholder.clear();
920        }
921        K::Toggle { on } => *on = false,
922        K::Segmented { selected, .. } => *selected = 0,
923        _ => {}
924    }
925    i
926}
927
928/// Every live item, across every chrome. dayscript's `toolbar:` step walks it to resolve an
929/// item's dispatch id, and an app has one bar per window at a time, so a duplicate id across two
930/// chromes would be an app bug rather than an ambiguity to resolve here.
931pub fn toolbar_model() -> Vec<ToolbarItem> {
932    MODELS.with(|m| m.borrow().iter().flat_map(|(_, i)| i.clone()).collect())
933}
934
935/// Show/hide the sidebar pane of the navigation host `host`: the behavior behind the sidebar
936/// affordance a nav host contributes for itself (`day_spec::SIDEBAR_TOGGLE_ID`). `false` when
937/// the toolkit has no pane to toggle there. The item's action makes this call, and dayscript's
938/// `toolbar:` step presses the item like any other, so a walkthrough drives the same path a
939/// click does (docs/toolbars.md).
940pub fn toggle_sidebar(host: RNode) -> bool {
941    with_tree(|t| t.toggle_sidebar(host))
942}
943
944/// Drop a closed window's contributions, chrome model and the value closures only they owned.
945///
946/// Called before the window's scope is disposed. Disposal runs every contribution's cleanup,
947/// and each one re-composes the window it belonged to, a merge that asks the other
948/// contributions' gates whether their page is showing, through signals the same disposal has
949/// already dropped. Withdrawing the whole window here first leaves those cleanups nothing to
950/// re-compose (a token that is already gone is a no-op), so a closed window never merges its
951/// own dying bar.
952pub(crate) fn forget_window(root: RNode) {
953    let gone: Vec<ToolbarItem> = CONTRIBUTIONS.with(|m| {
954        let mut m = m.borrow_mut();
955        let mut gone = Vec::new();
956        m.retain(|_, c| {
957            let mine = c.window() == root;
958            if mine {
959                gone.extend(c.items.clone());
960            }
961            !mine
962        });
963        gone
964    });
965    forget_closures(&gone);
966    MODELS.with(|m| {
967        m.borrow_mut()
968            .retain(|(c, _)| !matches!(c, Chrome::Window(r) if *r == root))
969    });
970    DELIVERED.with(|d| {
971        d.borrow_mut().remove(&root);
972    });
973    SLOT_IDS.with(|ids| {
974        SLOT_TARGETS.with(|t| {
975            let mut t = t.borrow_mut();
976            ids.borrow_mut().retain(|(r, _), slot| {
977                if *r == root {
978                    t.remove(slot);
979                }
980                *r != root
981            });
982        })
983    });
984}
985
986/// Reset every chrome's toolbar state (tests; pairs with `uninstall_tree`).
987pub fn reset_toolbars() {
988    MODELS.with(|m| m.borrow_mut().clear());
989    CONTRIBUTIONS.with(|m| m.borrow_mut().clear());
990    VALUE_ACTIONS.with(|m| m.borrow_mut().clear());
991    SLOT_IDS.with(|m| m.borrow_mut().clear());
992    SLOT_TARGETS.with(|m| m.borrow_mut().clear());
993    DELIVERED.with(|m| m.borrow_mut().clear());
994    RECOMPOSE_PENDING.with(|p| p.set(false));
995    PAGE_STACK.with(|s| s.borrow_mut().clear());
996    BUILDING.with(|b| b.set(None));
997}
998
999/// Focus the active window's search control, revealing a collapsed desktop search item.
1000/// Returns false when the active window has no toolbar search contribution.
1001pub fn focus_toolbar_search() -> bool {
1002    let root = crate::windows::focused_root();
1003    let has = MODELS.with(|models| {
1004        models.borrow().iter().any(|(chrome, items)| {
1005            *chrome == Chrome::Window(root)
1006                && items.iter().any(|item| item.id == day_spec::SEARCH_ITEM_ID)
1007        })
1008    });
1009    if has {
1010        with_tree(|t| {
1011            t.patch_window_toolbar(
1012                root,
1013                ToolbarPatch::Focus {
1014                    item: day_spec::SEARCH_ITEM_ID.to_owned(),
1015                },
1016            )
1017        });
1018    }
1019    has
1020}
1021
1022#[cfg(test)]
1023mod tests {
1024
1025    #[test]
1026    fn native_events_recompose_after_the_delivered_baseline_is_recorded() {
1027        let passes = std::cell::Cell::new(0);
1028        let delivered = std::cell::Cell::new(false);
1029        super::recompose_serially(|| {
1030            passes.set(passes.get() + 1);
1031            if passes.get() == 1 {
1032                // Simulate a native edit draining an event before the caller records delivery.
1033                super::recompose_serially(|| panic!("must not diff the stale baseline"));
1034                super::recompose_serially(|| panic!("nested requests must coalesce"));
1035                delivered.set(true);
1036            } else {
1037                assert!(delivered.get());
1038            }
1039        });
1040        assert_eq!(passes.get(), 2);
1041        super::recompose_serially(|| passes.set(passes.get() + 1));
1042        assert_eq!(passes.get(), 3, "the guard releases after a settled update");
1043    }
1044    use super::*;
1045    use day_spec::{ToolbarItemKind as K, ToolbarMirror, ToolbarOp};
1046
1047    fn item(id: &str, label: &str) -> ToolbarItem {
1048        ToolbarItem {
1049            id: id.into(),
1050            kind: K::Button,
1051            label: label.into(),
1052            tooltip: None,
1053            icon: None,
1054            enabled: true,
1055            action: 0,
1056            placement: day_spec::ToolbarPlacement::Automatic,
1057            label_style: day_spec::LabelStyle::Automatic,
1058            prominent: false,
1059            column: day_spec::ToolbarColumn::Window,
1060        }
1061    }
1062
1063    /// Apply the diff the way a backend does and compare with the target.
1064    fn check(prev: &[ToolbarItem], next: &[ToolbarItem]) -> Vec<ToolbarOp> {
1065        let (ops, patches) = diff_bar(prev, next);
1066        let mut mirror = ToolbarMirror::default();
1067        for (i, p) in prev.iter().enumerate() {
1068            mirror.insert(i, p.clone());
1069        }
1070        // Removals first, then insertions in ascending position: the contract backends rely on.
1071        let first_insert = ops
1072            .iter()
1073            .position(|o| matches!(o, ToolbarOp::Insert { .. }))
1074            .unwrap_or(ops.len());
1075        assert!(
1076            ops[first_insert..]
1077                .iter()
1078                .all(|o| matches!(o, ToolbarOp::Insert { .. }))
1079        );
1080        for op in &ops {
1081            mirror.apply(op);
1082        }
1083        for p in &patches {
1084            mirror.patch(p);
1085        }
1086        assert_eq!(mirror.items(), next);
1087        ops
1088    }
1089
1090    #[test]
1091    fn identical_bars_need_no_edit() {
1092        let bar = vec![item("a", "A"), item("b", "B")];
1093        assert!(check(&bar, &bar).is_empty());
1094    }
1095
1096    #[test]
1097    fn inserts_and_removes_touch_only_their_items() {
1098        let prev = vec![item("a", "A"), item("b", "B"), item("search", "S")];
1099        let next = vec![item("a", "A"), item("c", "C"), item("search", "S")];
1100        let ops = check(&prev, &next);
1101        assert_eq!(ops.len(), 2);
1102        assert!(ops.iter().all(|o| match o {
1103            ToolbarOp::Remove { id } => id == "b",
1104            ToolbarOp::Insert { item, index } => item.id == "c" && *index == 1,
1105        }));
1106    }
1107
1108    #[test]
1109    fn a_reorder_moves_the_fewest_items() {
1110        let prev = vec![
1111            item("a", "A"),
1112            item("b", "B"),
1113            item("c", "C"),
1114            item("d", "D"),
1115        ];
1116        let next = vec![
1117            item("d", "D"),
1118            item("a", "A"),
1119            item("b", "B"),
1120            item("c", "C"),
1121        ];
1122        let ops = check(&prev, &next);
1123        // `d` moves; `a`, `b` and `c` stay put.
1124        assert_eq!(ops.len(), 2, "{ops:?}");
1125    }
1126
1127    #[test]
1128    fn a_value_change_is_a_patch_and_a_label_change_an_edit() {
1129        let mut toggled = item("t", "T");
1130        toggled.kind = K::Toggle { on: false };
1131        let mut on = toggled.clone();
1132        on.kind = K::Toggle { on: true };
1133        on.enabled = false;
1134        let (ops, patches) = diff_bar(std::slice::from_ref(&toggled), std::slice::from_ref(&on));
1135        assert!(ops.is_empty());
1136        assert_eq!(patches.len(), 2);
1137        check(&[item("x", "Old")], &[item("x", "New")]);
1138    }
1139
1140    #[test]
1141    fn longest_increasing_skips_unmatched() {
1142        assert_eq!(
1143            longest_increasing(&[Some(2), None, Some(0), Some(1), Some(3)]),
1144            vec![2, 3, 4]
1145        );
1146        assert!(longest_increasing(&[None, None]).is_empty());
1147    }
1148}