Skip to main content

day_core/
status.rs

1// Copyright © The Daybrite Project
2// SPDX-License-Identifier: MPL-2.0
3
4//! The app's presence outside its windows (docs/status-item.md): status items, progress on the
5//! Dock or taskbar icon, the macOS Dock icon itself, and whether the app keeps running when its
6//! last window closes.
7
8use std::cell::{Cell, RefCell};
9use std::collections::HashMap;
10
11use day_spec::{AppProgress, StatusItemSpec};
12
13/// Whether the app keeps running when its last window closes (docs/windows.md "Keeping the app
14/// running"), set with [`set_keep_running`].
15#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
16pub enum KeepRunning {
17    /// Keep running while the app shows a status item, so a tray app's menu stays usable with
18    /// every window closed; otherwise follow the platform's rule.
19    #[default]
20    Automatic,
21    /// Keep running with no window open, status item or not: a background helper, an app that
22    /// reopens its window from the Dock or a global shortcut. The app ends when it calls
23    /// `day::quit()` or the user quits from a menu.
24    Always,
25    /// End the app when its last window closes, on every platform, macOS included.
26    Never,
27}
28
29day_reactive::tls_slots! {
30    status;
31    static POLICY: Cell<KeepRunning> = const { Cell::new(KeepRunning::Automatic) };
32    /// The status items, in the order the app first added them, each with the dispatch ids its
33    /// menu and click hold (swept when the item changes or goes).
34    static ITEMS: RefCell<Vec<(StatusItemSpec, Vec<u64>)>> = const { RefCell::new(Vec::new()) };
35    /// What the toolkit was last told about keeping the process alive.
36    static TOLD_KEEP: Cell<Option<bool>> = const { Cell::new(None) };
37    static TRACKING: RefCell<HashMap<String, Tracking>> = RefCell::new(HashMap::new());
38    static NEXT_TRACKING: Cell<u64> = const { Cell::new(1) };
39}
40
41struct Tracking {
42    token: u64,
43    pending: Option<StatusItemSpec>,
44}
45
46/// Backend hook: freeze a status item's installed model and handlers during native menu
47/// tracking. Updates are coalesced until [`end_menu_tracking`]. The token prevents a delayed
48/// close callback from ending a later session (including removal and recreation of the id).
49pub fn begin_menu_tracking(id: &str) -> u64 {
50    let token = NEXT_TRACKING.with(|next| {
51        let token = next.get();
52        next.set(token.wrapping_add(1).max(1));
53        token
54    });
55    TRACKING.with(|tracking| {
56        let mut tracking = tracking.borrow_mut();
57        let pending = tracking.remove(id).and_then(|old| old.pending);
58        tracking.insert(id.to_owned(), Tracking { token, pending });
59    });
60    token
61}
62
63/// Backend hook: finish tracking *after* the native selection has dispatched, then install
64/// the most recent pending update. AppKit calls this on the next main-loop turn because
65/// `menuDidClose:` precedes the selected item's action.
66pub fn end_menu_tracking(id: &str, token: u64) {
67    let pending = TRACKING.with(|tracking| {
68        let mut tracking = tracking.borrow_mut();
69        if tracking.get(id).is_some_and(|t| t.token == token) {
70            tracking.remove(id).and_then(|t| t.pending)
71        } else {
72            None
73        }
74    });
75    if let Some(spec) = pending {
76        set_status_item(spec);
77    }
78}
79
80/// Choose whether the app keeps running when its last window closes (docs/windows.md).
81pub fn set_keep_running(policy: KeepRunning) {
82    POLICY.with(|p| p.set(policy));
83    sync_keep_running();
84}
85
86/// The policy as set (default [`KeepRunning::Automatic`]).
87pub fn keep_running_policy() -> KeepRunning {
88    POLICY.with(|p| p.get())
89}
90
91/// Whether closing the last window should leave the app running right now.
92pub(crate) fn keeps_running() -> bool {
93    match keep_running_policy() {
94        KeepRunning::Always => true,
95        KeepRunning::Never => false,
96        KeepRunning::Automatic => ITEMS.with(|i| !i.borrow().is_empty()),
97    }
98}
99
100/// Tell the toolkit when the answer changes, so it stops (or resumes) ending the app on its own.
101fn sync_keep_running() {
102    let keep = keeps_running();
103    if TOLD_KEEP.with(|t| t.replace(Some(keep))) != Some(keep) && crate::tree::has_tree() {
104        crate::with_tree(|t| t.set_keep_running(keep));
105    }
106}
107
108/// Re-send the keep-running answer: the backend that boots after the app set its policy (a
109/// `set_keep_running` in `root()` runs before the tree exists on some paths).
110pub(crate) fn boot_sync() {
111    TOLD_KEEP.with(|t| t.set(None));
112    if keeps_running() {
113        sync_keep_running();
114    }
115}
116
117fn action_ids(spec: &StatusItemSpec) -> Vec<u64> {
118    let mut ids = crate::menu::collect_ids(&spec.menu);
119    if spec.activate != 0 {
120        ids.push(spec.activate);
121    }
122    ids
123}
124
125/// Show `spec`, or update the shown item with the same id (docs/status-item.md). Replacing an
126/// item drops the closures its previous menu and click held. While a backend is tracking the
127/// menu, keep the installed snapshot and coalesce updates until selection has finished.
128pub fn set_status_item(spec: StatusItemSpec) {
129    let mut spec = Some(spec);
130    let superseded = TRACKING.with(|tracking| {
131        let mut tracking = tracking.borrow_mut();
132        let active = tracking.get_mut(&spec.as_ref().unwrap().id)?;
133        active.pending.replace(spec.take().unwrap())
134    });
135    if spec.is_none() {
136        if let Some(old) = superseded {
137            // A discarded intermediate update is never shown. Release only its own actions;
138            // installed and latest-pending specs may explicitly reuse the same ids.
139            let mut keep = ITEMS.with(|items| {
140                items
141                    .borrow()
142                    .iter()
143                    .find(|(s, _)| s.id == old.id)
144                    .map(|(_, ids)| ids.clone())
145                    .unwrap_or_default()
146            });
147            TRACKING.with(|tracking| {
148                if let Some(next) = tracking
149                    .borrow()
150                    .get(&old.id)
151                    .and_then(|t| t.pending.as_ref())
152                {
153                    keep.extend(action_ids(next));
154                }
155            });
156            let stale: Vec<_> = action_ids(&old)
157                .into_iter()
158                .filter(|id| !keep.contains(id))
159                .collect();
160            crate::menu::forget_actions(&stale);
161        }
162        return;
163    }
164    let spec = spec.unwrap();
165    let ids = action_ids(&spec);
166    let stale = ITEMS.with(|items| {
167        let mut items = items.borrow_mut();
168        match items.iter_mut().find(|(s, _)| s.id == spec.id) {
169            Some((s, old)) => {
170                let stale: Vec<u64> = old.iter().copied().filter(|i| !ids.contains(i)).collect();
171                *s = spec;
172                *old = ids;
173                stale
174            }
175            None => {
176                items.push((spec, ids));
177                Vec::new()
178            }
179        }
180    });
181    crate::menu::forget_actions(&stale);
182    send_items();
183}
184
185/// Remove the status item `id`, if shown.
186pub fn remove_status_item(id: &str) {
187    let pending =
188        TRACKING.with(|tracking| tracking.borrow_mut().remove(id).and_then(|t| t.pending));
189    if let Some(pending) = pending {
190        crate::menu::forget_actions(&action_ids(&pending));
191    }
192    let stale = ITEMS.with(|items| {
193        let mut items = items.borrow_mut();
194        let i = items.iter().position(|(s, _)| s.id == id)?;
195        Some(items.remove(i).1)
196    });
197    if let Some(stale) = stale {
198        crate::menu::forget_actions(&stale);
199        send_items();
200    }
201}
202
203/// The status items currently shown, in order.
204pub fn status_items() -> Vec<StatusItemSpec> {
205    ITEMS.with(|i| i.borrow().iter().map(|(s, _)| s.clone()).collect())
206}
207
208fn send_items() {
209    let items = status_items();
210    crate::with_tree(|t| t.set_status_items(&items));
211    sync_keep_running();
212}
213
214/// Show `progress` on the app's Dock or taskbar icon (`Cap::AppProgress`, docs/status-item.md).
215pub fn set_app_progress(progress: AppProgress) {
216    crate::with_tree(|t| t.set_app_progress(progress));
217}
218
219/// Show or hide the app's Dock icon while it runs (`Cap::DockVisibility`, macOS). A menu-bar
220/// app hides it and lives in its status item; `[app.macos] dock = false` in Day.toml hides it
221/// from launch, before the first frame.
222pub fn set_dock_visible(visible: bool) {
223    crate::with_tree(|t| t.set_dock_visible(visible));
224}
225
226/// Forget everything (tests; pairs with `uninstall_tree`).
227pub fn reset_status() {
228    TRACKING.with(|tracking| tracking.borrow_mut().clear());
229    ITEMS.with(|i| i.borrow_mut().clear());
230    POLICY.with(|p| p.set(KeepRunning::Automatic));
231    TOLD_KEEP.with(|t| t.set(None));
232}