Skip to main content

fractal/
window.rs

1use std::cell::Cell;
2
3use adw::{prelude::*, subclass::prelude::*};
4use gtk::{gdk, gio, glib, glib::clone};
5use tracing::{error, warn};
6
7use crate::{
8    APP_ID, Application, PROFILE, SETTINGS_KEY_CURRENT_SESSION,
9    account_chooser_dialog::AccountChooserDialog,
10    account_settings::AccountSettings,
11    account_switcher::{AccountSwitcherButton, AccountSwitcherPopover},
12    components::OfflineBanner,
13    error_page::ErrorPage,
14    intent::SessionIntent,
15    login::Login,
16    prelude::*,
17    secret::SESSION_ID_LENGTH,
18    session::{Session, SessionState},
19    session_list::{FailedSession, SessionInfo},
20    session_view::SessionView,
21    toast,
22    utils::{FixedSelection, LoadingState},
23};
24
25/// A page of the main window stack.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::EnumString, strum::AsRefStr)]
27#[strum(serialize_all = "kebab-case")]
28enum WindowPage {
29    /// The loading page.
30    Loading,
31    /// The login view.
32    Login,
33    /// The session view.
34    Session,
35    /// The error page.
36    Error,
37}
38
39mod imp {
40    use std::{cell::RefCell, rc::Rc};
41
42    use glib::subclass::InitializingObject;
43
44    use super::*;
45
46    #[derive(Debug, Default, gtk::CompositeTemplate, glib::Properties)]
47    #[template(resource = "/org/gnome/Fractal/ui/window.ui")]
48    #[properties(wrapper_type = super::Window)]
49    pub struct Window {
50        #[template_child]
51        main_stack: TemplateChild<gtk::Stack>,
52        #[template_child]
53        loading: TemplateChild<gtk::WindowHandle>,
54        #[template_child]
55        login: TemplateChild<Login>,
56        #[template_child]
57        error_page: TemplateChild<ErrorPage>,
58        #[template_child]
59        pub(super) session_view: TemplateChild<SessionView>,
60        #[template_child]
61        toast_overlay: TemplateChild<adw::ToastOverlay>,
62        /// Whether the window should be in compact view.
63        ///
64        /// It means that the horizontal size is not large enough to hold all
65        /// the content.
66        #[property(get, set = Self::set_compact, explicit_notify)]
67        compact: Cell<bool>,
68        /// The selection of the logged-in sessions.
69        ///
70        /// The one that is selected being the one that is visible.
71        #[property(get)]
72        session_selection: FixedSelection,
73        /// The account switcher popover.
74        pub(super) account_switcher: AccountSwitcherPopover,
75    }
76
77    #[glib::object_subclass]
78    impl ObjectSubclass for Window {
79        const NAME: &'static str = "Window";
80        type Type = super::Window;
81        type ParentType = adw::ApplicationWindow;
82
83        fn class_init(klass: &mut Self::Class) {
84            AccountSwitcherButton::ensure_type();
85            OfflineBanner::ensure_type();
86
87            Self::bind_template(klass);
88
89            klass.add_binding_action(gdk::Key::v, gdk::ModifierType::CONTROL_MASK, "win.paste");
90            klass.add_binding_action(gdk::Key::Insert, gdk::ModifierType::SHIFT_MASK, "win.paste");
91            klass.install_action("win.paste", None, |obj, _, _| {
92                obj.imp().session_view.handle_paste_action();
93            });
94
95            klass.install_action(
96                "win.open-account-settings",
97                Some(&String::static_variant_type()),
98                |obj, _, variant| {
99                    if let Some(session_id) = variant.and_then(glib::Variant::get::<String>) {
100                        obj.imp().open_account_settings(&session_id);
101                    }
102                },
103            );
104
105            klass.install_action("win.new-session", None, |obj, _, _| {
106                obj.imp().set_visible_page(WindowPage::Login);
107            });
108            klass.install_action("win.show-session", None, |obj, _, _| {
109                obj.imp().show_session();
110            });
111
112            klass.install_action("win.toggle-fullscreen", None, |obj, _, _| {
113                if obj.is_fullscreen() {
114                    obj.unfullscreen();
115                } else {
116                    obj.fullscreen();
117                }
118            });
119        }
120
121        fn instance_init(obj: &InitializingObject<Self>) {
122            obj.init_template();
123        }
124    }
125
126    #[glib::derived_properties]
127    impl ObjectImpl for Window {
128        fn constructed(&self) {
129            self.parent_constructed();
130
131            // Development Profile
132            if PROFILE.should_use_devel_class() {
133                self.obj().add_css_class("devel");
134            }
135
136            self.load_window_size();
137
138            self.main_stack.connect_transition_running_notify(clone!(
139                #[weak(rename_to = imp)]
140                self,
141                move |stack| if !stack.is_transition_running() {
142                    // Focus the default widget when the transition has ended.
143                    imp.grab_focus();
144                }
145            ));
146
147            self.account_switcher
148                .set_session_selection(Some(self.session_selection.clone()));
149
150            self.session_selection.set_item_equivalence_fn(|lhs, rhs| {
151                let lhs = lhs
152                    .downcast_ref::<SessionInfo>()
153                    .expect("session selection item should be a SessionInfo");
154                let rhs = rhs
155                    .downcast_ref::<SessionInfo>()
156                    .expect("session selection item should be a SessionInfo");
157
158                lhs.session_id() == rhs.session_id()
159            });
160            self.session_selection.connect_selected_item_notify(clone!(
161                #[weak(rename_to = imp)]
162                self,
163                move |_| {
164                    imp.update_selected_session();
165                }
166            ));
167            self.session_selection.connect_is_empty_notify(clone!(
168                #[weak(rename_to = imp)]
169                self,
170                move |session_selection| {
171                    imp.obj()
172                        .action_set_enabled("win.show-session", !session_selection.is_empty());
173                }
174            ));
175
176            let app = Application::default();
177            let session_list = app.session_list();
178
179            self.session_selection.set_model(Some(session_list.clone()));
180
181            if session_list.state() == LoadingState::Ready {
182                self.finish_session_selection_init();
183            } else {
184                session_list.connect_state_notify(clone!(
185                    #[weak(rename_to = imp)]
186                    self,
187                    move |session_list| {
188                        if session_list.state() == LoadingState::Ready {
189                            imp.finish_session_selection_init();
190                        }
191                    }
192                ));
193            }
194        }
195    }
196
197    impl WindowImpl for Window {
198        fn close_request(&self) -> glib::Propagation {
199            if let Err(error) = self.save_window_size() {
200                warn!("Could not save window state: {error}");
201            }
202            if let Err(error) = self.save_current_visible_session() {
203                warn!("Could not save current session: {error}");
204            }
205
206            glib::Propagation::Proceed
207        }
208    }
209
210    impl WidgetImpl for Window {
211        fn grab_focus(&self) -> bool {
212            match self.visible_page() {
213                WindowPage::Loading => false,
214                WindowPage::Login => self.login.grab_focus(),
215                WindowPage::Session => self.session_view.grab_focus(),
216                WindowPage::Error => self.error_page.grab_focus(),
217            }
218        }
219    }
220
221    impl ApplicationWindowImpl for Window {}
222    impl AdwApplicationWindowImpl for Window {}
223
224    impl Window {
225        /// Set whether the window should be in compact view.
226        fn set_compact(&self, compact: bool) {
227            if compact == self.compact.get() {
228                return;
229            }
230
231            self.compact.set(compact);
232            self.obj().notify_compact();
233        }
234
235        /// Finish the initialization of the session selection, when the session
236        /// list is ready.
237        fn finish_session_selection_init(&self) {
238            for item in self.session_selection.iter::<glib::Object>() {
239                if let Some(failed) = item.ok().and_downcast_ref::<FailedSession>() {
240                    toast!(self.obj(), failed.error().to_user_facing());
241                }
242            }
243
244            self.restore_current_visible_session();
245
246            self.session_selection.connect_selected_notify(clone!(
247                #[weak(rename_to = imp)]
248                self,
249                move |session_selection| {
250                    if session_selection.selected() == gtk::INVALID_LIST_POSITION {
251                        imp.select_first_session();
252                    }
253                }
254            ));
255
256            if self.session_selection.selected() == gtk::INVALID_LIST_POSITION {
257                self.select_first_session();
258            }
259        }
260
261        /// Select the first session in the session list.
262        ///
263        /// To be used when there is no current selection.
264        fn select_first_session(&self) {
265            // Select the first session in the list.
266            let selected_session = self.session_selection.item(0);
267
268            if selected_session.is_none() {
269                // There are no more sessions.
270                self.set_visible_page(WindowPage::Login);
271            }
272
273            self.session_selection.set_selected_item(selected_session);
274        }
275
276        /// Load the window size from the settings.
277        fn load_window_size(&self) {
278            let obj = self.obj();
279            let settings = Application::default().settings();
280
281            let width = settings.int("window-width");
282            let height = settings.int("window-height");
283            let is_maximized = settings.boolean("is-maximized");
284
285            obj.set_default_size(width, height);
286            obj.set_maximized(is_maximized);
287        }
288
289        /// Save the current window size to the settings.
290        fn save_window_size(&self) -> Result<(), glib::BoolError> {
291            let obj = self.obj();
292            let settings = Application::default().settings();
293
294            let size = obj.default_size();
295            settings.set_int("window-width", size.0)?;
296            settings.set_int("window-height", size.1)?;
297
298            settings.set_boolean("is-maximized", obj.is_maximized())?;
299
300            Ok(())
301        }
302
303        /// Restore the currently visible session from the settings.
304        fn restore_current_visible_session(&self) {
305            let settings = Application::default().settings();
306            let mut current_session_setting =
307                settings.string(SETTINGS_KEY_CURRENT_SESSION).to_string();
308
309            // Session IDs have been truncated in version 6 of StoredSession.
310            if current_session_setting.len() > SESSION_ID_LENGTH {
311                current_session_setting.truncate(SESSION_ID_LENGTH);
312
313                if let Err(error) =
314                    settings.set_string(SETTINGS_KEY_CURRENT_SESSION, &current_session_setting)
315                {
316                    warn!("Could not save current session: {error}");
317                }
318            }
319
320            if let Some(session) = Application::default()
321                .session_list()
322                .get(&current_session_setting)
323            {
324                self.session_selection.set_selected_item(Some(session));
325            }
326        }
327
328        /// Save the currently visible session to the settings.
329        fn save_current_visible_session(&self) -> Result<(), glib::BoolError> {
330            let settings = Application::default().settings();
331
332            settings.set_string(
333                SETTINGS_KEY_CURRENT_SESSION,
334                self.current_session_id().unwrap_or_default().as_str(),
335            )?;
336
337            Ok(())
338        }
339
340        /// The visible page of the window.
341        pub(super) fn visible_page(&self) -> WindowPage {
342            self.main_stack
343                .visible_child_name()
344                .expect("stack should always have a visible child name")
345                .as_str()
346                .try_into()
347                .expect("stack child name should be convertible to a WindowPage")
348        }
349
350        /// The ID of the currently visible session, if any.
351        pub(super) fn current_session_id(&self) -> Option<String> {
352            self.session_selection
353                .selected_item()
354                .and_downcast::<SessionInfo>()
355                .map(|s| s.session_id())
356        }
357
358        /// Set the current session by its ID.
359        ///
360        /// Returns `true` if the session was set as the current session.
361        pub(super) fn set_current_session_by_id(&self, session_id: &str) -> bool {
362            let Some(index) = Application::default().session_list().index(session_id) else {
363                return false;
364            };
365
366            let index = index as u32;
367            let prev_selected = self.session_selection.selected();
368
369            if index == prev_selected {
370                // Make sure the session is displayed;
371                self.show_session();
372            } else {
373                self.session_selection.set_selected(index);
374            }
375
376            true
377        }
378
379        /// Update the selected session in the session view.
380        fn update_selected_session(&self) {
381            let Some(selected_session) = self
382                .session_selection
383                .selected_item()
384                .and_downcast::<SessionInfo>()
385            else {
386                return;
387            };
388
389            let session = selected_session.downcast_ref::<Session>();
390            self.session_view.set_session(session);
391
392            // Show the selected session automatically only if we are not showing a more
393            // important view.
394            if matches!(
395                self.visible_page(),
396                WindowPage::Session | WindowPage::Loading
397            ) {
398                self.show_session();
399            }
400        }
401
402        /// Show the selected session.
403        ///
404        /// The displayed view will change according to the current session.
405        pub(super) fn show_session(&self) {
406            let Some(selected_session) = self
407                .session_selection
408                .selected_item()
409                .and_downcast::<SessionInfo>()
410            else {
411                return;
412            };
413
414            if let Some(session) = selected_session.downcast_ref::<Session>() {
415                if session.state() == SessionState::Ready {
416                    self.set_visible_page(WindowPage::Session);
417                } else {
418                    let ready_handler_cell: Rc<RefCell<Option<glib::SignalHandlerId>>> =
419                        Rc::default();
420                    let ready_handler = session.connect_ready(clone!(
421                        #[weak(rename_to = imp)]
422                        self,
423                        #[strong]
424                        ready_handler_cell,
425                        move |session| {
426                            if let Some(handler) = ready_handler_cell.take() {
427                                session.disconnect(handler);
428                            }
429
430                            imp.update_selected_session();
431                        }
432                    ));
433                    ready_handler_cell.replace(Some(ready_handler));
434
435                    self.set_visible_page(WindowPage::Loading);
436                }
437
438                // We need to grab the focus so that keyboard shortcuts work.
439                self.session_view.grab_focus();
440            } else if let Some(failed) = selected_session.downcast_ref::<FailedSession>() {
441                self.error_page
442                    .display_session_error(&failed.error().to_user_facing());
443                self.set_visible_page(WindowPage::Error);
444            } else {
445                self.set_visible_page(WindowPage::Loading);
446            }
447        }
448
449        /// Set the visible page of the window.
450        fn set_visible_page(&self, name: WindowPage) {
451            self.main_stack.set_visible_child_name(name.as_ref());
452        }
453
454        /// Open the error page and display the given secret error message.
455        pub(super) fn show_secret_error(&self, message: &str) {
456            self.error_page.display_secret_error(message);
457            self.set_visible_page(WindowPage::Error);
458        }
459
460        /// Add the given toast to the queue.
461        pub(super) fn add_toast(&self, toast: adw::Toast) {
462            self.toast_overlay.add_toast(toast);
463        }
464
465        /// Open the account settings for the session with the given ID.
466        fn open_account_settings(&self, session_id: &str) {
467            let Some(session) = Application::default()
468                .session_list()
469                .get(session_id)
470                .and_downcast::<Session>()
471            else {
472                error!("Tried to open account settings of unknown session with ID '{session_id}'");
473                return;
474            };
475
476            let dialog = AccountSettings::new(&session);
477            dialog.present(Some(&*self.obj()));
478        }
479    }
480}
481
482glib::wrapper! {
483    /// The main window.
484    pub struct Window(ObjectSubclass<imp::Window>)
485        @extends gtk::Widget, gtk::Window, gtk::ApplicationWindow, adw::ApplicationWindow,
486        @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget, gtk::Root, gtk::Native,
487                    gtk::ShortcutManager, gio::ActionMap, gio::ActionGroup;
488}
489
490impl Window {
491    pub fn new(app: &Application) -> Self {
492        glib::Object::builder()
493            .property("application", Some(app))
494            .property("icon-name", Some(APP_ID))
495            .build()
496    }
497
498    /// Add the given session to the session list and select it.
499    pub(crate) fn add_session(&self, session: Session) {
500        let index = Application::default().session_list().insert(session);
501        self.session_selection().set_selected(index as u32);
502        self.imp().show_session();
503    }
504
505    /// The ID of the currently visible session, if any.
506    pub(crate) fn current_session_id(&self) -> Option<String> {
507        self.imp().current_session_id()
508    }
509
510    /// Add the given toast to the queue.
511    pub(crate) fn add_toast(&self, toast: adw::Toast) {
512        self.imp().add_toast(toast);
513    }
514
515    /// The account switcher popover.
516    pub(crate) fn account_switcher(&self) -> &AccountSwitcherPopover {
517        &self.imp().account_switcher
518    }
519
520    /// The `SessionView` of this window.
521    pub(crate) fn session_view(&self) -> &SessionView {
522        &self.imp().session_view
523    }
524
525    /// Open the error page and display the given secret error message.
526    pub(crate) fn show_secret_error(&self, message: &str) {
527        self.imp().show_secret_error(message);
528    }
529
530    /// Ask the user to choose a session.
531    ///
532    /// The session list must be ready.
533    ///
534    /// Returns the ID of the selected session, if any.
535    pub(crate) async fn ask_session(&self) -> Option<String> {
536        let dialog = AccountChooserDialog::new(Application::default().session_list());
537        dialog.choose_account(self).await
538    }
539
540    /// Process the given session intent.
541    ///
542    /// The session must be ready.
543    pub(crate) fn process_session_intent(&self, session_id: &str, intent: SessionIntent) {
544        if !self.imp().set_current_session_by_id(session_id) {
545            error!("Cannot switch to unknown session with ID `{session_id}`");
546            return;
547        }
548
549        self.session_view().process_intent(intent);
550    }
551}