fractal/session/view/content/explore/
mod.rs

1use adw::{prelude::*, subclass::prelude::*};
2use gtk::{gio, glib, glib::clone, CompositeTemplate};
3use tracing::error;
4
5mod public_room_row;
6mod search;
7mod server;
8mod server_list;
9mod server_row;
10mod servers_popover;
11
12use self::{
13    public_room_row::PublicRoomRow, search::ExploreSearch, server::ExploreServer,
14    server_list::ExploreServerList, server_row::ExploreServerRow,
15    servers_popover::ExploreServersPopover,
16};
17use crate::{
18    components::LoadingRow,
19    prelude::*,
20    session::model::{RemoteRoom, Session},
21    utils::{LoadingState, SingleItemListModel},
22};
23
24mod imp {
25    use std::cell::OnceCell;
26
27    use glib::subclass::InitializingObject;
28
29    use super::*;
30
31    #[derive(Debug, Default, CompositeTemplate, glib::Properties)]
32    #[template(resource = "/org/gnome/Fractal/ui/session/view/content/explore/mod.ui")]
33    #[properties(wrapper_type = super::Explore)]
34    pub struct Explore {
35        #[template_child]
36        pub(super) header_bar: TemplateChild<adw::HeaderBar>,
37        #[template_child]
38        second_top_bar: TemplateChild<adw::Bin>,
39        #[template_child]
40        search_clamp: TemplateChild<adw::Clamp>,
41        #[template_child]
42        search_entry: TemplateChild<gtk::SearchEntry>,
43        #[template_child]
44        servers_button: TemplateChild<gtk::MenuButton>,
45        #[template_child]
46        servers_popover: TemplateChild<ExploreServersPopover>,
47        #[template_child]
48        stack: TemplateChild<gtk::Stack>,
49        #[template_child]
50        scrolled_window: TemplateChild<gtk::ScrolledWindow>,
51        #[template_child]
52        listview: TemplateChild<gtk::ListView>,
53        /// The current session.
54        #[property(get, set = Self::set_session, explicit_notify)]
55        session: glib::WeakRef<Session>,
56        /// The search of the view.
57        search: ExploreSearch,
58        /// The items added at the end of the list.
59        end_items: OnceCell<SingleItemListModel>,
60        /// The full list model.
61        full_model: OnceCell<gio::ListStore>,
62    }
63
64    #[glib::object_subclass]
65    impl ObjectSubclass for Explore {
66        const NAME: &'static str = "ContentExplore";
67        type Type = super::Explore;
68        type ParentType = adw::BreakpointBin;
69
70        fn class_init(klass: &mut Self::Class) {
71            Self::bind_template(klass);
72            Self::bind_template_callbacks(klass);
73
74            klass.set_accessible_role(gtk::AccessibleRole::Group);
75        }
76
77        fn instance_init(obj: &InitializingObject<Self>) {
78            obj.init_template();
79        }
80    }
81
82    #[glib::derived_properties]
83    impl ObjectImpl for Explore {
84        fn constructed(&self) {
85            self.parent_constructed();
86
87            // Listen to a change of selected server.
88            self.servers_popover.connect_selected_server_notify(clone!(
89                #[weak(rename_to = imp)]
90                self,
91                move |_| {
92                    imp.server_changed();
93                }
94            ));
95
96            // Load more items when scrolling, if needed.
97            let adj = self.scrolled_window.vadjustment();
98            adj.connect_value_changed(clone!(
99                #[weak(rename_to = imp)]
100                self,
101                move |adj| {
102                    if adj.upper() - adj.value() < adj.page_size() * 2.0 {
103                        imp.search.load_more();
104                    }
105                }
106            ));
107
108            // Set up the item factory for the GtkListView.
109            let factory = gtk::SignalListItemFactory::new();
110            factory.connect_bind(move |_, list_item| {
111                let Some(list_item) = list_item.downcast_ref::<gtk::ListItem>() else {
112                    error!("List item factory did not receive a list item: {list_item:?}");
113                    return;
114                };
115                list_item.set_activatable(false);
116                list_item.set_selectable(false);
117
118                let Some(item) = list_item.item() else {
119                    return;
120                };
121
122                if let Some(room) = item.downcast_ref::<RemoteRoom>() {
123                    let public_room_row = list_item.child_or_default::<PublicRoomRow>();
124                    public_room_row.set_room(room);
125                } else if let Some(loading_row) = item.downcast_ref::<LoadingRow>() {
126                    list_item.set_child(Some(loading_row));
127                }
128            });
129            self.listview.set_factory(Some(&factory));
130
131            let flattened_model = gtk::FlattenListModel::new(Some(self.full_model().clone()));
132            self.listview
133                .set_model(Some(&gtk::NoSelection::new(Some(flattened_model))));
134
135            // Listen to changes in the search loading state.
136            self.search.connect_loading_state_notify(clone!(
137                #[weak(rename_to = imp)]
138                self,
139                move |_| {
140                    imp.update_visible_child();
141                }
142            ));
143
144            // Listen to changes in the results.
145            self.search.list().connect_items_changed(clone!(
146                #[weak(rename_to = imp)]
147                self,
148                move |_, _, _, _| {
149                    imp.update_visible_child();
150                }
151            ));
152        }
153    }
154
155    impl WidgetImpl for Explore {
156        fn grab_focus(&self) -> bool {
157            self.search_entry.grab_focus()
158        }
159
160        fn map(&self) {
161            self.parent_map();
162            self.trigger_search();
163        }
164    }
165
166    impl BreakpointBinImpl for Explore {}
167
168    #[gtk::template_callbacks]
169    impl Explore {
170        /// Set the current session.
171        fn set_session(&self, session: Option<&Session>) {
172            if self.session.upgrade().as_ref() == session {
173                return;
174            }
175
176            self.session.set(session);
177
178            self.trigger_search();
179            self.obj().notify_session();
180        }
181
182        /// The items added at the end of the list.
183        fn end_items(&self) -> &SingleItemListModel {
184            self.end_items.get_or_init(|| {
185                let model = SingleItemListModel::new(&LoadingRow::new());
186                model.set_is_hidden(true);
187                model
188            })
189        }
190
191        /// The full list model.
192        fn full_model(&self) -> &gio::ListStore {
193            self.full_model.get_or_init(|| {
194                let model = gio::ListStore::new::<gio::ListModel>();
195                model.append(&self.search.list());
196                model.append(self.end_items());
197                model
198            })
199        }
200
201        /// Update the header when the view is narrow.
202        #[template_callback]
203        fn switch_to_narrow_mode(&self) {
204            if self
205                .header_bar
206                .title_widget()
207                .is_some_and(|widget| widget == *self.servers_button)
208            {
209                // We are already in narrow mode, nothing to do.
210                return;
211            }
212
213            // Unparent the children.
214            self.header_bar.remove(&*self.search_clamp);
215            self.header_bar.remove(&*self.servers_button);
216
217            // In narrow mode, the servers button is in the header bar, and the search entry
218            // is in the second top bar.
219            self.header_bar
220                .set_title_widget(Some(&*self.servers_button));
221            self.second_top_bar.set_child(Some(&*self.search_clamp));
222            self.second_top_bar.set_visible(true);
223        }
224
225        /// Update the header when the view is wide.
226        #[template_callback]
227        fn switch_to_wide_mode(&self) {
228            if self
229                .header_bar
230                .title_widget()
231                .is_some_and(|widget| widget == *self.search_clamp)
232            {
233                // We are already be in wide mode, nothing to do.
234                return;
235            }
236
237            // Unparent the children.
238            self.header_bar.remove(&*self.servers_button);
239            self.second_top_bar.set_child(None::<&gtk::Widget>);
240            self.second_top_bar.set_visible(false);
241
242            // In wide mode, both widgets are in the header bar.
243            self.header_bar.set_title_widget(Some(&*self.search_clamp));
244            self.header_bar.pack_end(&*self.servers_button);
245        }
246
247        /// Update the visible child according to the current state.
248        fn update_visible_child(&self) {
249            let loading_state = self.search.loading_state();
250            let is_empty = self.search.is_empty();
251
252            // Create or remove the loading row, as needed.
253            let show_loading_row = matches!(loading_state, LoadingState::Loading) && !is_empty;
254            self.end_items().set_is_hidden(!show_loading_row);
255
256            // Update the visible page.
257            let page_name = match loading_state {
258                LoadingState::Initial | LoadingState::Loading => {
259                    if is_empty {
260                        "loading"
261                    } else {
262                        "results"
263                    }
264                }
265                LoadingState::Ready => {
266                    if is_empty {
267                        "empty"
268                    } else {
269                        "results"
270                    }
271                }
272                LoadingState::Error => "error",
273            };
274            self.stack.set_visible_child_name(page_name);
275        }
276
277        /// Trigger a search with the current term.
278        #[template_callback]
279        pub(super) fn trigger_search(&self) {
280            if !self.obj().is_mapped() {
281                // Do not make a search if the view is not mapped.
282                return;
283            }
284
285            let Some(session) = self.session.upgrade() else {
286                return;
287            };
288
289            self.servers_popover.set_session(&session);
290
291            let text = self.search_entry.text().into();
292            let server = self
293                .servers_popover
294                .selected_server()
295                .expect("a server should be selected");
296            self.search.search(&session, Some(text), &server);
297        }
298
299        /// Handle when the selected server changed.
300        fn server_changed(&self) {
301            if let Some(server) = self.servers_popover.selected_server() {
302                self.servers_button.set_label(&server.name());
303                self.trigger_search();
304            }
305        }
306    }
307}
308
309glib::wrapper! {
310    /// A view to explore rooms in the public directory of homeservers.
311    pub struct Explore(ObjectSubclass<imp::Explore>)
312        @extends gtk::Widget, adw::BreakpointBin, @implements gtk::Accessible;
313}
314
315impl Explore {
316    pub fn new(session: &Session) -> Self {
317        glib::Object::builder().property("session", session).build()
318    }
319
320    /// The header bar of the explorer.
321    pub(crate) fn header_bar(&self) -> &adw::HeaderBar {
322        &self.imp().header_bar
323    }
324}