Skip to main content

tilo_app/framework/
window_bar.rs

1//! An app's window bar, declared as bindings.
2//!
3//! The bar is the window's own row (`ui_core::window_toolbar`): the app says
4//! what its slots are, the desktop draws them and the app hears what the
5//! person did in them. This module is that declaration in the framework's
6//! terms: an app builds one [`WindowBar`] out of what the window shows and
7//! does — the location, the views, the actions, toggles and menus — and the
8//! framework makes the slots of it.
9//!
10//! A slot is a *binding*, never an id the app builds and later takes apart,
11//! and never a callback: what pressing it does is a change the framework
12//! makes itself, through the same door an assistive technology, a script or
13//! an agent uses.
14//!
15//! - an **action** ([`WindowBar::action`]) submits a verb of the record, with
16//!   its arguments, named and pictured as the action is declared, and disabled
17//!   where the participant may not take it;
18//! - an **effect** ([`WindowBar::effect`]) invokes an effect the app declares
19//!   (what the window shows, what it asks the host for), answered wherever the
20//!   app already answers it;
21//! - a **flag** ([`WindowBar::flag`]) is a toggle bound to a bool of the
22//!   record: pressing it submits the opposite;
23//! - a **view switcher** ([`WindowBar::views`]) is a list of labels, each
24//!   bound to an action or an effect that shows that view, with the one shown
25//!   now named by its place in the list;
26//! - the **location** ([`WindowBar::label`], [`WindowBar::place`]) is what the
27//!   window shows, from the value the app read.
28//!
29//! Every id is the framework's, made from the invocation it dispatches; the
30//! app never writes one, never reads one back and never matches one by
31//! prefix. It hands the bar's events to [`WindowBar::take_events`], which
32//! submits each into what declared it.
33//!
34//! ```ignore
35//! fn window_bar(&self) -> WindowBar<Tracker> {
36//!     let place = self.nav.place.with(|place| *place);
37//!     WindowBar::new(self.dispatcher.clone())
38//!         .under(&self.link)
39//!         .label(self.location())
40//!         .views(
41//!             tr("tasks-views"),
42//!             Place::ALL.iter().position(|each| *each == place).unwrap_or_default(),
43//!             Place::ALL.map(|each| Choice::new(each.title(), &each.show())),
44//!         )
45//! }
46//! ```
47//!
48//! # The shell's own slots
49//!
50//! A window with a [`NavShell`] shows the shell's sidebar toggle, Back and
51//! inspector toggle in the bar. [`WindowBar::over`] (or [`WindowBar::under`]
52//! with the shell's [`WindowBarLink`], for an app painted inside the shell's
53//! content) starts the declaration from those slots and hands the shell the
54//! presses on them; the shell draws no header row of its own
55//! ([`NavShell::with_window_toolbar`]).
56//!
57//! # Declaring and hearing
58//!
59//! A window declares its bar for what it shows now, and hears the bar's
60//! events through it: a press names a slot of the slots on screen, so the
61//! declaration that made it is the one that dispatches it. A widget that
62//! settles each frame keeps the bar it declared, hears it, then declares it
63//! again:
64//!
65//! ```ignore
66//! fn settle(&mut self, ..) {
67//!     self.bar.take_events();
68//!     self.bar = self.window_bar();
69//!     self.bar.declare();
70//! }
71//! ```
72//!
73//! Outside a Tilo session (a window of its own, a test) nothing hosts the
74//! slots, and the app's own row — if it keeps one — stays as it was.
75
76use tilo_resource::{Effect, Path, Resource};
77use ui_core::widget::{NavShell, WindowBarLink};
78use ui_core::window_toolbar::{
79    ToolbarAction, ToolbarEvent, ToolbarLocation, ToolbarView, ToolbarViews, WindowToolbar,
80};
81
82use super::{BarActions, Dispatcher, Submit};
83
84/// The most entries one bar keeps: the app's own, past what any row and menu
85/// show together.
86const MAX_ENTRIES: usize = 128;
87
88/// What a slot of the bar does when pressed: a verb of the record with its
89/// arguments, a field edit, an effect the app answers. A binding is submitted
90/// through the window's dispatcher, never run by the app's own code, so the
91/// window's bar and an assistive technology take the same door.
92pub trait Binding<R: Resource> {
93    /// What a press submits; `None` where the app names something the record
94    /// does not publish (a control the framework reports unbound).
95    fn bound(self) -> Option<tilo_resource::Invoke<R>>;
96}
97
98impl<R: Resource> Binding<R> for tilo_resource::Invoke<R> {
99    fn bound(self) -> Option<tilo_resource::Invoke<R>> {
100        Some(self)
101    }
102}
103
104impl<R: Resource, E: Effect> Binding<R> for &E {
105    fn bound(self) -> Option<tilo_resource::Invoke<R>> {
106        Some(tilo_resource::Invoke::effect(self))
107    }
108}
109
110/// One view of a view switcher ([`WindowBar::views`]): what it says, and the
111/// binding that shows it.
112pub struct Choice<R: Resource> {
113    label: String,
114    /// The action or effect that shows this view; `None` where the app named
115    /// something the record does not publish.
116    press: Option<tilo_resource::Invoke<R>>,
117}
118
119impl<R: Resource> Choice<R> {
120    /// The view `label`, shown by `binding` — a verb with its arguments, a
121    /// field edit, an effect.
122    #[must_use]
123    pub fn new(label: impl Into<String>, binding: impl Binding<R>) -> Self {
124        Self {
125            label: label.into(),
126            press: binding.bound(),
127        }
128    }
129}
130
131/// The window's bar: the slots the app declares now, and what pressing each
132/// of them submits.
133pub struct WindowBar<R: Resource> {
134    /// The shell's own slots, and the app's location, views and leading
135    /// actions.
136    slots: WindowToolbar,
137    /// The app's own entries, in the order declared: the row's buttons and
138    /// the overflow menu's.
139    entries: Vec<ToolbarAction>,
140    /// The actions and effects of the app's record, each submitted through
141    /// the window's dispatcher when pressed.
142    actions: BarActions<R>,
143    /// The shell the bar starts from ([`Self::over`]): its slots' presses go
144    /// to it.
145    shell: Option<WindowBarLink>,
146    /// The entry the sidebar toggle presses, for a window whose app shows the
147    /// sidebar itself ([`Self::sidebar`]).
148    sidebar: Option<String>,
149}
150
151impl<R: Resource> WindowBar<R> {
152    /// The bar of the window running `dispatcher`'s record: nothing declared
153    /// yet.
154    #[must_use]
155    pub fn new(dispatcher: Dispatcher<R>) -> Self {
156        Self {
157            slots: WindowToolbar::default(),
158            entries: Vec::new(),
159            actions: BarActions::new(dispatcher),
160            shell: None,
161            sidebar: None,
162        }
163    }
164
165    /// The dispatcher this bar's actions are submitted through: what a window
166    /// keeps to declare its bar anew for what it shows now.
167    #[must_use]
168    pub fn dispatcher(&self) -> Dispatcher<R> {
169        self.actions.dispatcher().clone()
170    }
171
172    /// Start from `shell`'s own slots — its sidebar toggle, Back, the
173    /// inspector toggle — and hand it the presses on them. The shell draws
174    /// no header row of its own ([`NavShell::with_window_toolbar`]).
175    #[must_use]
176    pub fn over(mut self, shell: &NavShell) -> Self {
177        self.slots = shell.window_toolbar();
178        self.shell = Some(shell.window_bar_link());
179        self
180    }
181
182    /// Start from the slots the shell of `link` last laid out, for an app
183    /// painted inside the shell's content column.
184    #[must_use]
185    pub fn under(mut self, link: &WindowBarLink) -> Self {
186        self.slots = link.slots();
187        self.shell = Some(link.clone());
188        self
189    }
190
191    /// The window shows `label`: its mode, the open document, the place.
192    #[must_use]
193    pub fn label(mut self, label: impl Into<String>) -> Self {
194        self.slots
195            .location
196            .get_or_insert_with(ToolbarLocation::default)
197            .label = label.into();
198        self
199    }
200
201    /// The window's location whole: its label, its choices, its breadcrumb,
202    /// the anchor it reopens at ([`ToolbarLocation`]); `None` where the window
203    /// shows no location (its sidebar stands in front of the content).
204    #[must_use]
205    pub fn place(mut self, location: Option<ToolbarLocation>) -> Self {
206        self.slots.location = location;
207        self
208    }
209
210    /// The window's sidebar toggle and whether it is open, for a window whose
211    /// app shows the sidebar itself: pressing it submits `binding`.
212    #[must_use]
213    pub fn sidebar(mut self, open: bool, binding: impl Binding<R>) -> Self {
214        self.slots.sidebar = Some(open);
215        if let Some(invoke) = binding.bound() {
216            self.sidebar = Some(self.actions.action(invoke).id).filter(|id| !id.is_empty());
217        }
218        self
219    }
220
221    /// The app's views of what the window shows — Issues and Board, Month and
222    /// Week — as its view switcher: `chosen` is the place in `choices` of the
223    /// one shown now, and choosing one submits what it carries (the effect
224    /// that shows it).
225    #[must_use]
226    pub fn views(
227        mut self,
228        name: impl Into<String>,
229        chosen: usize,
230        choices: impl IntoIterator<Item = Choice<R>>,
231    ) -> Self {
232        let mut views = Vec::new();
233        for choice in choices {
234            let Some(invoke) = choice.press else {
235                continue;
236            };
237            let entry = self.actions.action(invoke);
238            views.push(ToolbarView {
239                id: entry.id,
240                label: choice.label,
241            });
242        }
243        let Some(last) = views.len().checked_sub(1) else {
244            self.slots.views = None;
245            return self;
246        };
247        let Some(chosen) = views.get(chosen.min(last)).map(|view| view.id.clone()) else {
248            self.slots.views = None;
249            return self;
250        };
251        self.slots.views = Some(ToolbarViews {
252            name: name.into(),
253            chosen,
254            choices: views,
255        });
256        self
257    }
258
259    /// The button that submits `submit` — a verb with its arguments, a field
260    /// edit, a form — named and pictured as the action is declared, disabled
261    /// where the participant may not take it. The framework submits it
262    /// through the window's dispatcher when it is pressed.
263    #[must_use]
264    pub fn action(mut self, submit: impl Submit<R>) -> Self {
265        let entry = self.actions.action(submit);
266        self.push(entry);
267        self
268    }
269
270    /// The button that invokes `effect`, named and pictured as it is declared,
271    /// and disabled where the participant may not take it. The effect is
272    /// answered wherever the app answers that effect: the window's own view,
273    /// or the host.
274    #[must_use]
275    pub fn effect<E: Effect>(mut self, effect: &E) -> Self {
276        let entry = self.actions.effect(effect);
277        self.push(entry);
278        self
279    }
280
281    /// The action `submit` as a leading action: on where the window is
282    /// (previous, next, today), right after the navigation.
283    #[must_use]
284    pub fn leading(mut self, submit: impl Submit<R>) -> Self {
285        let entry = self.actions.action(submit);
286        self.slots.leading.push(entry);
287        self
288    }
289
290    /// The record's flag at `field` as a toggle: it shows `on`, and pressing
291    /// it submits the opposite, disabled where the participant may not edit
292    /// the record.
293    #[must_use]
294    pub fn flag(
295        mut self,
296        icon: impl Into<String>,
297        name: impl Into<String>,
298        field: &Path<R, bool>,
299        on: bool,
300    ) -> Self {
301        let mut entry = self.actions.action(field.set(!on));
302        entry.icon = icon.into();
303        entry.name = name.into();
304        entry.toggles = true;
305        entry.on = on;
306        self.push(entry);
307        self
308    }
309
310    /// The entry declared last shows as pressed when `on` and as a toggle: an
311    /// effect that sets one of a window's own states (a unit, a panel) reads
312    /// as the state it sets.
313    #[must_use]
314    pub fn on(mut self, on: bool) -> Self {
315        if let Some(entry) = self.entries.last_mut() {
316            entry.toggles = true;
317            entry.on = on;
318        }
319        self
320    }
321
322    /// The entry declared last goes to the bar's overflowing menu, never the
323    /// row: an occasional action (import, export, the app's settings).
324    #[must_use]
325    pub fn in_menu(mut self) -> Self {
326        if let Some(entry) = self.entries.last_mut() {
327            entry.menu = true;
328        }
329        self
330    }
331
332    /// The entry declared last is pictured by `icon` and named `name`: the
333    /// window's own words for what the record's action or effect calls
334    /// itself, where the bar names it differently.
335    #[must_use]
336    pub fn named(mut self, icon: impl Into<String>, name: impl Into<String>) -> Self {
337        if let Some(entry) = self.entries.last_mut() {
338            entry.icon = icon.into();
339            entry.name = name.into();
340        }
341        self
342    }
343
344    /// The entry declared last is enabled only where `enabled` too: what it
345    /// would do is possible only sometimes (a theme that must be legible
346    /// before it applies), and never where the participant may not take it.
347    #[must_use]
348    pub fn enabled(mut self, enabled: bool) -> Self {
349        if let Some(entry) = self.entries.last_mut() {
350            entry.enabled &= enabled;
351        }
352        self
353    }
354
355    /// The slots as the app declared them: the shell's own, then the app's,
356    /// in the row and in the menu.
357    #[must_use]
358    pub fn slots(&self) -> WindowToolbar {
359        let mut toolbar = self.slots.clone();
360        NavShell::push_window_actions(&mut toolbar, self.entries.clone());
361        toolbar
362    }
363
364    /// Declare these slots as the window's.
365    pub fn declare(&self) {
366        ui_core::window_toolbar::declare(Some(self.slots()));
367    }
368
369    /// Submit what the person pressed in the window's bar since the last
370    /// call: on the shell's own slots, the sidebar toggle, then the record's
371    /// actions and effects. Returns whether anything was asked.
372    pub fn take_events(&self) -> bool {
373        let mut asked = false;
374        for event in ui_core::window_toolbar::take_events() {
375            asked = true;
376            self.press(&event);
377        }
378        asked
379    }
380
381    /// Submit one event the bar sent. Returns whether it was one of these
382    /// slots (then it is handled, even where the slot is gone).
383    pub fn press(&self, event: &ToolbarEvent) -> bool {
384        if let Some(shell) = &self.shell
385            && shell.event(event)
386        {
387            return true;
388        }
389        if matches!(event, ToolbarEvent::ToggleSidebar)
390            && let Some(id) = &self.sidebar
391        {
392            return self.actions.press(&ToolbarEvent::Action { id: id.clone() });
393        }
394        self.actions.press(event)
395    }
396
397    fn push(&mut self, entry: ToolbarAction) {
398        if self.entries.len() < MAX_ENTRIES {
399            self.entries.push(entry);
400        }
401    }
402}