state_management/
state_management.rs

1use gtk::prelude::*;
2use relm4::Worker;
3use relm4::factory::{FactoryVecDeque, FactoryView};
4use relm4::prelude::*;
5use relm4_components::open_dialog::{
6    OpenDialog, OpenDialogMsg, OpenDialogResponse, OpenDialogSettings,
7};
8use relm4_components::save_dialog::{
9    SaveDialog, SaveDialogMsg, SaveDialogResponse, SaveDialogSettings,
10};
11use serde::{Deserialize, Serialize};
12use std::path::PathBuf;
13
14const DEFAULT_SPACING: i32 = 5;
15const XALIGN_CENTER: f32 = 0.5;
16const CSS_CLASS_DESTRUCTIVE_ACTION: &str = "destructive-action";
17
18/// The view.
19///
20/// Any state held within the view components is private and self-contained, and is solely used to
21/// render information on screen. Any exchange of data with external components is done via events.
22/// As a result the view has no knowledge of the document - indeed it is possible to remove the
23/// document and replace it with a different implementation without the view knowing.
24#[derive(Debug)]
25struct Task {
26    name: String,
27    tags: FactoryVecDeque<Tag>,
28}
29
30#[derive(Debug)]
31enum TaskInput {
32    // events of Task object
33    ChangedName(String),
34
35    // events broadcast downwards to nested Tag objects
36    AddedTag(String),
37    DeletedTag(usize),
38}
39
40#[derive(Debug)]
41enum TaskOutput {
42    // events of Task object
43    Name(DynamicIndex, String),
44    Delete(DynamicIndex),
45
46    // events bubbled up from nested Tag objects
47    AddTag(DynamicIndex, String),
48    DeleteTag(DynamicIndex, DynamicIndex),
49}
50
51#[relm4::factory]
52impl FactoryComponent for Task {
53    type Init = ();
54    type Input = TaskInput;
55    type Output = TaskOutput;
56    type CommandOutput = ();
57    type ParentWidget = gtk::ListBox;
58
59    view! {
60        gtk::Box {
61            set_orientation: gtk::Orientation::Vertical,
62            set_spacing: DEFAULT_SPACING,
63
64            gtk::Box {
65                set_orientation: gtk::Orientation::Horizontal,
66                set_spacing: DEFAULT_SPACING,
67
68                #[name(label)]
69                gtk::Entry {
70                    #[watch]
71                    set_text: &self.name,
72                    set_hexpand: true,
73                    set_halign: gtk::Align::Fill,
74
75                    connect_activate[sender, index] => move |entry| {
76                        // activate means 'enter' was pressed, so user is done editing
77                        let new_name: String = entry.text().into();
78                        sender.output(TaskOutput::Name(index.clone(), new_name)).unwrap();
79                    }
80                },
81
82                gtk::Button {
83                    set_icon_name: "edit-delete",
84                    set_tooltip: "Delete Task",
85
86                    connect_clicked[sender, index] => move |_| {
87                        sender.output(TaskOutput::Delete(index.clone())).unwrap();
88                    }
89                },
90            },
91
92            gtk::Box {
93                set_spacing: DEFAULT_SPACING,
94                set_orientation: gtk::Orientation::Horizontal,
95
96                gtk::MenuButton {
97                    set_icon_name: "plus",
98                    set_tooltip: "Add Tag",
99
100                    #[wrap(Some)]
101                    set_popover = &gtk::Popover {
102                        gtk::Box {
103                            set_orientation: gtk::Orientation::Vertical,
104                            set_spacing: DEFAULT_SPACING,
105
106                            gtk::Button {
107                                set_label: "#home",
108
109                                connect_clicked[sender, index] => move |_| {
110                                    sender.output(TaskOutput::AddTag(index.clone(), "#home".into())).unwrap();
111                                }
112                            },
113
114                            gtk::Button {
115                                set_label: "#work",
116
117                                connect_clicked[sender, index] => move |_| {
118                                    sender.output(TaskOutput::AddTag(index.clone(), "#work".into())).unwrap();
119                                }
120                            }
121                        }
122                    }
123                },
124
125                #[local_ref]
126                tag_list_box -> gtk::Box {
127                    set_spacing: DEFAULT_SPACING,
128                },
129            }
130        }
131    }
132
133    fn update(&mut self, message: Self::Input, _sender: FactorySender<Self>) {
134        match message {
135            TaskInput::ChangedName(name) => {
136                self.name = name;
137            }
138            TaskInput::AddedTag(name) => {
139                self.tags.guard().push_back(name);
140            }
141            TaskInput::DeletedTag(index) => {
142                self.tags.guard().remove(index);
143            }
144        }
145    }
146
147    fn init_widgets(
148        &mut self,
149        index: &Self::Index,
150        root: Self::Root,
151        _returned_widget: &<Self::ParentWidget as FactoryView>::ReturnedWidget,
152        sender: FactorySender<Self>,
153    ) -> Self::Widgets {
154        let tag_list_box = self.tags.widget();
155
156        let widgets = view_output!();
157
158        widgets
159    }
160
161    fn init_model(_name: Self::Init, index: &DynamicIndex, sender: FactorySender<Self>) -> Self {
162        let task_index = index.clone();
163
164        let tags = FactoryVecDeque::builder().launch_default().forward(
165            sender.output_sender(),
166            move |output| match output {
167                TagOutput::Delete(tag_index) => {
168                    TaskOutput::DeleteTag(task_index.clone(), tag_index)
169                }
170            },
171        );
172
173        Self {
174            name: "".into(),
175            tags,
176        }
177    }
178}
179
180#[derive(Debug)]
181struct Tag {
182    name: String,
183}
184
185#[derive(Debug)]
186enum TagInput {}
187
188#[derive(Debug)]
189enum TagOutput {
190    Delete(DynamicIndex),
191}
192
193#[relm4::factory]
194impl FactoryComponent for Tag {
195    type Init = String;
196    type Input = TagInput;
197    type Output = TagOutput;
198    type CommandOutput = ();
199    type ParentWidget = gtk::Box;
200
201    view! {
202        gtk::MenuButton {
203            #[watch]
204            set_label: &self.name,
205
206            #[wrap(Some)]
207            set_popover = &gtk::Popover {
208                gtk::Button {
209                    set_label: "Delete",
210
211                    connect_clicked[sender, index] => move |_| {
212                        sender.output(TagOutput::Delete(index.clone())).unwrap();
213                    }
214                }
215            }
216        }
217    }
218
219    fn init_model(name: Self::Init, _index: &DynamicIndex, _sender: FactorySender<Self>) -> Self {
220        Self { name }
221    }
222}
223
224/// The document is a headless component which holds and manages the data model.
225/// It receives input events FROM the App to update the data model.
226/// When updates to the model occur, it sends output events TO the App.
227///
228/// The document's interface is just input and output events. As a result you have a lot of freedom
229/// in how you choose to store the data model within the component, which backing store you use
230/// (such as the file system, a database, or a Web API), and how you synchronise to the backing
231/// store (e.g. manual save/load control, auto-saving on each change, batching up changes before
232/// syncing, and so on).
233struct Document {
234    /// The application data model.
235    /// In this case we have just stored the whole thing in memory because our requirements are
236    /// simple. In a real app you might choose a more elaborate approach.
237    model: Model,
238}
239
240#[derive(Default, Serialize, Deserialize)]
241struct TagModel {
242    name: String,
243}
244#[derive(Default, Serialize, Deserialize)]
245struct TaskModel {
246    name: String,
247    tags: Vec<TagModel>,
248}
249#[derive(Default, Serialize, Deserialize)]
250struct Model {
251    tasks: Vec<TaskModel>,
252}
253
254#[derive(Debug)]
255enum DocumentInput {
256    // extra operations on the document itself (in this case, related to file I/O)
257    Open(PathBuf),
258    Save(PathBuf),
259
260    // events related to the model that the document stores
261    Clear,
262    AddTask,
263    DeleteTask(DynamicIndex),
264    ChangeTaskName(DynamicIndex, String),
265    AddTag(DynamicIndex, String),
266    DeleteTag(DynamicIndex, DynamicIndex),
267}
268
269#[derive(Debug)]
270enum DocumentOutput {
271    Cleared,
272    AddedTask,
273    DeletedTask(usize),
274    ChangedTaskName(usize, String),
275    AddedTag(usize, String),
276    DeletedTag(usize, usize),
277}
278
279impl Worker for Document {
280    type Init = ();
281    type Input = DocumentInput;
282    type Output = DocumentOutput;
283
284    fn init(_init: Self::Init, _sender: ComponentSender<Self>) -> Self {
285        let model = Model::default();
286        Self { model }
287    }
288
289    fn update(&mut self, input: DocumentInput, sender: ComponentSender<Self>) {
290        match input {
291            DocumentInput::Save(path) => {
292                println!("Save as JSON to {path:?}");
293
294                // TODO in a real app you would report any errors from saving the document
295                if let Ok(json) = serde_json::to_string(&self.model) {
296                    std::fs::write(path, json).unwrap();
297                }
298            }
299            DocumentInput::Open(path) => {
300                println!("Open tasks document at {path:?}");
301
302                if let Ok(json) = std::fs::read_to_string(path)
303                    && let Ok(new_model) = serde_json::from_str(&json)
304                {
305                    // update the data model
306                    self.model = new_model;
307
308                    // refresh the view from the data model
309                    let _ = sender.output(DocumentOutput::Cleared);
310
311                    for (task_index, task) in self.model.tasks.iter().enumerate() {
312                        let _ = sender.output(DocumentOutput::AddedTask);
313
314                        let task_name = task.name.clone();
315                        let _ =
316                            sender.output(DocumentOutput::ChangedTaskName(task_index, task_name));
317
318                        for tag in &task.tags {
319                            let tag_name = tag.name.clone();
320                            let _ = sender.output(DocumentOutput::AddedTag(task_index, tag_name));
321                        }
322                    }
323                }
324            }
325            DocumentInput::Clear => {
326                self.model.tasks.clear();
327
328                let _ = sender.output(DocumentOutput::Cleared);
329            }
330            DocumentInput::AddTask => {
331                self.model.tasks.push(TaskModel::default());
332
333                let _ = sender.output(DocumentOutput::AddedTask);
334            }
335            DocumentInput::DeleteTask(index) => {
336                self.model.tasks.remove(index.current_index());
337
338                let _ = sender.output(DocumentOutput::DeletedTask(index.current_index()));
339            }
340            DocumentInput::ChangeTaskName(index, name) => {
341                if let Some(task) = self.model.tasks.get_mut(index.current_index()) {
342                    task.name.clone_from(&name);
343                }
344
345                // We don't technically need to send an event, because gtk::Entry updates itself
346                // this is just to make the example consistent.
347                let _ = sender.output(DocumentOutput::ChangedTaskName(index.current_index(), name));
348            }
349            DocumentInput::AddTag(task_index, name) => {
350                if let Some(task) = self.model.tasks.get_mut(task_index.current_index()) {
351                    task.tags.push(TagModel { name: name.clone() })
352                }
353
354                let _ = sender.output(DocumentOutput::AddedTag(task_index.current_index(), name));
355            }
356            DocumentInput::DeleteTag(task_index, tag_index) => {
357                if let Some(task) = self.model.tasks.get_mut(task_index.current_index()) {
358                    task.tags.remove(tag_index.current_index());
359                }
360
361                let _ = sender.output(DocumentOutput::DeletedTag(
362                    task_index.current_index(),
363                    tag_index.current_index(),
364                ));
365            }
366        }
367    }
368}
369
370/// The App is at the top level.
371/// It acts as a bridge between the view and the document, forwarding events between them.
372struct App {
373    view: FactoryVecDeque<Task>,
374    document: Controller<Document>,
375    save_dialog: Controller<SaveDialog>,
376    open_dialog: Controller<OpenDialog>,
377}
378
379#[derive(Debug)]
380enum AppInput {
381    Clear,
382    Cleared,
383
384    AddTask,
385    AddedTask,
386
387    DeleteTask(DynamicIndex),
388    DeletedTask(usize),
389
390    ChangeTaskName(DynamicIndex, String),
391    ChangedTaskName(usize, String),
392
393    AddTag(DynamicIndex, String),
394    AddedTag(usize, String),
395
396    DeleteTag(DynamicIndex, DynamicIndex),
397    DeletedTag(usize, usize),
398
399    // No-op event for when load/save dialogs result in Cancel
400    None,
401    Open,
402    OpenResponse(PathBuf),
403    Save,
404    SaveResponse(PathBuf),
405}
406
407#[relm4::component]
408impl SimpleComponent for App {
409    type Init = ();
410    type Input = AppInput;
411    type Output = ();
412
413    view! {
414        main_window = gtk::ApplicationWindow {
415            set_width_request: 360,
416            set_title: Some("Tasks"),
417
418            gtk::Box {
419                set_orientation: gtk::Orientation::Vertical,
420
421                gtk::HeaderBar {
422                    set_show_title_buttons: false,
423
424                    #[wrap(Some)]
425                    set_title_widget = &gtk::Label {
426                        set_text: ""
427                    },
428
429                    pack_start = &gtk::Button {
430                        set_icon_name: "plus",
431                        set_tooltip: "Add Task",
432
433                        connect_clicked[sender] => move |_| {
434                            sender.input(AppInput::AddTask);
435                        }
436                    },
437
438                    pack_end = &gtk::Button {
439                        set_label: "Save",
440                        connect_clicked => AppInput::Save,
441                    },
442                    pack_end = &gtk::Button {
443                        set_label: "Open",
444                        connect_clicked => AppInput::Open,
445                    },
446                },
447
448                gtk::ScrolledWindow {
449                    set_hscrollbar_policy: gtk::PolicyType::Never,
450                    set_min_content_height: 360,
451                    set_vexpand: true,
452
453                    #[local_ref]
454                    task_list_box -> gtk::ListBox {
455                        set_selection_mode: gtk::SelectionMode::None,
456                    }
457                },
458
459                gtk::Box {
460                    set_hexpand: true,
461                    set_spacing: DEFAULT_SPACING,
462                    set_orientation: gtk::Orientation::Horizontal,
463
464                    gtk::Label {
465                        set_text: "Press Enter after editing task names",
466                        set_hexpand: true,
467                        set_xalign: XALIGN_CENTER,
468                    },
469
470                    gtk::Button {
471                        set_icon_name: "edit-delete",
472                        set_tooltip: "Delete All Tasks",
473                        add_css_class: CSS_CLASS_DESTRUCTIVE_ACTION,
474
475                        connect_clicked[sender] => move |_| {
476                            sender.input(AppInput::Clear);
477                        }
478                    }
479                }
480            }
481        }
482    }
483
484    fn update(&mut self, msg: AppInput, _sender: ComponentSender<Self>) {
485        match msg {
486            AppInput::Clear => {
487                self.document.emit(DocumentInput::Clear);
488            }
489            AppInput::Cleared => {
490                self.view.guard().clear();
491            }
492            AppInput::AddTask => {
493                self.document.emit(DocumentInput::AddTask);
494            }
495            AppInput::AddedTask => {
496                self.view.guard().push_back(());
497            }
498            AppInput::DeleteTask(index) => {
499                self.document.emit(DocumentInput::DeleteTask(index));
500            }
501            AppInput::DeletedTask(index) => {
502                self.view.guard().remove(index);
503            }
504            AppInput::ChangeTaskName(index, name) => {
505                self.document
506                    .emit(DocumentInput::ChangeTaskName(index, name));
507            }
508            AppInput::ChangedTaskName(index, name) => {
509                self.view.guard().send(index, TaskInput::ChangedName(name));
510            }
511            AppInput::AddTag(index, name) => {
512                self.document.emit(DocumentInput::AddTag(index, name));
513            }
514            AppInput::AddedTag(index, name) => {
515                self.view.guard().send(index, TaskInput::AddedTag(name));
516            }
517            AppInput::DeleteTag(task_index, tag_index) => {
518                self.document
519                    .emit(DocumentInput::DeleteTag(task_index, tag_index));
520            }
521            AppInput::DeletedTag(task_index, tag_index) => {
522                self.view
523                    .guard()
524                    .send(task_index, TaskInput::DeletedTag(tag_index));
525            }
526            AppInput::None => {}
527            AppInput::Save => {
528                let name = "tasks.json".into();
529                self.save_dialog.emit(SaveDialogMsg::SaveAs(name));
530            }
531            AppInput::SaveResponse(path) => {
532                self.document.emit(DocumentInput::Save(path));
533            }
534            AppInput::Open => {
535                self.open_dialog.emit(OpenDialogMsg::Open);
536            }
537            AppInput::OpenResponse(path) => {
538                self.document.emit(DocumentInput::Open(path));
539            }
540        }
541    }
542
543    fn init(
544        _: Self::Init,
545        root: Self::Root,
546        sender: ComponentSender<Self>,
547    ) -> ComponentParts<Self> {
548        let view =
549            FactoryVecDeque::builder()
550                .launch_default()
551                .forward(sender.input_sender(), |msg| match msg {
552                    TaskOutput::Delete(index) => AppInput::DeleteTask(index),
553                    TaskOutput::Name(index, name) => AppInput::ChangeTaskName(index, name),
554                    TaskOutput::AddTag(index, name) => AppInput::AddTag(index, name),
555                    TaskOutput::DeleteTag(task_index, tag_index) => {
556                        AppInput::DeleteTag(task_index, tag_index)
557                    }
558                });
559
560        let document =
561            Document::builder()
562                .launch(())
563                .forward(sender.input_sender(), |msg| match msg {
564                    DocumentOutput::Cleared => AppInput::Cleared,
565                    DocumentOutput::DeletedTask(index) => AppInput::DeletedTask(index),
566                    DocumentOutput::DeletedTag(task_index, tag_index) => {
567                        AppInput::DeletedTag(task_index, tag_index)
568                    }
569                    DocumentOutput::AddedTask => AppInput::AddedTask,
570                    DocumentOutput::AddedTag(index, name) => AppInput::AddedTag(index, name),
571                    DocumentOutput::ChangedTaskName(index, name) => {
572                        AppInput::ChangedTaskName(index, name)
573                    }
574                });
575
576        let save_dialog = SaveDialog::builder()
577            .transient_for_native(&root)
578            .launch(SaveDialogSettings {
579                create_folders: true,
580                accept_label: "Save".into(),
581                cancel_label: "Cancel".into(),
582                is_modal: true,
583                filters: tasks_filename_filters(),
584            })
585            .forward(sender.input_sender(), |response| match response {
586                SaveDialogResponse::Accept(path) => AppInput::SaveResponse(path),
587                SaveDialogResponse::Cancel => AppInput::None,
588            });
589
590        let open_dialog = OpenDialog::builder()
591            .transient_for_native(&root)
592            .launch(OpenDialogSettings {
593                create_folders: false,
594                folder_mode: false,
595                cancel_label: "Cancel".into(),
596                accept_label: "Open".into(),
597                is_modal: true,
598                filters: tasks_filename_filters(),
599            })
600            .forward(sender.input_sender(), |response| match response {
601                OpenDialogResponse::Accept(path) => AppInput::OpenResponse(path),
602                OpenDialogResponse::Cancel => AppInput::None,
603            });
604
605        let app = App {
606            view,
607            document,
608            open_dialog,
609            save_dialog,
610        };
611
612        let task_list_box = app.view.widget();
613        let widgets = view_output!();
614
615        ComponentParts {
616            model: app,
617            widgets,
618        }
619    }
620}
621
622fn tasks_filename_filters() -> Vec<gtk::FileFilter> {
623    let filename_filter = gtk::FileFilter::default();
624    filename_filter.set_name(Some("JSON (.json)"));
625    filename_filter.add_suffix("json");
626
627    vec![filename_filter]
628}
629
630///
631/// This example demonstrates how to interact with persistent state in a Relm4 app, using Relm4's
632/// one-way event-based data flow.
633///
634/// Events bubble up from view components to the top level, where they are forwarded down into the
635/// document which persists them. When the persistent data model is changed, the document bubbles
636/// events back up to the top level, where they are forwarded back down to the relevant view.
637///
638/// In an app with persistent state, view Components do not update their own view state as soon as
639/// changes happen. Instead, an Inversion Of Control is used. They forward the change as an output
640/// event (which goes up the view hierarchy), and trust that the persistent state store (the
641/// document) will call them back with the relevant view state update later.
642///
643/// (This is the difference between e.g. the `AddTag` event (view -> document) which expresses the
644/// change we would like to persist, and the `AddedTag` event (document -> view) which contains the
645/// persistent change that has actually happened.)
646///
647fn main() {
648    let app = RelmApp::new("relm4.example.state_management");
649
650    app.run::<App>(());
651}