notedeck

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

mod.rs (27677B)


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