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 }