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>
This commit is contained in:
@@ -11,6 +11,7 @@ pub mod note;
|
|||||||
pub mod preview;
|
pub mod preview;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
pub mod relay;
|
pub mod relay;
|
||||||
|
pub mod search;
|
||||||
pub mod search_results;
|
pub mod search_results;
|
||||||
pub mod side_panel;
|
pub mod side_panel;
|
||||||
pub mod support;
|
pub mod support;
|
||||||
|
|||||||
219
crates/notedeck_columns/src/ui/search/mod.rs
Normal file
219
crates/notedeck_columns/src/ui/search/mod.rs
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
63
crates/notedeck_columns/src/ui/search/state.rs
Normal file
63
crates/notedeck_columns/src/ui/search/state.rs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ use enostr::Pubkey;
|
|||||||
use crate::deck_state::DeckState;
|
use crate::deck_state::DeckState;
|
||||||
use crate::login_manager::AcquireKeyState;
|
use crate::login_manager::AcquireKeyState;
|
||||||
use crate::profile_state::ProfileState;
|
use crate::profile_state::ProfileState;
|
||||||
|
use crate::ui::search::SearchQueryState;
|
||||||
|
|
||||||
/// Various state for views
|
/// Various state for views
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
@@ -13,6 +14,7 @@ pub struct ViewState {
|
|||||||
pub id_to_deck_state: HashMap<egui::Id, DeckState>,
|
pub id_to_deck_state: HashMap<egui::Id, DeckState>,
|
||||||
pub id_state_map: HashMap<egui::Id, AcquireKeyState>,
|
pub id_state_map: HashMap<egui::Id, AcquireKeyState>,
|
||||||
pub id_string_map: HashMap<egui::Id, String>,
|
pub id_string_map: HashMap<egui::Id, String>,
|
||||||
|
pub searches: HashMap<egui::Id, SearchQueryState>,
|
||||||
pub pubkey_to_profile_state: HashMap<Pubkey, ProfileState>,
|
pub pubkey_to_profile_state: HashMap<Pubkey, ProfileState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user