From 7066327c28717de189dbc38209d3a4847eb7e838 Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Wed, 29 Apr 2026 19:58:54 -0400 Subject: [PATCH 5/6] feat(messages): In-channel message search - Add a SearchBar above the message list that searches the currently-loaded timeline using a case-insensitive substring match against the message body. Bind it to a new channel-messages.toggle-search action wired to Ctrl+Shift+F. - Surface a match counter (current/total) and previous/next buttons next to the entry, plus reuse the existing flash_requires_attention helper to scroll to and briefly highlight the focused match. - Reset matches when the bar closes or the active channel changes. --- CHANGELOG.md | 1 + data/resources/style.css | 10 + data/resources/ui/channel_messages.blp | 55 ++++++ data/resources/ui/shortcuts.blp | 5 + src/backend/message/display_message.rs | 8 + src/backend/timeline/mod.rs | 10 + src/gui/channel_messages.rs | 248 ++++++++++++++++++++++++- src/gui/message_item.rs | 25 +++ src/gui/window.rs | 11 ++ 9 files changed, 372 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47ec77a..16880cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Send formatted messages with markdown-style markers (`**bold**`, `*italic*`, `~~strike~~`, `||spoiler||`, `` `monospace` ``). - Display incoming edited messages with an `edited` indicator and edit your own sent messages from their context menu. - Multi-select messages from their context menu and delete the selection locally with a single action. +- In-channel message search (Ctrl+Shift+F) over the loaded timeline with prev/next navigation and a match counter. ## [0.20.4] - 2026-04-22 diff --git a/data/resources/style.css b/data/resources/style.css index 1c0cdfd..e00e789 100644 --- a/data/resources/style.css +++ b/data/resources/style.css @@ -294,3 +294,13 @@ unread-indicator { background: alpha(@accent_bg_color, 0.7); } } + + +/* Persistent highlight for the message currently focused by the in-channel + search bar. Stays applied while the user is parked on this match; + cleared when navigating to a different match or closing the search. */ +.search-match .message-bubble { + background: alpha(@accent_bg_color, 0.35); + outline: 2px solid alpha(@accent_color, 0.7); + outline-offset: -2px; +} \ No newline at end of file diff --git a/data/resources/ui/channel_messages.blp b/data/resources/ui/channel_messages.blp index eb927f8..a166f7b 100644 --- a/data/resources/ui/channel_messages.blp +++ b/data/resources/ui/channel_messages.blp @@ -43,6 +43,61 @@ template $FlChannelMessages: Box { hexpand: true; orientation: vertical; + // In-channel message search. + SearchBar search_bar { + key-capture-widget: scrolled_window; + search-mode-enabled: bind template.search-active bidirectional; + + Adw.Clamp { + maximum-size: 600; + + Box { + orientation: horizontal; + spacing: 6; + + SearchEntry search_entry { + hexpand: true; + placeholder-text: _("Search loaded messages"); + search-changed => $on_search_query_changed() swapped; + previous-match => $on_search_previous() swapped; + next-match => $on_search_next() swapped; + stop-search => $on_search_stop() swapped; + } + + Label { + styles [ + "caption", + "dim-label", + ] + + label: bind template.search-summary; + } + + Button { + icon-name: "go-up-symbolic"; + tooltip-text: C_("tooltip", "Previous match"); + sensitive: bind template.has-matches; + clicked => $on_search_previous() swapped; + + styles [ + "flat", + ] + } + + Button { + icon-name: "go-down-symbolic"; + tooltip-text: C_("tooltip", "Next match"); + sensitive: bind template.has-matches; + clicked => $on_search_next() swapped; + + styles [ + "flat", + ] + } + } + } + } + Overlay { [overlay] Adw.Spinner { diff --git a/data/resources/ui/shortcuts.blp b/data/resources/ui/shortcuts.blp index ed2a959..79339cc 100644 --- a/data/resources/ui/shortcuts.blp +++ b/data/resources/ui/shortcuts.blp @@ -58,5 +58,10 @@ Adw.ShortcutsDialog help_overlay { title: C_("shortcut window", "Load more messages"); accelerator: "&l"; } + + Adw.ShortcutsItem { + title: C_("shortcut window", "Search messages in current channel"); + accelerator: "&f"; + } } } diff --git a/src/backend/message/display_message.rs b/src/backend/message/display_message.rs index 4cd5e7f..4ccb763 100644 --- a/src/backend/message/display_message.rs +++ b/src/backend/message/display_message.rs @@ -137,6 +137,7 @@ mod imp { #[derive(Debug, Default)] pub struct DisplayMessage { pub(super) requires_attention: Cell, + pub(super) is_search_match: Cell, } #[glib::object_subclass] @@ -155,6 +156,7 @@ mod imp { .read_only() .build(), ParamSpecBoolean::builder("requires-attention").build(), + ParamSpecBoolean::builder("is-search-match").build(), ] }); @@ -168,6 +170,11 @@ mod imp { .get() .expect("requires-attention parameter to be boolean"), ), + "is-search-match" => self.is_search_match.set( + value + .get() + .expect("is-search-match parameter to be boolean"), + ), _ => unimplemented!(), } } @@ -176,6 +183,7 @@ mod imp { match pspec.name() { "textual-description" => self.obj().textual_description().to_value(), "requires-attention" => self.requires_attention.get().to_value(), + "is-search-match" => self.is_search_match.get().to_value(), _ => unimplemented!(), } } diff --git a/src/backend/timeline/mod.rs b/src/backend/timeline/mod.rs index 18dd436..40fe324 100644 --- a/src/backend/timeline/mod.rs +++ b/src/backend/timeline/mod.rs @@ -69,6 +69,16 @@ impl Timeline { } } + /// Returns the index of the item with the given timestamp, if any. + /// Used by the in-channel search to scroll the list view to the + /// matched row even when it has not yet been materialized. + pub fn position_of(&self, timestamp: u64) -> Option { + let list = self.imp().list.borrow(); + list.binary_search_by_key(×tamp, |i| i.timestamp()) + .ok() + .map(|i| i as u32) + } + pub fn iter_forwards(&self) -> impl Iterator + 'static { let current_items = self.imp().list.borrow(); current_items.clone().into_iter() diff --git a/src/gui/channel_messages.rs b/src/gui/channel_messages.rs index 747b36a..4f00b73 100644 --- a/src/gui/channel_messages.rs +++ b/src/gui/channel_messages.rs @@ -85,6 +85,204 @@ impl ChannelMessages { self.set_has_selection(count > 0); } + /// Connect the `search-active` property so the entry is focused when + /// the bar opens and the matches are cleared when it closes. + fn setup_search(&self) { + self.connect_notify_local( + Some("search-active"), + clone!( + #[weak(rename_to = s)] + self, + move |_, _| { + if s.search_active() { + s.imp().search_entry.grab_focus(); + s.attach_search_message_listener(); + } else { + s.detach_search_message_listener(); + s.clear_current_search_match(); + s.imp().search_entry.set_text(""); + s.imp().search_matches.replace(Vec::new()); + s.imp().search_index.set(0); + s.set_has_matches(false); + s.set_search_summary(String::new()); + } + } + ), + ); + } + + /// While the search bar is open, watch the active channel for new + /// messages so the result set stays in sync with the timeline. Only + /// one handler is alive at a time; `detach_search_message_listener` or + /// the next `attach` call clears the previous one. + fn attach_search_message_listener(&self) { + self.detach_search_message_listener(); + let Some(channel) = self.active_channel() else { + return; + }; + let handler = channel.connect_local( + "message", + false, + clone!( + #[weak(rename_to = s)] + self, + #[upgrade_or_default] + move |_| { + let query = s.imp().search_entry.text().to_string(); + s.refresh_search(&query); + None + } + ), + ); + self.imp() + .search_message_handler + .replace(Some((channel, handler))); + } + + fn detach_search_message_listener(&self) { + if let Some((channel, handler)) = self.imp().search_message_handler.take() { + channel.disconnect(handler); + } + } + + /// Re-run the loaded-message search using `query`. The search is + /// case-insensitive substring match against the message body. + pub fn refresh_search(&self, query: &str) { + let imp = self.imp(); + let Some(channel) = self.active_channel() else { + self.clear_current_search_match(); + imp.search_matches.replace(Vec::new()); + imp.search_index.set(0); + self.set_has_matches(false); + self.set_search_summary(String::new()); + return; + }; + + let trimmed = query.trim(); + if trimmed.is_empty() { + imp.search_matches.replace(Vec::new()); + self.clear_current_search_match(); + imp.search_index.set(0); + self.set_has_matches(false); + self.set_search_summary(String::new()); + return; + } + + let needle = trimmed.to_lowercase(); + let matches: Vec = channel + .timeline() + .iter_forwards() + .filter_map(|i| i.dynamic_cast::().ok()) + .filter(|m| { + m.body() + .map(|b| b.to_lowercase().contains(&needle)) + .unwrap_or(false) + }) + .map(|m| m.timestamp()) + .collect(); + + let total = matches.len(); + imp.search_matches.replace(matches); + // Snap to the latest match (most recent in time) by default so the + // user lands at the bottom of the conversation, matching how the + // existing scroll-to-unread heuristic works. + imp.search_index.set(total.saturating_sub(1)); + self.set_has_matches(total > 0); + self.update_search_summary(); + self.focus_current_search_match(); + } + + /// Move the search cursor to the next or previous match and flash that + /// message. + pub fn goto_search_match(&self, forwards: bool) { + let imp = self.imp(); + let total = imp.search_matches.borrow().len(); + if total == 0 { + return; + } + let current = imp.search_index.get(); + let next = if forwards { + (current + 1) % total + } else if current == 0 { + total - 1 + } else { + current - 1 + }; + imp.search_index.set(next); + self.update_search_summary(); + self.focus_current_search_match(); + } + + fn update_search_summary(&self) { + let imp = self.imp(); + let total = imp.search_matches.borrow().len(); + let summary = if total == 0 { + String::new() + } else { + // Translators: e.g. "3 of 12" indicating the focused match + // index out of total search matches. + gettextrs::gettext("{current} of {total}") + .replace("{current}", &(imp.search_index.get() + 1).to_string()) + .replace("{total}", &total.to_string()) + }; + self.set_search_summary(summary); + } + + /// Drop the persistent highlight from the previously-focused match, + /// if any. Used both when navigating to a new match and when the + /// search bar closes. + fn clear_current_search_match(&self) { + if let Some(prev) = self.imp().current_search_match.take() { + prev.set_property("is-search-match", false); + } + } + + /// Mark the currently-indexed match with the persistent `search-match` + /// highlight and scroll the list view so the row is visible (and + /// materialized as a `MessageItem` that can react to the property). + fn focus_current_search_match(&self) { + let imp = self.imp(); + let Some(timestamp) = imp + .search_matches + .borrow() + .get(imp.search_index.get()) + .copied() + else { + self.clear_current_search_match(); + return; + }; + let Some(channel) = self.active_channel() else { + self.clear_current_search_match(); + return; + }; + let timeline = channel.timeline(); + let Some(item) = timeline.get_by_timestamp(timestamp) else { + self.clear_current_search_match(); + return; + }; + let Some(msg) = item.dynamic_cast::().ok() else { + self.clear_current_search_match(); + return; + }; + // Skip the property churn if the focused match has not changed + // (e.g. typing more characters while the latest match remains the + // best hit). + let already_focused = imp + .current_search_match + .borrow() + .as_ref() + .is_some_and(|prev| prev == &msg); + if !already_focused { + self.clear_current_search_match(); + msg.set_property("is-search-match", true); + imp.current_search_match.replace(Some(msg)); + } + if let Some(position) = timeline.position_of(timestamp) { + imp.list_view + .scroll_to(position, gtk::ListScrollFlags::NONE, None); + } + } + pub fn focus_input(&self) { self.imp().text_entry.grab_focus(); } @@ -431,7 +629,7 @@ pub mod imp { use crate::gui::components::ItemRow; use crate::gui::components::time_divider::TimeDivider; use crate::{ - backend::{Channel, Manager, message::TextMessage}, + backend::{Channel, Manager, message::{DisplayMessage, TextMessage}}, gui::{error_dialog::ErrorDialog, message_item::MessageItem, text_entry::TextEntry}, }; @@ -446,6 +644,8 @@ pub mod imp { #[template_child] pub(super) text_entry: TemplateChild, #[template_child] + pub(super) search_entry: TemplateChild, + #[template_child] pub(super) list_view: TemplateChild, #[template_child] no_channels_page: TemplateChild, @@ -478,6 +678,29 @@ pub mod imp { #[property(get, set)] has_selection: Cell, + #[property(get, set)] + search_active: Cell, + #[property(get, set)] + search_summary: RefCell, + #[property(get, set)] + has_matches: Cell, + + /// Cached timestamps of every message currently matching the search + /// query, in chronological order. + pub(super) search_matches: RefCell>, + /// Index into `search_matches` of the currently focused match. + pub(super) search_index: Cell, + /// Handler installed on the active channel's `message` signal + /// while the search bar is open, so newly-arrived messages are + /// folded into the match set without the user re-running the + /// search by hand. + pub(super) search_message_handler: + RefCell>, + /// The match the user is currently parked on. Held so we can clear + /// its `is-search-match` flag when navigating to a different match + /// or when the search bar closes. + pub(super) current_search_match: RefCell>, + /// Whether we currently believe the user is composing a message in the /// active channel and have informed the peer with a `Started` event. pub(super) sending_typing: Cell, @@ -520,6 +743,7 @@ pub mod imp { self.obj().setup_send_on_enter(); self.obj().setup_typing_settings(); self.obj().setup_typing_send(); + self.obj().setup_search(); } } @@ -528,6 +752,7 @@ pub mod imp { // forget about it. self.obj().send_typing_stopped(); self.obj().exit_selection_mode(); + self.obj().set_search_active(false); if let Some(active_chan) = self.active_channel.borrow().as_ref() { active_chan.set_property("draft", self.text_entry.text()); @@ -820,6 +1045,27 @@ pub mod imp { } } + #[template_callback] + fn on_search_query_changed(&self) { + let query = self.search_entry.text().to_string(); + self.obj().refresh_search(&query); + } + + #[template_callback] + fn on_search_previous(&self) { + self.obj().goto_search_match(false); + } + + #[template_callback] + fn on_search_next(&self) { + self.obj().goto_search_match(true); + } + + #[template_callback] + fn on_search_stop(&self) { + self.obj().set_search_active(false); + } + #[template_callback] fn handle_row_activated(&self, row: gtk::ListBoxRow) { if let Ok(msg) = row diff --git a/src/gui/message_item.rs b/src/gui/message_item.rs index d88306f..dbf64a7 100644 --- a/src/gui/message_item.rs +++ b/src/gui/message_item.rs @@ -33,6 +33,7 @@ impl MessageItem { s.setup_loaded(); s.setup_text(); s.setup_requires_attention(); + s.setup_search_match(); s.setup_pending_and_error(); s.setup_selection(); s @@ -325,6 +326,30 @@ impl MessageItem { message.notify("requires-attention"); } + /// Reflect the message's `is-search-match` state by toggling the + /// `search-match` CSS class. Unlike `requires-attention`, this state + /// is not auto-cleared after a delay; it stays set until the channel + /// search navigates to a different match or closes. + pub fn setup_search_match(&self) { + let message = self.message(); + self.track_notify_local(&message, "is-search-match", { + let s = self.downgrade(); + move |m, _| { + let Some(s) = s.upgrade() else { + return; + }; + if m.property("is-search-match") { + s.add_css_class("search-match"); + } else { + s.remove_css_class("search-match"); + } + } + }); + if message.property("is-search-match") { + self.add_css_class("search-match"); + } + } + pub fn setup_pending_and_error(&self) { let message = self.message(); message.connect_notify_local( diff --git a/src/gui/window.rs b/src/gui/window.rs index 6335f3d..ce097ce 100644 --- a/src/gui/window.rs +++ b/src/gui/window.rs @@ -20,6 +20,7 @@ impl Window { app.set_accels_for_action("window.close", &["q"]); app.set_accels_for_action("channel-messages.activate-input", &["i"]); app.set_accels_for_action("channel-messages.load-more", &["l"]); + app.set_accels_for_action("channel-messages.toggle-search", &["f"]); for i in 1..=9 { app.set_accels_for_action( &format!("channel-list.activate-channel({i})"), @@ -531,10 +532,20 @@ pub mod imp { channel_messages.load_more(); } )); + let action_toggle_search = SimpleAction::new("toggle-search", None); + action_toggle_search.connect_activate(clone!( + #[strong(rename_to = channel_messages)] + self.channel_messages, + move |_, _| { + let active = !channel_messages.search_active(); + channel_messages.set_search_active(active); + } + )); let actions = SimpleActionGroup::new(); obj.insert_action_group("channel-messages", Some(&actions)); actions.add_action(&action_activate_input); actions.add_action(&action_load_more); + actions.add_action(&action_toggle_search); // Channel list actions. -- 2.53.0