fractal/
window.rs

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