Skip to main content

ravenrs_app_ui/
pane.rs

1//! A list beside its form, as a piece of a screen an application made itself.
2
3use std::cell::{Cell, RefCell};
4use std::rc::Rc;
5
6use ravenrs_app::{Application, EntityInfo, ModelError, Object, ObjectSpace, Value, ViewInfo};
7use ravenrs_layout::Align;
8use ravenrs_style::TextStyle;
9use ravenrs_widgets::grid::Row;
10use ravenrs_widgets::{
11    BuildCx, Button, ButtonStyle, Card, GridRowAction, HStack, IconName, Keyed, NodeId, Text, VStack, View,
12    ViewExt,
13};
14
15use crate::list_view::CellText;
16use crate::session::{Session, SpaceFactory, Words};
17use crate::values::key_from_text;
18use crate::{DetailView, ListView, SystemModule};
19
20/// What a pane says about its records.
21#[derive(Clone, Debug)]
22#[non_exhaustive]
23#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
24pub struct PaneWords {
25    /// Over the form of a new record.
26    pub new_title: String,
27    /// Over the form of a record being changed.
28    pub edit_title: String,
29    /// The button that keeps a new record.
30    pub add: String,
31    /// The button that keeps changes.
32    pub save_changes: String,
33    /// The button that gives up on changes.
34    pub cancel: String,
35    /// The button of a row that opens its form, before the record's name.
36    pub edit_row: String,
37    /// The button of a row that takes it away, before the record's name.
38    pub delete_row: String,
39    /// The question before taking it away.
40    pub delete_question: String,
41    /// What taking it away does.
42    pub delete_detail: String,
43    /// The answer that takes it away.
44    pub delete_answer: String,
45}
46
47#[cfg_attr(feature = "live", ravenrs_live::expose)]
48impl PaneWords {
49    /// What a pane says about its records, in Brazilian Portuguese where it does not say otherwise.
50    pub fn new(
51        new_title: impl Into<String>,
52        edit_title: impl Into<String>,
53        add: impl Into<String>,
54        delete_question: impl Into<String>,
55        delete_detail: impl Into<String>,
56        delete_answer: impl Into<String>,
57    ) -> Self {
58        let answer: String = delete_answer.into();
59        let texts = ravenrs_app_i18n::Texts::new(ravenrs_app_i18n::Language::BrazilianPortuguese);
60        Self {
61            new_title: new_title.into(),
62            edit_title: edit_title.into(),
63            add: add.into(),
64            save_changes: String::new(),
65            cancel: String::new(),
66            edit_row: String::new(),
67            delete_row: answer.clone(),
68            delete_question: delete_question.into(),
69            delete_detail: delete_detail.into(),
70            delete_answer: answer,
71        }
72        .with_texts(&texts)
73    }
74
75    /// The words that are the same for every pane, from a catalog of texts in its language.
76    pub fn with_texts(mut self, texts: &ravenrs_app_i18n::Texts) -> Self {
77        self.save_changes = texts.get("pane.save_changes");
78        self.cancel = texts.get("pane.cancel");
79        self.edit_row = texts.get("pane.edit_row");
80        self
81    }
82}
83
84type DoneHandler = Rc<dyn Fn(&'static str)>;
85type FailHandler = Rc<dyn Fn(String)>;
86
87/// What happened, for the application to say in its own way.
88#[derive(Clone, Copy, Debug, PartialEq, Eq)]
89enum Outcome {
90    Saved,
91    Deleted,
92}
93
94/// A list of an entity's records beside a form that adds or changes them, with a button on each
95/// row to open it and one to take it away. It fits inside a screen an application made itself.
96#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
97pub struct EntityPane {
98    session: Session,
99    entity: EntityInfo,
100    words: PaneWords,
101    told: RefCell<Option<DoneHandler>>,
102    failed: RefCell<Option<FailHandler>>,
103    cell_text: Option<CellText>,
104    seen: Cell<u64>,
105    beside_from: f32,
106    form_width: f32,
107    version: Option<Rc<dyn Fn() -> u64>>,
108}
109
110impl std::fmt::Debug for EntityPane {
111    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112        f.debug_struct("EntityPane").field("entity", &self.entity.name).finish_non_exhaustive()
113    }
114}
115
116#[cfg_attr(feature = "live", ravenrs_live::expose)]
117impl EntityPane {
118    /// A pane over this entity. `space` makes a unit of work for each record opened.
119    pub fn new<S: ObjectSpace + 'static>(
120        entity: EntityInfo,
121        space: impl Fn() -> S + 'static,
122        words: Words,
123        pane: PaneWords,
124    ) -> Result<Self, ModelError> {
125        let application = Application::builder(entity.caption.clone())
126            .module(SystemModule::with_words(words.clone()))
127            .entity(entity.clone())
128            .build()?;
129        let factory: SpaceFactory =
130            Rc::new(move || Rc::new(RefCell::new(space())) as Rc<RefCell<dyn ObjectSpace>>);
131        let session = Session::standalone(application, factory, words);
132        session.open_new(&entity.name);
133        Ok(Self {
134            session,
135            entity,
136            words: pane,
137            told: RefCell::default(),
138            failed: RefCell::default(),
139            cell_text: None,
140            seen: Cell::new(0),
141            beside_from: 760.0,
142            form_width: 340.0,
143            version: None,
144        })
145    }
146
147    /// Runs with a word of what was kept or taken away.
148    pub fn on_done(self, handler: impl Fn(&'static str) + 'static) -> Self {
149        *self.told.borrow_mut() = Some(Rc::new(handler));
150        self
151    }
152
153    /// Runs with what went wrong, in words for people.
154    pub fn on_failed(self, handler: impl Fn(String) + 'static) -> Self {
155        *self.failed.borrow_mut() = Some(Rc::new(handler));
156        self
157    }
158
159    /// Says what a cell shows, when its value alone is not it.
160    pub fn cell_text(mut self, text: impl Fn(&str, &Object) -> Option<String> + 'static) -> Self {
161        self.cell_text = Some(Rc::new(text));
162        self
163    }
164
165    /// The window width from which the form goes beside the list. 760 by default.
166    pub fn beside_from(mut self, width: f32) -> Self {
167        self.beside_from = width;
168        self
169    }
170
171    /// Reads this while building, and reads the records again when it changes, for what
172    /// the rest of the application does to them.
173    pub fn version(mut self, version: impl Fn() -> u64 + 'static) -> Self {
174        self.version = Some(Rc::new(version));
175        self
176    }
177
178    fn view_of(&self, kind: &str) -> Option<ViewInfo> {
179        ViewInfo::from_model(&self.session.model(), &format!("{}_{kind}", self.entity.name)).ok()
180    }
181
182    fn body(&self, beside: bool) -> impl View + use<> {
183        let session = self.session.clone();
184        let form = session.form();
185        let editing = form.as_ref().is_some_and(|form| !form.is_new);
186        let words = self.words.clone();
187        let list_view = self.view_of("ListView").map(|view| {
188            let (opener, entity) = (session.clone(), self.entity.clone());
189            let key_of = {
190                let entity = entity.clone();
191                move |row: &Row| {
192                    let field = entity.key_field()?;
193                    let at = row_key_position(&entity, field.name.as_str());
194                    row.get(at).map(|cell| key_from_text(&entity, &cell.key_text()))
195                }
196            };
197            let (asker, remover) = (session.clone(), self.clone_handles());
198            let open_key = key_of.clone();
199            let question = words.clone();
200            let open_entity = entity.clone();
201            let mut list = ListView::new(session.clone(), session.frame(), view)
202                .compact()
203                .row_action(GridRowAction::new(words.edit_row.clone(), IconName::Pencil, move |row| {
204                    if let Some(key) = open_key(row) {
205                        opener.open_detail(&open_entity.name, &key);
206                    }
207                }))
208                .row_action(GridRowAction::new(words.delete_row.clone(), IconName::Trash2, move |row| {
209                    let Some(key) = key_of(row) else { return };
210                    let (session, remover) = (asker.clone(), remover.clone());
211                    let name = entity.name.clone();
212                    asker.confirm().ask(
213                        question.delete_question.clone(),
214                        question.delete_detail.clone(),
215                        question.delete_answer.clone(),
216                        move || remover.delete(&session, &name, &key),
217                    );
218                }));
219            if let Some(text) = &self.cell_text {
220                list = list.cell_text(text.clone());
221            }
222            list
223        });
224        let detail = self.view_of("DetailView").map(|view| DetailView::new(session.clone(), view));
225        let key = form.as_ref().map_or(0, |form| Rc::as_ptr(form) as usize as u64);
226        let title = if editing { words.edit_title.clone() } else { words.new_title.clone() };
227        let handles = self.clone_handles();
228        let (saver, canceler, entity_name) = (session.clone(), session.clone(), self.entity.name.clone());
229        let again = entity_name.clone();
230        let buttons = HStack::new((
231            editing.then(|| {
232                Button::new(words.cancel.clone())
233                    .style(ButtonStyle::Secondary)
234                    .on_click(move || canceler.open_new(&again))
235            }),
236            Button::new(if editing { words.save_changes.clone() } else { words.add.clone() })
237                .icon(if editing { IconName::Check } else { IconName::Plus })
238                .on_click(move || handles.save(&saver, &entity_name)),
239        ))
240        .spacing(8.0);
241        let form_card = Card::new(
242            VStack::new((Text::new(title).style(TextStyle::Headline), Keyed::new(key, detail), buttons))
243                .spacing(12.0)
244                .align(Align::Stretch),
245        );
246        let list_card = Card::new(VStack::new((list_view,)).align(Align::Stretch));
247        let arranged: ravenrs_widgets::AnyView = if beside {
248            Box::new(
249                HStack::new((list_card.grow(1.0), form_card.width(self.form_width)))
250                    .spacing(16.0)
251                    .align(Align::Start),
252            )
253        } else {
254            Box::new(VStack::new((list_card, form_card)).spacing(16.0).align(Align::Stretch))
255        };
256        arranged
257    }
258
259    fn clone_handles(&self) -> Handles {
260        Handles { told: self.told.borrow().clone(), failed: self.failed.borrow().clone() }
261    }
262}
263
264/// Where the key is among a list's columns: after those showing, unless it shows too.
265fn row_key_position(entity: &EntityInfo, key: &str) -> usize {
266    let shown: Vec<&str> = entity.fields.iter().filter(|f| f.in_list).map(|f| f.name.as_str()).collect();
267    shown.iter().position(|name| *name == key).unwrap_or(shown.len())
268}
269
270/// What a pane tells the application.
271#[derive(Clone)]
272struct Handles {
273    told: Option<DoneHandler>,
274    failed: Option<FailHandler>,
275}
276
277#[cfg_attr(feature = "live", ravenrs_live::expose)]
278impl Handles {
279    fn say(&self, outcome: Outcome) {
280        if let Some(told) = &self.told {
281            told(match outcome {
282                Outcome::Saved => "saved",
283                Outcome::Deleted => "deleted",
284            });
285        }
286    }
287
288    fn fail(&self, session: &Session, message: String) {
289        session.set_error(None);
290        if let Some(failed) = &self.failed {
291            failed(message);
292        }
293    }
294
295    fn save(&self, session: &Session, entity: &str) {
296        let (saved, name) = (self.clone(), entity.to_owned());
297        let done = session.save_then(move |session| {
298            session.open_new(&name);
299            saved.say(Outcome::Saved);
300        });
301        match done {
302            Ok(()) => {}
303            // What fails a rule is on the form already.
304            Err(_) if session.is_checking() => {}
305            Err(message) => self.fail(session, message),
306        }
307    }
308
309    fn delete(&self, session: &Session, entity: &str, key: &Value) {
310        let found = session.frame().space().borrow().find(entity, key);
311        let Ok(Some(object)) = found else { return };
312        match session.delete(&[object]) {
313            Ok(()) => {
314                session.open_new(entity);
315                self.say(Outcome::Deleted);
316            }
317            Err(message) => self.fail(session, message),
318        }
319    }
320}
321
322impl View for EntityPane {
323    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
324        if let Some(version) = &self.version {
325            let now = version();
326            if self.seen.replace(now) != now {
327                self.session.records_changed();
328            }
329        }
330        let from = self.beside_from;
331        let beside = cx.viewport().width >= from;
332        let body = self.body(beside);
333        let parts = cx.child(&body);
334        let confirm = cx.child(self.session.confirm());
335        cx.container(
336            ravenrs_layout::LayoutStyle {
337                align: Align::Stretch,
338                ..ravenrs_layout::LayoutStyle::stack(ravenrs_layout::Axis::Vertical)
339            },
340            &[parts, confirm],
341            ravenrs_widgets::Paint::None,
342        )
343    }
344}