notedeck

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

commit 5c9237e0708c9154a7e854cf41deccf6bb73deb6
parent fbc5d679aec8d6287f2a077b1bda38d1e68a0d2e
Author: William Casarin <jb55@jb55.com>
Date:   Fri, 14 Nov 2025 17:38:32 -0800

Merge fix profile searches by martti #1193

Martti Malmi (7):
      placeholder: Search posts, @users, #hashtags...
      search: user results by default, search posts btn
      recent searches, autofocus on back / fwd nav
      badge on recent searched profiles
      fix build errors
      remove comments and fix warnings
      fix profiles not showing up in search

Diffstat:
Mcrates/notedeck_columns/src/ui/search/mod.rs | 605++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mcrates/notedeck_columns/src/ui/search/state.rs | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mcrates/notedeck_columns/src/ui/settings.rs | 2++
3 files changed, 582 insertions(+), 93 deletions(-)

diff --git a/crates/notedeck_columns/src/ui/search/mod.rs b/crates/notedeck_columns/src/ui/search/mod.rs @@ -8,20 +8,23 @@ use crate::{ ui::timeline::TimelineTabView, }; use egui_winit::clipboard::Clipboard; -use nostrdb::{Filter, Ndb, Transaction}; -use notedeck::{tr, tr_plural, JobsCache, Localization, NoteAction, NoteContext, NoteRef}; +use nostrdb::{Filter, Ndb, ProfileRecord, Transaction}; +use notedeck::{ + fonts::get_font_size, name::get_display_name, profile::get_profile_url, tr, tr_plural, Images, + JobsCache, Localization, NoteAction, NoteContext, NoteRef, NotedeckTextStyle, +}; use notedeck_ui::{ context_menu::{input_context, PasteBehavior}, icons::search_icon, - padding, NoteOptions, + padding, NoteOptions, ProfilePic, }; use std::time::{Duration, Instant}; 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}; @@ -51,10 +54,15 @@ impl<'a, 'd> SearchView<'a, 'd> { } pub fn show(&mut self, ui: &mut egui::Ui) -> BodyResponse<NoteAction> { - padding(8.0, ui, |ui| self.show_impl(ui)).inner + padding(8.0, ui, |ui| self.show_impl(ui)) + .inner + .map_output(|action| match action { + SearchViewAction::NoteAction(note_action) => note_action, + SearchViewAction::NavigateToProfile(pubkey) => NoteAction::Profile(pubkey), + }) } - pub fn show_impl(&mut self, ui: &mut egui::Ui) -> BodyResponse<NoteAction> { + fn show_impl(&mut self, ui: &mut egui::Ui) -> BodyResponse<SearchViewAction> { ui.spacing_mut().item_spacing = egui::vec2(0.0, 12.0); let search_resp = search_box( @@ -67,53 +75,39 @@ impl<'a, 'd> SearchView<'a, 'd> { search_resp.process(self.query); + let keyboard_resp = handle_keyboard_navigation( + ui, + &mut self.query.selected_index, + &self.query.user_results, + ); + let mut search_action = None; let mut body_resp = BodyResponse::none(); match &self.query.state { - SearchState::New | SearchState::Navigating => {} - SearchState::Typing(TypingType::Mention(mention_name)) => 's: { - let Ok(results) = self - .note_context - .ndb - .search_profile(self.txn, mention_name, 10) - else { - break 's; - }; - - let search_res = MentionPickerView::new( - self.note_context.img_cache, - self.note_context.ndb, - self.txn, - &results, - ) - .show_in_rect(ui.available_rect_before_wrap(), ui); - - let Some(res) = search_res.output else { - break 's; - }; - - search_action = match res { - MentionPickerResponse::SelectResult(Some(index)) => { - let Some(pk_bytes) = results.get(index) else { - break 's; - }; - - let username = self - .note_context - .ndb - .get_profile_by_pubkey(self.txn, pk_bytes) - .ok() - .and_then(|p| p.record().profile().and_then(|p| p.name())) - .unwrap_or(&self.query.string); - - Some(SearchAction::NewSearch { - search_type: SearchType::Profile(Pubkey::new(**pk_bytes)), - new_search_text: format!("@{username}"), - }) + SearchState::New + | SearchState::Navigating + | SearchState::Typing(TypingType::Mention(_)) => { + if !self.query.string.is_empty() && !self.query.string.starts_with('@') { + self.query.user_results = self + .note_context + .ndb + .search_profile(self.txn, &self.query.string, 10) + .unwrap_or_default() + .iter() + .map(|&pk| pk.to_vec()) + .collect(); + if let Some(action) = self.show_search_suggestions(ui, keyboard_resp) { + search_action = Some(action); + } + } else if self.query.string.starts_with('@') { + self.handle_mention_search(ui, &mut search_action); + } 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); } - MentionPickerResponse::DeleteMention => Some(SearchAction::CloseMention), - MentionPickerResponse::SelectResult(None) => break 's, - }; + } } SearchState::PerformSearch(search_type) => { execute_search( @@ -125,7 +119,10 @@ impl<'a, 'd> SearchView<'a, 'd> { &mut self.query.notes, ); search_action = Some(SearchAction::Searched); - body_resp.insert(self.show_search_results(ui)); + body_resp.insert( + self.show_search_results(ui) + .map_output(SearchViewAction::NoteAction), + ); } SearchState::Searched => { ui.label(tr_plural!( @@ -136,25 +133,222 @@ impl<'a, 'd> SearchView<'a, 'd> { self.query.notes.units.len(), // count query = &self.query.string )); - body_resp.insert(self.show_search_results(ui)); + body_resp.insert( + self.show_search_results(ui) + .map_output(SearchViewAction::NoteAction), + ); } - SearchState::Typing(TypingType::AutoSearch) => { - ui.label(tr!( - self.note_context.i18n, - "Searching for '{query}'", - "Search in progress message", - query = &self.query.string - )); + }; - body_resp.insert(self.show_search_results(ui)); + if let Some(action) = search_action { + if let Some(view_action) = action.process(self.query) { + body_resp.output = Some(view_action); } + } + + body_resp + } + + fn handle_mention_search( + &mut self, + ui: &mut egui::Ui, + search_action: &mut Option<SearchAction>, + ) { + let mention_name = if let Some(mention_text) = self.query.string.get(1..) { + mention_text + } else { + return; }; - if let Some(resp) = search_action { - resp.process(self.query); + 's: { + let Ok(results) = self + .note_context + .ndb + .search_profile(self.txn, mention_name, 10) + else { + break 's; + }; + + let search_res = MentionPickerView::new( + self.note_context.img_cache, + self.note_context.ndb, + self.txn, + &results, + ) + .show_in_rect(ui.available_rect_before_wrap(), ui); + + let Some(res) = search_res.output else { + break 's; + }; + + *search_action = match res { + MentionPickerResponse::SelectResult(Some(index)) => { + let Some(pk_bytes) = results.get(index) else { + break 's; + }; + + let username = self + .note_context + .ndb + .get_profile_by_pubkey(self.txn, pk_bytes) + .ok() + .and_then(|p| p.record().profile().and_then(|p| p.name())) + .unwrap_or(&self.query.string); + + Some(SearchAction::NewSearch { + search_type: SearchType::Profile(Pubkey::new(**pk_bytes)), + new_search_text: format!("@{username}"), + }) + } + MentionPickerResponse::DeleteMention => Some(SearchAction::CloseMention), + MentionPickerResponse::SelectResult(None) => break 's, + }; } + } - body_resp + fn show_search_suggestions( + &mut self, + ui: &mut egui::Ui, + keyboard_resp: KeyboardResponse, + ) -> Option<SearchAction> { + ui.add_space(8.0); + + let is_selected = self.query.selected_index == 0; + let search_posts_clicked = ui + .add(search_posts_button( + &self.query.string, + is_selected, + ui.available_width(), + )) + .clicked() + || (is_selected && keyboard_resp.enter_pressed); + + if search_posts_clicked { + let search_type = SearchType::get_type(&self.query.string); + // If it's a profile (npub), navigate to the profile instead of searching posts + if let SearchType::Profile(pubkey) = search_type { + return Some(SearchAction::NavigateToProfile(pubkey)); + } + return Some(SearchAction::NewSearch { + search_type, + new_search_text: self.query.string.clone(), + }); + } + + if keyboard_resp.enter_pressed && self.query.selected_index > 0 { + let user_idx = (self.query.selected_index - 1) as usize; + if let Some(pk_bytes) = self.query.user_results.get(user_idx) { + if let Ok(pk_array) = TryInto::<[u8; 32]>::try_into(pk_bytes.as_slice()) { + return Some(SearchAction::NavigateToProfile(Pubkey::new(pk_array))); + } + } + } + + for (i, pk_bytes) in self.query.user_results.iter().enumerate() { + if let Ok(pk_array) = TryInto::<[u8; 32]>::try_into(pk_bytes.as_slice()) { + let pubkey = Pubkey::new(pk_array); + let profile = self + .note_context + .ndb + .get_profile_by_pubkey(self.txn, &pk_array) + .ok(); + let is_selected = self.query.selected_index == (i + 1) as i32; + + let resp = ui.add(recent_profile_item( + profile.as_ref(), + &pubkey, + &self.query.string, + is_selected, + ui.available_width(), + self.note_context.img_cache, + self.note_context.accounts, + )); + + if resp.clicked() { + return Some(SearchAction::NavigateToProfile(pubkey)); + } + } + } + + 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(), + pubkey, + query, + is_selected, + ui.available_width(), + self.note_context.img_cache, + self.note_context.accounts, + )); + + 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> { @@ -202,17 +396,23 @@ fn execute_search( ctx.request_repaint(); } +enum SearchViewAction { + NoteAction(NoteAction), + NavigateToProfile(Pubkey), +} + enum SearchAction { NewSearch { search_type: SearchType, new_search_text: String, }, + NavigateToProfile(Pubkey), Searched, CloseMention, } impl SearchAction { - fn process(self, state: &mut SearchQueryState) { + fn process(self, state: &mut SearchQueryState) -> Option<SearchViewAction> { match self { SearchAction::NewSearch { search_type, @@ -220,9 +420,27 @@ impl SearchAction { } => { state.state = SearchState::PerformSearch(search_type); state.string = new_search_text; + state.selected_index = -1; + 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 => { + state.state = SearchState::New; + state.selected_index = -1; + None + } + SearchAction::Searched => { + state.state = SearchState::Searched; + state.selected_index = -1; + state.user_results.clear(); + state.add_recent_query(state.string.clone()); + None } - SearchAction::CloseMention => state.state = SearchState::New, - SearchAction::Searched => state.state = SearchState::Searched, } } } @@ -236,29 +454,52 @@ 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 state.string.chars().nth(0) != Some('@') { - if self.input_changed { - state.state = SearchState::Typing(TypingType::AutoSearch); - state.debouncer.bounce(); + if self.input_changed { + if state.string.starts_with('@') { + state.selected_index = -1; + if let Some(mention_text) = state.string.get(1..) { + state.state = SearchState::Typing(TypingType::Mention(mention_text.to_owned())); + } + } else if state.state == SearchState::Searched { + state.state = SearchState::New; + state.selected_index = 0; + } else if !state.string.is_empty() { + state.selected_index = 0; + } else { + state.selected_index = -1; } + } + } +} - if state.state == SearchState::Typing(TypingType::AutoSearch) - && state.debouncer.should_act() - { - state.state = SearchState::PerformSearch(SearchType::get_type(&state.string)); - } +struct KeyboardResponse { + enter_pressed: bool, +} - return; - } +fn handle_keyboard_navigation( + ui: &mut egui::Ui, + selected_index: &mut i32, + user_results: &[Vec<u8>], +) -> KeyboardResponse { + let max_index = if user_results.is_empty() { + -1 + } else { + user_results.len() as i32 + }; - if self.input_changed { - if let Some(mention_text) = state.string.get(1..) { - state.state = SearchState::Typing(TypingType::Mention(mention_text.to_owned())); - } - } + if ui.input(|i| i.key_pressed(Key::ArrowDown)) { + *selected_index = (*selected_index + 1).min(max_index); + } else if ui.input(|i| i.key_pressed(Key::ArrowUp)) { + *selected_index = (*selected_index - 1).max(-1); } + + let enter_pressed = ui.input(|i| i.key_pressed(Key::Enter)); + + KeyboardResponse { enter_pressed } } fn search_box( @@ -307,8 +548,8 @@ fn search_box( .hint_text( RichText::new(tr!( i18n, - "Search notes...", - "Placeholder for search notes input field" + "Search", + "Placeholder for search input field" )) .weak(), ) @@ -465,3 +706,201 @@ fn search_hashtag( let qrs = ndb.query(txn, &[filter], max_results as i32).ok()?; Some(qrs.into_iter().map(NoteRef::from_query_result).collect()) } + +fn recent_profile_item<'a>( + profile: Option<&'a ProfileRecord<'_>>, + _pubkey: &'a Pubkey, + _query: &'a str, + is_selected: bool, + width: f32, + cache: &'a mut Images, + _accounts: &'a notedeck::Accounts, +) -> 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()); + + let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand); + + 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; + let spacing = 8.0; + let body_font_size = get_font_size(ui.ctx(), &NotedeckTextStyle::Body); + + 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 text = format!("Search posts for \"{}\"", query); + let name_font = egui::FontId::new(body_font_size, NotedeckTextStyle::Body.font_family()); + let painter = ui.painter(); + let text_galley = painter.layout( + text, + name_font, + ui.visuals().text_color(), + width - min_img_size - spacing - 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()); + + resp + } +} diff --git a/crates/notedeck_columns/src/ui/search/state.rs b/crates/notedeck_columns/src/ui/search/state.rs @@ -1,6 +1,5 @@ use crate::timeline::TimelineTab; -use notedeck::debouncer::Debouncer; -use std::time::Duration; +use enostr::Pubkey; use super::SearchType; @@ -16,7 +15,12 @@ pub enum SearchState { #[derive(Debug, Eq, PartialEq)] pub enum TypingType { Mention(String), - AutoSearch, +} + +#[derive(Debug, Clone)] +pub enum RecentSearchItem { + Query(String), + Profile { pubkey: Pubkey, query: String }, } #[derive(Debug, Eq, PartialEq, Clone)] @@ -37,20 +41,24 @@ 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 + /// Current search state pub state: SearchState, /// A bit of context to know if we're navigating to the view. We /// can use this to know when to request focus on the textedit pub focus_state: FocusState, - /// When was the input updated? We use this to debounce searches - pub debouncer: Debouncer, - /// The search results pub notes: TimelineTab, + + /// Currently selected item index in search results (-1 = none, 0 = "search posts", 1+ = users) + pub selected_index: i32, + + /// 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,7 +74,47 @@ impl SearchQueryState { state: SearchState::New, notes: TimelineTab::default(), focus_state: FocusState::Navigating, - debouncer: Debouncer::new(Duration::from_millis(200)), + 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(); + } } diff --git a/crates/notedeck_columns/src/ui/settings.rs b/crates/notedeck_columns/src/ui/settings.rs @@ -501,7 +501,9 @@ impl<'a> SettingsView<'a> { self.settings.animate_nav_transitions, )); } + }); + ui.horizontal_wrapped(|ui| { ui.label(richtext_small(tr!( self.note_context.i18n, "Max hashtags per note:",