fractal/session/view/content/room_history/message_toolbar/completion/
completion_popover.rs

1use gettextrs::gettext;
2use gtk::{CompositeTemplate, gdk, glib, glib::clone, prelude::*, subclass::prelude::*};
3use pulldown_cmark::{Event, Parser, Tag};
4use secular::normalized_lower_lay_string;
5
6use super::{CompletionMemberList, CompletionRoomList};
7use crate::{
8    components::{AvatarImageSafetySetting, Pill, PillSource, PillSourceRow},
9    session::{model::Room, view::content::room_history::message_toolbar::MessageToolbar},
10    utils::BoundObject,
11};
12
13/// The maximum number of rows presented in the popover.
14const MAX_ROWS: usize = 32;
15/// The sigil for a user ID.
16const USER_ID_SIGIL: char = '@';
17/// The sigil for a room alias.
18const ROOM_ALIAS_SIGIL: char = '#';
19
20mod imp {
21    use std::{
22        cell::{Cell, RefCell},
23        marker::PhantomData,
24    };
25
26    use glib::subclass::InitializingObject;
27
28    use super::*;
29
30    #[derive(Debug, Default, CompositeTemplate, glib::Properties)]
31    #[template(
32        resource = "/org/gnome/Fractal/ui/session/view/content/room_history/message_toolbar/completion/completion_popover.ui"
33    )]
34    #[properties(wrapper_type = super::CompletionPopover)]
35    pub struct CompletionPopover {
36        #[template_child]
37        list: TemplateChild<gtk::ListBox>,
38        /// The parent `GtkTextView` to autocomplete.
39        #[property(get = Self::view)]
40        view: PhantomData<gtk::TextView>,
41        /// The current room.
42        #[property(get, set = Self::set_room, explicit_notify, nullable)]
43        room: glib::WeakRef<Room>,
44        /// The sorted and filtered room members.
45        #[property(get)]
46        member_list: CompletionMemberList,
47        /// The sorted and filtered rooms.
48        #[property(get)]
49        room_list: CompletionRoomList,
50        /// The rows in the popover.
51        rows: [PillSourceRow; MAX_ROWS],
52        /// The selected row in the popover.
53        selected: Cell<Option<usize>>,
54        /// The current autocompleted word.
55        current_word: RefCell<Option<(gtk::TextIter, gtk::TextIter, SearchTerm)>>,
56        /// Whether the popover is inhibited for the current word.
57        inhibit: Cell<bool>,
58        /// The buffer to autocomplete.
59        buffer: BoundObject<gtk::TextBuffer>,
60    }
61
62    #[glib::object_subclass]
63    impl ObjectSubclass for CompletionPopover {
64        const NAME: &'static str = "ContentCompletionPopover";
65        type Type = super::CompletionPopover;
66        type ParentType = gtk::Popover;
67
68        fn class_init(klass: &mut Self::Class) {
69            Self::bind_template(klass);
70        }
71
72        fn instance_init(obj: &InitializingObject<Self>) {
73            obj.init_template();
74        }
75    }
76
77    #[glib::derived_properties]
78    impl ObjectImpl for CompletionPopover {
79        fn constructed(&self) {
80            self.parent_constructed();
81            let obj = self.obj();
82
83            for row in &self.rows {
84                self.list.append(row);
85            }
86
87            obj.connect_parent_notify(|obj| {
88                let imp = obj.imp();
89                imp.update_buffer();
90
91                if obj.parent().is_some() {
92                    let view = obj.view();
93
94                    view.connect_buffer_notify(clone!(
95                        #[weak]
96                        imp,
97                        move |_| {
98                            imp.update_buffer();
99                        }
100                    ));
101
102                    let key_events = gtk::EventControllerKey::new();
103                    key_events.connect_key_pressed(clone!(
104                        #[weak]
105                        imp,
106                        #[upgrade_or]
107                        glib::Propagation::Proceed,
108                        move |_, key, _, modifier| imp.handle_key_pressed(key, modifier)
109                    ));
110                    view.add_controller(key_events);
111
112                    // Close popup when the entry is not focused.
113                    view.connect_has_focus_notify(clone!(
114                        #[weak]
115                        obj,
116                        move |view| {
117                            if !view.has_focus() && obj.get_visible() {
118                                obj.popdown();
119                            }
120                        }
121                    ));
122                }
123            });
124
125            self.list.connect_row_activated(clone!(
126                #[weak(rename_to = imp)]
127                self,
128                move |_, row| {
129                    if let Some(row) = row.downcast_ref::<PillSourceRow>() {
130                        imp.row_activated(row);
131                    }
132                }
133            ));
134        }
135    }
136
137    impl WidgetImpl for CompletionPopover {}
138    impl PopoverImpl for CompletionPopover {}
139
140    impl CompletionPopover {
141        /// Set the current room.
142        fn set_room(&self, room: Option<&Room>) {
143            // `RoomHistory` should have a strong reference to the list so we can use
144            // `get_or_create_members()`.
145            self.member_list
146                .set_members(room.map(Room::get_or_create_members));
147
148            self.room_list
149                .set_rooms(room.and_then(Room::session).map(|s| s.room_list()));
150
151            self.room.set(room);
152        }
153
154        /// The parent `GtkTextView` to autocomplete.
155        fn view(&self) -> gtk::TextView {
156            self.obj().parent().and_downcast::<gtk::TextView>().unwrap()
157        }
158
159        /// The ancestor `MessageToolbar`.
160        fn message_toolbar(&self) -> MessageToolbar {
161            self.obj()
162                .ancestor(MessageToolbar::static_type())
163                .and_downcast::<MessageToolbar>()
164                .unwrap()
165        }
166
167        /// Handle a change of buffer.
168        fn update_buffer(&self) {
169            self.buffer.disconnect_signals();
170
171            if self.obj().parent().is_some() {
172                let buffer = self.view().buffer();
173                let handler_id = buffer.connect_cursor_position_notify(clone!(
174                    #[weak(rename_to = imp)]
175                    self,
176                    move |_| {
177                        imp.update_completion(false);
178                    }
179                ));
180                self.buffer.set(buffer, vec![handler_id]);
181
182                self.update_completion(false);
183            }
184        }
185
186        /// The number of visible rows.
187        fn visible_rows_count(&self) -> usize {
188            self.rows
189                .iter()
190                .filter(|row| row.get_visible())
191                .fuse()
192                .count()
193        }
194
195        /// Handle when a key was pressed.
196        fn handle_key_pressed(
197            &self,
198            key: gdk::Key,
199            modifier: gdk::ModifierType,
200        ) -> glib::Propagation {
201            // Do not capture key press if there is a mask other than CapsLock.
202            if modifier != gdk::ModifierType::NO_MODIFIER_MASK
203                && modifier != gdk::ModifierType::LOCK_MASK
204            {
205                return glib::Propagation::Proceed;
206            }
207
208            // If the popover is not visible, we only handle tab to open the popover.
209            if !self.obj().is_visible() {
210                if matches!(key, gdk::Key::Tab | gdk::Key::KP_Tab) {
211                    self.update_completion(true);
212                    return glib::Propagation::Stop;
213                }
214
215                return glib::Propagation::Proceed;
216            }
217
218            // Activate the selected row on enter or tab.
219            if matches!(
220                key,
221                gdk::Key::Return
222                    | gdk::Key::KP_Enter
223                    | gdk::Key::ISO_Enter
224                    | gdk::Key::Tab
225                    | gdk::Key::KP_Tab
226            ) {
227                self.activate_selected_row();
228                return glib::Propagation::Stop;
229            }
230
231            // Move up in the list on key up, if possible.
232            if matches!(key, gdk::Key::Up | gdk::Key::KP_Up) {
233                let idx = self.selected_row_index().unwrap_or_default();
234                if idx > 0 {
235                    self.select_row_at_index(Some(idx - 1));
236                }
237                return glib::Propagation::Stop;
238            }
239
240            // Move down in the list on key down, if possible.
241            if matches!(key, gdk::Key::Down | gdk::Key::KP_Down) {
242                let new_idx = if let Some(idx) = self.selected_row_index() {
243                    idx + 1
244                } else {
245                    0
246                };
247
248                let max = self.visible_rows_count();
249
250                if new_idx < max {
251                    self.select_row_at_index(Some(new_idx));
252                }
253                return glib::Propagation::Stop;
254            }
255
256            // Close the popover on escape.
257            if matches!(key, gdk::Key::Escape) {
258                self.inhibit();
259                return glib::Propagation::Stop;
260            }
261
262            glib::Propagation::Proceed
263        }
264
265        /// The word that is currently used for filtering.
266        ///
267        /// Returns the start and end position of the word, as well as the
268        /// search term.
269        fn current_word(&self) -> Option<(gtk::TextIter, gtk::TextIter, SearchTerm)> {
270            self.current_word.borrow().clone()
271        }
272
273        /// Set the word that is currently used for filtering.
274        fn set_current_word(&self, word: Option<(gtk::TextIter, gtk::TextIter, SearchTerm)>) {
275            if self.current_word() == word {
276                return;
277            }
278
279            self.current_word.replace(word);
280        }
281
282        /// Update completion.
283        ///
284        /// If trigger is `true`, the search term will not look for `@` at the
285        /// start of the word.
286        fn update_completion(&self, trigger: bool) {
287            let search = self.find_search_term(trigger);
288
289            if self.is_inhibited() && search.is_none() {
290                self.inhibit.set(false);
291            } else if !self.is_inhibited() {
292                if let Some((start, end, term)) = search {
293                    self.set_current_word(Some((start, end, term)));
294                    self.update_accessible_label();
295                    self.update_search();
296                } else {
297                    self.obj().popdown();
298                    self.select_row_at_index(None);
299                    self.set_current_word(None);
300                }
301            }
302        }
303
304        /// Find the current search term in the underlying buffer.
305        ///
306        /// Returns the start and end of the search word and the term to search
307        /// for.
308        ///
309        /// If trigger is `true`, the search term will not look for `@` at the
310        /// start of the word.
311        fn find_search_term(
312            &self,
313            trigger: bool,
314        ) -> Option<(gtk::TextIter, gtk::TextIter, SearchTerm)> {
315            // Vocabular used in this method:
316            // - `word`: sequence of characters that form a valid ID or display name. This
317            //   includes characters that are usually not considered to be in words because
318            //   of the grammar of Matrix IDs.
319            // - `trigger`: character used to trigger the popover, usually the first
320            //   character of the corresponding ID.
321
322            let (word_start, word_end) = self.cursor_word_boundaries(trigger)?;
323
324            let mut term_start = word_start;
325            let term_start_char = term_start.char();
326            let is_room = term_start_char == ROOM_ALIAS_SIGIL;
327
328            // Remove the starting sigil for searching.
329            if matches!(term_start_char, USER_ID_SIGIL | ROOM_ALIAS_SIGIL) {
330                term_start.forward_cursor_position();
331            }
332
333            let term = self.view().buffer().text(&term_start, &word_end, true);
334
335            // If the cursor jumped to another word, abort the completion.
336            if let Some((_, _, prev_term)) = self.current_word() {
337                if !term.contains(&prev_term.term) && !prev_term.term.contains(term.as_str()) {
338                    return None;
339                }
340            }
341
342            let target = if is_room {
343                SearchTermTarget::Room
344            } else {
345                SearchTermTarget::Member
346            };
347            let term = SearchTerm {
348                target,
349                term: term.into(),
350            };
351
352            Some((word_start, word_end, term))
353        }
354
355        /// Find the word boundaries for the current cursor position.
356        ///
357        /// If trigger is `true`, the search term will not look for `@` at the
358        /// start of the word.
359        ///
360        /// Returns a `(start, end)` tuple.
361        fn cursor_word_boundaries(&self, trigger: bool) -> Option<(gtk::TextIter, gtk::TextIter)> {
362            let buffer = self.view().buffer();
363            let cursor = buffer.iter_at_mark(&buffer.get_insert());
364            let mut word_start = cursor;
365
366            // Search for the beginning of the word.
367            while word_start.backward_cursor_position() {
368                let c = word_start.char();
369                if !is_possible_word_char(c) {
370                    word_start.forward_cursor_position();
371                    break;
372                }
373            }
374
375            if !matches!(word_start.char(), USER_ID_SIGIL | ROOM_ALIAS_SIGIL)
376                && !trigger
377                && (cursor == word_start || self.current_word().is_none())
378            {
379                // No trigger or not updating the word.
380                return None;
381            }
382
383            let mut ctx = SearchContext::default();
384            let mut word_end = word_start;
385            while word_end.forward_cursor_position() {
386                let c = word_end.char();
387                if ctx.has_id_separator {
388                    // The server name of an ID.
389                    if ctx.has_port_separator {
390                        // The port number
391                        if ctx.port.len() <= 5 && c.is_ascii_digit() {
392                            ctx.port.push(c);
393                        } else {
394                            break;
395                        }
396                    } else {
397                        // An IPv6 address, IPv4 address, or a domain name.
398                        if matches!(ctx.server_name, ServerNameContext::Unknown) {
399                            if c == '[' {
400                                ctx.server_name = ServerNameContext::Ipv6(c.into());
401                            } else if c.is_alphanumeric() {
402                                ctx.server_name = ServerNameContext::Ipv4OrDomain(c.into());
403                            } else {
404                                break;
405                            }
406                        } else if let ServerNameContext::Ipv6(address) = &mut ctx.server_name {
407                            if address.ends_with(']') {
408                                if c == ':' {
409                                    ctx.has_port_separator = true;
410                                } else {
411                                    break;
412                                }
413                            } else if address.len() > 46 {
414                                break;
415                            } else if c.is_ascii_hexdigit() || matches!(c, ':' | '.' | ']') {
416                                address.push(c);
417                            } else {
418                                break;
419                            }
420                        } else if let ServerNameContext::Ipv4OrDomain(address) =
421                            &mut ctx.server_name
422                        {
423                            if c == ':' {
424                                ctx.has_port_separator = true;
425                            } else if c.is_ascii_alphanumeric() || matches!(c, '-' | '.') {
426                                address.push(c);
427                            } else {
428                                break;
429                            }
430                        } else {
431                            break;
432                        }
433                    }
434                } else {
435                    // Localpart or display name.
436                    if !ctx.is_outside_ascii
437                        && (c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '=' | '-' | '/'))
438                    {
439                        ctx.localpart.push(c);
440                    } else if c.is_alphanumeric() {
441                        ctx.is_outside_ascii = true;
442                    } else if !ctx.is_outside_ascii && c == ':' {
443                        ctx.has_id_separator = true;
444                    } else {
445                        break;
446                    }
447                }
448            }
449
450            // It the cursor is not at the word, there is no need for completion.
451            if cursor != word_end && !cursor.in_range(&word_start, &word_end) {
452                return None;
453            }
454
455            // If we are in markdown that would be escaped, there is no need for completion.
456            if self.in_escaped_markdown(&word_start, &word_end) {
457                return None;
458            }
459
460            Some((word_start, word_end))
461        }
462
463        /// Check if the text is in markdown that would be escaped.
464        ///
465        /// This includes:
466        /// - Inline code
467        /// - Block code
468        /// - Links (because nested links are not allowed in HTML)
469        /// - Images
470        fn in_escaped_markdown(
471            &self,
472            word_start: &gtk::TextIter,
473            word_end: &gtk::TextIter,
474        ) -> bool {
475            let buffer = self.view().buffer();
476            let (buf_start, buf_end) = buffer.bounds();
477
478            // If the word is at the start or the end of the buffer, it cannot be escaped.
479            if *word_start == buf_start || *word_end == buf_end {
480                return false;
481            }
482
483            let text = buffer.slice(&buf_start, &buf_end, true);
484
485            // Find the word string slice indexes, because GtkTextIter only gives us
486            // the char offset but the parser gives us indexes.
487            let word_start_offset = usize::try_from(word_start.offset()).unwrap_or_default();
488            let word_end_offset = usize::try_from(word_end.offset()).unwrap_or_default();
489            let mut word_start_index = 0;
490            let mut word_end_index = 0;
491            if word_start_offset != 0 && word_end_offset != 0 {
492                for (offset, (index, _char)) in text.char_indices().enumerate() {
493                    if word_start_offset == offset {
494                        word_start_index = index;
495                    }
496                    if word_end_offset == offset {
497                        word_end_index = index;
498                    }
499
500                    if word_start_index != 0 && word_end_index != 0 {
501                        break;
502                    }
503                }
504            }
505
506            // Look if word is in escaped markdown.
507            let mut in_escaped_tag = false;
508            for (event, range) in Parser::new(&text).into_offset_iter() {
509                match event {
510                    Event::Start(tag) => {
511                        in_escaped_tag = matches!(
512                            tag,
513                            Tag::CodeBlock(_) | Tag::Link { .. } | Tag::Image { .. }
514                        );
515                    }
516                    Event::End(_) => {
517                        // A link or a code block only contains text so an end tag
518                        // always means the end of an escaped part.
519                        in_escaped_tag = false;
520                    }
521                    Event::Code(_) if range.contains(&word_start_index) => {
522                        return true;
523                    }
524                    Event::Text(_) if in_escaped_tag && range.contains(&word_start_index) => {
525                        return true;
526                    }
527                    _ => {}
528                }
529
530                if range.end <= word_end_index {
531                    break;
532                }
533            }
534
535            false
536        }
537
538        /// Update the popover for the current search term.
539        fn update_search(&self) {
540            let term = self
541                .current_word()
542                .map(|(_, _, term)| term.into_normalized_parts());
543
544            let list = match term {
545                Some((SearchTermTarget::Room, term)) => {
546                    self.room_list.set_search_term(term.as_deref());
547                    self.room_list.list()
548                }
549                term => {
550                    self.member_list
551                        .set_search_term(term.and_then(|(_, t)| t).as_deref());
552                    self.member_list.list()
553                }
554            };
555
556            let obj = self.obj();
557            let new_len = list.n_items();
558            if new_len == 0 {
559                obj.popdown();
560                self.select_row_at_index(None);
561            } else {
562                for (idx, row) in self.rows.iter().enumerate() {
563                    let item = list.item(idx as u32);
564                    if let Some(source) = item.clone().and_downcast::<PillSource>() {
565                        row.set_source(Some(source));
566                        row.set_visible(true);
567                    } else if row.get_visible() {
568                        row.set_visible(false);
569                    } else {
570                        // All remaining rows should be hidden too.
571                        break;
572                    }
573                }
574
575                self.update_pointing_to();
576                self.popup();
577            }
578        }
579
580        /// Show the popover.
581        fn popup(&self) {
582            if self
583                .selected_row_index()
584                .is_none_or(|index| index >= self.visible_rows_count())
585            {
586                self.select_row_at_index(Some(0));
587            }
588            self.obj().popup();
589        }
590
591        /// Update the location where the popover is pointing to.
592        fn update_pointing_to(&self) {
593            let view = self.view();
594            let (start, ..) = self.current_word().expect("the current word is known");
595            let location = view.iter_location(&start);
596            let (x, y) = view.buffer_to_window_coords(
597                gtk::TextWindowType::Widget,
598                location.x(),
599                location.y(),
600            );
601            self.obj()
602                .set_pointing_to(Some(&gdk::Rectangle::new(x - 6, y - 2, 0, 0)));
603        }
604
605        /// The index of the selected row.
606        fn selected_row_index(&self) -> Option<usize> {
607            self.selected.get()
608        }
609
610        /// Select the row at the given index.
611        fn select_row_at_index(&self, idx: Option<usize>) {
612            if self.selected_row_index() == idx || idx >= Some(self.visible_rows_count()) {
613                return;
614            }
615
616            if let Some(row) = idx.map(|idx| &self.rows[idx]) {
617                // Make sure the row is visible.
618                let row_bounds = row.compute_bounds(&*self.list).unwrap();
619                let lower = row_bounds.top_left().y().into();
620                let upper = row_bounds.bottom_left().y().into();
621                self.list.adjustment().unwrap().clamp_page(lower, upper);
622
623                self.list.select_row(Some(row));
624            } else {
625                self.list.select_row(gtk::ListBoxRow::NONE);
626            }
627            self.selected.set(idx);
628        }
629
630        /// Activate the row that is currently selected.
631        fn activate_selected_row(&self) {
632            if let Some(idx) = self.selected_row_index() {
633                self.rows[idx].activate();
634            } else {
635                self.inhibit();
636            }
637        }
638
639        /// Handle a row being activated.
640        fn row_activated(&self, row: &PillSourceRow) {
641            let Some(source) = row.source() else {
642                return;
643            };
644
645            let Some((mut start, mut end, _)) = self.current_word.take() else {
646                return;
647            };
648
649            let view = self.view();
650            let buffer = view.buffer();
651
652            buffer.delete(&mut start, &mut end);
653
654            // We do not need to watch safety settings for mentions, rooms will be watched
655            // automatically.
656            let pill = Pill::new(&source, AvatarImageSafetySetting::None, None);
657            self.message_toolbar()
658                .current_composer_state()
659                .add_widget(pill, &mut start);
660
661            self.obj().popdown();
662            self.select_row_at_index(None);
663            view.grab_focus();
664        }
665
666        /// Whether the completion is inhibited.
667        fn is_inhibited(&self) -> bool {
668            self.inhibit.get()
669        }
670
671        /// Inhibit the completion.
672        fn inhibit(&self) {
673            if !self.is_inhibited() {
674                self.inhibit.set(true);
675                self.obj().popdown();
676                self.select_row_at_index(None);
677            }
678        }
679
680        /// Update the accessible label of the popover.
681        fn update_accessible_label(&self) {
682            let Some((_, _, term)) = self.current_word() else {
683                return;
684            };
685
686            let label = if matches!(term.target, SearchTermTarget::Room) {
687                gettext("Public Room Mention Auto-completion")
688            } else {
689                gettext("Room Member Mention Auto-completion")
690            };
691            self.obj()
692                .update_property(&[gtk::accessible::Property::Label(&label)]);
693        }
694    }
695}
696
697glib::wrapper! {
698    /// A popover to autocomplete Matrix IDs for its parent `gtk::TextView`.
699    pub struct CompletionPopover(ObjectSubclass<imp::CompletionPopover>)
700        @extends gtk::Widget, gtk::Popover, @implements gtk::Accessible;
701}
702
703impl CompletionPopover {
704    pub fn new() -> Self {
705        glib::Object::new()
706    }
707}
708
709impl Default for CompletionPopover {
710    fn default() -> Self {
711        Self::new()
712    }
713}
714
715/// A search term.
716#[derive(Debug, Clone, PartialEq, Eq)]
717pub struct SearchTerm {
718    /// The target of the search.
719    target: SearchTermTarget,
720    /// The term to search for.
721    term: String,
722}
723
724impl SearchTerm {
725    /// Normalize and return the parts of this search term.
726    fn into_normalized_parts(self) -> (SearchTermTarget, Option<String>) {
727        let term = (!self.term.is_empty()).then(|| normalized_lower_lay_string(&self.term));
728        (self.target, term)
729    }
730}
731
732/// The possible targets of a search term.
733#[derive(Debug, Clone, Copy, PartialEq, Eq)]
734enum SearchTermTarget {
735    /// A room member.
736    Member,
737    /// A room.
738    Room,
739}
740
741/// The context for a search.
742#[derive(Default)]
743struct SearchContext {
744    localpart: String,
745    is_outside_ascii: bool,
746    has_id_separator: bool,
747    server_name: ServerNameContext,
748    has_port_separator: bool,
749    port: String,
750}
751
752/// The context for a server name.
753#[derive(Default)]
754enum ServerNameContext {
755    Ipv6(String),
756    // According to the Matrix spec definition, the IPv4 grammar is a
757    // subset of the domain name grammar.
758    Ipv4OrDomain(String),
759    #[default]
760    Unknown,
761}
762
763/// Whether the given char can be counted as a word char.
764fn is_possible_word_char(c: char) -> bool {
765    c.is_alphanumeric()
766        || matches!(
767            c,
768            '.' | '_' | '=' | '-' | '/' | ':' | '[' | ']' | USER_ID_SIGIL | ROOM_ALIAS_SIGIL
769        )
770}