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}