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