Browse Source

room-history: Implement mention of users in the message entry

Show a popover triggered by the character `@` or the `Tab` key.
merge-requests/1327/merge
Kévin Commaille 4 years ago
parent
commit
f8e9147f7d
No known key found for this signature in database
GPG Key ID: DD507DAE96E8245C
  1. 21
      Cargo.lock
  2. 2
      Cargo.toml
  3. 2
      data/resources/resources.gresource.xml
  4. 26
      data/resources/style.css
  5. 24
      data/resources/ui/content-completion-popover.ui
  6. 42
      data/resources/ui/content-completion-row.ui
  7. 849
      src/session/content/room_history/completion/completion_popover.rs
  8. 125
      src/session/content/room_history/completion/completion_row.rs
  9. 5
      src/session/content/room_history/completion/mod.rs
  10. 48
      src/session/content/room_history/mod.rs
  11. 5
      src/session/room/member_list.rs
  12. 2
      src/session/room/mod.rs

21
Cargo.lock generated

@ -1062,11 +1062,13 @@ dependencies = [
"mime_guess",
"num_enum",
"once_cell",
"pulldown-cmark",
"qrcode",
"rand 0.8.5",
"regex",
"rqrr",
"ruma",
"secular",
"serde",
"serde_json",
"sourceview5",
@ -1380,6 +1382,15 @@ dependencies = [
"version_check",
]
[[package]]
name = "getopts"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5"
dependencies = [
"unicode-width",
]
[[package]]
name = "getrandom"
version = "0.1.16"
@ -3365,6 +3376,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d9cc634bc78768157b5cbfe988ffcd1dcba95cd2b2f03a88316c08c6d00ed63"
dependencies = [
"bitflags",
"getopts",
"memchr",
"unicase",
]
@ -3761,6 +3773,15 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd"
[[package]]
name = "secular"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3dc3eccdf599b53eba8a34a1190bd47394948258d1c43dca9cceb2426e25bb5"
dependencies = [
"unicode-normalization",
]
[[package]]
name = "security-framework"
version = "2.7.0"

2
Cargo.toml

@ -50,6 +50,8 @@ mime_guess = "2.0.3"
num_enum = "0.5.6"
thiserror = "1.0.25"
rqrr = "0.4.0"
secular = { version = "1.0.1", features = ["bmp", "normalization"] }
pulldown-cmark = "0.9.2"
[dependencies.sourceview]
package = "sourceview5"

2
data/resources/resources.gresource.xml

@ -43,6 +43,8 @@
<file compressed="true" preprocess="xml-stripblanks" alias="components-password-entry-row.ui">ui/components-password-entry-row.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="components-reaction-chooser.ui">ui/components-reaction-chooser.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="components-video-player.ui">ui/components-video-player.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="content-completion-popover.ui">ui/content-completion-popover.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="content-completion-row.ui">ui/content-completion-row.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="content-divider-row.ui">ui/content-divider-row.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="content-explore-item.ui">ui/content-explore-item.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="content-explore.ui">ui/content-explore.ui</file>

26
data/resources/style.css

@ -496,6 +496,32 @@ message-reactions .reaction-count {
color: @view_fg_color;
}
.completion-popover contents {
padding: 0;
}
.completion-popover viewport {
padding: 8px;
}
.completion-popover list {
background-color: transparent;
}
.completion-popover .completion-row {
border-radius: 6px;
margin: 3px 0px;
padding: 6px;
}
.completion-popover .completion-row:first-child {
margin-top: 0px;
}
.completion-popover .completion-row:last-child {
margin-bottom: 0px;
}
/* Event Source Dialog */

24
data/resources/ui/content-completion-popover.ui

@ -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>

42
data/resources/ui/content-completion-row.ui

@ -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>

849
src/session/content/room_history/completion/completion_popover.rs

@ -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(&not_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) -> &gtk::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: &gtk::TextIter, word_end: &gtk::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()
}
}

125
src/session/content/room_history/completion/completion_row.rs

@ -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()
}
}

5
src/session/content/room_history/completion/mod.rs

@ -0,0 +1,5 @@
mod completion_popover;
mod completion_row;
pub use completion_popover::CompletionPopover;
pub use completion_row::CompletionRow;

48
src/session/content/room_history/mod.rs

