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:
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>,
 }