notedeck

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

mod.rs (29685B)


      1 use egui::{vec2, Align, Color32, CornerRadius, Key, RichText, Stroke, TextEdit};
      2 use enostr::{NoteId, Pubkey};
      3 use state::TypingType;
      4 
      5 use crate::{
      6     timeline::{TimelineTab, TimelineUnits},
      7     ui::timeline::TimelineTabView,
      8 };
      9 use egui_winit::clipboard::Clipboard;
     10 use nostrdb::{Filter, Ndb, ProfileRecord, Transaction};
     11 use notedeck::{
     12     fonts::get_font_size, name::get_display_name, profile::get_profile_url, tr, tr_plural,
     13     DragResponse, Images, Localization, MediaJobSender, NoteAction, NoteContext, NoteRef,
     14     NotedeckTextStyle,
     15 };
     16 
     17 use notedeck_ui::{
     18     context_menu::{input_context, PasteBehavior},
     19     icons::search_icon,
     20     padding, NoteOptions, ProfilePic,
     21 };
     22 use std::time::{Duration, Instant};
     23 use tracing::{error, info, warn};
     24 
     25 mod state;
     26 
     27 pub use state::{FocusState, RecentSearchItem, SearchQueryState, SearchState};
     28 
     29 use super::mentions_picker::{MentionPickerResponse, MentionPickerView};
     30 
     31 pub struct SearchView<'a, 'd> {
     32     query: &'a mut SearchQueryState,
     33     note_options: NoteOptions,
     34     txn: &'a Transaction,
     35     note_context: &'a mut NoteContext<'d>,
     36 }
     37 
     38 impl<'a, 'd> SearchView<'a, 'd> {
     39     pub fn new(
     40         txn: &'a Transaction,
     41         note_options: NoteOptions,
     42         query: &'a mut SearchQueryState,
     43         note_context: &'a mut NoteContext<'d>,
     44     ) -> Self {
     45         Self {
     46             txn,
     47             query,
     48             note_options,
     49             note_context,
     50         }
     51     }
     52 
     53     pub fn show(&mut self, ui: &mut egui::Ui) -> DragResponse<NoteAction> {
     54         padding(8.0, ui, |ui| self.show_impl(ui))
     55             .inner
     56             .map_output(|action| match action {
     57                 SearchViewAction::NoteAction(note_action) => note_action,
     58                 SearchViewAction::NavigateToProfile(pubkey) => NoteAction::Profile(pubkey),
     59             })
     60     }
     61 
     62     fn show_impl(&mut self, ui: &mut egui::Ui) -> DragResponse<SearchViewAction> {
     63         ui.spacing_mut().item_spacing = egui::vec2(0.0, 12.0);
     64 
     65         let search_resp = search_box(
     66             self.note_context.i18n,
     67             &mut self.query.string,
     68             self.query.focus_state.clone(),
     69             ui,
     70             self.note_context.clipboard,
     71         );
     72 
     73         search_resp.process(self.query);
     74 
     75         let keyboard_resp = handle_keyboard_navigation(
     76             ui,
     77             &mut self.query.selected_index,
     78             &self.query.user_results,
     79         );
     80 
     81         let mut search_action = None;
     82         let mut body_resp = DragResponse::none();
     83         match &self.query.state {
     84             SearchState::New
     85             | SearchState::Navigating
     86             | SearchState::Typing(TypingType::Mention(_)) => {
     87                 if !self.query.string.is_empty() && !self.query.string.starts_with('@') {
     88                     self.query.user_results = self
     89                         .note_context
     90                         .ndb
     91                         .search_profile(self.txn, &self.query.string, 10)
     92                         .unwrap_or_default()
     93                         .iter()
     94                         .map(|&pk| pk.to_vec())
     95                         .collect();
     96                     if let Some(action) = self.show_search_suggestions(ui, keyboard_resp) {
     97                         search_action = Some(action);
     98                     }
     99                 } else if self.query.string.starts_with('@') {
    100                     self.handle_mention_search(ui, &mut search_action);
    101                 } else {
    102                     self.query.user_results.clear();
    103                     self.query.selected_index = -1;
    104                     if let Some(action) = self.show_recent_searches(ui, keyboard_resp) {
    105                         search_action = Some(action);
    106                     }
    107                 }
    108             }
    109             SearchState::PerformSearch(search_type) => {
    110                 execute_search(
    111                     ui.ctx(),
    112                     search_type,
    113                     &self.query.string,
    114                     self.note_context.ndb,
    115                     self.txn,
    116                     &mut self.query.notes,
    117                 );
    118                 search_action = Some(SearchAction::Searched);
    119                 body_resp.insert(
    120                     self.show_search_results(ui)
    121                         .map_output(SearchViewAction::NoteAction),
    122                 );
    123             }
    124             SearchState::Searched => {
    125                 ui.label(tr_plural!(
    126                     self.note_context.i18n,
    127                     "Got {count} result for '{query}'",  // one
    128                     "Got {count} results for '{query}'", // other
    129                     "Search results count",              // comment
    130                     self.query.notes.units.len(),        // count
    131                     query = &self.query.string
    132                 ));
    133                 body_resp.insert(
    134                     self.show_search_results(ui)
    135                         .map_output(SearchViewAction::NoteAction),
    136                 );
    137             }
    138         };
    139 
    140         if let Some(action) = search_action {
    141             if let Some(view_action) = action.process(self.query) {
    142                 body_resp.output = Some(view_action);
    143             }
    144         }
    145 
    146         body_resp
    147     }
    148 
    149     fn handle_mention_search(
    150         &mut self,
    151         ui: &mut egui::Ui,
    152         search_action: &mut Option<SearchAction>,
    153     ) {
    154         let mention_name = if let Some(mention_text) = self.query.string.get(1..) {
    155             mention_text
    156         } else {
    157             return;
    158         };
    159 
    160         's: {
    161             let Ok(results) = self
    162                 .note_context
    163                 .ndb
    164                 .search_profile(self.txn, mention_name, 10)
    165             else {
    166                 break 's;
    167             };
    168 
    169             let search_res = MentionPickerView::new(
    170                 self.note_context.img_cache,
    171                 self.note_context.ndb,
    172                 self.txn,
    173                 &results,
    174                 self.note_context.jobs,
    175             )
    176             .show_in_rect(ui.available_rect_before_wrap(), ui);
    177 
    178             let Some(res) = search_res.output else {
    179                 break 's;
    180             };
    181 
    182             *search_action = match res {
    183                 MentionPickerResponse::SelectResult(Some(index)) => {
    184                     let Some(pk_bytes) = results.get(index) else {
    185                         break 's;
    186                     };
    187 
    188                     let username = self
    189                         .note_context
    190                         .ndb
    191                         .get_profile_by_pubkey(self.txn, pk_bytes)
    192                         .ok()
    193                         .and_then(|p| p.record().profile().and_then(|p| p.name()))
    194                         .unwrap_or(&self.query.string);
    195 
    196                     Some(SearchAction::NewSearch {
    197                         search_type: SearchType::Profile(Pubkey::new(**pk_bytes)),
    198                         new_search_text: format!("@{username}"),
    199                     })
    200                 }
    201                 MentionPickerResponse::DeleteMention => Some(SearchAction::CloseMention),
    202                 MentionPickerResponse::SelectResult(None) => break 's,
    203             };
    204         }
    205     }
    206 
    207     fn show_search_suggestions(
    208         &mut self,
    209         ui: &mut egui::Ui,
    210         keyboard_resp: KeyboardResponse,
    211     ) -> Option<SearchAction> {
    212         ui.add_space(8.0);
    213 
    214         let is_selected = self.query.selected_index == 0;
    215         let search_posts_clicked = ui
    216             .add(search_posts_button(
    217                 &self.query.string,
    218                 is_selected,
    219                 ui.available_width(),
    220             ))
    221             .clicked()
    222             || (is_selected && keyboard_resp.enter_pressed);
    223 
    224         if search_posts_clicked {
    225             let search_type = SearchType::get_type(&self.query.string);
    226             // If it's a profile (npub), navigate to the profile instead of searching posts
    227             if let SearchType::Profile(pubkey) = search_type {
    228                 return Some(SearchAction::NavigateToProfile(pubkey));
    229             }
    230             return Some(SearchAction::NewSearch {
    231                 search_type,
    232                 new_search_text: self.query.string.clone(),
    233             });
    234         }
    235 
    236         if keyboard_resp.enter_pressed && self.query.selected_index > 0 {
    237             let user_idx = (self.query.selected_index - 1) as usize;
    238             if let Some(pk_bytes) = self.query.user_results.get(user_idx) {
    239                 if let Ok(pk_array) = TryInto::<[u8; 32]>::try_into(pk_bytes.as_slice()) {
    240                     return Some(SearchAction::NavigateToProfile(Pubkey::new(pk_array)));
    241                 }
    242             }
    243         }
    244 
    245         for (i, pk_bytes) in self.query.user_results.iter().enumerate() {
    246             if let Ok(pk_array) = TryInto::<[u8; 32]>::try_into(pk_bytes.as_slice()) {
    247                 let pubkey = Pubkey::new(pk_array);
    248                 let profile = self
    249                     .note_context
    250                     .ndb
    251                     .get_profile_by_pubkey(self.txn, &pk_array)
    252                     .ok();
    253                 let is_selected = self.query.selected_index == (i + 1) as i32;
    254 
    255                 let resp = ui.add(recent_profile_item(
    256                     profile.as_ref(),
    257                     is_selected,
    258                     ui.available_width(),
    259                     self.note_context.img_cache,
    260                     self.note_context.jobs,
    261                 ));
    262 
    263                 if resp.clicked() {
    264                     return Some(SearchAction::NavigateToProfile(pubkey));
    265                 }
    266             }
    267         }
    268 
    269         None
    270     }
    271 
    272     fn show_recent_searches(
    273         &mut self,
    274         ui: &mut egui::Ui,
    275         keyboard_resp: KeyboardResponse,
    276     ) -> Option<SearchAction> {
    277         if self.query.recent_searches.is_empty() {
    278             return None;
    279         }
    280 
    281         ui.add_space(8.0);
    282         ui.horizontal(|ui| {
    283             ui.label("Recent");
    284             ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
    285                 if ui.button(RichText::new("Clear all").size(14.0)).clicked() {
    286                     self.query.clear_recent_searches();
    287                 }
    288             });
    289         });
    290         ui.add_space(4.0);
    291 
    292         let recent_searches = self.query.recent_searches.clone();
    293         for (i, search_item) in recent_searches.iter().enumerate() {
    294             let is_selected = self.query.selected_index == i as i32;
    295 
    296             match search_item {
    297                 RecentSearchItem::Query(query) => {
    298                     let resp = ui.add(recent_search_item(
    299                         query,
    300                         is_selected,
    301                         ui.available_width(),
    302                         false,
    303                     ));
    304 
    305                     if resp.clicked() || (is_selected && keyboard_resp.enter_pressed) {
    306                         return Some(SearchAction::NewSearch {
    307                             search_type: SearchType::get_type(query),
    308                             new_search_text: query.clone(),
    309                         });
    310                     }
    311 
    312                     if resp.secondary_clicked()
    313                         || (is_selected && ui.input(|i| i.key_pressed(Key::Delete)))
    314                     {
    315                         self.query.remove_recent_search(i);
    316                     }
    317                 }
    318                 RecentSearchItem::Profile { pubkey, query: _ } => {
    319                     let profile = self
    320                         .note_context
    321                         .ndb
    322                         .get_profile_by_pubkey(self.txn, pubkey.bytes())
    323                         .ok();
    324                     let resp = ui.add(recent_profile_item(
    325                         profile.as_ref(),
    326                         is_selected,
    327                         ui.available_width(),
    328                         self.note_context.img_cache,
    329                         self.note_context.jobs,
    330                     ));
    331 
    332                     if resp.clicked() || (is_selected && keyboard_resp.enter_pressed) {
    333                         return Some(SearchAction::NavigateToProfile(*pubkey));
    334                     }
    335 
    336                     if resp.secondary_clicked()
    337                         || (is_selected && ui.input(|i| i.key_pressed(Key::Delete)))
    338                     {
    339                         self.query.remove_recent_search(i);
    340                     }
    341                 }
    342             }
    343         }
    344 
    345         None
    346     }
    347 
    348     fn show_search_results(&mut self, ui: &mut egui::Ui) -> DragResponse<NoteAction> {
    349         let scroll_out = egui::ScrollArea::vertical()
    350             .id_salt(SearchView::scroll_id())
    351             .show(ui, |ui| {
    352                 TimelineTabView::new(
    353                     &self.query.notes,
    354                     self.note_options,
    355                     self.txn,
    356                     self.note_context,
    357                 )
    358                 .show(ui)
    359             });
    360 
    361         DragResponse::scroll(scroll_out)
    362     }
    363 
    364     pub fn scroll_id() -> egui::Id {
    365         egui::Id::new("search_results")
    366     }
    367 }
    368 
    369 fn execute_search(
    370     ctx: &egui::Context,
    371     search_type: &SearchType,
    372     raw_input: &String,
    373     ndb: &Ndb,
    374     txn: &Transaction,
    375     tab: &mut TimelineTab,
    376 ) {
    377     if raw_input.is_empty() {
    378         return;
    379     }
    380 
    381     let max_results = 500;
    382 
    383     let Some(note_refs) = search_type.search(raw_input, ndb, txn, max_results) else {
    384         return;
    385     };
    386 
    387     tab.units = TimelineUnits::from_refs_single(note_refs);
    388     tab.list.borrow_mut().reset();
    389     ctx.request_repaint();
    390 }
    391 
    392 enum SearchViewAction {
    393     NoteAction(NoteAction),
    394     NavigateToProfile(Pubkey),
    395 }
    396 
    397 enum SearchAction {
    398     NewSearch {
    399         search_type: SearchType,
    400         new_search_text: String,
    401     },
    402     NavigateToProfile(Pubkey),
    403     Searched,
    404     CloseMention,
    405 }
    406 
    407 impl SearchAction {
    408     fn process(self, state: &mut SearchQueryState) -> Option<SearchViewAction> {
    409         match self {
    410             SearchAction::NewSearch {
    411                 search_type,
    412                 new_search_text,
    413             } => {
    414                 state.state = SearchState::PerformSearch(search_type);
    415                 state.string = new_search_text;
    416                 state.selected_index = -1;
    417                 None
    418             }
    419             SearchAction::NavigateToProfile(pubkey) => {
    420                 state.add_recent_profile(pubkey, state.string.clone());
    421                 state.string.clear();
    422                 state.selected_index = -1;
    423                 Some(SearchViewAction::NavigateToProfile(pubkey))
    424             }
    425             SearchAction::CloseMention => {
    426                 state.state = SearchState::New;
    427                 state.selected_index = -1;
    428                 None
    429             }
    430             SearchAction::Searched => {
    431                 state.state = SearchState::Searched;
    432                 state.selected_index = -1;
    433                 state.user_results.clear();
    434                 state.add_recent_query(state.string.clone());
    435                 None
    436             }
    437         }
    438     }
    439 }
    440 
    441 struct SearchResponse {
    442     requested_focus: bool,
    443     input_changed: bool,
    444 }
    445 
    446 impl SearchResponse {
    447     fn process(self, state: &mut SearchQueryState) {
    448         if self.requested_focus {
    449             state.focus_state = FocusState::RequestedFocus;
    450         } else if state.focus_state == FocusState::RequestedFocus && !self.input_changed {
    451             state.focus_state = FocusState::Navigating;
    452         }
    453 
    454         if self.input_changed {
    455             if state.string.starts_with('@') {
    456                 state.selected_index = -1;
    457                 if let Some(mention_text) = state.string.get(1..) {
    458                     state.state = SearchState::Typing(TypingType::Mention(mention_text.to_owned()));
    459                 }
    460             } else if state.state == SearchState::Searched {
    461                 state.state = SearchState::New;
    462                 state.selected_index = 0;
    463             } else if !state.string.is_empty() {
    464                 state.selected_index = 0;
    465             } else {
    466                 state.selected_index = -1;
    467             }
    468         }
    469     }
    470 }
    471 
    472 struct KeyboardResponse {
    473     enter_pressed: bool,
    474 }
    475 
    476 fn handle_keyboard_navigation(
    477     ui: &mut egui::Ui,
    478     selected_index: &mut i32,
    479     user_results: &[Vec<u8>],
    480 ) -> KeyboardResponse {
    481     let max_index = if user_results.is_empty() {
    482         -1
    483     } else {
    484         user_results.len() as i32
    485     };
    486 
    487     if ui.input(|i| i.key_pressed(Key::ArrowDown)) {
    488         *selected_index = (*selected_index + 1).min(max_index);
    489     } else if ui.input(|i| i.key_pressed(Key::ArrowUp)) {
    490         *selected_index = (*selected_index - 1).max(-1);
    491     }
    492 
    493     let enter_pressed = ui.input(|i| i.key_pressed(Key::Enter));
    494 
    495     KeyboardResponse { enter_pressed }
    496 }
    497 
    498 fn search_box(
    499     i18n: &mut Localization,
    500     input: &mut String,
    501     focus_state: FocusState,
    502     ui: &mut egui::Ui,
    503     clipboard: &mut Clipboard,
    504 ) -> SearchResponse {
    505     ui.horizontal(|ui| {
    506         // Container for search input and icon
    507         let search_container = egui::Frame {
    508             inner_margin: egui::Margin::symmetric(8, 0),
    509             outer_margin: egui::Margin::ZERO,
    510             corner_radius: CornerRadius::same(18), // More rounded corners
    511             shadow: Default::default(),
    512             fill: if ui.visuals().dark_mode {
    513                 Color32::from_rgb(30, 30, 30)
    514             } else {
    515                 Color32::from_rgb(240, 240, 240)
    516             },
    517             stroke: if ui.visuals().dark_mode {
    518                 Stroke::new(1.0, Color32::from_rgb(60, 60, 60))
    519             } else {
    520                 Stroke::new(1.0, Color32::from_rgb(200, 200, 200))
    521             },
    522         };
    523 
    524         search_container
    525             .show(ui, |ui| {
    526                 // Use layout to align items vertically centered
    527                 ui.with_layout(egui::Layout::left_to_right(Align::Center), |ui| {
    528                     ui.spacing_mut().item_spacing = egui::vec2(8.0, 0.0);
    529 
    530                     let search_height = 34.0;
    531                     // Magnifying glass icon
    532                     ui.add(search_icon(16.0, search_height));
    533 
    534                     let before_len = input.len();
    535 
    536                     // Search input field
    537                     //let font_size = notedeck::fonts::get_font_size(ui.ctx(), &NotedeckTextStyle::Body);
    538                     let response = ui.add_sized(
    539                         [ui.available_width(), search_height],
    540                         TextEdit::singleline(input)
    541                             .hint_text(
    542                                 RichText::new(tr!(
    543                                     i18n,
    544                                     "Search",
    545                                     "Placeholder for search input field"
    546                                 ))
    547                                 .weak(),
    548                             )
    549                             //.desired_width(available_width - 32.0)
    550                             //.font(egui::FontId::new(font_size, egui::FontFamily::Proportional))
    551                             .margin(vec2(0.0, 8.0))
    552                             .frame(false),
    553                     );
    554 
    555                     if response.has_focus()
    556                         && ui
    557                             .input(|i| i.key_pressed(Key::ArrowUp) || i.key_pressed(Key::ArrowDown))
    558                     {
    559                         response.surrender_focus();
    560                     }
    561 
    562                     input_context(ui, &response, clipboard, input, PasteBehavior::Append);
    563 
    564                     let mut requested_focus = false;
    565                     if focus_state == FocusState::ShouldRequestFocus {
    566                         response.request_focus();
    567                         requested_focus = true;
    568                     }
    569 
    570                     let after_len = input.len();
    571 
    572                     let input_changed = before_len != after_len;
    573 
    574                     SearchResponse {
    575                         requested_focus,
    576                         input_changed,
    577                     }
    578                 })
    579                 .inner
    580             })
    581             .inner
    582     })
    583     .inner
    584 }
    585 
    586 #[derive(Debug, Eq, PartialEq)]
    587 pub enum SearchType {
    588     String,
    589     NoteId(NoteId),
    590     Profile(Pubkey),
    591     Hashtag(String),
    592 }
    593 
    594 impl SearchType {
    595     fn get_type(query: &str) -> Self {
    596         if query.len() == 63 && query.starts_with("note1") {
    597             if let Some(noteid) = NoteId::from_bech(query) {
    598                 return SearchType::NoteId(noteid);
    599             }
    600         } else if query.len() == 63 && query.starts_with("npub1") {
    601             if let Ok(pk) = Pubkey::try_from_bech32_string(query, false) {
    602                 return SearchType::Profile(pk);
    603             }
    604         } else if query.chars().nth(0).is_some_and(|c| c == '#') {
    605             if let Some(hashtag) = query.get(1..) {
    606                 return SearchType::Hashtag(hashtag.to_string());
    607             }
    608         }
    609 
    610         SearchType::String
    611     }
    612 
    613     fn search(
    614         &self,
    615         raw_query: &String,
    616         ndb: &Ndb,
    617         txn: &Transaction,
    618         max_results: u64,
    619     ) -> Option<Vec<NoteRef>> {
    620         match self {
    621             SearchType::String => search_string(raw_query, ndb, txn, max_results),
    622             SearchType::NoteId(noteid) => search_note(noteid, ndb, txn).map(|n| vec![n]),
    623             SearchType::Profile(pk) => search_pk(pk, ndb, txn, max_results),
    624             SearchType::Hashtag(hashtag) => search_hashtag(hashtag, ndb, txn, max_results),
    625         }
    626     }
    627 }
    628 
    629 fn search_string(
    630     query: &String,
    631     ndb: &Ndb,
    632     txn: &Transaction,
    633     max_results: u64,
    634 ) -> Option<Vec<NoteRef>> {
    635     let filter = Filter::new()
    636         .search(query)
    637         .kinds([1])
    638         .limit(max_results)
    639         .build();
    640 
    641     // TODO: execute in thread
    642 
    643     let before = Instant::now();
    644     let qrs = ndb.query(txn, &[filter], max_results as i32);
    645     let after = Instant::now();
    646     let duration = after - before;
    647 
    648     if duration > Duration::from_millis(20) {
    649         warn!(
    650             "query took {:?}... let's update this to use a thread!",
    651             after - before
    652         );
    653     }
    654 
    655     match qrs {
    656         Ok(qrs) => {
    657             info!("queried '{}' and got {} results", query, qrs.len());
    658 
    659             return Some(qrs.into_iter().map(NoteRef::from_query_result).collect());
    660         }
    661 
    662         Err(err) => {
    663             error!("fulltext query failed: {err}")
    664         }
    665     }
    666 
    667     None
    668 }
    669 
    670 fn search_note(noteid: &NoteId, ndb: &Ndb, txn: &Transaction) -> Option<NoteRef> {
    671     ndb.get_note_by_id(txn, noteid.bytes())
    672         .ok()
    673         .map(|n| NoteRef::from_note(&n))
    674 }
    675 
    676 fn search_pk(pk: &Pubkey, ndb: &Ndb, txn: &Transaction, max_results: u64) -> Option<Vec<NoteRef>> {
    677     let filter = Filter::new()
    678         .authors([pk.bytes()])
    679         .kinds([1])
    680         .limit(max_results)
    681         .build();
    682 
    683     let qrs = ndb.query(txn, &[filter], max_results as i32).ok()?;
    684     Some(qrs.into_iter().map(NoteRef::from_query_result).collect())
    685 }
    686 
    687 fn search_hashtag(
    688     hashtag_name: &str,
    689     ndb: &Ndb,
    690     txn: &Transaction,
    691     max_results: u64,
    692 ) -> Option<Vec<NoteRef>> {
    693     let filter = Filter::new()
    694         .kinds([1])
    695         .limit(max_results)
    696         .tags([hashtag_name], 't')
    697         .build();
    698 
    699     let qrs = ndb.query(txn, &[filter], max_results as i32).ok()?;
    700     Some(qrs.into_iter().map(NoteRef::from_query_result).collect())
    701 }
    702 
    703 fn recent_profile_item<'a>(
    704     profile: Option<&'a ProfileRecord<'_>>,
    705     is_selected: bool,
    706     width: f32,
    707     cache: &'a mut Images,
    708     jobs: &'a MediaJobSender,
    709 ) -> impl egui::Widget + 'a {
    710     move |ui: &mut egui::Ui| -> egui::Response {
    711         let min_img_size = 48.0;
    712         let spacing = 8.0;
    713         let body_font_size = get_font_size(ui.ctx(), &NotedeckTextStyle::Body);
    714         let x_button_size = 32.0;
    715 
    716         let (rect, resp) =
    717             ui.allocate_exact_size(vec2(width, min_img_size + 8.0), egui::Sense::click());
    718 
    719         let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand);
    720 
    721         if is_selected {
    722             ui.painter()
    723                 .rect_filled(rect, 4.0, ui.visuals().selection.bg_fill);
    724         }
    725 
    726         if resp.hovered() {
    727             ui.painter()
    728                 .rect_filled(rect, 4.0, ui.visuals().widgets.hovered.bg_fill);
    729         }
    730 
    731         let pfp_rect =
    732             egui::Rect::from_min_size(rect.min + vec2(4.0, 4.0), vec2(min_img_size, min_img_size));
    733 
    734         ui.put(
    735             pfp_rect,
    736             &mut ProfilePic::new(cache, jobs, get_profile_url(profile)).size(min_img_size),
    737         );
    738 
    739         let name = get_display_name(profile).name();
    740         let name_font = egui::FontId::new(body_font_size, NotedeckTextStyle::Body.font_family());
    741         let painter = ui.painter();
    742         let text_galley = painter.layout(
    743             name.to_string(),
    744             name_font,
    745             ui.visuals().text_color(),
    746             width - min_img_size - spacing - x_button_size - 8.0,
    747         );
    748 
    749         let galley_pos = egui::Pos2::new(
    750             pfp_rect.right() + spacing,
    751             rect.center().y - (text_galley.rect.height() / 2.0),
    752         );
    753 
    754         painter.galley(galley_pos, text_galley, ui.visuals().text_color());
    755 
    756         let x_rect = egui::Rect::from_min_size(
    757             egui::Pos2::new(rect.right() - x_button_size, rect.top()),
    758             vec2(x_button_size, rect.height()),
    759         );
    760 
    761         let x_center = x_rect.center();
    762         let x_size = 12.0;
    763         painter.line_segment(
    764             [
    765                 egui::Pos2::new(x_center.x - x_size / 2.0, x_center.y - x_size / 2.0),
    766                 egui::Pos2::new(x_center.x + x_size / 2.0, x_center.y + x_size / 2.0),
    767             ],
    768             egui::Stroke::new(1.5, ui.visuals().text_color()),
    769         );
    770         painter.line_segment(
    771             [
    772                 egui::Pos2::new(x_center.x + x_size / 2.0, x_center.y - x_size / 2.0),
    773                 egui::Pos2::new(x_center.x - x_size / 2.0, x_center.y + x_size / 2.0),
    774             ],
    775             egui::Stroke::new(1.5, ui.visuals().text_color()),
    776         );
    777 
    778         resp
    779     }
    780 }
    781 
    782 fn recent_search_item(
    783     query: &str,
    784     is_selected: bool,
    785     width: f32,
    786     _is_profile: bool,
    787 ) -> impl egui::Widget + '_ {
    788     move |ui: &mut egui::Ui| -> egui::Response {
    789         let min_img_size = 48.0;
    790         let spacing = 8.0;
    791         let body_font_size = get_font_size(ui.ctx(), &NotedeckTextStyle::Body);
    792         let x_button_size = 32.0;
    793 
    794         let (rect, resp) =
    795             ui.allocate_exact_size(vec2(width, min_img_size + 8.0), egui::Sense::click());
    796 
    797         if is_selected {
    798             ui.painter()
    799                 .rect_filled(rect, 4.0, ui.visuals().selection.bg_fill);
    800         }
    801 
    802         if resp.hovered() {
    803             ui.painter()
    804                 .rect_filled(rect, 4.0, ui.visuals().widgets.hovered.bg_fill);
    805         }
    806 
    807         let icon_rect =
    808             egui::Rect::from_min_size(rect.min + vec2(4.0, 4.0), vec2(min_img_size, min_img_size));
    809 
    810         ui.put(icon_rect, search_icon(min_img_size / 2.0, min_img_size));
    811 
    812         let name_font = egui::FontId::new(body_font_size, NotedeckTextStyle::Body.font_family());
    813         let painter = ui.painter();
    814         let text_galley = painter.layout(
    815             query.to_string(),
    816             name_font,
    817             ui.visuals().text_color(),
    818             width - min_img_size - spacing - x_button_size - 8.0,
    819         );
    820 
    821         let galley_pos = egui::Pos2::new(
    822             icon_rect.right() + spacing,
    823             rect.center().y - (text_galley.rect.height() / 2.0),
    824         );
    825 
    826         painter.galley(galley_pos, text_galley, ui.visuals().text_color());
    827 
    828         let x_rect = egui::Rect::from_min_size(
    829             egui::Pos2::new(rect.right() - x_button_size, rect.top()),
    830             vec2(x_button_size, rect.height()),
    831         );
    832 
    833         let x_center = x_rect.center();
    834         let x_size = 12.0;
    835         painter.line_segment(
    836             [
    837                 egui::Pos2::new(x_center.x - x_size / 2.0, x_center.y - x_size / 2.0),
    838                 egui::Pos2::new(x_center.x + x_size / 2.0, x_center.y + x_size / 2.0),
    839             ],
    840             egui::Stroke::new(1.5, ui.visuals().text_color()),
    841         );
    842         painter.line_segment(
    843             [
    844                 egui::Pos2::new(x_center.x + x_size / 2.0, x_center.y - x_size / 2.0),
    845                 egui::Pos2::new(x_center.x - x_size / 2.0, x_center.y + x_size / 2.0),
    846             ],
    847             egui::Stroke::new(1.5, ui.visuals().text_color()),
    848         );
    849 
    850         resp
    851     }
    852 }
    853 
    854 fn search_posts_button(query: &str, is_selected: bool, width: f32) -> impl egui::Widget + '_ {
    855     move |ui: &mut egui::Ui| -> egui::Response {
    856         let min_img_size = 48.0;
    857         let spacing = 8.0;
    858         let body_font_size = get_font_size(ui.ctx(), &NotedeckTextStyle::Body);
    859 
    860         let (rect, resp) =
    861             ui.allocate_exact_size(vec2(width, min_img_size + 8.0), egui::Sense::click());
    862 
    863         if is_selected {
    864             ui.painter()
    865                 .rect_filled(rect, 4.0, ui.visuals().selection.bg_fill);
    866         }
    867 
    868         if resp.hovered() {
    869             ui.painter()
    870                 .rect_filled(rect, 4.0, ui.visuals().widgets.hovered.bg_fill);
    871         }
    872 
    873         let icon_rect =
    874             egui::Rect::from_min_size(rect.min + vec2(4.0, 4.0), vec2(min_img_size, min_img_size));
    875 
    876         ui.put(icon_rect, search_icon(min_img_size / 2.0, min_img_size));
    877 
    878         let text = format!("Search posts for \"{query}\"");
    879         let name_font = egui::FontId::new(body_font_size, NotedeckTextStyle::Body.font_family());
    880         let painter = ui.painter();
    881         let text_galley = painter.layout(
    882             text,
    883             name_font,
    884             ui.visuals().text_color(),
    885             width - min_img_size - spacing - 8.0,
    886         );
    887 
    888         let galley_pos = egui::Pos2::new(
    889             icon_rect.right() + spacing,
    890             rect.center().y - (text_galley.rect.height() / 2.0),
    891         );
    892 
    893         painter.galley(galley_pos, text_galley, ui.visuals().text_color());
    894 
    895         resp
    896     }
    897 }