Browse Source
They are replaced by an item that shows the count of state rows that are hidden. This item can be expanded to show all the hidden state rows.merge-requests/1958/merge
34 changed files with 6709 additions and 1007 deletions
@ -0,0 +1,91 @@
|
||||
use gettextrs::gettext; |
||||
use gtk::{gio, prelude::*}; |
||||
|
||||
use super::QuickReactionChooser; |
||||
use crate::session::model::ReactionList; |
||||
|
||||
/// Helper struct for the context menu of a row presenting an [`Event`].
|
||||
///
|
||||
/// [`Event`]: crate::session::model::Event
|
||||
#[derive(Debug)] |
||||
pub(crate) struct EventActionsContextMenu { |
||||
/// The popover of the context menu.
|
||||
pub(crate) popover: gtk::PopoverMenu, |
||||
/// The menu model of the popover.
|
||||
menu_model: gio::Menu, |
||||
/// The quick reaction chooser in the context menu.
|
||||
quick_reaction_chooser: QuickReactionChooser, |
||||
} |
||||
|
||||
impl EventActionsContextMenu { |
||||
/// The identifier in the context menu for the quick reaction chooser.
|
||||
const QUICK_REACTION_CHOOSER_ID: &str = "quick-reaction-chooser"; |
||||
|
||||
/// Whether the menu includes an item for the quick reaction chooser.
|
||||
fn has_quick_reaction_chooser(&self) -> bool { |
||||
let first_section = self |
||||
.menu_model |
||||
.item_link(0, gio::MENU_LINK_SECTION) |
||||
.and_downcast::<gio::Menu>() |
||||
.expect("event context menu should have at least one section"); |
||||
first_section |
||||
.item_attribute_value(0, "custom", Some(&String::static_variant_type())) |
||||
.and_then(|variant| variant.get::<String>()) |
||||
.is_some_and(|value| value == Self::QUICK_REACTION_CHOOSER_ID) |
||||
} |
||||
|
||||
/// Add the quick reaction chooser to this menu, if it is not already
|
||||
/// present, and set the reaction list.
|
||||
pub(crate) fn add_quick_reaction_chooser(&self, reactions: ReactionList) { |
||||
if !self.has_quick_reaction_chooser() { |
||||
let section_menu = gio::Menu::new(); |
||||
let item = gio::MenuItem::new(None, None); |
||||
item.set_attribute_value( |
||||
"custom", |
||||
Some(&Self::QUICK_REACTION_CHOOSER_ID.to_variant()), |
||||
); |
||||
section_menu.append_item(&item); |
||||
self.menu_model.insert_section(0, None, §ion_menu); |
||||
|
||||
self.popover.add_child( |
||||
&self.quick_reaction_chooser, |
||||
Self::QUICK_REACTION_CHOOSER_ID, |
||||
); |
||||
} |
||||
|
||||
self.quick_reaction_chooser.set_reactions(Some(reactions)); |
||||
} |
||||
|
||||
/// Remove the quick reaction chooser from this menu, if it is present.
|
||||
pub(crate) fn remove_quick_reaction_chooser(&self) { |
||||
if !self.has_quick_reaction_chooser() { |
||||
return; |
||||
} |
||||
|
||||
self.popover.remove_child(&self.quick_reaction_chooser); |
||||
self.menu_model.remove(0); |
||||
} |
||||
} |
||||
|
||||
impl Default for EventActionsContextMenu { |
||||
fn default() -> Self { |
||||
let menu_model = gtk::Builder::from_resource( |
||||
"/org/gnome/Fractal/ui/session/view/content/room_history/event_actions/context_menu.ui", |
||||
) |
||||
.object::<gio::Menu>("event-actions-menu") |
||||
.expect("GResource and menu should exist"); |
||||
|
||||
let popover = gtk::PopoverMenu::builder() |
||||
.has_arrow(false) |
||||
.halign(gtk::Align::Start) |
||||
.menu_model(&menu_model) |
||||
.build(); |
||||
popover.update_property(&[gtk::accessible::Property::Label(&gettext("Context Menu"))]); |
||||
|
||||
Self { |
||||
popover, |
||||
menu_model, |
||||
quick_reaction_chooser: Default::default(), |
||||
} |
||||
} |
||||
} |
||||
@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?> |
||||
<interface> |
||||
<menu id="event-menu"> |
||||
<menu id="event-actions-menu"> |
||||
<section> |
||||
<item> |
||||
<!-- Translators: In this string, 'Reply' is a verb. --> |
||||
@ -0,0 +1,648 @@
|
||||
use std::ops::Deref; |
||||
|
||||
use adw::{prelude::*, subclass::prelude::*}; |
||||
use gettextrs::gettext; |
||||
use gtk::{gdk, gio, glib, glib::clone}; |
||||
use ruma::events::room::message::MessageType; |
||||
use tracing::error; |
||||
|
||||
use crate::{ |
||||
prelude::*, |
||||
session::{ |
||||
model::{Event, MessageState, Room}, |
||||
view::EventDetailsDialog, |
||||
}, |
||||
spawn, spawn_tokio, toast, |
||||
}; |
||||
|
||||
/// Trait to help a row that presents an `Event` to provide the proper actions.
|
||||
pub(crate) trait EventActionsGroup: ObjectSubclass { |
||||
/// The current event of the row, if any.
|
||||
fn event(&self) -> Option<Event>; |
||||
|
||||
/// The current `GdkTexture` of the row, if any.
|
||||
fn texture(&self) -> Option<gdk::Texture>; |
||||
|
||||
/// The current `GtkPopoverMenu` of the row, if any.
|
||||
fn popover(&self) -> Option<gtk::PopoverMenu>; |
||||
|
||||
/// Get the `GActionGroup` with the proper actions for the current event.
|
||||
fn event_actions_group(&self) -> Option<gio::SimpleActionGroup> |
||||
where |
||||
Self: glib::clone::Downgrade, |
||||
Self::Type: IsA<gtk::Widget>, |
||||
Self::Weak: 'static, |
||||
<Self::Weak as glib::clone::Upgrade>::Strong: Deref, |
||||
<<Self::Weak as glib::clone::Upgrade>::Strong as Deref>::Target: EventActionsGroup, |
||||
<<<Self::Weak as glib::clone::Upgrade>::Strong as Deref>::Target as ObjectSubclass>::Type: |
||||
IsA<gtk::Widget>, |
||||
{ |
||||
let event = self.event()?; |
||||
let action_group = gio::SimpleActionGroup::new(); |
||||
let room = event.room(); |
||||
let has_event_id = event.event_id().is_some(); |
||||
|
||||
if has_event_id { |
||||
action_group.add_action_entries([ |
||||
// Create a permalink.
|
||||
gio::ActionEntry::builder("permalink") |
||||
.activate(clone!( |
||||
#[weak(rename_to = imp)] |
||||
self, |
||||
move |_, _, _| { |
||||
spawn!(async move { |
||||
let Some(event) = imp.event() else { |
||||
return; |
||||
}; |
||||
let Some(permalink) = event.matrix_to_uri().await else { |
||||
return; |
||||
}; |
||||
|
||||
let obj = imp.obj(); |
||||
obj.clipboard().set_text(&permalink.to_string()); |
||||
toast!(obj, gettext("Message link copied to clipboard")); |
||||
}); |
||||
} |
||||
)) |
||||
.build(), |
||||
// View event details.
|
||||
gio::ActionEntry::builder("view-details") |
||||
.activate(clone!( |
||||
#[weak(rename_to = imp)] |
||||
self, |
||||
move |_, _, _| { |
||||
let Some(event) = imp.event() else { |
||||
return; |
||||
}; |
||||
|
||||
let dialog = EventDetailsDialog::new(&event); |
||||
dialog.present(Some(&*imp.obj())); |
||||
} |
||||
)) |
||||
.build(), |
||||
]); |
||||
|
||||
if room.is_joined() { |
||||
action_group.add_action_entries([ |
||||
// Report the event.
|
||||
gio::ActionEntry::builder("report") |
||||
.activate(clone!( |
||||
#[weak(rename_to = imp)] |
||||
self, |
||||
move |_, _, _| { |
||||
spawn!(async move { |
||||
imp.report_event().await; |
||||
}); |
||||
} |
||||
)) |
||||
.build(), |
||||
]); |
||||
} |
||||
} else { |
||||
let state = event.state(); |
||||
|
||||
if matches!( |
||||
state, |
||||
MessageState::Sending |
||||
| MessageState::RecoverableError |
||||
| MessageState::PermanentError |
||||
) { |
||||
// Cancel the event.
|
||||
action_group.add_action_entries([gio::ActionEntry::builder("cancel-send") |
||||
.activate(clone!( |
||||
#[weak(rename_to = imp)] |
||||
self, |
||||
move |_, _, _| { |
||||
spawn!(async move { |
||||
imp.cancel_send().await; |
||||
}); |
||||
} |
||||
)) |
||||
.build()]); |
||||
} |
||||
} |
||||
|
||||
self.add_message_like_actions(&action_group, &room, &event); |
||||
|
||||
Some(action_group) |
||||
} |
||||
|
||||
/// Add actions to the given action group for the given event, if it is
|
||||
/// message-like.
|
||||
///
|
||||
/// See [`Event::is_message_like()`] for the definition of a message
|
||||
/// event.
|
||||
fn add_message_like_actions( |
||||
&self, |
||||
action_group: &gio::SimpleActionGroup, |
||||
room: &Room, |
||||
event: &Event, |
||||
) where |
||||
Self: glib::clone::Downgrade, |
||||
Self::Type: IsA<gtk::Widget>, |
||||
Self::Weak: 'static, |
||||
<Self::Weak as glib::clone::Upgrade>::Strong: Deref, |
||||
<<Self::Weak as glib::clone::Upgrade>::Strong as Deref>::Target: EventActionsGroup, |
||||
<<<Self::Weak as glib::clone::Upgrade>::Strong as Deref>::Target as ObjectSubclass>::Type: |
||||
IsA<gtk::Widget>, |
||||
{ |
||||
if !event.is_message_like() { |
||||
return; |
||||
} |
||||
|
||||
let own_member = room.own_member(); |
||||
let own_user_id = own_member.user_id(); |
||||
let is_from_own_user = event.sender_id() == *own_user_id; |
||||
let permissions = room.permissions(); |
||||
let has_event_id = event.event_id().is_some(); |
||||
|
||||
// Redact/remove the event.
|
||||
if has_event_id |
||||
&& ((is_from_own_user && permissions.can_redact_own()) |
||||
|| permissions.can_redact_other()) |
||||
{ |
||||
action_group.add_action_entries([gio::ActionEntry::builder("remove") |
||||
.activate(clone!( |
||||
#[weak(rename_to = imp)] |
||||
self, |
||||
move |_, _, _| { |
||||
spawn!(async move { |
||||
imp.redact_message().await; |
||||
}); |
||||
} |
||||
)) |
||||
.build()]); |
||||
} |
||||
|
||||
// Send/redact a reaction.
|
||||
if event.can_be_reacted_to() { |
||||
action_group.add_action_entries([ |
||||
gio::ActionEntry::builder("toggle-reaction") |
||||
.parameter_type(Some(&String::static_variant_type())) |
||||
.activate(clone!( |
||||
#[weak(rename_to = imp)] |
||||
self, |
||||
move |_, _, variant| { |
||||
let Some(key) = variant |
||||
.expect("toggle-reaction action should have a parameter") |
||||
.get::<String>() |
||||
else { |
||||
error!("Could not parse reaction to toggle"); |
||||
return; |
||||
}; |
||||
|
||||
spawn!(async move { |
||||
imp.toggle_reaction(key).await; |
||||
}); |
||||
} |
||||
)) |
||||
.build(), |
||||
gio::ActionEntry::builder("show-reactions-chooser") |
||||
.activate(clone!( |
||||
#[weak(rename_to = imp)] |
||||
self, |
||||
move |_, _, _| { |
||||
imp.show_reactions_chooser(); |
||||
} |
||||
)) |
||||
.build(), |
||||
]); |
||||
} |
||||
|
||||
// Reply.
|
||||
if event.can_be_replied_to() { |
||||
action_group.add_action_entries([gio::ActionEntry::builder("reply") |
||||
.activate(clone!( |
||||
#[weak(rename_to = imp)] |
||||
self, |
||||
move |_, _, _| { |
||||
let Some(event) = imp.event() else { |
||||
error!("Could not reply to timeline item that is not an event"); |
||||
return; |
||||
}; |
||||
let Some(event_id) = event.event_id() else { |
||||
error!("Event to reply to does not have an event ID"); |
||||
return; |
||||
}; |
||||
|
||||
if imp |
||||
.obj() |
||||
.activate_action( |
||||
"room-history.reply", |
||||
Some(&event_id.as_str().to_variant()), |
||||
) |
||||
.is_err() |
||||
{ |
||||
error!("Could not activate `room-history.reply` action"); |
||||
} |
||||
} |
||||
)) |
||||
.build()]); |
||||
} |
||||
|
||||
self.add_message_actions(action_group, room, event); |
||||
} |
||||
|
||||
/// Add actions to the given action group for the given event, if it
|
||||
/// is a message.
|
||||
#[allow(clippy::too_many_lines)] |
||||
fn add_message_actions(&self, action_group: &gio::SimpleActionGroup, room: &Room, event: &Event) |
||||
where |
||||
Self: glib::clone::Downgrade, |
||||
Self::Type: IsA<gtk::Widget>, |
||||
Self::Weak: 'static, |
||||
<Self::Weak as glib::clone::Upgrade>::Strong: Deref, |
||||
<<Self::Weak as glib::clone::Upgrade>::Strong as Deref>::Target: EventActionsGroup, |
||||
<<<Self::Weak as glib::clone::Upgrade>::Strong as Deref>::Target as ObjectSubclass>::Type: |
||||
IsA<gtk::Widget>, |
||||
{ |
||||
let Some(message) = event.message() else { |
||||
return; |
||||
}; |
||||
|
||||
let own_member = room.own_member(); |
||||
let own_user_id = own_member.user_id(); |
||||
let is_from_own_user = event.sender_id() == *own_user_id; |
||||
let permissions = room.permissions(); |
||||
let has_event_id = event.event_id().is_some(); |
||||
|
||||
match message.msgtype() { |
||||
MessageType::Text(_) | MessageType::Emote(_) => { |
||||
// Copy text.
|
||||
action_group.add_action_entries([gio::ActionEntry::builder("copy-text") |
||||
.activate(clone!( |
||||
#[weak(rename_to = imp)] |
||||
self, |
||||
move |_, _, _| { |
||||
imp.copy_text(); |
||||
} |
||||
)) |
||||
.build()]); |
||||
|
||||
// Edit message.
|
||||
if has_event_id && is_from_own_user && permissions.can_send_message() { |
||||
action_group.add_action_entries([gio::ActionEntry::builder("edit") |
||||
.activate(clone!( |
||||
#[weak(rename_to = imp)] |
||||
self, |
||||
move |_, _, _| { |
||||
imp.edit_message(); |
||||
} |
||||
)) |
||||
.build()]); |
||||
} |
||||
} |
||||
MessageType::File(_) => { |
||||
// Save message's file.
|
||||
action_group.add_action_entries([gio::ActionEntry::builder("file-save") |
||||
.activate(clone!( |
||||
#[weak(rename_to = imp)] |
||||
self, |
||||
move |_, _, _| { |
||||
spawn!(async move { |
||||
imp.save_file().await; |
||||
}); |
||||
} |
||||
)) |
||||
.build()]); |
||||
} |
||||
MessageType::Notice(_) => { |
||||
// Copy text.
|
||||
action_group.add_action_entries([gio::ActionEntry::builder("copy-text") |
||||
.activate(clone!( |
||||
#[weak(rename_to = imp)] |
||||
self, |
||||
move |_, _, _| { |
||||
imp.copy_text(); |
||||
} |
||||
)) |
||||
.build()]); |
||||
} |
||||
MessageType::Image(_) => { |
||||
action_group.add_action_entries([ |
||||
// Copy the texture to the clipboard.
|
||||
gio::ActionEntry::builder("copy-image") |
||||
.activate(clone!( |
||||
#[weak(rename_to = imp)] |
||||
self, |
||||
move |_, _, _| { |
||||
let Some(texture) = imp.texture() else { |
||||
error!("Could not find texture to copy"); |
||||
return; |
||||
}; |
||||
|
||||
let obj = imp.obj(); |
||||
obj.clipboard().set_texture(&texture); |
||||
toast!(obj, gettext("Thumbnail copied to clipboard")); |
||||
} |
||||
)) |
||||
.build(), |
||||
// Save the image to a file.
|
||||
gio::ActionEntry::builder("save-image") |
||||
.activate(clone!( |
||||
#[weak(rename_to = imp)] |
||||
self, |
||||
move |_, _, _| { |
||||
spawn!(async move { |
||||
imp.save_file().await; |
||||
}); |
||||
} |
||||
)) |
||||
.build(), |
||||
]); |
||||
} |
||||
MessageType::Video(_) => { |
||||
// Save the video to a file.
|
||||
action_group.add_action_entries([gio::ActionEntry::builder("save-video") |
||||
.activate(clone!( |
||||
#[weak(rename_to = imp)] |
||||
self, |
||||
move |_, _, _| { |
||||
spawn!(async move { |
||||
imp.save_file().await; |
||||
}); |
||||
} |
||||
)) |
||||
.build()]); |
||||
} |
||||
MessageType::Audio(_) => { |
||||
// Save the audio to a file.
|
||||
action_group.add_action_entries([gio::ActionEntry::builder("save-audio") |
||||
.activate(clone!( |
||||
#[weak(rename_to = imp)] |
||||
self, |
||||
move |_, _, _| { |
||||
spawn!(async move { |
||||
imp.save_file().await; |
||||
}); |
||||
} |
||||
)) |
||||
.build()]); |
||||
} |
||||
_ => {} |
||||
} |
||||
|
||||
if let Some(media_message) = event.media_message() { |
||||
if media_message.caption().is_some() { |
||||
// Copy caption.
|
||||
action_group.add_action_entries([gio::ActionEntry::builder("copy-text") |
||||
.activate(clone!( |
||||
#[weak(rename_to = imp)] |
||||
self, |
||||
move |_, _, _| { |
||||
imp.copy_text(); |
||||
} |
||||
)) |
||||
.build()]); |
||||
} |
||||
} |
||||
} |
||||
|
||||
/// Replace the context menu with an emoji chooser for reactions.
|
||||
fn show_reactions_chooser(&self) |
||||
where |
||||
Self::Type: IsA<gtk::Widget>, |
||||
{ |
||||
let Some(popover) = self.popover() else { |
||||
return; |
||||
}; |
||||
|
||||
let obj = self.obj(); |
||||
let (_, rectangle) = popover.pointing_to(); |
||||
|
||||
let emoji_chooser = gtk::EmojiChooser::builder() |
||||
.has_arrow(false) |
||||
.pointing_to(&rectangle) |
||||
.build(); |
||||
|
||||
emoji_chooser.connect_emoji_picked(clone!( |
||||
#[strong] |
||||
obj, |
||||
move |_, emoji| { |
||||
let _ = obj.activate_action("event.toggle-reaction", Some(&emoji.to_variant())); |
||||
} |
||||
)); |
||||
emoji_chooser.connect_closed(|emoji_chooser| { |
||||
emoji_chooser.unparent(); |
||||
}); |
||||
emoji_chooser.set_parent(&*obj); |
||||
|
||||
popover.popdown(); |
||||
emoji_chooser.popup(); |
||||
} |
||||
|
||||
/// Copy the text of this row.
|
||||
fn copy_text(&self) |
||||
where |
||||
Self::Type: IsA<gtk::Widget>, |
||||
{ |
||||
let Some(event) = self.event() else { |
||||
error!("Could not copy text of timeline item that is not an event"); |
||||
return; |
||||
}; |
||||
let Some(message) = event.message() else { |
||||
error!("Could not copy text of event that is not a message"); |
||||
return; |
||||
}; |
||||
|
||||
let text = match message.msgtype() { |
||||
MessageType::Text(text_message) => text_message.body.clone(), |
||||
MessageType::Emote(emote_message) => { |
||||
let display_name = event.sender().display_name(); |
||||
format!("{display_name} {}", emote_message.body) |
||||
} |
||||
MessageType::Notice(notice_message) => notice_message.body.clone(), |
||||
_ => { |
||||
if let Some(caption) = event |
||||
.media_message() |
||||
.and_then(|m| m.caption().map(|(caption, _)| caption.to_owned())) |
||||
{ |
||||
caption |
||||
} else { |
||||
error!("Could not copy text of event that is not a textual message"); |
||||
return; |
||||
} |
||||
} |
||||
}; |
||||
|
||||
let obj = self.obj(); |
||||
obj.clipboard().set_text(&text); |
||||
toast!(obj, gettext("Text copied to clipboard")); |
||||
} |
||||
|
||||
/// Edit the message of this row.
|
||||
fn edit_message(&self) |
||||
where |
||||
Self::Type: IsA<gtk::Widget>, |
||||
{ |
||||
let Some(event) = self.event() else { |
||||
error!("Could not edit timeline item that is not an event"); |
||||
return; |
||||
}; |
||||
let Some(event_id) = event.event_id() else { |
||||
error!("Could not edit event without an event ID"); |
||||
return; |
||||
}; |
||||
|
||||
if self |
||||
.obj() |
||||
.activate_action("room-history.edit", Some(&event_id.as_str().to_variant())) |
||||
.is_err() |
||||
{ |
||||
error!("Could not activate `room-history.edit` action"); |
||||
} |
||||
} |
||||
|
||||
/// Save the media file of this row.
|
||||
async fn save_file(&self) |
||||
where |
||||
Self::Type: IsA<gtk::Widget>, |
||||
{ |
||||
let Some(event) = self.event() else { |
||||
error!("Could not save file of timeline item that is not an event"); |
||||
return; |
||||
}; |
||||
let Some(session) = event.room().session() else { |
||||
// Should only happen if the process is being closed.
|
||||
return; |
||||
}; |
||||
let Some(media_message) = event.media_message() else { |
||||
error!("Could not save file for non-media event"); |
||||
return; |
||||
}; |
||||
|
||||
let client = session.client(); |
||||
media_message.save_to_file(&client, &*self.obj()).await; |
||||
} |
||||
|
||||
/// Redact the event of this row.
|
||||
async fn redact_message(&self) |
||||
where |
||||
Self::Type: IsA<gtk::Widget>, |
||||
{ |
||||
let Some(event) = self.event() else { |
||||
error!("Could not redact timeline item that is not an event"); |
||||
return; |
||||
}; |
||||
let Some(event_id) = event.event_id() else { |
||||
error!("Event to redact does not have an event ID"); |
||||
return; |
||||
}; |
||||
let obj = self.obj(); |
||||
|
||||
let confirm_dialog = adw::AlertDialog::builder() |
||||
.default_response("cancel") |
||||
.heading(gettext("Remove Message?")) |
||||
.body(gettext( |
||||
"Do you really want to remove this message? This cannot be undone.", |
||||
)) |
||||
.build(); |
||||
confirm_dialog.add_responses(&[ |
||||
("cancel", &gettext("Cancel")), |
||||
("remove", &gettext("Remove")), |
||||
]); |
||||
confirm_dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive); |
||||
|
||||
if confirm_dialog.choose_future(&*obj).await != "remove" { |
||||
return; |
||||
} |
||||
|
||||
if event.room().redact(&[event_id], None).await.is_err() { |
||||
toast!(obj, gettext("Could not remove message")); |
||||
} |
||||
} |
||||
|
||||
/// Toggle the reaction with the given key for the event of this row.
|
||||
async fn toggle_reaction(&self, key: String) |
||||
where |
||||
Self::Type: IsA<gtk::Widget>, |
||||
{ |
||||
let Some(event) = self.event() else { |
||||
error!("Could not toggle reaction on timeline item that is not an event"); |
||||
return; |
||||
}; |
||||
|
||||
if event.room().toggle_reaction(key, &event).await.is_err() { |
||||
toast!(self.obj(), gettext("Could not toggle reaction")); |
||||
} |
||||
} |
||||
|
||||
/// Report the current event.
|
||||
async fn report_event(&self) |
||||
where |
||||
Self::Type: IsA<gtk::Widget>, |
||||
{ |
||||
let Some(event) = self.event() else { |
||||
error!("Could not report timeline item that is not an event"); |
||||
return; |
||||
}; |
||||
let Some(event_id) = event.event_id() else { |
||||
error!("Event to report does not have an event ID"); |
||||
return; |
||||
}; |
||||
let obj = self.obj(); |
||||
|
||||
// Ask the user to confirm, and provide optional reason.
|
||||
let reason_entry = adw::EntryRow::builder() |
||||
.title(gettext("Reason (optional)")) |
||||
.build(); |
||||
let list_box = gtk::ListBox::builder() |
||||
.css_classes(["boxed-list"]) |
||||
.margin_top(6) |
||||
.accessible_role(gtk::AccessibleRole::Group) |
||||
.build(); |
||||
list_box.append(&reason_entry); |
||||
|
||||
let confirm_dialog = adw::AlertDialog::builder() |
||||
.default_response("cancel") |
||||
.heading(gettext("Report Event?")) |
||||
.body(gettext( |
||||
"Reporting an event will send its unique ID to the administrator of your homeserver. The administrator will not be able to see the content of the event if it is encrypted or redacted.", |
||||
)) |
||||
.extra_child(&list_box) |
||||
.build(); |
||||
confirm_dialog.add_responses(&[ |
||||
("cancel", &gettext("Cancel")), |
||||
// Translators: This is a verb, as in 'Report Event'.
|
||||
("report", &gettext("Report")), |
||||
]); |
||||
confirm_dialog.set_response_appearance("report", adw::ResponseAppearance::Destructive); |
||||
|
||||
if confirm_dialog.choose_future(&*obj).await != "report" { |
||||
return; |
||||
} |
||||
|
||||
let reason = Some(reason_entry.text()) |
||||
.filter(|s| !s.is_empty()) |
||||
.map(Into::into); |
||||
|
||||
if event |
||||
.room() |
||||
.report_events(&[(event_id, reason)]) |
||||
.await |
||||
.is_err() |
||||
{ |
||||
toast!(obj, gettext("Could not report event")); |
||||
} |
||||
} |
||||
|
||||
/// Cancel sending the event of this row.
|
||||
async fn cancel_send(&self) |
||||
where |
||||
Self::Type: IsA<gtk::Widget>, |
||||
{ |
||||
let Some(event) = self.event() else { |
||||
error!("Could not discard timeline item that is not an event"); |
||||
return; |
||||
}; |
||||
|
||||
let matrix_timeline = event.timeline().matrix_timeline(); |
||||
let identifier = event.identifier(); |
||||
let handle = spawn_tokio!(async move { matrix_timeline.redact(&identifier, None).await }); |
||||
|
||||
if let Err(error) = handle.await.unwrap() { |
||||
error!("Could not discard local event: {error}"); |
||||
toast!(self.obj(), gettext("Could not discard message")); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,6 @@
|
||||
mod context_menu; |
||||
mod group; |
||||
mod quick_reaction_chooser; |
||||
|
||||
use self::quick_reaction_chooser::*; |
||||
pub(super) use self::{context_menu::*, group::*}; |
||||
@ -0,0 +1,309 @@
|
||||
use gtk::{gdk, gio, glib, glib::clone, prelude::*, subclass::prelude::*}; |
||||
use tracing::error; |
||||
|
||||
use super::StateContent; |
||||
use crate::{ |
||||
prelude::*, |
||||
session::{ |
||||
model::Event, |
||||
view::content::room_history::{EventActionsGroup, RoomHistory}, |
||||
}, |
||||
utils::{key_bindings, BoundObject, BoundObjectWeakRef}, |
||||
}; |
||||
|
||||
mod imp { |
||||
use std::{cell::RefCell, rc::Rc}; |
||||
|
||||
use super::*; |
||||
|
||||
#[derive(Debug, Default, glib::Properties)] |
||||
#[properties(wrapper_type = super::StateGroupItemRow)] |
||||
pub struct StateGroupItemRow { |
||||
content: StateContent, |
||||
/// The state event presented by this row.
|
||||
#[property(get, set = Self::set_event, explicit_notify)] |
||||
event: BoundObjectWeakRef<Event>, |
||||
/// The event action group of this row.
|
||||
action_group: RefCell<Option<gio::SimpleActionGroup>>, |
||||
/// The popover of this row.
|
||||
popover: BoundObject<gtk::PopoverMenu>, |
||||
permissions_handler: RefCell<Option<glib::SignalHandlerId>>, |
||||
} |
||||
|
||||
#[glib::object_subclass] |
||||
impl ObjectSubclass for StateGroupItemRow { |
||||
const NAME: &'static str = "ContentStateGroupItemRow"; |
||||
type Type = super::StateGroupItemRow; |
||||
type ParentType = gtk::ListBoxRow; |
||||
|
||||
fn class_init(klass: &mut Self::Class) { |
||||
klass.set_css_name("state-group-item-row"); |
||||
|
||||
klass.install_action("context-menu.activate", None, |obj, _, _| { |
||||
obj.imp().open_context_menu(0, 0); |
||||
}); |
||||
key_bindings::add_context_menu_bindings(klass, "context-menu.activate"); |
||||
|
||||
klass.install_action("context-menu.close", None, |obj, _, _| { |
||||
if let Some(popover) = obj.imp().popover.obj() { |
||||
popover.popdown(); |
||||
} |
||||
}); |
||||
} |
||||
} |
||||
|
||||
#[glib::derived_properties] |
||||
impl ObjectImpl for StateGroupItemRow { |
||||
fn constructed(&self) { |
||||
self.parent_constructed(); |
||||
let obj = self.obj(); |
||||
|
||||
obj.set_child(Some(&self.content)); |
||||
|
||||
// Open context menu on long tap.
|
||||
let long_press_gesture = gtk::GestureLongPress::builder() |
||||
.touch_only(true) |
||||
.exclusive(true) |
||||
.build(); |
||||
long_press_gesture.connect_pressed(clone!( |
||||
#[weak(rename_to = imp)] |
||||
self, |
||||
move |gesture, x, y| { |
||||
if imp.action_group.borrow().is_some() { |
||||
gesture.set_state(gtk::EventSequenceState::Claimed); |
||||
gesture.reset(); |
||||
imp.open_context_menu(x as i32, y as i32); |
||||
} |
||||
} |
||||
)); |
||||
obj.add_controller(long_press_gesture); |
||||
|
||||
// Open context menu on right click.
|
||||
let right_click_gesture = gtk::GestureClick::builder() |
||||
.button(3) |
||||
.exclusive(true) |
||||
.build(); |
||||
right_click_gesture.connect_released(clone!( |
||||
#[weak(rename_to = imp)] |
||||
self, |
||||
move |gesture, n_press, x, y| { |
||||
if n_press > 1 { |
||||
return; |
||||
} |
||||
|
||||
if imp.action_group.borrow().is_some() { |
||||
gesture.set_state(gtk::EventSequenceState::Claimed); |
||||
imp.open_context_menu(x as i32, y as i32); |
||||
} |
||||
} |
||||
)); |
||||
obj.add_controller(right_click_gesture); |
||||
} |
||||
|
||||
fn dispose(&self) { |
||||
self.disconnect_event_signals(); |
||||
|
||||
if let Some(popover) = self.popover.obj() { |
||||
popover.unparent(); |
||||
} |
||||
} |
||||
} |
||||
|
||||
impl WidgetImpl for StateGroupItemRow {} |
||||
impl ListBoxRowImpl for StateGroupItemRow {} |
||||
|
||||
impl EventActionsGroup for StateGroupItemRow { |
||||
fn event(&self) -> Option<Event> { |
||||
self.event.obj() |
||||
} |
||||
|
||||
fn texture(&self) -> Option<gdk::Texture> { |
||||
None |
||||
} |
||||
|
||||
fn popover(&self) -> Option<gtk::PopoverMenu> { |
||||
self.popover.obj() |
||||
} |
||||
} |
||||
|
||||
impl StateGroupItemRow { |
||||
/// Set the state event presented by this row.
|
||||
fn set_event(&self, event: Option<&Event>) { |
||||
if self.event.obj().as_ref() == event { |
||||
return; |
||||
} |
||||
|
||||
self.disconnect_event_signals(); |
||||
|
||||
if let Some(event) = event { |
||||
let permissions_handler = event.room().permissions().connect_changed(clone!( |
||||
#[weak(rename_to = imp)] |
||||
self, |
||||
move |_| { |
||||
imp.update_actions(); |
||||
} |
||||
)); |
||||
self.permissions_handler.replace(Some(permissions_handler)); |
||||
|
||||
let state_notify_handler = event.connect_state_notify(clone!( |
||||
#[weak(rename_to = imp)] |
||||
self, |
||||
move |_| { |
||||
imp.update_actions(); |
||||
} |
||||
)); |
||||
let source_notify_handler = event.connect_source_notify(clone!( |
||||
#[weak(rename_to = imp)] |
||||
self, |
||||
move |_| { |
||||
imp.update_actions(); |
||||
} |
||||
)); |
||||
let edit_source_notify_handler = event.connect_latest_edit_source_notify(clone!( |
||||
#[weak(rename_to = imp)] |
||||
self, |
||||
move |_| { |
||||
imp.update_actions(); |
||||
} |
||||
)); |
||||
|
||||
self.event.set( |
||||
event, |
||||
vec![ |
||||
state_notify_handler, |
||||
source_notify_handler, |
||||
edit_source_notify_handler, |
||||
], |
||||
); |
||||
} |
||||
|
||||
self.content.set_event(event); |
||||
self.update_actions(); |
||||
|
||||
self.obj().notify_event(); |
||||
} |
||||
|
||||
/// Update the actions available for the given event.
|
||||
fn update_actions(&self) { |
||||
let obj = self.obj(); |
||||
let action_group = self.event_actions_group(); |
||||
let has_context_menu = action_group.is_some(); |
||||
|
||||
obj.insert_action_group("event", action_group.as_ref()); |
||||
self.action_group.replace(action_group); |
||||
|
||||
obj.update_property(&[gtk::accessible::Property::HasPopup(has_context_menu)]); |
||||
obj.action_set_enabled("context-menu.activate", has_context_menu); |
||||
obj.action_set_enabled("context-menu.close", has_context_menu); |
||||
} |
||||
|
||||
/// Set the popover of this row.
|
||||
fn set_popover(&self, popover: Option<gtk::PopoverMenu>) { |
||||
let prev_popover = self.popover.obj(); |
||||
|
||||
if prev_popover == popover { |
||||
return; |
||||
} |
||||
let obj = self.obj(); |
||||
|
||||
if let Some(popover) = prev_popover { |
||||
if popover.parent().is_some_and(|w| w == *obj) { |
||||
popover.unparent(); |
||||
} |
||||
} |
||||
self.popover.disconnect_signals(); |
||||
|
||||
if let Some(popover) = popover { |
||||
popover.unparent(); |
||||
popover.set_parent(&*obj); |
||||
|
||||
let parent_handler = popover.connect_parent_notify(clone!( |
||||
#[weak] |
||||
obj, |
||||
move |popover| { |
||||
if popover.parent().is_none_or(|w| w != obj) { |
||||
obj.imp().popover.disconnect_signals(); |
||||
} |
||||
} |
||||
)); |
||||
|
||||
self.popover.set(popover, vec![parent_handler]); |
||||
} |
||||
} |
||||
|
||||
/// Open the context menu of the row at the given coordinates.
|
||||
fn open_context_menu(&self, x: i32, y: i32) { |
||||
let obj = self.obj(); |
||||
|
||||
if self.action_group.borrow().is_none() { |
||||
// There are no possible actions.
|
||||
self.set_popover(None); |
||||
return; |
||||
} |
||||
|
||||
let Some(room_history) = obj |
||||
.ancestor(RoomHistory::static_type()) |
||||
.and_downcast::<RoomHistory>() |
||||
else { |
||||
error!("Could not find RoomHistory ancestor"); |
||||
self.set_popover(None); |
||||
return; |
||||
}; |
||||
|
||||
let menu = room_history.event_context_menu(); |
||||
menu.remove_quick_reaction_chooser(); |
||||
|
||||
room_history.enable_sticky_mode(false); |
||||
obj.add_css_class("has-open-popup"); |
||||
|
||||
// Reset the state when the popover is closed.
|
||||
let closed_handler_cell: Rc<RefCell<Option<glib::SignalHandlerId>>> = Rc::default(); |
||||
let closed_handler = menu.popover.connect_closed(clone!( |
||||
#[weak] |
||||
obj, |
||||
#[weak] |
||||
room_history, |
||||
#[strong] |
||||
closed_handler_cell, |
||||
move |popover| { |
||||
room_history.enable_sticky_mode(true); |
||||
obj.remove_css_class("has-open-popup"); |
||||
|
||||
if let Some(handler) = closed_handler_cell.take() { |
||||
popover.disconnect(handler); |
||||
} |
||||
} |
||||
)); |
||||
closed_handler_cell.replace(Some(closed_handler)); |
||||
|
||||
self.set_popover(Some(menu.popover.clone())); |
||||
|
||||
menu.popover |
||||
.set_pointing_to(Some(&gdk::Rectangle::new(x, y, 0, 0))); |
||||
menu.popover.popup(); |
||||
} |
||||
|
||||
/// Disconnect the signal handlers.
|
||||
fn disconnect_event_signals(&self) { |
||||
if let Some(event) = self.event.obj() { |
||||
self.event.disconnect_signals(); |
||||
|
||||
if let Some(handler) = self.permissions_handler.take() { |
||||
event.room().permissions().disconnect(handler); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
glib::wrapper! { |
||||
/// A row presenting a state event that is part of a group.
|
||||
pub struct StateGroupItemRow(ObjectSubclass<imp::StateGroupItemRow>) |
||||
@extends gtk::Widget, gtk::ListBoxRow, @implements gtk::Accessible; |
||||
} |
||||
|
||||
impl StateGroupItemRow { |
||||
pub fn new(event: &Event) -> Self { |
||||
glib::Object::builder().property("event", event).build() |
||||
} |
||||
} |
||||
@ -0,0 +1,248 @@
|
||||
use adw::{prelude::*, subclass::prelude::*}; |
||||
use gtk::{gio, glib, glib::clone, CompositeTemplate}; |
||||
|
||||
use super::StateGroupItemRow; |
||||
use crate::{ |
||||
ngettext_f, |
||||
prelude::*, |
||||
session::{ |
||||
model::{Event, Room}, |
||||
view::content::room_history::ReadReceiptsList, |
||||
}, |
||||
utils::{key_bindings, BoundObject, GroupingListGroup}, |
||||
}; |
||||
|
||||
mod imp { |
||||
use std::{ |
||||
cell::{Cell, OnceCell}, |
||||
marker::PhantomData, |
||||
}; |
||||
|
||||
use glib::subclass::InitializingObject; |
||||
|
||||
use super::*; |
||||
|
||||
#[derive(Debug, Default, CompositeTemplate, glib::Properties)] |
||||
#[template(
|
||||
resource = "/org/gnome/Fractal/ui/session/view/content/room_history/state/group_row.ui" |
||||
)] |
||||
#[properties(wrapper_type = super::StateGroupRow)] |
||||
pub struct StateGroupRow { |
||||
#[template_child] |
||||
label: TemplateChild<gtk::Label>, |
||||
#[template_child] |
||||
list_box: TemplateChild<gtk::ListBox>, |
||||
/// The group displayed by this widget.
|
||||
#[property(get, set = Self::set_group, explicit_notify, nullable)] |
||||
group: BoundObject<GroupingListGroup>, |
||||
/// The room containing the events of the current group.
|
||||
#[property(get = Self::room)] |
||||
room: PhantomData<Option<Room>>, |
||||
/// The list model containing the read receipts lists of the children.
|
||||
read_receipts_lists: OnceCell<gio::ListStore>, |
||||
/// The list model containing all the read receiptsof the children.
|
||||
#[property(get = Self::read_receipts_list_model_owned)] |
||||
read_receipts_list_model: OnceCell<gtk::FlattenListModel>, |
||||
/// Whether this group contains read receipts.
|
||||
#[property(get = Self::has_read_receipts)] |
||||
has_read_receipts: PhantomData<bool>, |
||||
/// Whether this group is expanded.
|
||||
#[property(get, set = Self::set_is_expanded, construct)] |
||||
is_expanded: Cell<bool>, |
||||
} |
||||
|
||||
#[glib::object_subclass] |
||||
impl ObjectSubclass for StateGroupRow { |
||||
const NAME: &'static str = "ContentStateGroupRow"; |
||||
type Type = super::StateGroupRow; |
||||
type ParentType = adw::Bin; |
||||
|
||||
fn class_init(klass: &mut Self::Class) { |
||||
ReadReceiptsList::ensure_type(); |
||||
|
||||
Self::bind_template(klass); |
||||
Self::bind_template_callbacks(klass); |
||||
|
||||
klass.set_css_name("state-group-row"); |
||||
klass.set_accessible_role(gtk::AccessibleRole::ListItem); |
||||
|
||||
klass.install_action("state-group-row.toggle-expanded", None, |obj, _, _| { |
||||
obj.imp().toggle_expanded(); |
||||
}); |
||||
key_bindings::add_activate_bindings(klass, "state-group-row.toggle-expanded"); |
||||
} |
||||
|
||||
fn instance_init(obj: &InitializingObject<Self>) { |
||||
obj.init_template(); |
||||
} |
||||
} |
||||
|
||||
#[glib::derived_properties] |
||||
impl ObjectImpl for StateGroupRow {} |
||||
|
||||
impl WidgetImpl for StateGroupRow {} |
||||
impl BinImpl for StateGroupRow {} |
||||
|
||||
#[gtk::template_callbacks] |
||||
impl StateGroupRow { |
||||
/// Set the group presented by this row.
|
||||
fn set_group(&self, group: Option<GroupingListGroup>) { |
||||
let prev_group = self.group.obj(); |
||||
|
||||
if prev_group == group { |
||||
return; |
||||
} |
||||
|
||||
self.group.disconnect_signals(); |
||||
|
||||
let removed = prev_group.map(|group| group.n_items()).unwrap_or_default(); |
||||
let added = group |
||||
.as_ref() |
||||
.map(GroupingListGroup::n_items) |
||||
.unwrap_or_default(); |
||||
|
||||
if let Some(group) = group { |
||||
let items_changed_handler = group.connect_items_changed(clone!( |
||||
#[weak(rename_to = imp)] |
||||
self, |
||||
move |_, position, removed, added| { |
||||
imp.items_changed(position, removed, added); |
||||
} |
||||
)); |
||||
|
||||
self.list_box.bind_model(Some(&group), |item| { |
||||
let event = item |
||||
.downcast_ref::<Event>() |
||||
.expect("group item should be an event"); |
||||
|
||||
StateGroupItemRow::new(event).upcast() |
||||
}); |
||||
|
||||
self.group.set(group, vec![items_changed_handler]); |
||||
} |
||||
|
||||
self.items_changed(0, removed, added); |
||||
|
||||
let obj = self.obj(); |
||||
obj.notify_group(); |
||||
obj.notify_room(); |
||||
} |
||||
|
||||
/// The room containing the events of this group.
|
||||
fn room(&self) -> Option<Room> { |
||||
// Get the room of the first event, since they are all in the same room.
|
||||
self.group |
||||
.obj() |
||||
.and_then(|group| group.item(0)) |
||||
.and_downcast::<Event>() |
||||
.map(|event| event.room()) |
||||
} |
||||
|
||||
/// The list model containing the read receipts lists of the children.
|
||||
fn read_receipts_lists(&self) -> &gio::ListStore { |
||||
self.read_receipts_lists |
||||
.get_or_init(gio::ListStore::new::<gio::ListStore>) |
||||
} |
||||
|
||||
/// The list model containing all the read receipts of the children.
|
||||
fn read_receipts_list_model(&self) -> >k::FlattenListModel { |
||||
self.read_receipts_list_model.get_or_init(|| { |
||||
gtk::FlattenListModel::new(Some(self.read_receipts_lists().clone())) |
||||
}) |
||||
} |
||||
/// The owned list model containing all the read receipts of the
|
||||
/// children.
|
||||
fn read_receipts_list_model_owned(&self) -> gtk::FlattenListModel { |
||||
self.read_receipts_list_model().clone() |
||||
} |
||||
|
||||
/// Whether this group contains read receipts.
|
||||
fn has_read_receipts(&self) -> bool { |
||||
self.read_receipts_list_model().n_items() > 0 |
||||
} |
||||
|
||||
/// Set whether this row is expanded.
|
||||
fn set_is_expanded(&self, is_expanded: bool) { |
||||
let obj = self.obj(); |
||||
|
||||
if is_expanded { |
||||
obj.set_state_flags(gtk::StateFlags::CHECKED, false); |
||||
} else { |
||||
obj.unset_state_flags(gtk::StateFlags::CHECKED); |
||||
} |
||||
|
||||
self.is_expanded.set(is_expanded); |
||||
|
||||
obj.notify_is_expanded(); |
||||
obj.update_state(&[gtk::accessible::State::Expanded(Some(is_expanded))]); |
||||
} |
||||
|
||||
/// Toggle whether this group is expanded.
|
||||
#[template_callback] |
||||
fn toggle_expanded(&self) { |
||||
self.set_is_expanded(!self.is_expanded.get()); |
||||
} |
||||
|
||||
/// Handle when items changed in the underlying group.
|
||||
fn items_changed(&self, position: u32, removed: u32, added: u32) { |
||||
let Some(group) = self.group.obj() else { |
||||
self.read_receipts_lists().remove_all(); |
||||
self.update_label(); |
||||
self.obj().notify_has_read_receipts(); |
||||
return; |
||||
}; |
||||
|
||||
let had_read_receipts = self.has_read_receipts(); |
||||
|
||||
let added_read_receipts = (position..position + added) |
||||
.map(|pos| { |
||||
group |
||||
.item(pos) |
||||
.and_downcast::<Event>() |
||||
.expect("state group item should be an event") |
||||
.read_receipts() |
||||
}) |
||||
.collect::<Vec<_>>(); |
||||
self.read_receipts_lists() |
||||
.splice(position, removed, &added_read_receipts); |
||||
|
||||
self.update_label(); |
||||
|
||||
if had_read_receipts != self.has_read_receipts() { |
||||
self.obj().notify_has_read_receipts(); |
||||
} |
||||
} |
||||
|
||||
/// Update the label of this row for the current state.
|
||||
fn update_label(&self) { |
||||
let n = self |
||||
.group |
||||
.obj() |
||||
.map(|group| group.n_items()) |
||||
.unwrap_or_default(); |
||||
|
||||
let label = ngettext_f( |
||||
// Translators: This is a change in the room, not a change between
|
||||
// rooms. Do NOT translate the content between '{' and '}', this
|
||||
// is a variable name.
|
||||
"1 room change", |
||||
"{n} room changes", |
||||
n, |
||||
&[("n", &n.to_string())], |
||||
); |
||||
self.label.set_label(&label); |
||||
} |
||||
} |
||||
} |
||||
|
||||
glib::wrapper! { |
||||
/// A row presenting a group of state events.
|
||||
pub struct StateGroupRow(ObjectSubclass<imp::StateGroupRow>) |
||||
@extends gtk::Widget, adw::Bin, @implements gtk::Accessible; |
||||
} |
||||
|
||||
impl StateGroupRow { |
||||
pub fn new() -> Self { |
||||
glib::Object::new() |
||||
} |
||||
} |
||||
@ -0,0 +1,83 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?> |
||||
<interface> |
||||
<template class="ContentStateGroupRow" parent="AdwBin"> |
||||
<property name="focusable">True</property> |
||||
<accessibility> |
||||
<relation name="labelled-by">label</relation> |
||||
</accessibility> |
||||
<style> |
||||
<class name="room-history-row"/> |
||||
</style> |
||||
<property name="child"> |
||||
<object class="GtkBox"> |
||||
<style> |
||||
<class name="event-content"/> |
||||
</style> |
||||
<property name="spacing">2</property> |
||||
<property name="orientation">vertical</property> |
||||
<child> |
||||
<object class="GtkBox"> |
||||
<style> |
||||
<class name="expander-title"/> |
||||
</style> |
||||
<property name="spacing">6</property> |
||||
<child> |
||||
<object class="GtkLabel" id="label"> |
||||
<style> |
||||
<class name="dimmed"/> |
||||
</style> |
||||
<property name="wrap">True</property> |
||||
<property name="wrap-mode">word-char</property> |
||||
<property name="xalign">0.0</property> |
||||
<property name="hexpand">True</property> |
||||
</object> |
||||
</child> |
||||
<child> |
||||
<object class="GtkImage" id="arrow"> |
||||
<property name="icon-name">expander-arrow-symbolic</property> |
||||
<property name="accessible-role">presentation</property> |
||||
<style> |
||||
<class name="arrow"/> |
||||
<class name="dimmed"/> |
||||
</style> |
||||
</object> |
||||
</child> |
||||
<child> |
||||
<object class="GtkGestureClick"> |
||||
<signal name="released" handler="toggle_expanded" swapped="yes"/> |
||||
</object> |
||||
</child> |
||||
</object> |
||||
</child> |
||||
<child> |
||||
<object class="GtkRevealer"> |
||||
<property name="reveal-child" bind-source="ContentStateGroupRow" bind-property="is-expanded" bind-flags="sync-create"/> |
||||
<property name="child"> |
||||
<object class="GtkListBox" id="list_box"> |
||||
<style> |
||||
<class name="expander-content"/> |
||||
</style> |
||||
<property name="selection-mode">none</property> |
||||
</object> |
||||
</property> |
||||
</object> |
||||
</child> |
||||
<child> |
||||
<object class="ContentReadReceiptsList" id="read_receipts"> |
||||
<binding name="visible"> |
||||
<lookup name="has-read-receipts">ContentStateGroupRow</lookup> |
||||
</binding> |
||||
<binding name="members"> |
||||
<lookup name="members"> |
||||
<lookup name="room">ContentStateGroupRow</lookup> |
||||
</lookup> |
||||
</binding> |
||||
<binding name="source"> |
||||
<lookup name="read-receipts-list-model">ContentStateGroupRow</lookup> |
||||
</binding> |
||||
</object> |
||||
</child> |
||||
</object> |
||||
</property> |
||||
</template> |
||||
</interface> |
||||
@ -0,0 +1,9 @@
|
||||
mod content; |
||||
mod creation; |
||||
mod group_item_row; |
||||
mod group_row; |
||||
mod row; |
||||
mod tombstone; |
||||
|
||||
use self::{content::*, creation::*, group_item_row::*, tombstone::*}; |
||||
pub(super) use self::{group_row::*, row::*}; |
||||
@ -0,0 +1,58 @@
|
||||
use adw::{prelude::*, subclass::prelude::*}; |
||||
use gtk::{glib, CompositeTemplate}; |
||||
|
||||
use super::StateContent; |
||||
use crate::session::{model::Event, view::content::room_history::ReadReceiptsList}; |
||||
|
||||
mod imp { |
||||
use std::cell::RefCell; |
||||
|
||||
use glib::subclass::InitializingObject; |
||||
|
||||
use super::*; |
||||
|
||||
#[derive(Debug, Default, CompositeTemplate, glib::Properties)] |
||||
#[template(resource = "/org/gnome/Fractal/ui/session/view/content/room_history/state/row.ui")] |
||||
#[properties(wrapper_type = super::StateRow)] |
||||
pub struct StateRow { |
||||
/// The state event displayed by this widget.
|
||||
#[property(get, set)] |
||||
event: RefCell<Option<Event>>, |
||||
} |
||||
|
||||
#[glib::object_subclass] |
||||
impl ObjectSubclass for StateRow { |
||||
const NAME: &'static str = "ContentStateRow"; |
||||
type Type = super::StateRow; |
||||
type ParentType = adw::Bin; |
||||
|
||||
fn class_init(klass: &mut Self::Class) { |
||||
ReadReceiptsList::ensure_type(); |
||||
StateContent::ensure_type(); |
||||
|
||||
Self::bind_template(klass); |
||||
} |
||||
|
||||
fn instance_init(obj: &InitializingObject<Self>) { |
||||
obj.init_template(); |
||||
} |
||||
} |
||||
|
||||
#[glib::derived_properties] |
||||
impl ObjectImpl for StateRow {} |
||||
|
||||
impl WidgetImpl for StateRow {} |
||||
impl BinImpl for StateRow {} |
||||
} |
||||
|
||||
glib::wrapper! { |
||||
/// A row presenting a state event.
|
||||
pub struct StateRow(ObjectSubclass<imp::StateRow>) |
||||
@extends gtk::Widget, adw::Bin, @implements gtk::Accessible; |
||||
} |
||||
|
||||
impl StateRow { |
||||
pub fn new() -> Self { |
||||
glib::Object::new() |
||||
} |
||||
} |
||||
@ -0,0 +1,409 @@
|
||||
use std::{cmp::Ordering, ops::RangeInclusive}; |
||||
|
||||
use gtk::{gio, glib, prelude::*, subclass::prelude::*}; |
||||
|
||||
mod imp { |
||||
use std::{cell::RefCell, marker::PhantomData}; |
||||
|
||||
use super::*; |
||||
|
||||
#[derive(Debug, Default, glib::Properties)] |
||||
#[properties(wrapper_type = super::GroupingListGroup)] |
||||
pub struct GroupingListGroup { |
||||
/// The underlying model.
|
||||
#[property(get, set = Self::set_model, construct_only)] |
||||
model: glib::WeakRef<gio::ListModel>, |
||||
/// The range of items in this group.
|
||||
pub(super) range: RefCell<Option<RangeInclusive<u32>>>, |
||||
/// The position of the first item in this group.
|
||||
#[property(get = Self::start)] |
||||
start: PhantomData<u32>, |
||||
/// The position of the last item in this group.
|
||||
#[property(get = Self::end)] |
||||
end: PhantomData<u32>, |
||||
/// The batch of changes that have not been signalled yet.
|
||||
pub(super) batch: RefCell<Vec<ChangesBatch>>, |
||||
} |
||||
|
||||
#[glib::object_subclass] |
||||
impl ObjectSubclass for GroupingListGroup { |
||||
const NAME: &'static str = "GroupingListGroup"; |
||||
type Type = super::GroupingListGroup; |
||||
type Interfaces = (gio::ListModel,); |
||||
} |
||||
|
||||
#[glib::derived_properties] |
||||
impl ObjectImpl for GroupingListGroup {} |
||||
|
||||
impl ListModelImpl for GroupingListGroup { |
||||
fn item_type(&self) -> glib::Type { |
||||
self.model |
||||
.upgrade() |
||||
.map_or_else(glib::Object::static_type, |model| model.item_type()) |
||||
} |
||||
|
||||
fn n_items(&self) -> u32 { |
||||
self.range |
||||
.borrow() |
||||
.as_ref() |
||||
.map(|range| range.end() - range.start() + 1) |
||||
.unwrap_or_default() |
||||
} |
||||
|
||||
fn item(&self, position: u32) -> Option<glib::Object> { |
||||
if position >= self.n_items() { |
||||
return None; |
||||
} |
||||
|
||||
self.model |
||||
.upgrade() |
||||
.and_then(|m| m.item(self.start() + position)) |
||||
} |
||||
} |
||||
|
||||
impl GroupingListGroup { |
||||
/// Set the underlying model.
|
||||
fn set_model(&self, model: &gio::ListModel) { |
||||
if self.model.upgrade().is_some_and(|prev| prev == *model) { |
||||
return; |
||||
} |
||||
|
||||
self.model.set(Some(model)); |
||||
|
||||
self.obj().notify_model(); |
||||
} |
||||
|
||||
/// Set the range of items in this group.
|
||||
pub(super) fn set_range(&self, range: RangeInclusive<u32>) { |
||||
// TODO: optimize when the range has changed but not the items.
|
||||
if self |
||||
.range |
||||
.borrow() |
||||
.as_ref() |
||||
.is_some_and(|prev| *prev == range) |
||||
{ |
||||
return; |
||||
} |
||||
|
||||
let items_changes = if let Some(prev) = self.range.take() { |
||||
let prev_start = *prev.start(); |
||||
let new_start = *range.start(); |
||||
let start_change = match new_start.cmp(&prev_start) { |
||||
Ordering::Less => Some((0, 0, prev_start - new_start)), |
||||
Ordering::Equal => None, |
||||
Ordering::Greater => Some((0, new_start - prev_start, 0)), |
||||
}; |
||||
|
||||
let prev_end = *prev.end(); |
||||
let new_end = *range.end(); |
||||
let end_change = match new_end.cmp(&prev_end) { |
||||
Ordering::Less => Some((new_end, prev_end - new_end, 0)), |
||||
Ordering::Equal => None, |
||||
Ordering::Greater => Some((prev_end, 0, new_end - prev_end)), |
||||
}; |
||||
|
||||
[start_change, end_change] |
||||
} else { |
||||
[Some((0, 0, range.end() - range.start())), None] |
||||
}; |
||||
|
||||
self.range.replace(Some(range)); |
||||
|
||||
let obj = self.obj(); |
||||
for change in items_changes { |
||||
let Some((pos, removed, added)) = change else { |
||||
continue; |
||||
}; |
||||
|
||||
obj.items_changed(pos, removed, added); |
||||
} |
||||
} |
||||
|
||||
/// The position of the first item in this group.
|
||||
fn start(&self) -> u32 { |
||||
*self |
||||
.range |
||||
.borrow() |
||||
.as_ref() |
||||
.expect("range should be initialized") |
||||
.start() |
||||
} |
||||
|
||||
/// The position of the last item in this group.
|
||||
fn end(&self) -> u32 { |
||||
*self |
||||
.range |
||||
.borrow() |
||||
.as_ref() |
||||
.expect("range should be initialized") |
||||
.end() |
||||
} |
||||
|
||||
/// The bounds of this group.
|
||||
pub(super) fn bounds(&self) -> (u32, u32) { |
||||
self.range |
||||
.borrow() |
||||
.as_ref() |
||||
.map(|range| (*range.start(), *range.end())) |
||||
.expect("range should be initialized") |
||||
} |
||||
|
||||
/// Add a change to the batch.
|
||||
pub(super) fn push_change(&self, change: ChangesBatch) { |
||||
let mut batch = self.batch.borrow_mut(); |
||||
|
||||
match &change { |
||||
ChangesBatch::Remove(_) => batch.push(change), |
||||
ChangesBatch::Add(add) => { |
||||
// Merge the change with the previous one if they are contiguous.
|
||||
if let Some(prev_change) = |
||||
batch.last_mut().and_then(|previous| match previous { |
||||
ChangesBatch::Remove(_) => None, |
||||
ChangesBatch::Add(prev_change) => { |
||||
((prev_change.position + prev_change.added) == add.position) |
||||
.then_some(prev_change) |
||||
} |
||||
}) |
||||
{ |
||||
prev_change.added += add.added; |
||||
} else { |
||||
batch.push(change); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
glib::wrapper! { |
||||
/// A group of items in a [`GroupingListModel`].
|
||||
///
|
||||
/// [`GroupingListModel`]: super::GroupingListModel
|
||||
pub struct GroupingListGroup(ObjectSubclass<imp::GroupingListGroup>) |
||||
@implements gio::ListModel; |
||||
} |
||||
|
||||
impl GroupingListGroup { |
||||
/// Construct a new `GroupingListGroup` for the given model and range.
|
||||
pub fn new(model: &gio::ListModel, range: RangeInclusive<u32>) -> Self { |
||||
let obj = glib::Object::builder::<Self>() |
||||
.property("model", model) |
||||
.build(); |
||||
obj.imp().set_range(range); |
||||
obj |
||||
} |
||||
|
||||
/// The bounds of this group.
|
||||
pub(super) fn bounds(&self) -> (u32, u32) { |
||||
self.imp().bounds() |
||||
} |
||||
|
||||
/// Whether this group contains the given position.
|
||||
pub(super) fn contains(&self, position: u32) -> bool { |
||||
self.imp() |
||||
.range |
||||
.borrow() |
||||
.as_ref() |
||||
.is_some_and(|range| range.contains(&position)) |
||||
} |
||||
|
||||
/// Handle the given removal of items that might affect this group.
|
||||
///
|
||||
/// This function panics if there would be no items left in this group.
|
||||
pub(super) fn handle_removal(&self, position: u32, removed: u32) { |
||||
if removed == 0 { |
||||
// Nothing to do.
|
||||
return; |
||||
} |
||||
|
||||
let imp = self.imp(); |
||||
let (start, end) = imp.bounds(); |
||||
|
||||
if position > end { |
||||
// This group is not affected.
|
||||
return; |
||||
} |
||||
|
||||
let removal_end = position + removed - 1; |
||||
|
||||
assert!( |
||||
position > start || removal_end < end, |
||||
"should not remove whole group", |
||||
); |
||||
|
||||
let (remove, new_range) = if removal_end < start { |
||||
let new_range = (start - removed)..=(end - removed); |
||||
(None, new_range) |
||||
} else if position <= start { |
||||
let remove = RemoveBatch { |
||||
position: 0, |
||||
removed: removal_end - start + 1, |
||||
}; |
||||
let new_range = position..=(end - removed); |
||||
(Some(remove), new_range) |
||||
} else if position > start && removal_end <= end { |
||||
let remove = RemoveBatch { |
||||
position: position - start, |
||||
removed, |
||||
}; |
||||
let new_range = start..=(end - removed); |
||||
(Some(remove), new_range) |
||||
} else { |
||||
// position > start && removal_end > end
|
||||
let remove = RemoveBatch { |
||||
position: position - start, |
||||
removed: end - position + 1, |
||||
}; |
||||
#[allow(clippy::range_minus_one)] // We need an inclusive range.
|
||||
let new_range = start..=(position - 1); |
||||
(Some(remove), new_range) |
||||
}; |
||||
|
||||
if let Some(remove) = remove { |
||||
imp.push_change(remove.into()); |
||||
} |
||||
|
||||
*imp.range.borrow_mut() = Some(new_range); |
||||
} |
||||
|
||||
/// Handle the given addition of items that might affect this group.
|
||||
pub(super) fn handle_addition(&self, position: u32, added: u32) { |
||||
if added == 0 { |
||||
// Nothing to do.
|
||||
return; |
||||
} |
||||
|
||||
let imp = self.imp(); |
||||
let (start, end) = imp.bounds(); |
||||
|
||||
if position > end { |
||||
// This group is not affected.
|
||||
return; |
||||
} |
||||
|
||||
let (add, new_range) = if position <= start { |
||||
let new_range = (start + added)..=(end + added); |
||||
(None, new_range) |
||||
} else { |
||||
// start < position <= end
|
||||
let add = AddBatch { |
||||
position: position - start, |
||||
added, |
||||
}; |
||||
let new_range = start..=(end + added); |
||||
(Some(add), new_range) |
||||
}; |
||||
|
||||
if let Some(add) = add { |
||||
imp.push_change(add.into()); |
||||
} |
||||
|
||||
*imp.range.borrow_mut() = Some(new_range); |
||||
} |
||||
|
||||
/// Add items to this list item.
|
||||
///
|
||||
/// This function assumes that the new items are contiguous to the current
|
||||
/// ones.
|
||||
pub(super) fn add(&self, position: u32, added: u32) { |
||||
if added == 0 { |
||||
// Nothing to do.
|
||||
return; |
||||
} |
||||
|
||||
let imp = self.imp(); |
||||
let (start, end) = imp.bounds(); |
||||
|
||||
let (add, new_range) = if position <= start { |
||||
let new_range = position..=end; |
||||
let add = AddBatch { position: 0, added }; |
||||
(add, new_range) |
||||
} else { |
||||
// start < position <= end
|
||||
let add = AddBatch { |
||||
position: position - start, |
||||
added, |
||||
}; |
||||
let new_range = start..=(end + added); |
||||
(add, new_range) |
||||
}; |
||||
|
||||
imp.push_change(add.into()); |
||||
*imp.range.borrow_mut() = Some(new_range); |
||||
} |
||||
|
||||
/// Process the accumulated batch of changes.
|
||||
pub(super) fn process_batch(&self) { |
||||
let batch = self.imp().batch.take(); |
||||
|
||||
// Do not process removals right away, to batch a removal with the corresponding
|
||||
// addition, if any.
|
||||
let mut previous_remove = None; |
||||
|
||||
for changes in batch { |
||||
match changes { |
||||
ChangesBatch::Remove(remove) => { |
||||
if let Some(remove) = previous_remove.replace(remove) { |
||||
self.items_changed(remove.position, remove.removed, 0); |
||||
} |
||||
} |
||||
ChangesBatch::Add(add) => { |
||||
let removed = if let Some(remove) = previous_remove.take() { |
||||
if remove.position == add.position { |
||||
remove.removed |
||||
} else { |
||||
self.items_changed(remove.position, remove.removed, 0); |
||||
0 |
||||
} |
||||
} else { |
||||
0 |
||||
}; |
||||
self.items_changed(add.position, removed, add.added); |
||||
} |
||||
} |
||||
} |
||||
|
||||
if let Some(remove) = previous_remove.take() { |
||||
self.items_changed(remove.position, remove.removed, 0); |
||||
} |
||||
} |
||||
} |
||||
|
||||
// A batch of changes.
|
||||
#[derive(Debug, Clone, Copy)] |
||||
enum ChangesBatch { |
||||
// Remove items.
|
||||
Remove(RemoveBatch), |
||||
// Add items.
|
||||
Add(AddBatch), |
||||
} |
||||
|
||||
impl From<RemoveBatch> for ChangesBatch { |
||||
fn from(value: RemoveBatch) -> Self { |
||||
Self::Remove(value) |
||||
} |
||||
} |
||||
|
||||
impl From<AddBatch> for ChangesBatch { |
||||
fn from(value: AddBatch) -> Self { |
||||
Self::Add(value) |
||||
} |
||||
} |
||||
|
||||
// A batch of removals.
|
||||
#[derive(Debug, Clone, Copy)] |
||||
struct RemoveBatch { |
||||
// The position of the first item that was removed.
|
||||
position: u32, |
||||
// The number of items that were removed.
|
||||
removed: u32, |
||||
} |
||||
|
||||
// A batch of additions.
|
||||
#[derive(Debug, Clone, Copy)] |
||||
struct AddBatch { |
||||
// The position of the first item that was added.
|
||||
position: u32, |
||||
// The number of items that were added.
|
||||
added: u32, |
||||
} |
||||
@ -0,0 +1,603 @@
|
||||
use std::{cmp::Ordering, fmt, ops::RangeInclusive}; |
||||
|
||||
use gtk::{gio, glib, glib::clone, prelude::*, subclass::prelude::*}; |
||||
|
||||
mod group; |
||||
#[cfg(test)] |
||||
mod tests; |
||||
|
||||
pub(crate) use self::group::GroupingListGroup; |
||||
use crate::utils::BoundObject; |
||||
|
||||
/// A function to determine if an item should be grouped with another contiguous
|
||||
/// item.
|
||||
///
|
||||
/// This function MUST always return `true` when used with any two items in the
|
||||
/// same group.
|
||||
pub(crate) type GroupFn = dyn Fn(&glib::Object, &glib::Object) -> bool; |
||||
|
||||
mod imp { |
||||
use std::{ |
||||
cell::{OnceCell, RefCell}, |
||||
collections::{HashSet, VecDeque}, |
||||
}; |
||||
|
||||
use super::*; |
||||
|
||||
#[derive(Default, glib::Properties)] |
||||
#[properties(wrapper_type = super::GroupingListModel)] |
||||
pub struct GroupingListModel { |
||||
/// The underlying model.
|
||||
#[property(get, set = Self::set_model, explicit_notify, nullable)] |
||||
model: BoundObject<gio::ListModel>, |
||||
/// The function to determine if adjacent items should be grouped.
|
||||
pub(super) group_fn: OnceCell<Box<GroupFn>>, |
||||
/// The groups created by this list model.
|
||||
items: RefCell<VecDeque<GroupingListItem>>, |
||||
} |
||||
|
||||
impl fmt::Debug for GroupingListModel { |
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
||||
f.debug_struct("GroupingListModel") |
||||
.field("model", &self.model) |
||||
.field("items", &self.items) |
||||
.finish_non_exhaustive() |
||||
} |
||||
} |
||||
|
||||
#[glib::object_subclass] |
||||
impl ObjectSubclass for GroupingListModel { |
||||
const NAME: &'static str = "GroupingListModel"; |
||||
type Type = super::GroupingListModel; |
||||
type Interfaces = (gio::ListModel,); |
||||
} |
||||
|
||||
#[glib::derived_properties] |
||||
impl ObjectImpl for GroupingListModel {} |
||||
|
||||
impl ListModelImpl for GroupingListModel { |
||||
fn item_type(&self) -> glib::Type { |
||||
glib::Object::static_type() |
||||
} |
||||
|
||||
fn n_items(&self) -> u32 { |
||||
self.items.borrow().len() as u32 |
||||
} |
||||
|
||||
fn item(&self, position: u32) -> Option<glib::Object> { |
||||
let model = self.model.obj()?; |
||||
self.items |
||||
.borrow() |
||||
.get(position as usize) |
||||
.and_then(|item| match item { |
||||
GroupingListItem::Singleton(position) => model.item(*position), |
||||
GroupingListItem::Group(obj) => Some(obj.clone().upcast()), |
||||
}) |
||||
} |
||||
} |
||||
|
||||
impl GroupingListModel { |
||||
/// The function to determine if adjacent items should be grouped.
|
||||
fn group_fn(&self) -> &GroupFn { |
||||
self.group_fn.get().expect("group Fn should be initialized") |
||||
} |
||||
|
||||
/// Set the underlying model.
|
||||
fn set_model(&self, model: Option<gio::ListModel>) { |
||||
let prev_model = self.model.obj(); |
||||
|
||||
if prev_model == model { |
||||
return; |
||||
} |
||||
|
||||
self.model.disconnect_signals(); |
||||
|
||||
if let Some(model) = model { |
||||
let items_changed_handler = model.connect_items_changed(clone!( |
||||
#[weak(rename_to = imp)] |
||||
self, |
||||
move |model, position, removed, added| { |
||||
imp.items_changed(model, position, removed, added); |
||||
} |
||||
)); |
||||
|
||||
self.model.set(model.clone(), vec![items_changed_handler]); |
||||
|
||||
let removed = prev_model.map(|model| model.n_items()).unwrap_or_default(); |
||||
self.items_changed(&model, 0, removed, model.n_items()); |
||||
} else { |
||||
let removed = self.n_items(); |
||||
self.items.borrow_mut().clear(); |
||||
self.obj().items_changed(0, removed, 0); |
||||
} |
||||
|
||||
self.obj().notify_model(); |
||||
} |
||||
|
||||
/// Find the index of the list item containing the given position in the
|
||||
/// underlying model.
|
||||
fn model_position_to_index(&self, position: u32) -> Option<usize> { |
||||
for (index, item) in self.items.borrow().iter().enumerate() { |
||||
if item.contains(position) { |
||||
return Some(index); |
||||
} |
||||
|
||||
// Because items are sorted, we can return early when the position is in a gap
|
||||
// between items. This should only happen during `items_changed`.
|
||||
if item.end() > position { |
||||
return None; |
||||
} |
||||
} |
||||
|
||||
None |
||||
} |
||||
|
||||
/// Handle when items have changed in the underlying model.
|
||||
#[allow(clippy::too_many_lines)] |
||||
fn items_changed(&self, model: &gio::ListModel, position: u32, removed: u32, added: u32) { |
||||
if removed == 0 && added == 0 { |
||||
// Nothing to do.
|
||||
return; |
||||
} |
||||
|
||||
// Index of the list item that contains the item right before the changes in the
|
||||
// model.
|
||||
let index_before_changes = position |
||||
.checked_sub(1) |
||||
.and_then(|position| self.model_position_to_index(position)); |
||||
|
||||
let mut replaced_list_items = HashSet::new(); |
||||
|
||||
let mut list_items_removed = |
||||
self.items_removed(position, removed, index_before_changes); |
||||
let mut list_items_added = self.items_added( |
||||
model, |
||||
position, |
||||
added, |
||||
&mut replaced_list_items, |
||||
index_before_changes, |
||||
); |
||||
|
||||
let position_after_changes = position + added; |
||||
let mut index_after_changes = self.model_position_to_index(position_after_changes); |
||||
|
||||
// Check if the list item after the changes can be merged with the previous list
|
||||
// item.
|
||||
if let Some(index_after_changes) = |
||||
index_after_changes.as_mut().filter(|index| **index > 0) |
||||
{ |
||||
let mut items = self.items.borrow_mut(); |
||||
|
||||
let previous_item_in_other_list_item = !items |
||||
.get(*index_after_changes) |
||||
.expect("list item index should be valid") |
||||
.contains(position_after_changes - 1); |
||||
|
||||
if previous_item_in_other_list_item { |
||||
let item_after_changes = model |
||||
.item(position_after_changes) |
||||
.expect("item position should be valid"); |
||||
let previous_item = model |
||||
.item(position_after_changes - 1) |
||||
.expect("item position should be valid"); |
||||
|
||||
if self.group_fn()(&item_after_changes, &previous_item) { |
||||
// We can merge the items.
|
||||
*index_after_changes -= 1; |
||||
|
||||
let (removed_list_item, list_item_to_merge_into) = if index_before_changes |
||||
.is_some_and(|index| index == *index_after_changes) |
||||
{ |
||||
// Merge into the list item before changes.
|
||||
let list_item_after_changes = items |
||||
.remove(*index_after_changes + 1) |
||||
.expect("list item index should be valid"); |
||||
let list_item_before_changes = items |
||||
.get_mut(*index_after_changes) |
||||
.expect("list item index should be valid"); |
||||
(list_item_after_changes, list_item_before_changes) |
||||
} else { |
||||
// Merge into the list item after changes.
|
||||
let previous_list_item = items |
||||
.remove(*index_after_changes) |
||||
.expect("list item index should be valid"); |
||||
let list_item_after_changes = items |
||||
.get_mut(*index_after_changes) |
||||
.expect("list item index should be valid"); |
||||
(previous_list_item, list_item_after_changes) |
||||
}; |
||||
|
||||
let list_item_replacement = list_item_to_merge_into.add( |
||||
removed_list_item.start(), |
||||
removed_list_item.len(), |
||||
model, |
||||
); |
||||
|
||||
if let Some(replacement) = list_item_replacement { |
||||
*list_item_to_merge_into = replacement; |
||||
replaced_list_items.insert(*index_after_changes); |
||||
} |
||||
|
||||
if let Some(added) = list_items_added.checked_sub(1) { |
||||
list_items_added = added; |
||||
} else { |
||||
list_items_removed += 1; |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
let obj = self.obj(); |
||||
|
||||
if list_items_removed > 0 || list_items_added > 0 { |
||||
let index_at_changes = index_before_changes |
||||
.map(|index| index + 1) |
||||
.unwrap_or_default(); |
||||
obj.items_changed( |
||||
index_at_changes as u32, |
||||
list_items_removed as u32, |
||||
list_items_added as u32, |
||||
); |
||||
} |
||||
|
||||
// Change groups with a single item to singletons.
|
||||
for index in index_before_changes.into_iter().chain(index_after_changes) { |
||||
let mut items = self.items.borrow_mut(); |
||||
let item = items |
||||
.get_mut(index) |
||||
.expect("list item index should be valid"); |
||||
|
||||
if matches!(item, GroupingListItem::Group(_)) && item.len() == 1 { |
||||
*item = GroupingListItem::Singleton(item.start()); |
||||
replaced_list_items.insert(index); |
||||
} |
||||
} |
||||
|
||||
for index in replaced_list_items { |
||||
obj.items_changed(index as u32, 1, 1); |
||||
} |
||||
|
||||
// Generate a list of groups before syncing them, to avoid holding a ref while
|
||||
// we send signals about changed items.
|
||||
let groups = self |
||||
.items |
||||
.borrow() |
||||
.range(index_before_changes.unwrap_or_default()..) |
||||
.filter_map(|list_item| match list_item { |
||||
GroupingListItem::Singleton(_) => None, |
||||
GroupingListItem::Group(group) => Some(group.clone()), |
||||
}) |
||||
.collect::<Vec<_>>(); |
||||
groups.into_iter().for_each(|group| group.process_batch()); |
||||
} |
||||
|
||||
/// Handle when items were removed in the underlying model.
|
||||
///
|
||||
/// Returns a `(position, removed)` tuple if items were removed in this
|
||||
/// list.
|
||||
fn items_removed( |
||||
&self, |
||||
position: u32, |
||||
removed: u32, |
||||
index_before_changes: Option<usize>, |
||||
) -> usize { |
||||
if removed == 0 { |
||||
// Nothing to do.
|
||||
return 0; |
||||
} |
||||
|
||||
// Index of the list item that contains the item right after the changes in the
|
||||
// model.
|
||||
let index_after_changes = position |
||||
.checked_add(removed) |
||||
.and_then(|position| self.model_position_to_index(position)); |
||||
|
||||
let mut items = self.items.borrow_mut(); |
||||
|
||||
// Update the range of the list item before changes, if it's not the same as the
|
||||
// list item after the changes and if it contains removed items.
|
||||
if let Some(index_before_changes) = index_before_changes.filter(|index| { |
||||
index_after_changes.is_none_or(|index_after_changes| index_after_changes > *index) |
||||
}) { |
||||
items |
||||
.get_mut(index_before_changes) |
||||
.expect("list item index should be valid") |
||||
.handle_removal(position, removed); |
||||
} |
||||
|
||||
// Update the range of the list items after the changes.
|
||||
if let Some(index_after_changes) = index_after_changes { |
||||
items |
||||
.range_mut(index_after_changes..) |
||||
.for_each(|list_item| list_item.handle_removal(position, removed)); |
||||
} |
||||
|
||||
// If items were removed, we should have at least one list item.
|
||||
let max_index = items.len() - 1; |
||||
|
||||
let removal_start = if let Some(index_before_changes) = index_before_changes { |
||||
// The list items removal starts at the list item after the one before the
|
||||
// changes.
|
||||
index_before_changes |
||||
.checked_add(1) |
||||
.filter(|index| *index <= max_index) |
||||
} else { |
||||
// There is no list item before, we are at the start of the list items.
|
||||
Some(0) |
||||
}; |
||||
|
||||
let removal_end = if let Some(index_after_changes) = index_after_changes { |
||||
// The list items removal starts at the list item before the one after the
|
||||
// changes.
|
||||
index_after_changes.checked_sub(1) |
||||
} else { |
||||
// There is no list item after, we are at the end of the list items.
|
||||
Some(max_index) |
||||
}; |
||||
|
||||
// Remove list items if needed.
|
||||
let Some((removal_start, removal_end)) = removal_start |
||||
.zip(removal_end) |
||||
.filter(|(removal_start, removal_end)| removal_start <= removal_end) |
||||
else { |
||||
return 0; |
||||
}; |
||||
|
||||
let is_at_items_start = removal_start == 0; |
||||
let is_at_items_end = removal_end == items.len().saturating_sub(1); |
||||
|
||||
// Try to optimize the removal by using the most appropriate `VecDeque` method.
|
||||
if is_at_items_start && is_at_items_end { |
||||
// Remove all items.
|
||||
items.clear(); |
||||
} else if is_at_items_end { |
||||
// Remove the end of the items.
|
||||
items.truncate(removal_start); |
||||
} else { |
||||
// We can only remove each item separately.
|
||||
for i in (removal_start..=removal_end).rev() { |
||||
items.remove(i); |
||||
} |
||||
} |
||||
|
||||
removal_end - removal_start + 1 |
||||
} |
||||
|
||||
/// Handle when items were added to the underlying model.
|
||||
///
|
||||
/// Returns the number of items that were added, if any.
|
||||
fn items_added( |
||||
&self, |
||||
model: &gio::ListModel, |
||||
position: u32, |
||||
added: u32, |
||||
replaced_list_items: &mut HashSet<usize>, |
||||
index_before_changes: Option<usize>, |
||||
) -> usize { |
||||
if added == 0 { |
||||
// Nothing to do.
|
||||
return 0; |
||||
} |
||||
|
||||
let mut list_items_added = 0; |
||||
|
||||
let position_before = position.checked_sub(1); |
||||
// The previous item in the underlying model and the index of the list item that
|
||||
// contains it.
|
||||
let mut previous_item_and_index = |
||||
position_before.and_then(|position| model.item(position).zip(index_before_changes)); |
||||
|
||||
let group_fn = self.group_fn(); |
||||
let mut items = self.items.borrow_mut(); |
||||
|
||||
for current_position in position..position + added { |
||||
let item = model |
||||
.item(current_position) |
||||
.expect("item position should be valid"); |
||||
|
||||
if let Some((previous_item, previous_index)) = &mut previous_item_and_index { |
||||
let previous_list_item = items |
||||
.get_mut(*previous_index) |
||||
.expect("list item index should be valid"); |
||||
|
||||
if group_fn(&item, previous_item) { |
||||
// Add the position to the list item.
|
||||
let list_item_replacement = |
||||
previous_list_item.add(current_position, 1, model); |
||||
|
||||
if let Some(replacement) = list_item_replacement { |
||||
*previous_list_item = replacement; |
||||
|
||||
if current_position == position { |
||||
// We will need to send a signal because we replaced a list item
|
||||
// that already existed.
|
||||
replaced_list_items.insert(*previous_index); |
||||
} |
||||
} |
||||
|
||||
// The previous item changed but the list item that contains it is the same.
|
||||
*previous_item = item; |
||||
|
||||
continue; |
||||
} else if previous_list_item.contains(current_position) { |
||||
// We need to split the group.
|
||||
let end_list_item = previous_list_item.split(current_position); |
||||
|
||||
items.insert(*previous_index + 1, end_list_item); |
||||
list_items_added += 1; |
||||
} |
||||
} |
||||
|
||||
// The item is a singleton.
|
||||
let index = previous_item_and_index |
||||
.take() |
||||
.map(|(_, index)| index + 1) |
||||
.unwrap_or_default(); |
||||
items.insert(index, GroupingListItem::Singleton(current_position)); |
||||
list_items_added += 1; |
||||
|
||||
previous_item_and_index = Some((item, index)); |
||||
} |
||||
|
||||
let (_, last_index_with_changes) = |
||||
previous_item_and_index.expect("there should have been at least one addition"); |
||||
let index_after_changes = last_index_with_changes + 1; |
||||
|
||||
// Update the ranges of the list items after the changes.
|
||||
if index_after_changes < items.len() { |
||||
items |
||||
.range_mut(index_after_changes..) |
||||
.for_each(|list_item| list_item.handle_addition(position, added)); |
||||
} |
||||
|
||||
list_items_added |
||||
} |
||||
} |
||||
} |
||||
|
||||
glib::wrapper! { |
||||
/// A list model that groups some items according to a function.
|
||||
pub struct GroupingListModel(ObjectSubclass<imp::GroupingListModel>) |
||||
@implements gio::ListModel; |
||||
} |
||||
|
||||
impl GroupingListModel { |
||||
/// Construct a new `GroupingListModel` with the given function to determine
|
||||
/// if adjacent items should be grouped.
|
||||
pub fn new<GroupFn>(group_fn: GroupFn) -> Self |
||||
where |
||||
GroupFn: Fn(&glib::Object, &glib::Object) -> bool + 'static, |
||||
{ |
||||
let obj = glib::Object::new::<Self>(); |
||||
// Ignore the error because we cannot `.expect()` when the value is a function.
|
||||
let _ = obj.imp().group_fn.set(Box::new(group_fn)); |
||||
obj |
||||
} |
||||
} |
||||
|
||||
/// An item in the [`GroupingListModel`].
|
||||
#[derive(Debug, Clone)] |
||||
enum GroupingListItem { |
||||
/// An item that is not in a group.
|
||||
Singleton(u32), |
||||
|
||||
/// A group of items.
|
||||
Group(GroupingListGroup), |
||||
} |
||||
|
||||
impl GroupingListItem { |
||||
/// Construct a list item with the given range for the given model.
|
||||
fn with_range(range: RangeInclusive<u32>, model: &gio::ListModel) -> Self { |
||||
if range.start() == range.end() { |
||||
Self::Singleton(*range.start()) |
||||
} else { |
||||
Self::Group(GroupingListGroup::new(model, range)) |
||||
} |
||||
} |
||||
|
||||
/// Whether this list item contains the given position.
|
||||
fn contains(&self, position: u32) -> bool { |
||||
match self { |
||||
Self::Singleton(pos) => *pos == position, |
||||
Self::Group(group) => group.contains(position), |
||||
} |
||||
} |
||||
|
||||
/// The position of the first item in this list item.
|
||||
fn start(&self) -> u32 { |
||||
match self { |
||||
Self::Singleton(position) => *position, |
||||
Self::Group(group) => group.start(), |
||||
} |
||||
} |
||||
|
||||
/// The position of the last item in this list item.
|
||||
fn end(&self) -> u32 { |
||||
match self { |
||||
Self::Singleton(position) => *position, |
||||
Self::Group(group) => group.end(), |
||||
} |
||||
} |
||||
|
||||
/// The length of the range of this list item.
|
||||
fn len(&self) -> u32 { |
||||
match self { |
||||
Self::Singleton(_) => 1, |
||||
Self::Group(group) => { |
||||
let (start, end) = group.bounds(); |
||||
end - start + 1 |
||||
} |
||||
} |
||||
} |
||||
|
||||
/// Handle the given removal of items that might affect this group.
|
||||
///
|
||||
/// This function panics if there would be no items left in this group.
|
||||
fn handle_removal(&mut self, position: u32, removed: u32) { |
||||
match self { |
||||
Self::Singleton(pos) => match position.cmp(pos) { |
||||
Ordering::Less => *pos -= removed, |
||||
Ordering::Equal => panic!("should not remove list item"), |
||||
Ordering::Greater => {} |
||||
}, |
||||
Self::Group(group) => group.handle_removal(position, removed), |
||||
} |
||||
} |
||||
|
||||
/// Handle the given addition of items that might affect this group.
|
||||
fn handle_addition(&mut self, position: u32, added: u32) { |
||||
match self { |
||||
Self::Singleton(pos) => { |
||||
if position <= *pos { |
||||
*pos += added; |
||||
} |
||||
} |
||||
Self::Group(group) => group.handle_addition(position, added), |
||||
} |
||||
} |
||||
|
||||
/// Add items to this list item.
|
||||
///
|
||||
/// Returns the newly created group if this item was a singleton.
|
||||
///
|
||||
/// Panics if the added items are not contiguous to the current ones.
|
||||
fn add(&self, position: u32, added: u32, model: &gio::ListModel) -> Option<Self> { |
||||
debug_assert!( |
||||
(position + added) >= self.start() || position <= self.end().saturating_add(1), |
||||
"items to add should be contiguous" |
||||
); |
||||
|
||||
match self { |
||||
Self::Singleton(pos) => { |
||||
let start = position.min(*pos); |
||||
let end = start + added; |
||||
Some(Self::with_range(start..=end, model)) |
||||
} |
||||
Self::Group(group) => { |
||||
group.add(position, added); |
||||
None |
||||
} |
||||
} |
||||
} |
||||
|
||||
/// Split this group at the given position.
|
||||
///
|
||||
/// `position` must be greater than the start and lower or equal to the end
|
||||
/// of this group.
|
||||
///
|
||||
/// Returns the new list item containing the second part of the group,
|
||||
/// starting at `at`.
|
||||
fn split(&self, position: u32) -> Self { |
||||
match self { |
||||
Self::Singleton(_) => panic!("singleton cannot be split"), |
||||
Self::Group(group) => { |
||||
let model = group.model().expect("model should be initialized"); |
||||
let end = group.end(); |
||||
|
||||
group.handle_removal(position, end - position + 1); |
||||
|
||||
Self::with_range(position..=end, &model) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in new issue