Skip to main content

ravenrs_app_ui/
shell.rs

1//! The window of an application: navigation, commands and the view.
2
3use std::cell::RefCell;
4use std::rc::Rc;
5
6use ravenrs_app::{
7    ActionInput, Application, Frame, FrameContext, ModelNode, ObjectSpace, Value, ViewInfo, ViewKind,
8};
9use ravenrs_core::Insets;
10use ravenrs_layout::{Align, Axis, LayoutStyle};
11use ravenrs_reactive::{Binding, State};
12use ravenrs_style::{Semantic, TextStyle, Weight};
13use ravenrs_widgets::{
14    Alert, AnyView, Breadcrumb, BuildCx, Button, ButtonSize, ButtonStyle, Date, Dialog, Divider, IconButton,
15    IconName, Keyed, NodeId, Paint, ScrollView, Sidebar, SidebarCollapse, SidebarItem, SidebarSection, Text,
16    ThemePicker, Tone, VStack, View, ViewExt,
17};
18
19use crate::container::{ActionContainer, icon_named};
20use crate::detail_view::DetailView;
21use crate::editors::EditorRegistry;
22use crate::list_view::ListView;
23use crate::session::{Session, Words};
24use crate::values::display;
25
26const NARROW_WINDOW: f32 = 720.0;
27/// How wide a window of something shown over the screens is.
28const PRESENTED_WIDTH: f32 = 960.0;
29/// How far everything on a page stands from its edges, so it all starts on one line.
30const PAGE_GUTTER: f32 = 16.0;
31/// The longest line of a description that is still comfortable to read.
32const READING_WIDTH: f32 = 760.0;
33
34/// The pieces of a window, for a template to arrange.
35#[non_exhaustive]
36#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
37pub struct TemplateParts {
38    /// The navigation.
39    pub navigation: AnyView,
40    /// Where the view is, and the commands.
41    pub header: AnyView,
42    /// The view, or what went wrong above it.
43    pub content: AnyView,
44}
45
46impl std::fmt::Debug for TemplateParts {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        f.debug_struct("TemplateParts").finish_non_exhaustive()
49    }
50}
51
52/// Arranges a window. Replacing it changes the whole look of an application, and nothing else.
53pub trait FrameTemplate {
54    /// Puts the pieces where they go.
55    fn arrange(&self, parts: TemplateParts) -> AnyView;
56}
57
58/// Navigation on the side, commands above the view.
59#[derive(Clone, Copy, Debug, Default)]
60#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
61pub struct MainTemplate;
62
63impl FrameTemplate for MainTemplate {
64    fn arrange(&self, parts: TemplateParts) -> AnyView {
65        let main = VStack::new((parts.header, parts.content)).spacing(0.0).align(Align::Stretch).grow(1.0);
66        Box::new(
67            ravenrs_widgets::HStack::new((parts.navigation, main))
68                .spacing(0.0)
69                .align(Align::Stretch)
70                .grow(1.0),
71        )
72    }
73}
74
75fn hash_of(text: &str) -> u64 {
76    text.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x100_0000_01b3))
77}
78
79/// A whole application window, generated from its model.
80#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
81pub struct AppShell {
82    session: Session,
83    template: Rc<dyn FrameTemplate>,
84    theme_menu: bool,
85}
86
87impl std::fmt::Debug for AppShell {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        f.debug_struct("AppShell").field("session", &self.session).finish_non_exhaustive()
90    }
91}
92
93#[cfg_attr(feature = "live", ravenrs_live::expose)]
94impl AppShell {
95    /// A window over this application. `space` makes the unit of work of each view.
96    pub fn new<S: ObjectSpace + 'static>(application: Application, space: impl Fn() -> S + 'static) -> Self {
97        Self::with_options(application, space, Words::default(), EditorRegistry::default())
98    }
99
100    /// Like [`new`](Self::new), with the words of its screens and controls of its own.
101    pub fn with_options<S: ObjectSpace + 'static>(
102        application: Application,
103        space: impl Fn() -> S + 'static,
104        words: Words,
105        editors: EditorRegistry,
106    ) -> Self {
107        let factory: Rc<dyn Fn() -> Rc<RefCell<dyn ObjectSpace>>> =
108            Rc::new(move || Rc::new(RefCell::new(space())) as Rc<RefCell<dyn ObjectSpace>>);
109        Self::from_factory(application, factory, words, editors)
110    }
111
112    pub(crate) fn from_factory(
113        application: Application,
114        factory: crate::session::SpaceFactory,
115        words: Words,
116        editors: EditorRegistry,
117    ) -> Self {
118        let today = Date::from_days(ravenrs_app::local_now().days);
119        let session = Session::new(application, factory, words, editors, today);
120        if let Some(first) = session.first_view() {
121            session.show_view_now(&first);
122        }
123        Self { session, template: Rc::new(MainTemplate), theme_menu: true }
124    }
125
126    /// Arranges the window with this.
127    pub fn template(mut self, template: impl FrameTemplate + 'static) -> Self {
128        self.template = Rc::new(template);
129        self
130    }
131
132    /// Keeps people on the theme the application started with, for one that has a look of its own.
133    pub fn without_theme_menu(mut self) -> Self {
134        self.theme_menu = false;
135        self
136    }
137
138    /// Hands exported files over to this, with their name and bytes, instead of asking the
139    /// person where to save them.
140    pub fn on_file(self, handler: impl Fn(String, Vec<u8>) + 'static) -> Self {
141        self.session.set_on_file(Rc::new(handler));
142        self
143    }
144
145    /// Hands addresses to open over to this.
146    pub fn on_url(self, handler: impl Fn(String) + 'static) -> Self {
147        self.session.set_on_url(Rc::new(handler));
148        self
149    }
150
151    /// What its screens share.
152    pub fn session(&self) -> Session {
153        self.session.clone()
154    }
155
156    fn navigation(&self, current: Option<&ViewInfo>) -> AnyView {
157        let model = self.session.model();
158        let mut content: Vec<AnyView> = Vec::new();
159        if let Some(root) = model.get("NavigationItems") {
160            for group in root.in_order() {
161                if let Some(view) = group.text("View") {
162                    if self.session.can_open(None, &group.id, view) {
163                        content.push(self.navigation_item(group, view, current));
164                    }
165                    continue;
166                }
167                let items: Vec<AnyView> = group
168                    .in_order()
169                    .into_iter()
170                    .filter_map(|item| {
171                        let view = item.text("View")?;
172                        self.session
173                            .can_open(Some(&group.id), &item.id, view)
174                            .then(|| self.navigation_item(item, view, current))
175                    })
176                    .collect();
177                if !items.is_empty() {
178                    content.push(Box::new(SidebarSection::new(
179                        group.text("Caption").unwrap_or(&group.id).to_owned(),
180                    )));
181                    content.extend(items);
182                }
183            }
184        }
185        let sidebar = Sidebar::new(content)
186            .collapsible(SidebarCollapse::Icons)
187            .narrow_below(NARROW_WINDOW)
188            .header(Text::new(self.session.title()).style(TextStyle::Headline).font_weight(Weight::SEMIBOLD));
189        let (due, _) = self.session.reminders().get();
190        let reminders = (due > 0).then(|| {
191            let showing = self.session.reminders().clone();
192            Button::new(format!("{} ({due})", self.session.words().reminders))
193                .style(ButtonStyle::Ghost)
194                .size(ButtonSize::Small)
195                .on_click(move || showing.update(|(_, open)| *open = true))
196        });
197        let account = self.session.account();
198        let running = crate::jobs::running(&self.session);
199        if running.is_none() && reminders.is_none() && account.is_none() && !self.theme_menu {
200            return Box::new(sidebar);
201        }
202        let last: AnyView = match account {
203            Some(account) => self.account_of(account),
204            None => Box::new(self.theme_menu.then_some(ThemePicker::new())),
205        };
206        Box::new(sidebar.footer(VStack::new((running, reminders, last)).spacing(4.0).align(Align::Stretch)))
207    }
208
209    fn account_of(&self, account: crate::session::Account) -> AnyView {
210        let words = self.session.words();
211        let (changing, leaving, sign_out) =
212            (self.session.clone(), self.session.clone(), account.sign_out.clone());
213        Box::new(
214            ravenrs_widgets::HStack::new((
215                Text::new(account.name).style(TextStyle::Callout).grow(1.0),
216                self.theme_menu.then_some(ThemePicker::new()),
217                IconButton::new(IconName::Key)
218                    .label(words.change_password.clone())
219                    .size(ButtonSize::Small)
220                    .on_click(move || crate::security::show_change_password(&changing)),
221                IconButton::new(IconName::LogOut)
222                    .label(words.sign_out.clone())
223                    .size(ButtonSize::Small)
224                    .on_click(move || {
225                        let sign_out = sign_out.clone();
226                        leaving.leaving(move || sign_out());
227                    }),
228            ))
229            .spacing(4.0)
230            .align(Align::Center),
231        )
232    }
233
234    fn navigation_item(&self, item: &ModelNode, view: &str, current: Option<&ViewInfo>) -> AnyView {
235        let model = self.session.model();
236        let entity_of = |id: &str| model.at(&["Views", id]).and_then(|n| n.text("Entity"));
237        let selected = current
238            .is_some_and(|c| c.id == view || (c.entity.is_some() && c.entity.as_deref() == entity_of(view)));
239        let icon = item.text("Icon").and_then(icon_named).unwrap_or(ravenrs_widgets::IconName::File);
240        let (session, target) = (self.session.clone(), view.to_owned());
241        let key = item.text("ObjectKey").map(str::to_owned);
242        Box::new(
243            SidebarItem::new(icon, item.text("Caption").unwrap_or(&item.id).to_owned())
244                .selected(selected)
245                .on_click(move || session.open_navigation(&target, key.as_deref())),
246        )
247    }
248
249    fn trail(&self, view: Option<&ViewInfo>) -> Vec<String> {
250        let Some(view) = view else { return vec![self.session.title()] };
251        let Some(entity) = view.entity.as_deref().and_then(|name| self.session.entity(name)) else {
252            let model = self.session.model();
253            let caption = model.at(&["Views", &view.id]).and_then(|node| node.text("Caption"));
254            return vec![caption.unwrap_or(&view.id).to_owned()];
255        };
256        let mut trail = vec![entity.caption.clone()];
257        if view.kind == ViewKind::Detail
258            && let Some(form) = self.session.form()
259        {
260            trail.push(if form.is_new {
261                self.session.words().new_record.clone()
262            } else {
263                entity
264                    .default_property()
265                    .map(|field| display(field, &form.original.get(&field.name)))
266                    .unwrap_or_default()
267            });
268        }
269        trail
270    }
271
272    fn header(&self, view: Option<&ViewInfo>) -> AnyView {
273        // What is showing in place of the view has its own commands.
274        if self.session.full().is_some() {
275            return Box::new(VStack::new(Vec::<AnyView>::new()));
276        }
277        // One place is a title; only going deeper makes it a way back.
278        let trail = self.trail(view);
279        let place: AnyView = match trail.as_slice() {
280            [only] => {
281                Box::new(Text::new(only.clone()).style(TextStyle::Headline).font_weight(Weight::SEMIBOLD))
282            }
283            _ => Box::new(Breadcrumb::new(trail)),
284        };
285        Box::new(
286            VStack::new((
287                VStack::new((place, ActionContainer::new(self.session.clone(), [])))
288                    .spacing(8.0)
289                    .align(Align::Stretch)
290                    .padding(Insets::symmetric(12.0, PAGE_GUTTER)),
291                Divider::horizontal(),
292            ))
293            .spacing(0.0)
294            .align(Align::Stretch),
295        )
296    }
297
298    fn content(&self, view: Option<&ViewInfo>) -> AnyView {
299        let session = &self.session;
300        let body: AnyView = match view {
301            _ if session.full().is_some() => session
302                .full()
303                .map_or_else(|| Box::new(Text::new(session.title())) as AnyView, |content| content()),
304            Some(view) if view.kind == ViewKind::List => Box::new(Keyed::new(
305                hash_of(&view.id),
306                ListView::new(session.clone(), session.frame(), view.clone())
307                    .padding(PAGE_GUTTER)
308                    .stretch()
309                    .grow(1.0),
310            )),
311            Some(view) if view.kind == ViewKind::Detail => {
312                let form = session.form().map_or(0, |form| Rc::as_ptr(&form) as usize as u64);
313                Box::new(ScrollView::new((Keyed::new(
314                    form,
315                    DetailView::new(session.clone(), view.clone()).padding(PAGE_GUTTER),
316                ),)))
317            }
318            Some(view) if view.kind == ViewKind::Dashboard => Box::new(ScrollView::new((Keyed::new(
319                hash_of(&view.id),
320                crate::DashboardView::new(session.clone(), view.clone()).padding(PAGE_GUTTER),
321            ),))),
322            Some(view) => Box::new(Text::new(view.id.clone())),
323            None => Box::new(Text::new(session.title()).style(TextStyle::Body)),
324        };
325        let description = view
326            .filter(|_| session.full().is_none())
327            .filter(|view| view.kind == ViewKind::List)
328            .and_then(|view| {
329                session
330                    .model()
331                    .at(&["Views", &view.id])
332                    .and_then(|node| node.text("Description"))
333                    .map(str::to_owned)
334            })
335            .map(|text| {
336                VStack::new((Text::new(text)
337                    .style(TextStyle::Callout)
338                    .foreground(Semantic::InkMuted)
339                    .stretch()
340                    .max_width(READING_WIDTH),))
341                .align(Align::Stretch)
342                .padding(Insets { top: PAGE_GUTTER, right: PAGE_GUTTER, bottom: 0.0, left: PAGE_GUTTER })
343            });
344        Box::new(
345            VStack::new((
346                description,
347                session.error().map(|error| {
348                    Alert::message(error).tone(Tone::Danger).padding(Insets {
349                        top: PAGE_GUTTER,
350                        right: PAGE_GUTTER,
351                        bottom: 0.0,
352                        left: PAGE_GUTTER,
353                    })
354                }),
355                body,
356            ))
357            .spacing(0.0)
358            .align(Align::Stretch)
359            .grow(1.0),
360        )
361    }
362}
363
364/// What closes the popup of a frame, when the window closes.
365fn popup_open(main: &Frame) -> Binding<bool> {
366    let (closing, canceling) = (main.clone(), main.clone());
367    Binding::new(
368        move || closing.open_popup().is_some(),
369        move |open: bool| {
370            if !open {
371                canceling.cancel_popup();
372            }
373        },
374    )
375}
376
377/// A list in a window of its own, for an action that asks for a record.
378struct PopupList {
379    session: Session,
380    action: String,
381    view: ViewInfo,
382    frame: Frame,
383}
384
385impl View for PopupList {
386    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
387        let main = self.session.frame();
388        let open = popup_open(&main);
389        let key_of = {
390            let session = self.session.clone();
391            move |frame: &Frame| {
392                frame.selected().first().and_then(|object| {
393                    let entity = session.entity(object.entity())?;
394                    entity.key_field().map(|field| object.get(&field.name))
395                })
396            }
397        };
398        let accept: Rc<dyn Fn(Option<Value>)> = {
399            let (session, main) = (self.session.clone(), main.clone());
400            Rc::new(move |key| {
401                let input = key.map_or(ActionInput::None, ActionInput::Parameter);
402                if let Err(error) = main.accept_popup(input) {
403                    session.set_error(Some(error.to_string()));
404                }
405            })
406        };
407        let (ok, frame) = (accept.clone(), self.frame.clone());
408        let on_open = accept;
409        let list = ListView::new(self.session.clone(), self.frame.clone(), self.view.clone())
410            .filter(self.session.popup_filter())
411            .on_open(Rc::new(move |key: &Value| on_open(Some(key.clone()))));
412        let words = self.session.words();
413        let cancel_frame = main;
414        cx.child(
415            &Dialog::new(open, self.action.clone())
416                .content(list.height(420.0))
417                .action(
418                    Button::new(words.cancel.clone())
419                        .style(ButtonStyle::Secondary)
420                        .on_click(move || cancel_frame.cancel_popup()),
421                )
422                .action(Button::new("OK").on_click(move || ok(key_of(&frame))))
423                .width(720.0),
424        )
425    }
426}
427
428/// A form in a window of its own, for an action that asks for a record to be filled in. It runs
429/// with the record, once that meets its rules.
430struct PopupForm {
431    /// Where the action is.
432    owner: Session,
433    /// Where the form is.
434    form: Session,
435    action: String,
436    view: ViewInfo,
437}
438
439impl View for PopupForm {
440    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
441        let main = self.owner.frame();
442        let open = popup_open(&main);
443        let (form, owner_frame) = (self.form.clone(), main.clone());
444        let words = self.owner.words();
445        let cancel_frame = main;
446        let key = self.form.form().map_or(0, |form| Rc::as_ptr(&form) as usize as u64);
447        let content = VStack::new((
448            self.form.error().map(|error| Alert::new(self.action.clone(), error).tone(Tone::Danger)),
449            Keyed::new(key, DetailView::new(self.form.clone(), self.view.clone())),
450        ))
451        .spacing(12.0)
452        .align(Align::Stretch);
453        cx.child(
454            &Dialog::new(open, self.action.clone())
455                .width(480.0)
456                .content(content)
457                .action(
458                    Button::new(words.cancel.clone())
459                        .style(ButtonStyle::Secondary)
460                        .on_click(move || cancel_frame.cancel_popup()),
461                )
462                .action(Button::new("OK").on_click(move || {
463                    let Some((record, _)) = form.check_form() else { return };
464                    if let Err(error) = owner_frame.accept_popup(ActionInput::Record(record)) {
465                        form.set_error(Some(error.to_string()));
466                    }
467                })),
468        )
469    }
470}
471
472/// What a popup holds while it is open.
473#[derive(Clone)]
474enum PopupParts {
475    List(Frame),
476    Form(Session),
477}
478
479/// The windows of a frame's actions: what they ask for, and what they show.
480pub(crate) struct PopupHost {
481    session: Session,
482}
483
484#[cfg_attr(feature = "live", ravenrs_live::expose)]
485impl PopupHost {
486    pub(crate) fn new(session: Session) -> Self {
487        Self { session }
488    }
489
490    fn parts_for(&self, info: &ViewInfo) -> PopupParts {
491        if info.kind != ViewKind::Detail {
492            let frame = self.session.new_frame(FrameContext::Popup);
493            frame.set_view(Some(info.clone()));
494            return PopupParts::List(frame);
495        }
496        let form = self.session.child(FrameContext::Popup);
497        if let Some(entity) = info.entity.as_deref().and_then(|name| self.session.entity(name)) {
498            let mut record = entity.new_object();
499            self.session.frame().prepare_popup(&mut record);
500            form.open_object(&entity, record, true);
501        }
502        PopupParts::Form(form)
503    }
504}
505
506impl View for PopupHost {
507    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
508        let session = &self.session;
509        crate::jobs::keep(cx, session);
510        let mut parts = Vec::new();
511        // Made once for each time it opens.
512        let held: State<Option<(String, PopupParts)>> = cx.use_state(|| None);
513        match session.frame().open_popup() {
514            Some((action, target)) => {
515                if let Ok(info) = ViewInfo::from_model(&session.model(), &target) {
516                    let kept = held.peek().clone().filter(|(id, _)| *id == action);
517                    let made = kept.map_or_else(
518                        || {
519                            let made = self.parts_for(&info);
520                            held.set(Some((action.clone(), made.clone())));
521                            made
522                        },
523                        |(_, made)| made,
524                    );
525                    let caption = session
526                        .frame()
527                        .action(&action)
528                        .map_or_else(|| action.clone(), |a| a.caption().to_owned());
529                    parts.push(match made {
530                        PopupParts::List(frame) => cx.child(&PopupList {
531                            session: session.clone(),
532                            action: caption,
533                            view: info,
534                            frame,
535                        }),
536                        PopupParts::Form(form) => {
537                            cx.child(&PopupForm { owner: session.clone(), form, action: caption, view: info })
538                        }
539                    });
540                }
541            }
542            None => {
543                if held.peek().is_some() {
544                    held.set(None);
545                }
546            }
547        }
548        parts.push(cx.child(&crate::reminders::Reminders::new(session.clone())));
549        if let Some((title, content)) = session.presented().get() {
550            let (read, write) = (session.presented().clone(), session.presented().clone());
551            let open = Binding::new(
552                move || read.get().is_some(),
553                move |open: bool| {
554                    if !open {
555                        write.set(None);
556                    }
557                },
558            );
559            parts.push(cx.child(&Dialog::new(open, title).width(PRESENTED_WIDTH).content(content())));
560        }
561        cx.container(LayoutStyle::stack(Axis::Vertical), &parts, Paint::None)
562    }
563}
564
565impl View for AppShell {
566    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
567        let session = &self.session;
568        let view = session.frame().view();
569        let view = view.as_ref();
570        let window = self.template.arrange(TemplateParts {
571            navigation: self.navigation(view),
572            header: self.header(view),
573            content: self.content(view),
574        });
575        let mut parts = vec![cx.child(&window)];
576        parts.push(cx.child(session.confirm()));
577        parts.push(cx.child(&PopupHost::new(session.clone())));
578        parts.push(cx.child(&session.toaster()));
579        // What changed, for screen readers; it takes no room.
580        parts.push(cx.child(&session.announcer().region()));
581        cx.container(
582            LayoutStyle {
583                grow: 1.0,
584                min_height: Some(0.0),
585                align: Align::Stretch,
586                align_self: Some(Align::Stretch),
587                ..LayoutStyle::stack(Axis::Vertical)
588            },
589            &parts,
590            Paint::None,
591        )
592    }
593}