Browse Source
Show a popover triggered by the character `@` or the `Tab` key.merge-requests/1327/merge
12 changed files with 1146 additions and 5 deletions
@ -0,0 +1,24 @@ |
|||||||
|
<?xml version="1.0" encoding="UTF-8"?> |
||||||
|
<interface> |
||||||
|
<template class="ContentCompletionPopover" parent="GtkPopover"> |
||||||
|
<style> |
||||||
|
<class name="completion-popover"/> |
||||||
|
</style> |
||||||
|
<property name="autohide">false</property> |
||||||
|
<property name="has-arrow">false</property> |
||||||
|
<property name="position">top</property> |
||||||
|
<property name="halign">start</property> |
||||||
|
<property name="valign">center</property> |
||||||
|
<property name="width-request">260</property> |
||||||
|
<property name="child"> |
||||||
|
<object class="GtkScrolledWindow" id="scrolled_window"> |
||||||
|
<property name="propagate-natural-height">true</property> |
||||||
|
<property name="hscrollbar-policy">never</property> |
||||||
|
<property name="max-content-height">280</property> |
||||||
|
<property name="child"> |
||||||
|
<object class="GtkListBox" id="list"/> |
||||||
|
</property> |
||||||
|
</object> |
||||||
|
</property> |
||||||
|
</template> |
||||||
|
</interface> |
||||||
@ -0,0 +1,42 @@ |
|||||||
|
<?xml version="1.0" encoding="UTF-8"?> |
||||||
|
<interface> |
||||||
|
<template class="ContentCompletionRow" parent="GtkListBoxRow"> |
||||||
|
<style> |
||||||
|
<class name="completion-row"/> |
||||||
|
</style> |
||||||
|
<child> |
||||||
|
<object class="GtkBox"> |
||||||
|
<property name="spacing">10</property> |
||||||
|
<child> |
||||||
|
<object class="ComponentsAvatar" id="avatar"> |
||||||
|
<property name="size">40</property> |
||||||
|
</object> |
||||||
|
</child> |
||||||
|
<child> |
||||||
|
<object class="GtkBox"> |
||||||
|
<property name="spacing">3</property> |
||||||
|
<property name="orientation">vertical</property> |
||||||
|
<child> |
||||||
|
<object class="GtkLabel" id="display_name"> |
||||||
|
<property name="xalign">0.0</property> |
||||||
|
<property name="hexpand">True</property> |
||||||
|
<property name="ellipsize">end</property> |
||||||
|
</object> |
||||||
|
</child> |
||||||
|
<child> |
||||||
|
<object class="GtkLabel" id="id"> |
||||||
|
<property name="xalign">0.0</property> |
||||||
|
<property name="hexpand">True</property> |
||||||
|
<property name="ellipsize">end</property> |
||||||
|
<style> |
||||||
|
<class name="dim-label"/> |
||||||
|
<class name="caption"/> |
||||||
|
</style> |
||||||
|
</object> |
||||||
|
</child> |
||||||
|
</object> |
||||||
|
</child> |
||||||
|
</object> |
||||||
|
</child> |
||||||
|
</template> |
||||||
|
</interface> |
||||||
@ -0,0 +1,849 @@ |
|||||||
|
use gtk::{ |
||||||
|
gdk, glib, |
||||||
|
glib::{clone, closure}, |
||||||
|
prelude::*, |
||||||
|
subclass::prelude::*, |
||||||
|
CompositeTemplate, |
||||||
|
}; |
||||||
|
use pulldown_cmark::{Event, Parser, Tag}; |
||||||
|
use ruma::OwnedUserId; |
||||||
|
use secular::lower_lay_string; |
||||||
|
|
||||||
|
use super::CompletionRow; |
||||||
|
use crate::{ |
||||||
|
components::Pill, |
||||||
|
prelude::*, |
||||||
|
session::room::{Member, MemberList, Membership}, |
||||||
|
}; |
||||||
|
|
||||||
|
const MAX_MEMBERS: usize = 32; |
||||||
|
|
||||||
|
#[derive(Debug, Default)] |
||||||
|
pub struct MemberWatch { |
||||||
|
handlers: Vec<glib::SignalHandlerId>, |
||||||
|
// The position of the member in the list model.
|
||||||
|
position: u32, |
||||||
|
} |
||||||
|
|
||||||
|
mod imp { |
||||||
|
use std::{ |
||||||
|
cell::{Cell, RefCell}, |
||||||
|
collections::HashMap, |
||||||
|
}; |
||||||
|
|
||||||
|
use glib::subclass::InitializingObject; |
||||||
|
use once_cell::sync::Lazy; |
||||||
|
|
||||||
|
use super::*; |
||||||
|
|
||||||
|
#[derive(Debug, Default, CompositeTemplate)] |
||||||
|
#[template(resource = "/org/gnome/Fractal/content-completion-popover.ui")] |
||||||
|
pub struct CompletionPopover { |
||||||
|
#[template_child] |
||||||
|
pub list: TemplateChild<gtk::ListBox>, |
||||||
|
/// The user ID of the current session.
|
||||||
|
pub user_id: RefCell<Option<String>>, |
||||||
|
/// The sorted and filtered room members.
|
||||||
|
pub filtered_members: gtk::FilterListModel, |
||||||
|
/// The rows in the popover.
|
||||||
|
pub rows: [CompletionRow; MAX_MEMBERS], |
||||||
|
/// The selected row in the popover.
|
||||||
|
pub selected: Cell<Option<usize>>, |
||||||
|
/// The current autocompleted word.
|
||||||
|
pub current_word: RefCell<Option<(gtk::TextIter, gtk::TextIter, String)>>, |
||||||
|
/// Whether the popover is inhibited for the current word.
|
||||||
|
pub inhibit: Cell<bool>, |
||||||
|
/// The buffer to complete with its cursor position signal handler ID.
|
||||||
|
pub buffer_handler: RefCell<Option<(gtk::TextBuffer, glib::SignalHandlerId)>>, |
||||||
|
/// The signal handler ID for when them members change.
|
||||||
|
pub members_changed_handler: RefCell<Option<glib::SignalHandlerId>>, |
||||||
|
/// List of signal handler IDs for properties of members of the current
|
||||||
|
/// list model with their position in it.
|
||||||
|
pub members_watch: RefCell<HashMap<OwnedUserId, MemberWatch>>, |
||||||
|
} |
||||||
|
|
||||||
|
#[glib::object_subclass] |
||||||
|
impl ObjectSubclass for CompletionPopover { |
||||||
|
const NAME: &'static str = "ContentCompletionPopover"; |
||||||
|
type Type = super::CompletionPopover; |
||||||
|
type ParentType = gtk::Popover; |
||||||
|
|
||||||
|
fn class_init(klass: &mut Self::Class) { |
||||||
|
Self::bind_template(klass); |
||||||
|
} |
||||||
|
|
||||||
|
fn instance_init(obj: &InitializingObject<Self>) { |
||||||
|
obj.init_template(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
impl ObjectImpl for CompletionPopover { |
||||||
|
fn properties() -> &'static [glib::ParamSpec] { |
||||||
|
static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| { |
||||||
|
vec![ |
||||||
|
glib::ParamSpecObject::new( |
||||||
|
"view", |
||||||
|
"View", |
||||||
|
"The parent GtkTextView to autocomplete", |
||||||
|
gtk::TextView::static_type(), |
||||||
|
glib::ParamFlags::READABLE, |
||||||
|
), |
||||||
|
glib::ParamSpecString::new( |
||||||
|
"user-id", |
||||||
|
"User ID", |
||||||
|
"The user ID of the current session", |
||||||
|
None, |
||||||
|
glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY, |
||||||
|
), |
||||||
|
glib::ParamSpecObject::new( |
||||||
|
"members", |
||||||
|
"Members", |
||||||
|
"The room members used for completion", |
||||||
|
MemberList::static_type(), |
||||||
|
glib::ParamFlags::READWRITE, |
||||||
|
), |
||||||
|
glib::ParamSpecObject::new( |
||||||
|
"filtered-members", |
||||||
|
"Filtered Members", |
||||||
|
"The sorted and filtered room members", |
||||||
|
gtk::FilterListModel::static_type(), |
||||||
|
glib::ParamFlags::READWRITE, |
||||||
|
), |
||||||
|
] |
||||||
|
}); |
||||||
|
|
||||||
|
PROPERTIES.as_ref() |
||||||
|
} |
||||||
|
|
||||||
|
fn set_property( |
||||||
|
&self, |
||||||
|
obj: &Self::Type, |
||||||
|
_id: usize, |
||||||
|
value: &glib::Value, |
||||||
|
pspec: &glib::ParamSpec, |
||||||
|
) { |
||||||
|
match pspec.name() { |
||||||
|
"user-id" => obj.set_user_id(value.get().unwrap()), |
||||||
|
"members" => obj.set_members(value.get().unwrap()), |
||||||
|
_ => unimplemented!(), |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { |
||||||
|
match pspec.name() { |
||||||
|
"view" => obj.view().to_value(), |
||||||
|
"user-id" => obj.user_id().to_value(), |
||||||
|
"members" => obj.members().to_value(), |
||||||
|
"filtered-members" => obj.filtered_members().to_value(), |
||||||
|
_ => unimplemented!(), |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
fn constructed(&self, obj: &Self::Type) { |
||||||
|
self.parent_constructed(obj); |
||||||
|
|
||||||
|
// Filter the members that are joined and that are not our user.
|
||||||
|
let joined = |
||||||
|
gtk::BoolFilter::builder() |
||||||
|
.expression(Member::this_expression("membership").chain_closure::<bool>( |
||||||
|
closure!(|_obj: Option<glib::Object>, membership: Membership| { |
||||||
|
membership == Membership::Join |
||||||
|
}), |
||||||
|
)) |
||||||
|
.build(); |
||||||
|
let not_user = gtk::BoolFilter::builder() |
||||||
|
.expression(gtk::ClosureExpression::new::<bool, _, _>( |
||||||
|
&[ |
||||||
|
Member::this_expression("user-id"), |
||||||
|
obj.property_expression("user-id"), |
||||||
|
], |
||||||
|
closure!( |
||||||
|
|_obj: Option<glib::Object>, user_id: &str, my_user_id: &str| { |
||||||
|
user_id != my_user_id |
||||||
|
} |
||||||
|
), |
||||||
|
)) |
||||||
|
.build(); |
||||||
|
let filter = gtk::EveryFilter::new(); |
||||||
|
filter.append(&joined); |
||||||
|
filter.append(¬_user); |
||||||
|
let first_model = gtk::FilterListModel::builder().filter(&filter).build(); |
||||||
|
|
||||||
|
// Sort the members list by activity, then display name.
|
||||||
|
let activity = gtk::NumericSorter::builder() |
||||||
|
.sort_order(gtk::SortType::Descending) |
||||||
|
.expression(Member::this_expression("latest-activity")) |
||||||
|
.build(); |
||||||
|
let display_name = gtk::StringSorter::builder() |
||||||
|
.ignore_case(true) |
||||||
|
.expression(Member::this_expression("display-name")) |
||||||
|
.build(); |
||||||
|
let sorter = gtk::MultiSorter::new(); |
||||||
|
sorter.append(&activity); |
||||||
|
sorter.append(&display_name); |
||||||
|
let second_model = gtk::SortListModel::builder() |
||||||
|
.sorter(&sorter) |
||||||
|
.model(&first_model) |
||||||
|
.build(); |
||||||
|
|
||||||
|
// Setup the search filter.
|
||||||
|
let search = gtk::StringFilter::builder() |
||||||
|
.ignore_case(true) |
||||||
|
.match_mode(gtk::StringFilterMatchMode::Substring) |
||||||
|
.expression(gtk::ClosureExpression::new::<String, _, _>( |
||||||
|
&[ |
||||||
|
Member::this_expression("user-id"), |
||||||
|
Member::this_expression("display-name"), |
||||||
|
], |
||||||
|
closure!( |
||||||
|
|_: Option<glib::Object>, user_id: &str, display_name: &str| { |
||||||
|
lower_lay_string(&format!("{display_name} {user_id}")) |
||||||
|
} |
||||||
|
), |
||||||
|
)) |
||||||
|
.build(); |
||||||
|
self.filtered_members.set_filter(Some(&search)); |
||||||
|
self.filtered_members.set_model(Some(&second_model)); |
||||||
|
|
||||||
|
for row in &self.rows { |
||||||
|
self.list.append(row); |
||||||
|
} |
||||||
|
|
||||||
|
obj.connect_parent_notify(|obj| { |
||||||
|
let priv_ = obj.imp(); |
||||||
|
|
||||||
|
if let Some((buffer, handler_id)) = priv_.buffer_handler.take() { |
||||||
|
buffer.disconnect(handler_id); |
||||||
|
} |
||||||
|
|
||||||
|
if obj.parent().is_some() { |
||||||
|
let view = obj.view(); |
||||||
|
let buffer = view.buffer(); |
||||||
|
let handler_id = |
||||||
|
buffer.connect_cursor_position_notify(clone!(@weak obj => move |_| { |
||||||
|
obj.update_completion(false); |
||||||
|
})); |
||||||
|
priv_.buffer_handler.replace(Some((buffer, handler_id))); |
||||||
|
|
||||||
|
let key_events = gtk::EventControllerKey::new(); |
||||||
|
view.add_controller(&key_events); |
||||||
|
key_events.connect_key_pressed(clone!(@weak obj => @default-return glib::signal::Inhibit(false), move |_, key, _, modifier| { |
||||||
|
if modifier.is_empty() { |
||||||
|
if obj.is_visible() { |
||||||
|
let priv_ = obj.imp(); |
||||||
|
if matches!(key, gdk::Key::Return | gdk::Key::KP_Enter | gdk::Key::Tab) { |
||||||
|
// Activate completion.
|
||||||
|
obj.activate_selected_row(); |
||||||
|
return glib::signal::Inhibit(true); |
||||||
|
} else if matches!(key, gdk::Key::Up | gdk::Key::KP_Up) { |
||||||
|
// Move up, if possible.
|
||||||
|
let idx = obj.selected_row_index().unwrap_or_default(); |
||||||
|
if idx > 0 { |
||||||
|
obj.select_row_at_index(Some(idx - 1)); |
||||||
|
} |
||||||
|
return glib::signal::Inhibit(true); |
||||||
|
} else if matches!(key, gdk::Key::Down | gdk::Key::KP_Down) { |
||||||
|
// Move down, if possible.
|
||||||
|
let new_idx = if let Some(idx) = obj.selected_row_index() { |
||||||
|
idx + 1 |
||||||
|
} else { |
||||||
|
0 |
||||||
|
}; |
||||||
|
let n_members = priv_.filtered_members.n_items() as usize; |
||||||
|
let max = MAX_MEMBERS.min(n_members); |
||||||
|
if new_idx < max { |
||||||
|
obj.select_row_at_index(Some(new_idx)); |
||||||
|
} |
||||||
|
return glib::signal::Inhibit(true); |
||||||
|
} else if matches!(key, gdk::Key::Escape) { |
||||||
|
// Close.
|
||||||
|
obj.inhibit(); |
||||||
|
return glib::signal::Inhibit(true); |
||||||
|
} |
||||||
|
} else if matches!(key, gdk::Key::Tab) { |
||||||
|
obj.update_completion(true); |
||||||
|
return glib::signal::Inhibit(true); |
||||||
|
} |
||||||
|
} |
||||||
|
glib::signal::Inhibit(false) |
||||||
|
})); |
||||||
|
|
||||||
|
// Close popup when the entry is not focused.
|
||||||
|
view.connect_has_focus_notify(clone!(@weak obj => move |view| { |
||||||
|
if !view.has_focus() && obj.get_visible() { |
||||||
|
obj.hide(); |
||||||
|
} |
||||||
|
})); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
self.list |
||||||
|
.connect_row_activated(clone!(@weak obj => move |_, row| { |
||||||
|
if let Some(row) = row.downcast_ref::<CompletionRow>() { |
||||||
|
obj.row_activated(row); |
||||||
|
} |
||||||
|
})); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
impl WidgetImpl for CompletionPopover {} |
||||||
|
impl PopoverImpl for CompletionPopover {} |
||||||
|
} |
||||||
|
|
||||||
|
glib::wrapper! { |
||||||
|
/// A popover to autocomplete Matrix IDs for its parent `gtk::TextView`.
|
||||||
|
pub struct CompletionPopover(ObjectSubclass<imp::CompletionPopover>) |
||||||
|
@extends gtk::Widget, gtk::Popover; |
||||||
|
} |
||||||
|
|
||||||
|
impl CompletionPopover { |
||||||
|
pub fn new() -> Self { |
||||||
|
glib::Object::new(&[]).expect("Failed to create CompletionPopover") |
||||||
|
} |
||||||
|
|
||||||
|
pub fn view(&self) -> gtk::TextView { |
||||||
|
self.parent() |
||||||
|
.and_then(|parent| parent.downcast::<gtk::TextView>().ok()) |
||||||
|
.unwrap() |
||||||
|
} |
||||||
|
|
||||||
|
pub fn user_id(&self) -> Option<String> { |
||||||
|
self.imp().user_id.borrow().clone() |
||||||
|
} |
||||||
|
|
||||||
|
pub fn set_user_id(&self, user_id: Option<String>) { |
||||||
|
let priv_ = self.imp(); |
||||||
|
|
||||||
|
if priv_.user_id.borrow().as_ref() == user_id.as_ref() { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
priv_.user_id.replace(user_id); |
||||||
|
self.notify("user-id"); |
||||||
|
} |
||||||
|
|
||||||
|
fn first_model(&self) -> Option<gtk::FilterListModel> { |
||||||
|
self.imp() |
||||||
|
.filtered_members |
||||||
|
.model() |
||||||
|
.and_then(|model| model.downcast::<gtk::SortListModel>().ok()) |
||||||
|
.and_then(|second_model| second_model.model()) |
||||||
|
.and_then(|model| model.downcast::<gtk::FilterListModel>().ok()) |
||||||
|
} |
||||||
|
|
||||||
|
pub fn members(&self) -> Option<MemberList> { |
||||||
|
self.first_model() |
||||||
|
.and_then(|first_model| first_model.model()) |
||||||
|
.and_then(|model| model.downcast::<MemberList>().ok()) |
||||||
|
} |
||||||
|
|
||||||
|
pub fn set_members(&self, members: Option<&MemberList>) { |
||||||
|
if let Some(first_model) = self.first_model() { |
||||||
|
let priv_ = self.imp(); |
||||||
|
|
||||||
|
if let Some(old_members) = first_model.model() { |
||||||
|
// Remove the old handlers.
|
||||||
|
if let Some(handler_id) = priv_.members_changed_handler.take() { |
||||||
|
old_members.disconnect(handler_id); |
||||||
|
} |
||||||
|
|
||||||
|
let mut members_watch = priv_.members_watch.take(); |
||||||
|
for member in old_members |
||||||
|
.snapshot() |
||||||
|
.into_iter() |
||||||
|
.filter_map(|obj| obj.downcast::<Member>().ok()) |
||||||
|
{ |
||||||
|
if let Some(watch) = members_watch.remove(&member.user_id()) { |
||||||
|
for handler_id in watch.handlers { |
||||||
|
member.disconnect(handler_id); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
first_model.set_model(members); |
||||||
|
|
||||||
|
if let Some(members) = members { |
||||||
|
self.members_changed(members, 0, 0, members.n_items()); |
||||||
|
|
||||||
|
priv_ |
||||||
|
.members_changed_handler |
||||||
|
.replace(Some(members.connect_items_changed( |
||||||
|
clone!(@weak self as obj => move |members, pos, removed, added| { |
||||||
|
obj.members_changed(members, pos, removed, added); |
||||||
|
}), |
||||||
|
))); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
fn members_changed(&self, members: &MemberList, pos: u32, removed: u32, added: u32) { |
||||||
|
let mut members_watch = self.imp().members_watch.borrow_mut(); |
||||||
|
|
||||||
|
// Remove the old members. We don't care about disconnecting them since
|
||||||
|
// they are gone.
|
||||||
|
for idx in pos..(pos + removed) { |
||||||
|
let user_id = members_watch |
||||||
|
.iter() |
||||||
|
.find_map(|(user_id, watch)| (watch.position == idx).then(|| user_id.to_owned())); |
||||||
|
|
||||||
|
if let Some(user_id) = user_id { |
||||||
|
members_watch.remove(&user_id); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
let upper_bound = if removed != added { |
||||||
|
members.n_items() |
||||||
|
} else { |
||||||
|
// If there are as many removed as added, we don't need to update
|
||||||
|
// the position of the following members.
|
||||||
|
pos + added |
||||||
|
}; |
||||||
|
for idx in pos..upper_bound { |
||||||
|
if let Some(member) = members |
||||||
|
.item(idx) |
||||||
|
.and_then(|obj| obj.downcast::<Member>().ok()) |
||||||
|
{ |
||||||
|
let watch = members_watch.entry(member.user_id()).or_default(); |
||||||
|
|
||||||
|
// Update the position of all members after pos.
|
||||||
|
watch.position = idx; |
||||||
|
|
||||||
|
// Listen to property changes for added members because the list
|
||||||
|
// models don't reevaluate the expressions when the membership
|
||||||
|
// or latest-activity change.
|
||||||
|
if idx < (pos + added) { |
||||||
|
watch.handlers.push(member.connect_notify_local( |
||||||
|
Some("membership"), |
||||||
|
clone!(@weak self as obj => move |member, _| { |
||||||
|
obj.reevaluate_member(member); |
||||||
|
}), |
||||||
|
)); |
||||||
|
watch.handlers.push(member.connect_notify_local( |
||||||
|
Some("latest-activity"), |
||||||
|
clone!(@weak self as obj => move |member, _| { |
||||||
|
obj.reevaluate_member(member); |
||||||
|
}), |
||||||
|
)); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// Force the given member to be reevaluated.
|
||||||
|
fn reevaluate_member(&self, member: &Member) { |
||||||
|
if let Some(members) = self.members() { |
||||||
|
let pos = self |
||||||
|
.imp() |
||||||
|
.members_watch |
||||||
|
.borrow() |
||||||
|
.get(&member.user_id()) |
||||||
|
.map(|watch| watch.position); |
||||||
|
|
||||||
|
if let Some(pos) = pos { |
||||||
|
// We pretend the item changed to get it reevaluated.
|
||||||
|
members.items_changed(pos, 1, 1) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
pub fn filtered_members(&self) -> >k::FilterListModel { |
||||||
|
&self.imp().filtered_members |
||||||
|
} |
||||||
|
|
||||||
|
fn current_word(&self) -> Option<(gtk::TextIter, gtk::TextIter, String)> { |
||||||
|
self.imp().current_word.borrow().clone() |
||||||
|
} |
||||||
|
|
||||||
|
fn set_current_word(&self, word: Option<(gtk::TextIter, gtk::TextIter, String)>) { |
||||||
|
if self.current_word() == word { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
self.imp().current_word.replace(word); |
||||||
|
} |
||||||
|
|
||||||
|
/// Update completion.
|
||||||
|
///
|
||||||
|
/// If trigger is `true`, the search term will not look for `@` at the start
|
||||||
|
/// of the word.
|
||||||
|
fn update_completion(&self, trigger: bool) { |
||||||
|
let search = self.find_search_term(trigger); |
||||||
|
|
||||||
|
if self.is_inhibited() && search.is_none() { |
||||||
|
self.imp().inhibit.set(false); |
||||||
|
} else if !self.is_inhibited() { |
||||||
|
if let Some((start, end, term)) = search { |
||||||
|
self.set_current_word(Some((start, end, term))); |
||||||
|
self.search_members(); |
||||||
|
} else { |
||||||
|
self.hide(); |
||||||
|
self.select_row_at_index(None); |
||||||
|
self.set_current_word(None); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// Find the current search term in the underlying buffer.
|
||||||
|
///
|
||||||
|
/// Returns the start and end of the search word and the term to search for.
|
||||||
|
///
|
||||||
|
/// If trigger is `true`, the search term will not look for `@` at the start
|
||||||
|
/// of the word.
|
||||||
|
fn find_search_term(&self, trigger: bool) -> Option<(gtk::TextIter, gtk::TextIter, String)> { |
||||||
|
// Vocabular used in this method:
|
||||||
|
// - `word`: sequence of characters that form a valid ID or display name. This
|
||||||
|
// includes characters that are usually not considered to be in words because
|
||||||
|
// of the grammar of Matrix IDs.
|
||||||
|
// - `trigger`: character used to trigger the popover, usually the first
|
||||||
|
// character of the corresponding ID.
|
||||||
|
|
||||||
|
#[derive(Default)] |
||||||
|
struct SearchContext { |
||||||
|
localpart: String, |
||||||
|
is_outside_ascii: bool, |
||||||
|
has_id_separator: bool, |
||||||
|
server_name: ServerNameContext, |
||||||
|
has_port_separator: bool, |
||||||
|
port: String, |
||||||
|
} |
||||||
|
|
||||||
|
enum ServerNameContext { |
||||||
|
Ipv6(String), |
||||||
|
// According to the Matrix spec definition, the IPv4 grammar is a
|
||||||
|
// subset of the domain name grammar.
|
||||||
|
Ipv4OrDomain(String), |
||||||
|
Unknown, |
||||||
|
} |
||||||
|
impl Default for ServerNameContext { |
||||||
|
fn default() -> Self { |
||||||
|
Self::Unknown |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
fn is_possible_word_char(c: char) -> bool { |
||||||
|
c.is_alphanumeric() || matches!(c, '.' | '_' | '=' | '-' | '/' | ':' | '[' | ']' | '@') |
||||||
|
} |
||||||
|
|
||||||
|
let buffer = self.view().buffer(); |
||||||
|
let cursor = buffer.iter_at_mark(&buffer.get_insert()); |
||||||
|
|
||||||
|
let mut word_start = cursor; |
||||||
|
// Search for the beginning of the word.
|
||||||
|
while word_start.backward_cursor_position() { |
||||||
|
let c = word_start.char(); |
||||||
|
if !is_possible_word_char(c) { |
||||||
|
word_start.forward_cursor_position(); |
||||||
|
break; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if word_start.char() != '@' |
||||||
|
&& !trigger |
||||||
|
&& (cursor == word_start || self.current_word().is_none()) |
||||||
|
{ |
||||||
|
// No trigger or not updating the word.
|
||||||
|
return None; |
||||||
|
} |
||||||
|
|
||||||
|
let mut ctx = SearchContext::default(); |
||||||
|
let mut word_end = word_start; |
||||||
|
while word_end.forward_cursor_position() { |
||||||
|
let c = word_end.char(); |
||||||
|
if !ctx.has_id_separator { |
||||||
|
// Localpart or display name.
|
||||||
|
if !ctx.is_outside_ascii |
||||||
|
&& (c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '=' | '-' | '/')) |
||||||
|
{ |
||||||
|
ctx.localpart.push(c); |
||||||
|
} else if c.is_alphanumeric() { |
||||||
|
ctx.is_outside_ascii = true; |
||||||
|
} else if !ctx.is_outside_ascii && c == ':' { |
||||||
|
ctx.has_id_separator = true; |
||||||
|
} else { |
||||||
|
break; |
||||||
|
} |
||||||
|
} else { |
||||||
|
// The server name of an ID.
|
||||||
|
if !ctx.has_port_separator { |
||||||
|
// An IPv6 address, IPv4 address, or a domain name.
|
||||||
|
if matches!(ctx.server_name, ServerNameContext::Unknown) { |
||||||
|
if c == '[' { |
||||||
|
ctx.server_name = ServerNameContext::Ipv6(c.into()) |
||||||
|
} else if c.is_alphanumeric() { |
||||||
|
ctx.server_name = ServerNameContext::Ipv4OrDomain(c.into()) |
||||||
|
} else { |
||||||
|
break; |
||||||
|
} |
||||||
|
} else if let ServerNameContext::Ipv6(address) = &mut ctx.server_name { |
||||||
|
if address.ends_with(']') { |
||||||
|
if c == ':' { |
||||||
|
ctx.has_port_separator = true; |
||||||
|
} else { |
||||||
|
break; |
||||||
|
} |
||||||
|
} else if address.len() > 46 { |
||||||
|
break; |
||||||
|
} else if c.is_ascii_hexdigit() || matches!(c, ':' | '.' | ']') { |
||||||
|
address.push(c); |
||||||
|
} else { |
||||||
|
break; |
||||||
|
} |
||||||
|
} else if let ServerNameContext::Ipv4OrDomain(address) = &mut ctx.server_name { |
||||||
|
if c == ':' { |
||||||
|
ctx.has_port_separator = true; |
||||||
|
} else if c.is_ascii_alphanumeric() || matches!(c, '-' | '.') { |
||||||
|
address.push(c); |
||||||
|
} else { |
||||||
|
break; |
||||||
|
} |
||||||
|
} else { |
||||||
|
break; |
||||||
|
} |
||||||
|
} else { |
||||||
|
// The port number
|
||||||
|
if ctx.port.len() <= 5 && c.is_ascii_digit() { |
||||||
|
ctx.port.push(c); |
||||||
|
} else { |
||||||
|
break; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if cursor != word_end && !cursor.in_range(&word_start, &word_end) { |
||||||
|
return None; |
||||||
|
} |
||||||
|
|
||||||
|
if self.in_escaped_markdown(&word_start, &word_end) { |
||||||
|
return None; |
||||||
|
} |
||||||
|
|
||||||
|
// Remove the starting `@` for searching.
|
||||||
|
let mut term_start = word_start; |
||||||
|
if term_start.char() == '@' { |
||||||
|
term_start.forward_cursor_position(); |
||||||
|
} |
||||||
|
|
||||||
|
let term = buffer.text(&term_start, &word_end, true); |
||||||
|
|
||||||
|
// If the cursor jumped to another word, abort the completion.
|
||||||
|
if let Some((_, _, prev_term)) = self.current_word() { |
||||||
|
if !term.contains(&prev_term) && !prev_term.contains(term.as_str()) { |
||||||
|
return None; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
Some((word_start, word_end, term.into())) |
||||||
|
} |
||||||
|
|
||||||
|
/// Check if the text is in markdown that would be escaped.
|
||||||
|
///
|
||||||
|
/// This includes:
|
||||||
|
/// - Inline code
|
||||||
|
/// - Block code
|
||||||
|
/// - Links (because nested links are not allowed in HTML)
|
||||||
|
/// - Images
|
||||||
|
fn in_escaped_markdown(&self, word_start: >k::TextIter, word_end: >k::TextIter) -> bool { |
||||||
|
let buffer = self.view().buffer(); |
||||||
|
let (buf_start, buf_end) = buffer.bounds(); |
||||||
|
|
||||||
|
// If the word is at the start or the end of the buffer, it cannot be escaped.
|
||||||
|
if *word_start == buf_start || *word_end == buf_end { |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
let text = buffer.slice(&buf_start, &buf_end, true); |
||||||
|
|
||||||
|
// Find the word string slice indexes, because GtkTextIter only gives us
|
||||||
|
// the char offset but the parser gives us indexes.
|
||||||
|
let word_start_offset = word_start.offset() as usize; |
||||||
|
let word_end_offset = word_end.offset() as usize; |
||||||
|
let mut word_start_index = 0; |
||||||
|
let mut word_end_index = 0; |
||||||
|
if word_start_offset != 0 && word_end_offset != 0 { |
||||||
|
for (offset, (index, _char)) in text.char_indices().enumerate() { |
||||||
|
if word_start_offset == offset { |
||||||
|
word_start_index = index; |
||||||
|
} |
||||||
|
if word_end_offset == offset { |
||||||
|
word_end_index = index; |
||||||
|
} |
||||||
|
|
||||||
|
if word_start_index != 0 && word_end_index != 0 { |
||||||
|
break; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Look if word is in escaped markdown.
|
||||||
|
let mut in_escaped_tag = false; |
||||||
|
for (event, range) in Parser::new(&text).into_offset_iter() { |
||||||
|
match event { |
||||||
|
Event::Start(tag) => { |
||||||
|
in_escaped_tag = |
||||||
|
matches!(tag, Tag::CodeBlock(_) | Tag::Link(..) | Tag::Image(..)); |
||||||
|
} |
||||||
|
Event::End(_) => { |
||||||
|
// A link or a code block only contains text so an end tag
|
||||||
|
// always means the end of an escaped part.
|
||||||
|
in_escaped_tag = false; |
||||||
|
} |
||||||
|
Event::Code(_) if range.contains(&word_start_index) => { |
||||||
|
return true; |
||||||
|
} |
||||||
|
Event::Text(_) if in_escaped_tag && range.contains(&word_start_index) => { |
||||||
|
return true; |
||||||
|
} |
||||||
|
_ => {} |
||||||
|
} |
||||||
|
|
||||||
|
if range.end <= word_end_index { |
||||||
|
break; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
false |
||||||
|
} |
||||||
|
|
||||||
|
fn search_members(&self) { |
||||||
|
let priv_ = self.imp(); |
||||||
|
let filtered_members = self.filtered_members(); |
||||||
|
let filter = filtered_members |
||||||
|
.filter() |
||||||
|
.and_then(|filter| filter.downcast::<gtk::StringFilter>().ok()) |
||||||
|
.unwrap(); |
||||||
|
let term = self |
||||||
|
.current_word() |
||||||
|
.and_then(|(_, _, term)| (!term.is_empty()).then(|| lower_lay_string(&term))); |
||||||
|
filter.set_search(term.as_deref()); |
||||||
|
|
||||||
|
let new_len = filtered_members.n_items(); |
||||||
|
if new_len == 0 { |
||||||
|
self.hide(); |
||||||
|
self.select_row_at_index(None); |
||||||
|
} else { |
||||||
|
for (idx, row) in priv_.rows.iter().enumerate() { |
||||||
|
if let Some(member) = filtered_members |
||||||
|
.item(idx as u32) |
||||||
|
.and_then(|obj| obj.downcast::<Member>().ok()) |
||||||
|
{ |
||||||
|
row.set_member(Some(member)); |
||||||
|
row.show(); |
||||||
|
} else if row.get_visible() { |
||||||
|
row.hide(); |
||||||
|
} else { |
||||||
|
// All remaining rows should be hidden too.
|
||||||
|
break; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
self.update_pointing_to(); |
||||||
|
self.popup(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
fn count_visible_rows(&self) -> usize { |
||||||
|
self.imp() |
||||||
|
.rows |
||||||
|
.iter() |
||||||
|
.filter(|row| row.get_visible()) |
||||||
|
.fuse() |
||||||
|
.count() |
||||||
|
} |
||||||
|
|
||||||
|
fn popup(&self) { |
||||||
|
if self |
||||||
|
.selected_row_index() |
||||||
|
.filter(|index| *index < self.count_visible_rows()) |
||||||
|
.is_none() |
||||||
|
{ |
||||||
|
self.select_row_at_index(Some(0)); |
||||||
|
} |
||||||
|
self.show() |
||||||
|
} |
||||||
|
|
||||||
|
fn update_pointing_to(&self) { |
||||||
|
let view = self.view(); |
||||||
|
let (start, ..) = self.current_word().unwrap(); |
||||||
|
let location = view.iter_location(&start); |
||||||
|
let (x, y) = |
||||||
|
view.buffer_to_window_coords(gtk::TextWindowType::Widget, location.x(), location.y()); |
||||||
|
self.set_pointing_to(Some(&gdk::Rectangle::new(x - 6, y - 2, 0, 0))); |
||||||
|
} |
||||||
|
|
||||||
|
fn selected_row_index(&self) -> Option<usize> { |
||||||
|
self.imp().selected.get() |
||||||
|
} |
||||||
|
|
||||||
|
fn select_row_at_index(&self, idx: Option<usize>) { |
||||||
|
if self.selected_row_index() == idx || idx >= Some(self.count_visible_rows()) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
let priv_ = self.imp(); |
||||||
|
|
||||||
|
if let Some(row) = idx.map(|idx| &priv_.rows[idx]) { |
||||||
|
// Make sure the row is visible.
|
||||||
|
let row_bounds = row.compute_bounds(&*priv_.list).unwrap(); |
||||||
|
let lower = row_bounds.top_left().y() as f64; |
||||||
|
let upper = row_bounds.bottom_left().y() as f64; |
||||||
|
priv_.list.adjustment().unwrap().clamp_page(lower, upper); |
||||||
|
|
||||||
|
priv_.list.select_row(Some(row)); |
||||||
|
} else { |
||||||
|
priv_.list.select_row(gtk::ListBoxRow::NONE); |
||||||
|
} |
||||||
|
priv_.selected.set(idx); |
||||||
|
} |
||||||
|
|
||||||
|
fn activate_selected_row(&self) { |
||||||
|
if let Some(idx) = self.selected_row_index() { |
||||||
|
self.imp().rows[idx].activate(); |
||||||
|
} else { |
||||||
|
self.inhibit(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
fn row_activated(&self, row: &CompletionRow) { |
||||||
|
if let Some(member) = row.member() { |
||||||
|
let priv_ = self.imp(); |
||||||
|
|
||||||
|
if let Some((mut start, mut end, _)) = priv_.current_word.take() { |
||||||
|
let view = self.view(); |
||||||
|
let buffer = view.buffer(); |
||||||
|
|
||||||
|
buffer.delete(&mut start, &mut end); |
||||||
|
|
||||||
|
let anchor = match start.child_anchor() { |
||||||
|
Some(anchor) => anchor, |
||||||
|
None => buffer.create_child_anchor(&mut start), |
||||||
|
}; |
||||||
|
let pill = Pill::for_user(member.upcast_ref()); |
||||||
|
view.add_child_at_anchor(&pill, &anchor); |
||||||
|
|
||||||
|
self.hide(); |
||||||
|
self.select_row_at_index(None); |
||||||
|
view.grab_focus(); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
fn is_inhibited(&self) -> bool { |
||||||
|
self.imp().inhibit.get() |
||||||
|
} |
||||||
|
|
||||||
|
fn inhibit(&self) { |
||||||
|
if !self.is_inhibited() { |
||||||
|
self.imp().inhibit.set(true); |
||||||
|
self.hide(); |
||||||
|
self.select_row_at_index(None); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
impl Default for CompletionPopover { |
||||||
|
fn default() -> Self { |
||||||
|
Self::new() |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,125 @@ |
|||||||
|
use gtk::{glib, prelude::*, subclass::prelude::*, CompositeTemplate}; |
||||||
|
|
||||||
|
use crate::{ |
||||||
|
components::Avatar, |
||||||
|
session::{room::Member, UserExt}, |
||||||
|
}; |
||||||
|
|
||||||
|
mod imp { |
||||||
|
use std::cell::RefCell; |
||||||
|
|
||||||
|
use glib::subclass::InitializingObject; |
||||||
|
use once_cell::sync::Lazy; |
||||||
|
|
||||||
|
use super::*; |
||||||
|
|
||||||
|
#[derive(Debug, Default, CompositeTemplate)] |
||||||
|
#[template(resource = "/org/gnome/Fractal/content-completion-row.ui")] |
||||||
|
pub struct CompletionRow { |
||||||
|
#[template_child] |
||||||
|
pub avatar: TemplateChild<Avatar>, |
||||||
|
#[template_child] |
||||||
|
pub display_name: TemplateChild<gtk::Label>, |
||||||
|
#[template_child] |
||||||
|
pub id: TemplateChild<gtk::Label>, |
||||||
|
/// The room member presented by this row.
|
||||||
|
pub member: RefCell<Option<Member>>, |
||||||
|
} |
||||||
|
|
||||||
|
#[glib::object_subclass] |
||||||
|
impl ObjectSubclass for CompletionRow { |
||||||
|
const NAME: &'static str = "ContentCompletionRow"; |
||||||
|
type Type = super::CompletionRow; |
||||||
|
type ParentType = gtk::ListBoxRow; |
||||||
|
|
||||||
|
fn class_init(klass: &mut Self::Class) { |
||||||
|
Self::bind_template(klass); |
||||||
|
} |
||||||
|
|
||||||
|
fn instance_init(obj: &InitializingObject<Self>) { |
||||||
|
obj.init_template(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
impl ObjectImpl for CompletionRow { |
||||||
|
fn properties() -> &'static [glib::ParamSpec] { |
||||||
|
static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| { |
||||||
|
vec![glib::ParamSpecObject::new( |
||||||
|
"member", |
||||||
|
"Member", |
||||||
|
"The room member presented by this row", |
||||||
|
Member::static_type(), |
||||||
|
glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY, |
||||||
|
)] |
||||||
|
}); |
||||||
|
|
||||||
|
PROPERTIES.as_ref() |
||||||
|
} |
||||||
|
|
||||||
|
fn set_property( |
||||||
|
&self, |
||||||
|
obj: &Self::Type, |
||||||
|
_id: usize, |
||||||
|
value: &glib::Value, |
||||||
|
pspec: &glib::ParamSpec, |
||||||
|
) { |
||||||
|
match pspec.name() { |
||||||
|
"member" => obj.set_member(value.get().unwrap()), |
||||||
|
_ => unimplemented!(), |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { |
||||||
|
match pspec.name() { |
||||||
|
"member" => obj.member().to_value(), |
||||||
|
_ => unimplemented!(), |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
impl WidgetImpl for CompletionRow {} |
||||||
|
impl ListBoxRowImpl for CompletionRow {} |
||||||
|
} |
||||||
|
|
||||||
|
glib::wrapper! { |
||||||
|
/// A popover to allow completion for a given text buffer.
|
||||||
|
pub struct CompletionRow(ObjectSubclass<imp::CompletionRow>) |
||||||
|
@extends gtk::Widget, gtk::ListBoxRow; |
||||||
|
} |
||||||
|
|
||||||
|
impl CompletionRow { |
||||||
|
pub fn new() -> Self { |
||||||
|
glib::Object::new(&[]).expect("Failed to create CompletionRow") |
||||||
|
} |
||||||
|
|
||||||
|
pub fn member(&self) -> Option<Member> { |
||||||
|
self.imp().member.borrow().clone() |
||||||
|
} |
||||||
|
|
||||||
|
pub fn set_member(&self, member: Option<Member>) { |
||||||
|
let priv_ = self.imp(); |
||||||
|
|
||||||
|
if priv_.member.borrow().as_ref() == member.as_ref() { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if let Some(member) = &member { |
||||||
|
priv_.avatar.set_item(Some(member.avatar().to_owned())); |
||||||
|
priv_.display_name.set_label(&member.display_name()); |
||||||
|
priv_.id.set_label(member.user_id().as_str()); |
||||||
|
} else { |
||||||
|
priv_.avatar.set_item(None); |
||||||
|
priv_.display_name.set_label(""); |
||||||
|
priv_.id.set_label(""); |
||||||
|
} |
||||||
|
|
||||||
|
priv_.member.replace(member); |
||||||
|
self.notify("member"); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
impl Default for CompletionRow { |
||||||
|
fn default() -> Self { |
||||||
|
Self::new() |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,5 @@ |
|||||||
|
mod completion_popover; |
||||||
|
mod completion_row; |
||||||
|
|
||||||
|
pub use completion_popover::CompletionPopover; |
||||||
|
pub use completion_row::CompletionRow; |
||||||
Loading…
Reference in new issue