Skip to main content

open_timeline_gui/
app.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2
3//!
4//! OpenTimeline egui desktop app
5//!
6
7use crate::Config;
8use crate::app_colours::{AppColours, ColourTheme};
9use crate::config::{RuntimeConfig, SharedConfig};
10use crate::games::{
11    DecadesGameGui, LeftRightGameGui, OrderEntitiesGameGui, WereTheyAliveWhenGameGui,
12    WhichDateGameGui,
13};
14use crate::primary_window::{
15    AppInfoGui, BackupMergeRestoreGui, EntityCountsGui, SearchGui, SettingsGui, StatsGui,
16    TagCountsGui, TimelineCountsGui,
17};
18use crate::shortcuts::global_shortcuts;
19use crate::windows::{
20    AppColoursGui, BreakOutWindows, EntityEditGui, EntityViewGui, TagBulkEditGui, TagViewGui,
21    TimelineEditGui, TimelineViewGui,
22};
23use bool_tag_expr::Tag;
24use eframe::App;
25use eframe::egui::{
26    self, Align, Button, CentralPanel, Context, Layout, OpenUrl, Pos2, SidePanel, Ui, Vec2,
27};
28use open_timeline_core::OpenTimelineId;
29use open_timeline_crud::db_url_from_path;
30use open_timeline_gui_core::{
31    BreakOutWindow, Draw, Reload, using_wayland, widget_x_spacing, widget_y_spacing,
32};
33use sqlx::{Pool, Sqlite, SqlitePool};
34use std::sync::Arc;
35use std::time::Duration;
36use tokio::sync::RwLock;
37use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
38
39/// Indicates which of the tabs in the main window is selected.
40#[derive(Debug, PartialEq, Eq, Clone)]
41enum MainTabSelected {
42    Search,
43    Entities,
44    Tags,
45    Timelines,
46    Stats,
47    BackupRestoreMerge,
48
49    GameDecades,
50    GameLeftRight,
51    GameOrderEntities,
52    GameAliveWhen,
53    GameWhichDate,
54
55    Settings,
56    AppInfo,
57}
58
59impl MainTabSelected {
60    fn to_label_text(&self) -> String {
61        match self {
62            Self::Search => String::from("Search"),
63            Self::Entities => String::from("Entities"),
64            Self::Tags => String::from("Tags"),
65            Self::Timelines => String::from("Timelines"),
66            Self::Stats => String::from("Stats"),
67            Self::BackupRestoreMerge => String::from("Backup | Restore | Merge"),
68
69            Self::GameDecades => String::from("Decades"),
70            Self::GameLeftRight => String::from("Left/Right"),
71            Self::GameOrderEntities => String::from("Order Entities"),
72            Self::GameAliveWhen => String::from("Alive When"),
73            Self::GameWhichDate => String::from("Which Date"),
74
75            Self::Settings => String::from("Settings"),
76            Self::AppInfo => String::from("Information"),
77        }
78    }
79}
80
81/// All possible action requests
82///
83/// e.g. "edit entity X", "view timeline Y", "bulk edit tag Z"
84#[derive(Debug)]
85pub enum ActionRequest {
86    Entity(EntityOrTimelineActionRequest),
87    Timeline(EntityOrTimelineActionRequest),
88    Tag(TagActionRequest),
89
90    // TODO: shouldn't send a channel, I think
91    AppColours(UnboundedSender<AppColours>),
92}
93
94/// All possible action requests for entities and timelines
95#[derive(Debug)]
96pub enum EntityOrTimelineActionRequest {
97    CreateNew,
98    ViewExisting(OpenTimelineId),
99    EditExisting(OpenTimelineId),
100}
101
102/// All possible action requests for tags
103#[derive(Debug)]
104pub enum TagActionRequest {
105    ViewExisting(Tag),
106    BulkEditExisting(Tag),
107}
108
109// TODO: impl a new()?
110/// Holds both the `tx` and `rx` ends of an unbounded channel.
111#[derive(Debug)]
112pub struct UnboundedChannel<T> {
113    pub tx: UnboundedSender<T>,
114    pub rx: UnboundedReceiver<T>,
115}
116
117impl<T> From<(UnboundedSender<T>, UnboundedReceiver<T>)> for UnboundedChannel<T> {
118    fn from(value: (UnboundedSender<T>, UnboundedReceiver<T>)) -> Self {
119        UnboundedChannel {
120            tx: value.0,
121            rx: value.1,
122        }
123    }
124}
125
126/// All data needed for the OpenTimeline (egui) desktop app
127pub struct OpenTimelineApp {
128    /// The position of the main window (if it's open)
129    position: Option<Pos2>,
130
131    /// Which of the sidebar tabs in the main window is selected
132    tab_selected: MainTabSelected,
133
134    /// All pop-out windows
135    windows: BreakOutWindows,
136
137    /// The search panel of the main window
138    search_gui: SearchGui,
139
140    /// The entity count panel of the main window
141    entity_counts_gui: EntityCountsGui,
142
143    // TODO: update to show both timeline and entity tags
144    /// The tags count panel of the main window
145    entity_tag_counts_gui: TagCountsGui,
146
147    /// The timeline count panel of the main window
148    timeline_counts_gui: TimelineCountsGui,
149
150    /// The stats panel of the main window
151    stats_gui: StatsGui,
152
153    /// The backup|merge|restore panel of the main window
154    backup_merge_restore_gui: BackupMergeRestoreGui,
155
156    /// The settings panel of the main window
157    settings_gui: SettingsGui,
158
159    /// The app info panel of the main window
160    app_info_gui: AppInfoGui,
161
162    /// Unbounded channel for requesting actions on entites, timelines, and
163    /// tags.  e.g. a request to edit an entity.
164    channel_action_request: UnboundedChannel<ActionRequest>,
165
166    /// Unbounded channel used for letting the main app know when a CUD
167    /// operation (read operation not important) has happened successfully.
168    /// This lets the main loop request app-wide reloads of data so that it
169    /// reflects the change(s).
170    channel_crud_operation_executed: UnboundedChannel<()>,
171
172    /// Tracks whether a global reload is required (i.e. if a message has been
173    /// received on `channel_crud_operation_executed`)
174    reload_required: bool,
175
176    /// The "decades" game panel of the main window
177    game_decades: DecadesGameGui,
178
179    /// The "left right" game panel of the main window
180    game_left_right: LeftRightGameGui,
181
182    /// The "order entities" game panel of the main window
183    game_order_entities: OrderEntitiesGameGui,
184
185    /// The "were they alive when" game panel of the main window
186    game_were_they_alive_when: WereTheyAliveWhenGameGui,
187
188    /// The "which_date" game panel of the main window
189    game_which_date: WhichDateGameGui,
190
191    /// Database pool
192    shared_config: SharedConfig,
193}
194
195impl OpenTimelineApp {
196    /// Create a new `OpenTimelineApp`
197    pub fn new() -> Self {
198        let channel_action_request: UnboundedChannel<ActionRequest> =
199            tokio::sync::mpsc::unbounded_channel().into();
200        let channel_crud_operation_executed: UnboundedChannel<()> =
201            tokio::sync::mpsc::unbounded_channel().into();
202
203        // Config
204        let (tx, rx) = tokio::sync::oneshot::channel();
205        tokio::spawn(async move {
206            let result = async move {
207                Config::ensure_setup().await?;
208                Config::load()
209            }
210            .await;
211            let _ = tx.send(result);
212        });
213        // TODO: remove unwrap()
214        let config = match rx.blocking_recv().unwrap() {
215            Ok(config) => config,
216            Err(error) => panic!("Initial config error: {error}"),
217        };
218
219        // Path to database
220        let db_path = Arc::new(RwLock::new(config.database_path()));
221
222        // Database pool
223        let (tx, rx) = tokio::sync::oneshot::channel();
224        tokio::spawn(async move {
225            let result: Result<Pool<Sqlite>, sqlx::Error> = async move {
226                let db_path = db_path.read().await;
227                let db_url = db_url_from_path(&db_path);
228                let db_pool = SqlitePool::connect(&db_url).await?;
229                Ok(db_pool)
230            }
231            .await;
232            let _ = tx.send(result);
233        });
234        let db_pool = match rx.blocking_recv().unwrap() {
235            Ok(db_pool) => db_pool,
236            Err(error) => panic!("Initial SQLite pool error: {error}"),
237        };
238        let shared_config = Arc::new(RwLock::new(RuntimeConfig {
239            db_pool: db_pool,
240            config: config.clone(),
241        }));
242
243        Self {
244            position: None,
245            tab_selected: MainTabSelected::Search,
246            windows: BreakOutWindows::default(),
247            search_gui: SearchGui::new(
248                Arc::clone(&shared_config),
249                channel_action_request.tx.clone(),
250            ),
251            entity_counts_gui: EntityCountsGui::new(
252                Arc::clone(&shared_config),
253                channel_action_request.tx.clone(),
254            ),
255            entity_tag_counts_gui: TagCountsGui::new(
256                Arc::clone(&shared_config),
257                channel_action_request.tx.clone(),
258            ),
259            timeline_counts_gui: TimelineCountsGui::new(
260                Arc::clone(&shared_config),
261                channel_action_request.tx.clone(),
262            ),
263            stats_gui: StatsGui::new(Arc::clone(&shared_config)),
264            backup_merge_restore_gui: BackupMergeRestoreGui::new(
265                Arc::clone(&shared_config),
266                channel_crud_operation_executed.tx.clone(),
267            ),
268            settings_gui: SettingsGui::new(
269                config,
270                Arc::clone(&shared_config),
271                channel_action_request.tx.clone(),
272                channel_crud_operation_executed.tx.clone(),
273            ),
274            app_info_gui: AppInfoGui::new(),
275            channel_action_request,
276            channel_crud_operation_executed,
277            reload_required: false,
278            game_decades: DecadesGameGui::new(Arc::clone(&shared_config)),
279            game_left_right: LeftRightGameGui::new(Arc::clone(&shared_config)),
280            game_order_entities: OrderEntitiesGameGui::new(Arc::clone(&shared_config)),
281            game_were_they_alive_when: WereTheyAliveWhenGameGui::new(Arc::clone(&shared_config)),
282            game_which_date: WhichDateGameGui::new(Arc::clone(&shared_config)),
283            shared_config,
284        }
285    }
286
287    fn draw_side_bar_option(
288        &mut self,
289        _ctx: &Context,
290        ui: &mut Ui,
291        tab_variant: MainTabSelected,
292        separator_after: bool,
293    ) {
294        ui.with_layout(Layout::top_down_justified(Align::LEFT), |ui| {
295            let tab = Button::selectable(
296                self.tab_selected == tab_variant,
297                tab_variant.to_label_text(),
298            );
299            let tab = ui.add(tab);
300            if tab.clicked() {
301                self.tab_selected = tab_variant;
302            }
303            if separator_after {
304                ui.separator();
305            }
306        });
307    }
308
309    fn draw_side_panel(&mut self, ctx: &Context, ui: &mut Ui) {
310        let space = widget_y_spacing(ui);
311        ui.add_space(space * 2.0);
312        open_timeline_gui_core::Label::heading(ui, "OpenTimeline");
313        ui.separator();
314
315        // Donate button
316        ui.scope(|ui| {
317            // Get the colours
318            let (button_fill, button_text) =
319                match self.shared_config.blocking_read().config.colour_theme {
320                    ColourTheme::Custom(app_colours) => {
321                        let fill = app_colours.donate_button_fill.into();
322                        let text = app_colours.donate_button_text.into();
323                        (fill, text)
324                    }
325                    _ => {
326                        let fill = AppColours::default_donate_button_fill();
327                        let text = AppColours::default_donate_button_text_colour();
328                        (fill, text)
329                    }
330                };
331
332            // Set colours
333            let style = ui.style_mut();
334            style.visuals.widgets.active.weak_bg_fill = button_fill;
335            style.visuals.widgets.inactive.weak_bg_fill = button_fill;
336            style.visuals.widgets.hovered.weak_bg_fill = button_fill;
337            style.visuals.override_text_color = Some(button_text);
338
339            // Draw button
340            let size = Vec2::new(ui.available_width(), 0.0);
341            let button = egui::Button::new("Donate");
342            if ui.add_sized(size, button).clicked() {
343                ctx.open_url(OpenUrl {
344                    url: "https://www.open-timeline.org/donate".to_owned(),
345                    new_tab: true,
346                });
347            }
348        });
349        ui.separator();
350
351        self.draw_side_bar_option(ctx, ui, MainTabSelected::Search, true);
352        self.draw_side_bar_option(ctx, ui, MainTabSelected::Entities, true);
353        self.draw_side_bar_option(ctx, ui, MainTabSelected::Tags, true);
354        self.draw_side_bar_option(ctx, ui, MainTabSelected::Timelines, true);
355        self.draw_side_bar_option(ctx, ui, MainTabSelected::Stats, true);
356        self.draw_side_bar_option(ctx, ui, MainTabSelected::BackupRestoreMerge, true);
357        ui.horizontal(|ui| {
358            let space = widget_x_spacing(ui) / 2.0;
359            ui.add_space(space);
360            ui.label("Games");
361        });
362
363        ui.indent("id_salt", |ui| {
364            self.draw_side_bar_option(ctx, ui, MainTabSelected::GameDecades, false);
365            self.draw_side_bar_option(ctx, ui, MainTabSelected::GameLeftRight, false);
366            self.draw_side_bar_option(ctx, ui, MainTabSelected::GameOrderEntities, false);
367            self.draw_side_bar_option(ctx, ui, MainTabSelected::GameAliveWhen, false);
368            self.draw_side_bar_option(ctx, ui, MainTabSelected::GameWhichDate, false);
369        });
370        ui.separator();
371
372        self.draw_side_bar_option(ctx, ui, MainTabSelected::Settings, false);
373        self.draw_side_bar_option(ctx, ui, MainTabSelected::AppInfo, false);
374    }
375
376    fn draw_central_panel(&mut self, ctx: &Context, ui: &mut Ui) {
377        open_timeline_gui_core::Label::heading(ui, &self.tab_selected.to_label_text());
378        ui.separator();
379
380        match self.tab_selected {
381            MainTabSelected::Search => {
382                self.windows.draw(ctx, ui);
383                self.search_gui.draw(ctx, ui);
384            }
385            MainTabSelected::Entities => {
386                self.windows.draw(ctx, ui);
387                self.entity_counts_gui.draw(ctx, ui);
388            }
389            MainTabSelected::Tags => {
390                self.windows.draw(ctx, ui);
391                self.entity_tag_counts_gui.draw(ctx, ui);
392            }
393            MainTabSelected::Timelines => {
394                self.windows.draw(ctx, ui);
395                self.timeline_counts_gui.draw(ctx, ui);
396            }
397            MainTabSelected::Stats => {
398                self.windows.draw(ctx, ui);
399                self.stats_gui.draw(ctx, ui);
400            }
401            MainTabSelected::BackupRestoreMerge => {
402                self.backup_merge_restore_gui.draw(ctx, ui);
403            }
404
405            MainTabSelected::GameDecades => self.game_decades.draw(ctx, ui),
406            MainTabSelected::GameLeftRight => self.game_left_right.draw(ctx, ui),
407            MainTabSelected::GameOrderEntities => self.game_order_entities.draw(ctx, ui),
408            MainTabSelected::GameAliveWhen => self.game_were_they_alive_when.draw(ctx, ui),
409            MainTabSelected::GameWhichDate => self.game_which_date.draw(ctx, ui),
410
411            MainTabSelected::Settings => {
412                self.windows.draw(ctx, ui);
413                self.settings_gui.draw(ctx, ui);
414            }
415            MainTabSelected::AppInfo => {
416                self.windows.draw(ctx, ui);
417                self.app_info_gui.draw(ctx, ui);
418            }
419        }
420    }
421
422    // TODO: improve the error handling
423    // TODO: rename (receives, and opens)
424    /// Receive requests and any associated OpenTimelineIds (e.g. open a new window
425    /// for the creation of a new entity, or open a new window for the viewing
426    /// of the timeline associated with the given ID).
427    fn create_any_new_windows(&mut self, ctx: &Context) {
428        let db = Arc::clone(&self.shared_config);
429        let tx_crud = self.channel_crud_operation_executed.tx.clone();
430        let tx_req = self.channel_action_request.tx.clone();
431        if let Ok(msg) = self.channel_action_request.rx.try_recv() {
432            let window: Box<dyn BreakOutWindow> = match msg {
433                // Entity windows
434                ActionRequest::Entity(action) => match action {
435                    EntityOrTimelineActionRequest::CreateNew => Box::new(
436                        EntityEditGui::new_window_for_creating_entity(db, tx_req, tx_crud),
437                    ),
438                    EntityOrTimelineActionRequest::EditExisting(id) => Box::new(
439                        EntityEditGui::new_window_for_editing_entity(db, tx_req, tx_crud, id),
440                    ),
441                    EntityOrTimelineActionRequest::ViewExisting(id) => {
442                        Box::new(EntityViewGui::new(db, tx_req, id))
443                    }
444                },
445                // Timeline windows
446                ActionRequest::Timeline(action) => match action {
447                    EntityOrTimelineActionRequest::CreateNew => Box::new(
448                        TimelineEditGui::new_window_for_creating_timeline(db, tx_req, tx_crud),
449                    ),
450                    EntityOrTimelineActionRequest::EditExisting(id) => Box::new(
451                        TimelineEditGui::new_window_for_editing_timeline(db, tx_req, tx_crud, id),
452                    ),
453                    EntityOrTimelineActionRequest::ViewExisting(id) => {
454                        Box::new(TimelineViewGui::new(db, ctx, tx_req, id))
455                    }
456                },
457                // Tag windows
458                ActionRequest::Tag(action) => match action {
459                    TagActionRequest::BulkEditExisting(tag) => {
460                        Box::new(TagBulkEditGui::new(db, tx_req, tx_crud, tag))
461                    }
462                    TagActionRequest::ViewExisting(tag) => {
463                        Box::new(TagViewGui::new(db, tx_req, tag))
464                    }
465                },
466                // Colour windows
467                ActionRequest::AppColours(tx_app_colours) => {
468                    debug!("recv ActionRequest::AppColours");
469                    // TODO: don't want to block
470                    let config = self.shared_config.blocking_read().config.clone();
471                    Box::new(AppColoursGui::new(config, tx_req, tx_app_colours))
472                }
473            };
474            self.windows.insert(ctx, self.position, window);
475        }
476    }
477}
478
479impl App for OpenTimelineApp {
480    fn update(&mut self, ctx: &Context, _frame: &mut eframe::Frame) {
481        ctx.request_repaint_after(Duration::from_millis(300));
482
483        // TODO: don't need to do every frame, only if changed
484        // Update the colour theme
485        self.settings_gui.check_for_app_colours_update();
486        AppColours::use_theme(ctx, self.settings_gui.theme());
487
488        // Get window position if we can (can't if using Wayland)
489        self.position = match using_wayland() {
490            false => ctx.input(|i| i.viewport().outer_rect).map(|rect| rect.min),
491            true => None,
492        };
493
494        // Check if there have been any CRUD operations and thus if a reload is in order
495        if let Ok(()) = self.channel_crud_operation_executed.rx.try_recv() {
496            self.reload_required = true;
497            self.windows.request_reload();
498            self.search_gui.request_reload();
499            self.entity_counts_gui.request_reload();
500            self.entity_tag_counts_gui.request_reload();
501            self.timeline_counts_gui.request_reload();
502            self.entity_tag_counts_gui.request_reload();
503            self.stats_gui.request_reload();
504        }
505
506        // Check for global shortcuts
507        global_shortcuts(ctx, &mut self.channel_action_request.tx);
508
509        // Open any new windows that need to be opened
510        self.create_any_new_windows(ctx);
511
512        // Draw the side panel
513        SidePanel::left("sidebar").show(ctx, |ui| {
514            self.draw_side_panel(ctx, ui);
515        });
516
517        // Draw the main central panel
518        CentralPanel::default().show(ctx, |ui| {
519            self.draw_central_panel(ctx, ui);
520        });
521
522        // The reload is requested in a single frame
523        self.reload_required = false;
524    }
525}