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 }