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#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::EnumString, strum::AsRefStr)]
27#[strum(serialize_all = "kebab-case")]
28enum WindowPage {
29 Loading,
31 Login,
33 Session,
35 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 #[property(get, set = Self::set_compact, explicit_notify)]
67 compact: Cell<bool>,
68 #[property(get)]
72 session_selection: FixedSelection,
73 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 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 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 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 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 fn select_first_session(&self) {
265 let selected_session = self.session_selection.item(0);
267
268 if selected_session.is_none() {
269 self.set_visible_page(WindowPage::Login);
271 }
272
273 self.session_selection.set_selected_item(selected_session);
274 }
275
276 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 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 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 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, ¤t_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(¤t_session_setting)
323 {
324 self.session_selection.set_selected_item(Some(session));
325 }
326 }
327
328 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 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 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 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 self.show_session();
372 } else {
373 self.session_selection.set_selected(index);
374 }
375
376 true
377 }
378
379 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 if matches!(
395 self.visible_page(),
396 WindowPage::Session | WindowPage::Loading
397 ) {
398 self.show_session();
399 }
400 }
401
402 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 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 fn set_visible_page(&self, name: WindowPage) {
451 self.main_stack.set_visible_child_name(name.as_ref());
452 }
453
454 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 pub(super) fn add_toast(&self, toast: adw::Toast) {
462 self.toast_overlay.add_toast(toast);
463 }
464
465 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 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 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 pub(crate) fn current_session_id(&self) -> Option<String> {
507 self.imp().current_session_id()
508 }
509
510 pub(crate) fn add_toast(&self, toast: adw::Toast) {
512 self.imp().add_toast(toast);
513 }
514
515 pub(crate) fn account_switcher(&self) -> &AccountSwitcherPopover {
517 &self.imp().account_switcher
518 }
519
520 pub(crate) fn session_view(&self) -> &SessionView {
522 &self.imp().session_view
523 }
524
525 pub(crate) fn show_secret_error(&self, message: &str) {
527 self.imp().show_secret_error(message);
528 }
529
530 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 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}