1use 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;
27const PRESENTED_WIDTH: f32 = 960.0;
29const PAGE_GUTTER: f32 = 16.0;
31const READING_WIDTH: f32 = 760.0;
33
34#[non_exhaustive]
36#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
37pub struct TemplateParts {
38 pub navigation: AnyView,
40 pub header: AnyView,
42 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
52pub trait FrameTemplate {
54 fn arrange(&self, parts: TemplateParts) -> AnyView;
56}
57
58#[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#[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 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 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 pub fn template(mut self, template: impl FrameTemplate + 'static) -> Self {
128 self.template = Rc::new(template);
129 self
130 }
131
132 pub fn without_theme_menu(mut self) -> Self {
134 self.theme_menu = false;
135 self
136 }
137
138 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 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 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 if self.session.full().is_some() {
275 return Box::new(VStack::new(Vec::<AnyView>::new()));
276 }
277 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
364fn 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
377struct 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
428struct PopupForm {
431 owner: Session,
433 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#[derive(Clone)]
474enum PopupParts {
475 List(Frame),
476 Form(Session),
477}
478
479pub(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 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 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}