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#[derive(Debug)]
25struct Task {
26 name: String,
27 tags: FactoryVecDeque<Tag>,
28}
29
30#[derive(Debug)]
31enum TaskInput {
32 ChangedName(String),
34
35 AddedTag(String),
37 DeletedTag(usize),
38}
39
40#[derive(Debug)]
41enum TaskOutput {
42 Name(DynamicIndex, String),
44 Delete(DynamicIndex),
45
46 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 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 = >k::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 = >k::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
224struct Document {
234 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 Open(PathBuf),
258 Save(PathBuf),
259
260 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 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 self.model = new_model;
307
308 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 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
370struct 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 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 = >k::Label {
426 set_text: ""
427 },
428
429 pack_start = >k::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 = >k::Button {
439 set_label: "Save",
440 connect_clicked => AppInput::Save,
441 },
442 pack_end = >k::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
630fn main() {
648 let app = RelmApp::new("relm4.example.state_management");
649
650 app.run::<App>(());
651}