commit 35a37245677e45fe3fecb959e64b93bf1180afcc
parent 7fe495d36064167d2e099dd226b961c318f1b01b
Author: Martti Malmi <sirius@iki.fi>
Date: Wed, 5 Nov 2025 14:48:11 +0200
recent searches, autofocus on back / fwd nav
Diffstat:
2 files changed, 289 insertions(+), 2 deletions(-)
diff --git a/crates/notedeck_columns/src/ui/search/mod.rs b/crates/notedeck_columns/src/ui/search/mod.rs
@@ -24,7 +24,7 @@ use tracing::{error, info, warn};
mod state;
-pub use state::{FocusState, SearchQueryState, SearchState};
+pub use state::{FocusState, RecentSearchItem, SearchQueryState, SearchState};
use super::mentions_picker::{MentionPickerResponse, MentionPickerView};
@@ -93,6 +93,9 @@ impl<'a, 'd> SearchView<'a, 'd> {
} else {
self.query.user_results.clear();
self.query.selected_index = -1;
+ if let Some(action) = self.show_recent_searches(ui, keyboard_resp) {
+ search_action = Some(action);
+ }
}
}
SearchState::PerformSearch(search_type) => {
@@ -234,6 +237,70 @@ impl<'a, 'd> SearchView<'a, 'd> {
None
}
+ fn show_recent_searches(&mut self, ui: &mut egui::Ui, keyboard_resp: KeyboardResponse) -> Option<SearchAction> {
+ if self.query.recent_searches.is_empty() {
+ return None;
+ }
+
+ ui.add_space(8.0);
+ ui.horizontal(|ui| {
+ ui.label("Recent");
+ ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+ if ui.button(RichText::new("Clear all").size(14.0)).clicked() {
+ self.query.clear_recent_searches();
+ }
+ });
+ });
+ ui.add_space(4.0);
+
+ let recent_searches = self.query.recent_searches.clone();
+ for (i, search_item) in recent_searches.iter().enumerate() {
+ let is_selected = self.query.selected_index == i as i32;
+
+ match search_item {
+ RecentSearchItem::Query(query) => {
+ let resp = ui.add(recent_search_item(
+ query,
+ is_selected,
+ ui.available_width(),
+ false,
+ ));
+
+ if resp.clicked() || (is_selected && keyboard_resp.enter_pressed) {
+ return Some(SearchAction::NewSearch {
+ search_type: SearchType::get_type(query),
+ new_search_text: query.clone(),
+ });
+ }
+
+ if resp.secondary_clicked() || (is_selected && ui.input(|i| i.key_pressed(Key::Delete))) {
+ self.query.remove_recent_search(i);
+ }
+ }
+ RecentSearchItem::Profile { pubkey, query } => {
+ let profile = self.note_context.ndb.get_profile_by_pubkey(self.txn, pubkey.bytes()).ok();
+ let resp = ui.add(recent_profile_item(
+ profile.as_ref(),
+ query,
+ is_selected,
+ ui.available_width(),
+ self.note_context.img_cache,
+ ));
+
+ if resp.clicked() || (is_selected && keyboard_resp.enter_pressed) {
+ return Some(SearchAction::NavigateToProfile(*pubkey));
+ }
+
+ if resp.secondary_clicked() || (is_selected && ui.input(|i| i.key_pressed(Key::Delete))) {
+ self.query.remove_recent_search(i);
+ }
+ }
+ }
+ }
+
+ None
+ }
+
fn show_search_results(&mut self, ui: &mut egui::Ui) -> BodyResponse<NoteAction> {
let scroll_out = egui::ScrollArea::vertical()
.id_salt(SearchView::scroll_id())
@@ -307,6 +374,9 @@ impl SearchAction {
None
}
SearchAction::NavigateToProfile(pubkey) => {
+ state.add_recent_profile(pubkey, state.string.clone());
+ state.string.clear();
+ state.selected_index = -1;
Some(SearchViewAction::NavigateToProfile(pubkey))
}
SearchAction::CloseMention => {
@@ -318,6 +388,7 @@ impl SearchAction {
state.state = SearchState::Searched;
state.selected_index = -1;
state.user_results.clear();
+ state.add_recent_query(state.string.clone());
None
}
}
@@ -333,6 +404,8 @@ impl SearchResponse {
fn process(self, state: &mut SearchQueryState) {
if self.requested_focus {
state.focus_state = FocusState::RequestedFocus;
+ } else if state.focus_state == FocusState::RequestedFocus && !self.input_changed {
+ state.focus_state = FocusState::Navigating;
}
if self.input_changed {
@@ -358,7 +431,11 @@ struct KeyboardResponse {
}
fn handle_keyboard_navigation(ui: &mut egui::Ui, selected_index: &mut i32, user_results: &[Vec<u8>]) -> KeyboardResponse {
- let max_index = user_results.len() as i32;
+ let max_index = if user_results.is_empty() {
+ -1
+ } else {
+ user_results.len() as i32
+ };
if ui.input(|i| i.key_pressed(Key::ArrowDown)) {
*selected_index = (*selected_index + 1).min(max_index);
@@ -569,6 +646,171 @@ fn search_hashtag(
Some(qrs.into_iter().map(NoteRef::from_query_result).collect())
}
+fn recent_profile_item<'a>(
+ profile: Option<&'a ProfileRecord<'_>>,
+ _query: &'a str,
+ is_selected: bool,
+ width: f32,
+ cache: &'a mut Images,
+) -> impl egui::Widget + 'a {
+ move |ui: &mut egui::Ui| -> egui::Response {
+ let min_img_size = 48.0;
+ let spacing = 8.0;
+ let body_font_size = get_font_size(ui.ctx(), &NotedeckTextStyle::Body);
+ let x_button_size = 32.0;
+
+ let (rect, resp) = ui.allocate_exact_size(
+ vec2(width, min_img_size + 8.0),
+ egui::Sense::click()
+ );
+
+ if is_selected {
+ ui.painter().rect_filled(
+ rect,
+ 4.0,
+ ui.visuals().selection.bg_fill,
+ );
+ }
+
+ if resp.hovered() {
+ ui.painter().rect_filled(
+ rect,
+ 4.0,
+ ui.visuals().widgets.hovered.bg_fill,
+ );
+ }
+
+ let pfp_rect = egui::Rect::from_min_size(
+ rect.min + vec2(4.0, 4.0),
+ vec2(min_img_size, min_img_size)
+ );
+
+ ui.put(
+ pfp_rect,
+ &mut ProfilePic::new(cache, get_profile_url(profile))
+ .size(min_img_size),
+ );
+
+ let name = get_display_name(profile).name();
+ let name_font = egui::FontId::new(body_font_size, NotedeckTextStyle::Body.font_family());
+ let painter = ui.painter();
+ let text_galley = painter.layout(
+ name.to_string(),
+ name_font,
+ ui.visuals().text_color(),
+ width - min_img_size - spacing - x_button_size - 8.0,
+ );
+
+ let galley_pos = egui::Pos2::new(
+ pfp_rect.right() + spacing,
+ rect.center().y - (text_galley.rect.height() / 2.0)
+ );
+
+ painter.galley(galley_pos, text_galley, ui.visuals().text_color());
+
+ let x_rect = egui::Rect::from_min_size(
+ egui::Pos2::new(rect.right() - x_button_size, rect.top()),
+ vec2(x_button_size, rect.height())
+ );
+
+ let x_center = x_rect.center();
+ let x_size = 12.0;
+ painter.line_segment(
+ [
+ egui::Pos2::new(x_center.x - x_size / 2.0, x_center.y - x_size / 2.0),
+ egui::Pos2::new(x_center.x + x_size / 2.0, x_center.y + x_size / 2.0),
+ ],
+ egui::Stroke::new(1.5, ui.visuals().text_color()),
+ );
+ painter.line_segment(
+ [
+ egui::Pos2::new(x_center.x + x_size / 2.0, x_center.y - x_size / 2.0),
+ egui::Pos2::new(x_center.x - x_size / 2.0, x_center.y + x_size / 2.0),
+ ],
+ egui::Stroke::new(1.5, ui.visuals().text_color()),
+ );
+
+ resp
+ }
+}
+
+fn recent_search_item(query: &str, is_selected: bool, width: f32, _is_profile: bool) -> impl egui::Widget + '_ {
+ move |ui: &mut egui::Ui| -> egui::Response {
+ let min_img_size = 48.0;
+ let spacing = 8.0;
+ let body_font_size = get_font_size(ui.ctx(), &NotedeckTextStyle::Body);
+ let x_button_size = 32.0;
+
+ let (rect, resp) = ui.allocate_exact_size(
+ vec2(width, min_img_size + 8.0),
+ egui::Sense::click()
+ );
+
+ if is_selected {
+ ui.painter().rect_filled(
+ rect,
+ 4.0,
+ ui.visuals().selection.bg_fill,
+ );
+ }
+
+ if resp.hovered() {
+ ui.painter().rect_filled(
+ rect,
+ 4.0,
+ ui.visuals().widgets.hovered.bg_fill,
+ );
+ }
+
+ let icon_rect = egui::Rect::from_min_size(
+ rect.min + vec2(4.0, 4.0),
+ vec2(min_img_size, min_img_size)
+ );
+
+ ui.put(icon_rect, search_icon(min_img_size / 2.0, min_img_size));
+
+ let name_font = egui::FontId::new(body_font_size, NotedeckTextStyle::Body.font_family());
+ let painter = ui.painter();
+ let text_galley = painter.layout(
+ query.to_string(),
+ name_font,
+ ui.visuals().text_color(),
+ width - min_img_size - spacing - x_button_size - 8.0,
+ );
+
+ let galley_pos = egui::Pos2::new(
+ icon_rect.right() + spacing,
+ rect.center().y - (text_galley.rect.height() / 2.0)
+ );
+
+ painter.galley(galley_pos, text_galley, ui.visuals().text_color());
+
+ let x_rect = egui::Rect::from_min_size(
+ egui::Pos2::new(rect.right() - x_button_size, rect.top()),
+ vec2(x_button_size, rect.height())
+ );
+
+ let x_center = x_rect.center();
+ let x_size = 12.0;
+ painter.line_segment(
+ [
+ egui::Pos2::new(x_center.x - x_size / 2.0, x_center.y - x_size / 2.0),
+ egui::Pos2::new(x_center.x + x_size / 2.0, x_center.y + x_size / 2.0),
+ ],
+ egui::Stroke::new(1.5, ui.visuals().text_color()),
+ );
+ painter.line_segment(
+ [
+ egui::Pos2::new(x_center.x + x_size / 2.0, x_center.y - x_size / 2.0),
+ egui::Pos2::new(x_center.x - x_size / 2.0, x_center.y + x_size / 2.0),
+ ],
+ egui::Stroke::new(1.5, ui.visuals().text_color()),
+ );
+
+ resp
+ }
+}
+
fn search_posts_button(query: &str, is_selected: bool, width: f32) -> impl egui::Widget + '_ {
move |ui: &mut egui::Ui| -> egui::Response {
let min_img_size = 48.0;
diff --git a/crates/notedeck_columns/src/ui/search/state.rs b/crates/notedeck_columns/src/ui/search/state.rs
@@ -1,4 +1,5 @@
use crate::timeline::TimelineTab;
+use enostr::Pubkey;
use super::SearchType;
@@ -16,6 +17,12 @@ pub enum TypingType {
Mention(String),
}
+#[derive(Debug, Clone)]
+pub enum RecentSearchItem {
+ Query(String),
+ Profile { pubkey: Pubkey, query: String },
+}
+
#[derive(Debug, Eq, PartialEq, Clone)]
pub enum FocusState {
/// Get ready to focus
@@ -49,6 +56,9 @@ pub struct SearchQueryState {
/// Cached user search results for the current query
pub user_results: Vec<Vec<u8>>,
+
+ /// Recent search history (most recent first, max 10)
+ pub recent_searches: Vec<RecentSearchItem>,
}
impl Default for SearchQueryState {
@@ -66,6 +76,41 @@ impl SearchQueryState {
focus_state: FocusState::Navigating,
selected_index: -1,
user_results: Vec::new(),
+ recent_searches: Vec::new(),
+ }
+ }
+
+ pub fn add_recent_query(&mut self, query: String) {
+ if query.is_empty() {
+ return;
+ }
+
+ let item = RecentSearchItem::Query(query.clone());
+ self.recent_searches.retain(|s| !matches!(s, RecentSearchItem::Query(q) if q == &query));
+ self.recent_searches.insert(0, item);
+ self.recent_searches.truncate(10);
+ }
+
+ pub fn add_recent_profile(&mut self, pubkey: Pubkey, query: String) {
+ if query.is_empty() {
+ return;
+ }
+
+ let item = RecentSearchItem::Profile { pubkey, query: query.clone() };
+ self.recent_searches.retain(|s| {
+ !matches!(s, RecentSearchItem::Profile { pubkey: pk, .. } if pk == &pubkey)
+ });
+ self.recent_searches.insert(0, item);
+ self.recent_searches.truncate(10);
+ }
+
+ pub fn remove_recent_search(&mut self, index: usize) {
+ if index < self.recent_searches.len() {
+ self.recent_searches.remove(index);
}
}
+
+ pub fn clear_recent_searches(&mut self) {
+ self.recent_searches.clear();
+ }
}