notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit 9edc9bf4a59b722d2d54b08db54ad5da6058402f
parent 5fde3277a1a795adf3fd5e91b212b33eb7886244
Author: William Casarin <jb55@jb55.com>
Date:   Fri,  7 Mar 2025 10:53:40 -0800

ui: add SearchView and SearchQueryState

Introduce a new view for searching for notes.

Fixes: https://linear.app/damus/issue/DECK-510/initial-search-query-view
Signed-off-by: William Casarin <jb55@jb55.com>

Diffstat:
Mcrates/notedeck_columns/src/ui/mod.rs | 1+
Acrates/notedeck_columns/src/ui/search/mod.rs | 219+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_columns/src/ui/search/state.rs | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_columns/src/view_state.rs | 2++
4 files changed, 285 insertions(+), 0 deletions(-)

diff --git a/crates/notedeck_columns/src/ui/mod.rs b/crates/notedeck_columns/src/ui/mod.rs @@ -11,6 +11,7 @@ pub mod note; pub mod preview; pub mod profile; pub mod relay; +pub mod search; pub mod search_results; pub mod side_panel; pub mod support; diff --git a/crates/notedeck_columns/src/ui/search/mod.rs b/crates/notedeck_columns/src/ui/search/mod.rs @@ -0,0 +1,219 @@ +use egui::{vec2, Align, Color32, RichText, Rounding, Stroke, TextEdit}; + +use super::padding; +use crate::ui::{note::NoteOptions, timeline::TimelineTabView}; +use nostrdb::{Filter, Ndb, Transaction}; +use notedeck::{Images, MuteFun, NoteCache, NoteRef}; +use std::time::{Duration, Instant}; +use tracing::{error, info, warn}; + +mod state; + +pub use state::{SearchQueryState, SearchState}; + +pub struct SearchView<'a> { + query: &'a mut SearchQueryState, + ndb: &'a Ndb, + note_options: NoteOptions, + txn: &'a Transaction, + note_cache: &'a mut NoteCache, + img_cache: &'a mut Images, + is_muted: &'a MuteFun, +} + +impl<'a> SearchView<'a> { + pub fn new( + ndb: &'a Ndb, + txn: &'a Transaction, + note_cache: &'a mut NoteCache, + img_cache: &'a mut Images, + is_muted: &'a MuteFun, + note_options: NoteOptions, + query: &'a mut SearchQueryState, + ) -> Self { + Self { + ndb, + txn, + note_cache, + img_cache, + is_muted, + query, + note_options, + } + } + + pub fn show(&mut self, ui: &mut egui::Ui) { + padding(8.0, ui, |ui| { + self.show_impl(ui); + }); + } + + pub fn show_impl(&mut self, ui: &mut egui::Ui) { + ui.spacing_mut().item_spacing = egui::vec2(0.0, 12.0); + + if search_box(self.query, ui) { + self.execute_search(ui.ctx()); + } + + match self.query.state { + SearchState::New => {} + + SearchState::Searched | SearchState::Typing => { + if self.query.state == SearchState::Typing { + ui.label(format!("Searching for '{}'", &self.query.string)); + } else { + ui.label(format!( + "Got {} results for '{}'", + self.query.notes.notes.len(), + &self.query.string + )); + } + + egui::ScrollArea::vertical().show(ui, |ui| { + let reversed = false; + TimelineTabView::new( + &self.query.notes, + reversed, + self.note_options, + self.txn, + self.ndb, + self.note_cache, + self.img_cache, + self.is_muted, + ) + .show(ui); + }); + } + } + } + + fn execute_search(&mut self, ctx: &egui::Context) { + if self.query.string.is_empty() { + return; + } + + let max_results = 500; + let filter = Filter::new() + .search(&self.query.string) + .kinds([1]) + .limit(max_results) + .build(); + + // TODO: execute in thread + + let before = Instant::now(); + let qrs = self.ndb.query(self.txn, &[filter], max_results as i32); + let after = Instant::now(); + let duration = after - before; + + if duration > Duration::from_millis(20) { + warn!( + "query took {:?}... let's update this to use a thread!", + after - before + ); + } + + match qrs { + Ok(qrs) => { + info!( + "queried '{}' and got {} results", + self.query.string, + qrs.len() + ); + + let note_refs = qrs.into_iter().map(NoteRef::from_query_result).collect(); + self.query.notes.notes = note_refs; + self.query.notes.list.borrow_mut().reset(); + ctx.request_repaint(); + } + + Err(err) => { + error!("fulltext query failed: {err}") + } + } + } +} + +fn search_box(query: &mut SearchQueryState, ui: &mut egui::Ui) -> bool { + ui.horizontal(|ui| { + // Container for search input and icon + let search_container = egui::Frame { + inner_margin: egui::Margin::symmetric(8.0, 0.0), + outer_margin: egui::Margin::ZERO, + rounding: Rounding::same(18.0), // More rounded corners + shadow: Default::default(), + fill: Color32::from_rgb(30, 30, 30), // Darker background to match screenshot + stroke: Stroke::new(1.0, Color32::from_rgb(60, 60, 60)), + }; + + search_container + .show(ui, |ui| { + // Use layout to align items vertically centered + ui.with_layout(egui::Layout::left_to_right(Align::Center), |ui| { + ui.spacing_mut().item_spacing = egui::vec2(8.0, 0.0); + + let search_height = 34.0; + // Magnifying glass icon + ui.add(search_icon(16.0, search_height)); + + let before_len = query.string.len(); + + // Search input field + //let font_size = notedeck::fonts::get_font_size(ui.ctx(), &NotedeckTextStyle::Body); + ui.add_sized( + [ui.available_width(), search_height], + TextEdit::singleline(&mut query.string) + .hint_text(RichText::new("Search notes...").weak()) + //.desired_width(available_width - 32.0) + //.font(egui::FontId::new(font_size, egui::FontFamily::Proportional)) + .margin(vec2(0.0, 8.0)) + .frame(false), + ); + + let after_len = query.string.len(); + + let changed = before_len != after_len; + if changed { + query.mark_updated(); + } + + // Execute search after debouncing + if query.should_search() { + query.mark_searched(SearchState::Searched); + true + } else { + false + } + }) + .inner + }) + .inner + }) + .inner +} + +/// Creates a magnifying glass icon widget +fn search_icon(size: f32, height: f32) -> impl egui::Widget { + move |ui: &mut egui::Ui| { + // Use the provided height parameter + let desired_size = vec2(size, height); + let (rect, response) = ui.allocate_exact_size(desired_size, egui::Sense::hover()); + + // Calculate center position - this ensures the icon is centered in its allocated space + let center_pos = rect.center(); + let stroke = Stroke::new(1.5, Color32::from_rgb(150, 150, 150)); + + // Draw circle + let circle_radius = size * 0.35; + ui.painter() + .circle(center_pos, circle_radius, Color32::TRANSPARENT, stroke); + + // Draw handle + let handle_start = center_pos + vec2(circle_radius * 0.7, circle_radius * 0.7); + let handle_end = handle_start + vec2(size * 0.25, size * 0.25); + ui.painter() + .line_segment([handle_start, handle_end], stroke); + + response + } +} diff --git a/crates/notedeck_columns/src/ui/search/state.rs b/crates/notedeck_columns/src/ui/search/state.rs @@ -0,0 +1,63 @@ +use crate::timeline::TimelineTab; +use notedeck::debouncer::Debouncer; +use std::time::Duration; + +#[derive(Debug, Eq, PartialEq)] +pub enum SearchState { + Typing, + Searched, + New, +} + +/// Search query state that exists between frames +#[derive(Debug)] +pub struct SearchQueryState { + /// This holds our search query while we're updating it + pub string: String, + + /// When the debouncer timer elapses, we execute the search and mark + /// our state as searchd. This will make sure we don't try to search + /// again next frames + pub state: SearchState, + + /// When was the input updated? We use this to debounce searches + pub debouncer: Debouncer, + + /// The search results + pub notes: TimelineTab, +} + +impl Default for SearchQueryState { + fn default() -> Self { + SearchQueryState::new() + } +} + +impl SearchQueryState { + pub fn new() -> Self { + Self { + string: "".to_string(), + state: SearchState::New, + notes: TimelineTab::default(), + debouncer: Debouncer::new(Duration::from_millis(200)), + } + } + + pub fn should_search(&self) -> bool { + self.state == SearchState::Typing && self.debouncer.should_act() + } + + /// Mark the search as updated. This will update our debouncer and clear + /// the searched flag, enabling us to search again. This should be + /// called when the search box changes + pub fn mark_updated(&mut self) { + self.state = SearchState::Typing; + self.debouncer.bounce(); + } + + /// Call this when you are about to do a search so that we don't try + /// to search again next frame + pub fn mark_searched(&mut self, state: SearchState) { + self.state = state; + } +} diff --git a/crates/notedeck_columns/src/view_state.rs b/crates/notedeck_columns/src/view_state.rs @@ -5,6 +5,7 @@ use enostr::Pubkey; use crate::deck_state::DeckState; use crate::login_manager::AcquireKeyState; use crate::profile_state::ProfileState; +use crate::ui::search::SearchQueryState; /// Various state for views #[derive(Default)] @@ -13,6 +14,7 @@ pub struct ViewState { pub id_to_deck_state: HashMap<egui::Id, DeckState>, pub id_state_map: HashMap<egui::Id, AcquireKeyState>, pub id_string_map: HashMap<egui::Id, String>, + pub searches: HashMap<egui::Id, SearchQueryState>, pub pubkey_to_profile_state: HashMap<Pubkey, ProfileState>, }