fractal/session/view/content/room_history/message_toolbar/completion/
completion_popover.rs1use 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
13const MAX_ROWS: usize = 32;
15const USER_ID_SIGIL: char = '@';
17const 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 #[property(get = Self::view)]
40 view: PhantomData<gtk::TextView>,
41 #[property(get, set = Self::set_room, explicit_notify, nullable)]
43 room: glib::WeakRef<Room>,
44 #[property(get)]
46 member_list: CompletionMemberList,
47 #[property(get)]
49 room_list: CompletionRoomList,
50 rows: [PillSourceRow; MAX_ROWS],
52 selected: Cell<Option<usize>>,
54 current_word: RefCell<Option<(gtk::TextIter, gtk::TextIter, SearchTerm)>>,
56 inhibit: Cell<bool>,
58 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 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 fn set_room(&self, room: Option<&Room>) {
143 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 fn view(&self) -> gtk::TextView {
156 self.obj().parent().and_downcast::<gtk::TextView>().unwrap()
157 }
158
159 fn message_toolbar(&self) -> MessageToolbar {
161 self.obj()
162 .ancestor(MessageToolbar::static_type())
163 .and_downcast::<MessageToolbar>()
164 .unwrap()
165 }
166
167 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 fn visible_rows_count(&self) -> usize {
188 self.rows
189 .iter()
190 .filter(|row| row.get_visible())
191 .fuse()
192 .count()
193 }
194
195 fn handle_key_pressed(
197 &self,
198 key: gdk::Key,
199 modifier: gdk::ModifierType,
200 ) -> glib::Propagation {
201 if modifier != gdk::ModifierType::NO_MODIFIER_MASK
203 && modifier != gdk::ModifierType::LOCK_MASK
204 {
205 return glib::Propagation::Proceed;
206 }
207
208 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 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 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 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 if matches!(key, gdk::Key::Escape) {
258 self.inhibit();
259 return glib::Propagation::Stop;
260 }
261
262 glib::Propagation::Proceed
263 }
264
265 fn current_word(&self) -> Option<(gtk::TextIter, gtk::TextIter, SearchTerm)> {
270 self.current_word.borrow().clone()
271 }
272
273 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 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 fn find_search_term(
312 &self,
313 trigger: bool,
314 ) -> Option<(gtk::TextIter, gtk::TextIter, SearchTerm)> {
315 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 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 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 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 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 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 if ctx.has_port_separator {
390 if ctx.port.len() <= 5 && c.is_ascii_digit() {
392 ctx.port.push(c);
393 } else {
394 break;
395 }
396 } else {
397 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 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 if cursor != word_end && !cursor.in_range(&word_start, &word_end) {
452 return None;
453 }
454
455 if self.in_escaped_markdown(&word_start, &word_end) {
457 return None;
458 }
459
460 Some((word_start, word_end))
461 }
462
463 fn in_escaped_markdown(
471 &self,
472 word_start: >k::TextIter,
473 word_end: >k::TextIter,
474 ) -> bool {
475 let buffer = self.view().buffer();
476 let (buf_start, buf_end) = buffer.bounds();
477
478 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 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 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 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 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 break;
572 }
573 }
574
575 self.update_pointing_to();
576 self.popup();
577 }
578 }
579
580 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 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 fn selected_row_index(&self) -> Option<usize> {
607 self.selected.get()
608 }
609
610 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 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 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 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 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 fn is_inhibited(&self) -> bool {
668 self.inhibit.get()
669 }
670
671 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
717pub struct SearchTerm {
718 target: SearchTermTarget,
720 term: String,
722}
723
724impl SearchTerm {
725 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
734enum SearchTermTarget {
735 Member,
737 Room,
739}
740
741#[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#[derive(Default)]
754enum ServerNameContext {
755 Ipv6(String),
756 Ipv4OrDomain(String),
759 #[default]
760 Unknown,
761}
762
763fn is_possible_word_char(c: char) -> bool {
765 c.is_alphanumeric()
766 || matches!(
767 c,
768 '.' | '_' | '=' | '-' | '/' | ':' | '[' | ']' | USER_ID_SIGIL | ROOM_ALIAS_SIGIL
769 )
770}