@ -1,4 +1,5 @@
mod attachment_dialog;
mod completion;
mod divider_row;
mod item_row;
mod message_row;
@ -29,8 +30,8 @@ use ruma::events::{room::message::LocationMessageEventContent, AnyMessageLikeEve
use sourceview::prelude::*;
use self::{
attachment_dialog::AttachmentDialog, divider_row::DividerRow, item_row::ItemRow,
state_row::StateRow, verification_info_bar::VerificationInfoBar,
attachment_dialog::AttachmentDialog, completion::CompletionPopover, divider_row::DividerRow,
item_row::ItemRow, state_row::StateRow, verification_info_bar::VerificationInfoBar,
};
use crate::{
components::{CustomEntry, DragOverlay, Pill, ReactionChooser, RoomTitle},
@ -66,6 +67,7 @@ mod imp {
pub sticky: Cell<bool>,
pub item_context_menu: OnceCell<gtk::PopoverMenu>,
pub item_reaction_chooser: ReactionChooser,
pub completion: CompletionPopover,
#[template_child]
pub headerbar: TemplateChild<adw::HeaderBar>,
#[template_child]
@ -332,6 +334,17 @@ mod imp {
}));
}
}));
self.message_entry
.connect_copy_clipboard(clone!(@weak obj => move |entry| {
entry.stop_signal_emission_by_name("copy-clipboard");
obj.copy_buffer_selection_to_clipboard();
}));
self.message_entry
.connect_cut_clipboard(clone!(@weak obj => move |entry| {
entry.stop_signal_emission_by_name("cut-clipboard");
obj.copy_buffer_selection_to_clipboard();
entry.buffer().delete_selection(true, true);
}));
key_events
.connect_key_pressed(clone!(@weak obj => @default-return Inhibit(false), move |_, key, _, modifier| {
@ -369,10 +382,16 @@ mod imp {
.bind("markdown-enabled", obj, "markdown-enabled")
.build();
self.completion.set_parent(&*self.message_entry);
obj.setup_drop_target();
self.parent_constructed(obj);
}
fn dispose(&self, _obj: &Self::Type) {
self.completion.unparent();
}
}
impl WidgetImpl for RoomHistory {}
@ -453,6 +472,7 @@ impl RoomHistory {
self.update_view();
self.start_loading();
self.update_room_state();
self.update_completion();
self.notify("room");
self.notify("empty");
}
@ -924,6 +944,30 @@ impl RoomHistory {
pub fn item_reaction_chooser(&self) -> &ReactionChooser {
&self.imp().item_reaction_chooser
}
// Update the completion for the current room.
fn update_completion(&self) {
if let Some(room) = self.room() {
let completion = &self.imp().completion;
completion.set_user_id(Some(room.session().user().unwrap().user_id().to_string()));
completion.set_members(Some(room.members()))
}
}
// Copy the selection in the message entry to the clipboard while replacing
// mentions.
fn copy_buffer_selection_to_clipboard(&self) {
if let Some((start, end)) = self.imp().message_entry.buffer().selection_bounds() {
let content: String = self
.split_buffer_mentions(start, end)
.map(|chunk| match chunk {
MentionChunk::Text(str) => str,
MentionChunk::Mention { name, .. } => name,
})
.collect();
self.clipboard().set_text(&content);
}
}
}
impl Default for RoomHistory {

5
src/session/room/member_list.rs

@ -116,8 +116,9 @@ impl MemberList {
}
let num_members_added = members.len().saturating_sub(prev_len);
// We can't have the borrow active when members are updated or items_changed is
// emitted because that will probably cause reads of the members field.
// We can't have the mut borrow active when members are updated or items_changed
// is emitted because that will probably cause reads of the members
// field.
std::mem::drop(members);
{

2
src/session/room/mod.rs

@ -48,6 +48,7 @@ pub use self::{
event_actions::EventActions,
highlight_flags::HighlightFlags,
member::{Member, Membership},
member_list::MemberList,
member_role::MemberRole,
power_levels::{PowerLevel, PowerLevels, RoomAction, POWER_LEVEL_MAX, POWER_LEVEL_MIN},
reaction_group::ReactionGroup,
@ -62,7 +63,6 @@ use crate::{
prelude::*,
session::{
avatar::update_room_avatar_from_file,
room::member_list::MemberList,
sidebar::{SidebarItem, SidebarItemImpl},
Avatar, Session, User,
},

Loading…
Cancel
Save