notedeck

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

commit c52a49f251a8d93396077c4fb895cc26bdf60006
parent 61f97fe15cddefebcea1c74b969a021c22aca070
Author: William Casarin <jb55@jb55.com>
Date:   Thu,  5 Feb 2026 00:23:36 -0800

ui: consolidate profile search into shared notedeck_ui widgets

Extract shared UI components used by both create_convo.rs and search/mod.rs:

- Add search_input_frame(), search_input_box(), SEARCH_INPUT_HEIGHT to widgets.rs
- Add profile_row_widget() with ProfileRowOptions for configurable profile rows
  (contact badge, X button, selection highlighting)
- Add search_profiles() function that prioritizes contacts and deduplicates results
- Update create_convo.rs and search/mod.rs to use shared widgets

This ensures both UIs have consistent styling and behavior, including:
- Contact badge shown for profiles in user's contact list
- Contacts sorted first in search results
- No duplicate results

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Diffstat:
Mcrates/notedeck_columns/src/ui/search/mod.rs | 219+++++++++++++++++++++++++++----------------------------------------------------
Mcrates/notedeck_messages/src/ui/create_convo.rs | 134+++++++++----------------------------------------------------------------------
Mcrates/notedeck_ui/src/contacts_list.rs | 227+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Mcrates/notedeck_ui/src/lib.rs | 10++++++++--
Mcrates/notedeck_ui/src/widgets.rs | 53++++++++++++++++++++++++++++++++++++++++++++++++++++-
5 files changed, 331 insertions(+), 312 deletions(-)

diff --git a/crates/notedeck_columns/src/ui/search/mod.rs b/crates/notedeck_columns/src/ui/search/mod.rs @@ -1,4 +1,4 @@ -use egui::{vec2, Align, Color32, CornerRadius, Key, RichText, Stroke, TextEdit}; +use egui::{vec2, Align, Key, RichText, TextEdit}; use enostr::{NoteId, Pubkey}; use state::TypingType; @@ -7,17 +7,17 @@ use crate::{ ui::timeline::TimelineTabView, }; use egui_winit::clipboard::Clipboard; -use nostrdb::{Filter, Ndb, ProfileRecord, Transaction}; +use nostrdb::{Filter, Ndb, Transaction}; use notedeck::{ - fonts::get_font_size, name::get_display_name, profile::get_profile_url, tr, tr_plural, - DragResponse, Images, Localization, MediaJobSender, NoteAction, NoteContext, NoteRef, - NotedeckTextStyle, + fonts::get_font_size, tr, tr_plural, DragResponse, IsFollowing, Localization, NoteAction, + NoteContext, NoteRef, NotedeckTextStyle, }; use notedeck_ui::{ context_menu::{input_context, PasteBehavior}, icons::search_icon, - padding, NoteOptions, ProfilePic, + padding, profile_row_widget, search_input_frame, search_profiles, NoteOptions, + ProfileRowOptions, SEARCH_INPUT_HEIGHT, }; use std::time::{Duration, Instant}; use tracing::{error, info, warn}; @@ -85,14 +85,21 @@ impl<'a, 'd> SearchView<'a, 'd> { | SearchState::Navigating | SearchState::Typing(TypingType::Mention(_)) => { if !self.query.string.is_empty() && !self.query.string.starts_with('@') { - self.query.user_results = self - .note_context - .ndb - .search_profile(self.txn, &self.query.string, 10) - .unwrap_or_default() - .iter() - .map(|&pk| pk.to_vec()) - .collect(); + self.query.user_results = search_profiles( + self.note_context.ndb, + self.txn, + &self.query.string, + self.note_context + .accounts + .get_selected_account() + .data + .contacts + .get_state(), + 128, + ) + .into_iter() + .map(|r| r.pk.to_vec()) + .collect(); if let Some(action) = self.show_search_suggestions(ui, keyboard_resp) { search_action = Some(action); } @@ -242,31 +249,45 @@ impl<'a, 'd> SearchView<'a, 'd> { } } - for (i, pk_bytes) in self.query.user_results.iter().enumerate() { - if let Ok(pk_array) = TryInto::<[u8; 32]>::try_into(pk_bytes.as_slice()) { - let pubkey = Pubkey::new(pk_array); - let profile = self - .note_context - .ndb - .get_profile_by_pubkey(self.txn, &pk_array) - .ok(); - let is_selected = self.query.selected_index == (i + 1) as i32; - - let resp = ui.add(recent_profile_item( - profile.as_ref(), - is_selected, - ui.available_width(), - self.note_context.img_cache, - self.note_context.jobs, - )); + let mut action = None; + egui::ScrollArea::vertical().show(ui, |ui| { + for (i, pk_bytes) in self.query.user_results.iter().enumerate() { + if let Ok(pk_array) = TryInto::<[u8; 32]>::try_into(pk_bytes.as_slice()) { + let pubkey = Pubkey::new(pk_array); + let profile = self + .note_context + .ndb + .get_profile_by_pubkey(self.txn, &pk_array) + .ok(); + let is_selected = self.query.selected_index == (i + 1) as i32; + let is_contact = self + .note_context + .accounts + .get_selected_account() + .data + .contacts + .is_following(&pk_array) + == IsFollowing::Yes; + + let options = ProfileRowOptions::new() + .selected(is_selected) + .contact_badge(is_contact); + let resp = ui.add(profile_row_widget( + profile.as_ref(), + self.note_context.img_cache, + self.note_context.jobs, + self.note_context.i18n, + options, + )); - if resp.clicked() { - return Some(SearchAction::NavigateToProfile(pubkey)); + if resp.clicked() { + action = Some(SearchAction::NavigateToProfile(pubkey)); + } } } - } + }); - None + action } fn show_recent_searches( @@ -321,12 +342,24 @@ impl<'a, 'd> SearchView<'a, 'd> { .ndb .get_profile_by_pubkey(self.txn, pubkey.bytes()) .ok(); - let resp = ui.add(recent_profile_item( + let is_contact = self + .note_context + .accounts + .get_selected_account() + .data + .contacts + .is_following(pubkey.bytes()) + == IsFollowing::Yes; + let options = ProfileRowOptions::new() + .selected(is_selected) + .x_button(true) + .contact_badge(is_contact); + let resp = ui.add(profile_row_widget( profile.as_ref(), - is_selected, - ui.available_width(), self.note_context.img_cache, self.note_context.jobs, + self.note_context.i18n, + options, )); if resp.clicked() || (is_selected && keyboard_resp.enter_pressed) { @@ -502,40 +535,17 @@ fn search_box( clipboard: &mut Clipboard, ) -> SearchResponse { ui.horizontal(|ui| { - // Container for search input and icon - let search_container = egui::Frame { - inner_margin: egui::Margin::symmetric(8, 0), - outer_margin: egui::Margin::ZERO, - corner_radius: CornerRadius::same(18), // More rounded corners - shadow: Default::default(), - fill: if ui.visuals().dark_mode { - Color32::from_rgb(30, 30, 30) - } else { - Color32::from_rgb(240, 240, 240) - }, - stroke: if ui.visuals().dark_mode { - Stroke::new(1.0, Color32::from_rgb(60, 60, 60)) - } else { - Stroke::new(1.0, Color32::from_rgb(200, 200, 200)) - }, - }; - - search_container + search_input_frame(ui.visuals().dark_mode) .show(ui, |ui| { - // Use layout to align items vertically centered ui.with_layout(egui::Layout::left_to_right(Align::Center), |ui| { ui.spacing_mut().item_spacing = egui::vec2(8.0, 0.0); - let search_height = 34.0; - // Magnifying glass icon - ui.add(search_icon(16.0, search_height)); + ui.add(search_icon(16.0, SEARCH_INPUT_HEIGHT)); let before_len = input.len(); - // Search input field - //let font_size = notedeck::fonts::get_font_size(ui.ctx(), &NotedeckTextStyle::Body); let response = ui.add_sized( - [ui.available_width(), search_height], + [ui.available_width(), SEARCH_INPUT_HEIGHT], TextEdit::singleline(input) .hint_text( RichText::new(tr!( @@ -545,8 +555,6 @@ fn search_box( )) .weak(), ) - //.desired_width(available_width - 32.0) - //.font(egui::FontId::new(font_size, egui::FontFamily::Proportional)) .margin(vec2(0.0, 8.0)) .frame(false), ); @@ -699,85 +707,6 @@ fn search_hashtag( Some(qrs.into_iter().map(NoteRef::from_query_result).collect()) } -fn recent_profile_item<'a>( - profile: Option<&'a ProfileRecord<'_>>, - is_selected: bool, - width: f32, - cache: &'a mut Images, - jobs: &'a MediaJobSender, -) -> impl egui::Widget + 'a { - move |ui: &mut egui::Ui| -> egui::Response { - let min_img_size = 48.0; - let spacing = 8.0; - let body_font_size = get_font_size(ui.ctx(), &NotedeckTextStyle::Body); - let x_button_size = 32.0; - - let (rect, resp) = - ui.allocate_exact_size(vec2(width, min_img_size + 8.0), egui::Sense::click()); - - let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand); - - if is_selected { - ui.painter() - .rect_filled(rect, 4.0, ui.visuals().selection.bg_fill); - } - - if resp.hovered() { - ui.painter() - .rect_filled(rect, 4.0, ui.visuals().widgets.hovered.bg_fill); - } - - let pfp_rect = - egui::Rect::from_min_size(rect.min + vec2(4.0, 4.0), vec2(min_img_size, min_img_size)); - - ui.put( - pfp_rect, - &mut ProfilePic::new(cache, jobs, get_profile_url(profile)).size(min_img_size), - ); - - let name = get_display_name(profile).name(); - let name_font = egui::FontId::new(body_font_size, NotedeckTextStyle::Body.font_family()); - let painter = ui.painter(); - let text_galley = painter.layout( - name.to_string(), - name_font, - ui.visuals().text_color(), - width - min_img_size - spacing - x_button_size - 8.0, - ); - - let galley_pos = egui::Pos2::new( - pfp_rect.right() + spacing, - rect.center().y - (text_galley.rect.height() / 2.0), - ); - - painter.galley(galley_pos, text_galley, ui.visuals().text_color()); - - let x_rect = egui::Rect::from_min_size( - egui::Pos2::new(rect.right() - x_button_size, rect.top()), - vec2(x_button_size, rect.height()), - ); - - let x_center = x_rect.center(); - let x_size = 12.0; - painter.line_segment( - [ - egui::Pos2::new(x_center.x - x_size / 2.0, x_center.y - x_size / 2.0), - egui::Pos2::new(x_center.x + x_size / 2.0, x_center.y + x_size / 2.0), - ], - egui::Stroke::new(1.5, ui.visuals().text_color()), - ); - painter.line_segment( - [ - egui::Pos2::new(x_center.x + x_size / 2.0, x_center.y - x_size / 2.0), - egui::Pos2::new(x_center.x - x_size / 2.0, x_center.y + x_size / 2.0), - ], - egui::Stroke::new(1.5, ui.visuals().text_color()), - ); - - resp - } -} - fn recent_search_item( query: &str, is_selected: bool, diff --git a/crates/notedeck_messages/src/ui/create_convo.rs b/crates/notedeck_messages/src/ui/create_convo.rs @@ -1,14 +1,10 @@ -use std::collections::HashSet; - -use egui::{Align, Color32, CornerRadius, Label, RichText, Stroke, TextEdit}; +use egui::{Label, RichText}; use enostr::Pubkey; use nostrdb::{Ndb, Transaction}; -use notedeck::{ - name::get_display_name, tr, ContactState, Images, Localization, MediaJobSender, - NotedeckTextStyle, -}; +use notedeck::{tr, ContactState, Images, Localization, MediaJobSender, NotedeckTextStyle}; use notedeck_ui::{ - contacts_list::ContactsCollection, icons::search_icon, profile_row, ContactsListView, + contacts_list::ContactsCollection, profile_row, search_input_box, search_profiles, + ContactsListView, ProfileSearchResult, }; use crate::cache::CreateConvoState; @@ -46,16 +42,16 @@ impl<'a> CreateConvoUi<'a> { } pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<CreateConvoResponse> { - let contacts_set = match self.contacts { - ContactState::Received { contacts, .. } => Some(contacts), - _ => None, - }; - let txn = Transaction::new(self.ndb).expect("txn"); // Search input ui.add_space(8.0); - search_input(&mut self.state.query, self.i18n, ui); + let hint = tr!( + self.i18n, + "Search profiles...", + "Placeholder for profile search input" + ); + ui.add(search_input_box(&mut self.state.query, &hint)); ui.add_space(12.0); let query = self.state.query.trim(); @@ -71,7 +67,7 @@ impl<'a> CreateConvoUi<'a> { .text_style(NotedeckTextStyle::Heading.text_style()), )); - if let Some(contacts) = contacts_set { + if let ContactState::Received { contacts, .. } = self.contacts { let resp = ContactsListView::new( ContactsCollection::Set(contacts), self.jobs, @@ -107,7 +103,7 @@ impl<'a> CreateConvoUi<'a> { .text_style(NotedeckTextStyle::Heading.text_style()), )); - let results = search_profiles(self.ndb, &txn, query, contacts_set); + let results = search_profiles(self.ndb, &txn, query, self.contacts, 128); if results.is_empty() { ui.add_space(20.0); @@ -135,111 +131,11 @@ impl<'a> CreateConvoUi<'a> { } } -/// Renders the search input field for profile search. -fn search_input(query: &mut String, i18n: &mut Localization, ui: &mut egui::Ui) { - ui.horizontal(|ui| { - let search_container = egui::Frame { - inner_margin: egui::Margin::symmetric(8, 0), - outer_margin: egui::Margin::ZERO, - corner_radius: CornerRadius::same(18), - shadow: Default::default(), - fill: if ui.visuals().dark_mode { - Color32::from_rgb(30, 30, 30) - } else { - Color32::from_rgb(240, 240, 240) - }, - stroke: if ui.visuals().dark_mode { - Stroke::new(1.0, Color32::from_rgb(60, 60, 60)) - } else { - Stroke::new(1.0, Color32::from_rgb(200, 200, 200)) - }, - }; - - search_container.show(ui, |ui| { - ui.with_layout(egui::Layout::left_to_right(Align::Center), |ui| { - ui.spacing_mut().item_spacing = egui::vec2(8.0, 0.0); - - let search_height = 34.0; - ui.add(search_icon(16.0, search_height)); - - ui.add_sized( - [ui.available_width(), search_height], - TextEdit::singleline(query) - .hint_text( - RichText::new(tr!( - i18n, - "Search profiles...", - "Placeholder for profile search input" - )) - .weak(), - ) - .margin(egui::vec2(0.0, 8.0)) - .frame(false), - ); - }); - }); - }); -} - -/// A profile search result. -struct SearchResult<'a> { - /// The public key bytes of the matched profile. - pk: &'a [u8; 32], - /// Whether this profile is in the user's contacts. - is_contact: bool, -} - -/// Searches for profiles matching `query` in nostrdb and the user's contacts. -/// Contacts are prioritized and appear first in results. Returns up to 20 matches. -fn search_profiles<'a>( - ndb: &Ndb, - txn: &'a Transaction, - query: &str, - contacts: Option<&'a HashSet<Pubkey>>, -) -> Vec<SearchResult<'a>> { - let mut results: Vec<SearchResult<'a>> = Vec::new(); - let mut seen: HashSet<&[u8; 32]> = HashSet::new(); - let query_lower = query.to_lowercase(); - - // First, add matching contacts (prioritized) - if let Some(contacts) = contacts { - for pk in contacts { - if let Ok(profile) = ndb.get_profile_by_pubkey(txn, pk.bytes()) { - let name = get_display_name(Some(&profile)).name(); - if name.to_lowercase().contains(&query_lower) { - results.push(SearchResult { - pk: pk.bytes(), - is_contact: true, - }); - seen.insert(pk.bytes()); - } - } - } - } - - // Then add nostrdb search results - if let Ok(pks) = ndb.search_profile(txn, query, 20) { - for pk_bytes in pks { - if !seen.contains(pk_bytes) { - let is_contact = contacts.is_some_and(|c| c.contains(pk_bytes)); - results.push(SearchResult { - pk: pk_bytes, - is_contact, - }); - seen.insert(pk_bytes); - } - } - } - - results.truncate(20); - results -} - /// Renders a scrollable list of search results. Returns `Some(CreateConvoResponse)` /// if the user selects a profile. fn search_results_list( ui: &mut egui::Ui, - results: &[SearchResult<'_>], + results: &[ProfileSearchResult], ndb: &Ndb, txn: &Transaction, img_cache: &mut Images, @@ -250,7 +146,7 @@ fn search_results_list( egui::ScrollArea::vertical().show(ui, |ui| { for result in results { - let profile = ndb.get_profile_by_pubkey(txn, result.pk).ok(); + let profile = ndb.get_profile_by_pubkey(txn, &result.pk).ok(); if profile_row( ui, @@ -261,7 +157,7 @@ fn search_results_list( i18n, ) { action = Some(CreateConvoResponse { - recipient: Pubkey::new(*result.pk), + recipient: Pubkey::new(result.pk), }); } } diff --git a/crates/notedeck_ui/src/contacts_list.rs b/crates/notedeck_ui/src/contacts_list.rs @@ -1,71 +1,153 @@ use std::collections::HashSet; use crate::ProfilePic; -use egui::{RichText, Sense}; +use egui::{RichText, Sense, Stroke}; use enostr::Pubkey; use nostrdb::{Ndb, ProfileRecord, Transaction}; use notedeck::{ - name::get_display_name, profile::get_profile_url, tr, DragResponse, Images, Localization, - MediaJobSender, + name::get_display_name, profile::get_profile_url, tr, ContactState, DragResponse, Images, + Localization, MediaJobSender, }; -/// Render a profile row with picture and name, optionally showing a contact badge. Returns true if clicked. -pub fn profile_row( - ui: &mut egui::Ui, - profile: Option<&ProfileRecord<'_>>, - is_contact: bool, - img_cache: &mut Images, - jobs: &MediaJobSender, - i18n: &mut Localization, -) -> bool { - let (rect, resp) = - ui.allocate_exact_size(egui::vec2(ui.available_width(), 56.0), Sense::click()); +/// Configuration options for profile row rendering. +#[derive(Default)] +pub struct ProfileRowOptions { + /// Show "Contact" badge next to the name + pub show_contact_badge: bool, + /// Show X button on the right (visual only - deletion handled by caller) + pub show_x_button: bool, + /// Highlight as selected (keyboard navigation) + pub is_selected: bool, +} - if !ui.clip_rect().intersects(rect) { - return false; +impl ProfileRowOptions { + pub fn new() -> Self { + Self::default() } - let name_str = get_display_name(profile).name(); - let profile_url = get_profile_url(profile); + pub fn contact_badge(mut self, show: bool) -> Self { + self.show_contact_badge = show; + self + } - let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand); + pub fn x_button(mut self, show: bool) -> Self { + self.show_x_button = show; + self + } - if resp.hovered() { - ui.painter() - .rect_filled(rect, 0.0, ui.visuals().widgets.hovered.weak_bg_fill); + pub fn selected(mut self, selected: bool) -> Self { + self.is_selected = selected; + self } +} + +/// Render a profile row with picture and name, with configurable options. +/// Returns the response for handling clicks. +pub fn profile_row_widget<'a>( + profile: Option<&'a ProfileRecord<'a>>, + img_cache: &'a mut Images, + jobs: &'a MediaJobSender, + i18n: &'a mut Localization, + options: ProfileRowOptions, +) -> impl egui::Widget + 'a { + move |ui: &mut egui::Ui| -> egui::Response { + let (rect, resp) = + ui.allocate_exact_size(egui::vec2(ui.available_width(), 56.0), Sense::click()); + + if !ui.clip_rect().intersects(rect) { + return resp; + } + + let name_str = get_display_name(profile).name(); + let profile_url = get_profile_url(profile); + + let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand); - let mut child_ui = ui.new_child(egui::UiBuilder::new().max_rect(rect)); - child_ui.horizontal(|ui| { - ui.add_space(16.0); - ui.add(&mut ProfilePic::new(img_cache, jobs, profile_url).size(48.0)); - ui.add_space(12.0); - ui.add( - egui::Label::new( - RichText::new(name_str) - .size(16.0) - .color(ui.visuals().text_color()), - ) - .selectable(false), - ); - if is_contact { + // Selection highlighting + if options.is_selected { + ui.painter() + .rect_filled(rect, 4.0, ui.visuals().selection.bg_fill); + } + + // Hover highlighting + if resp.hovered() { + ui.painter() + .rect_filled(rect, 4.0, ui.visuals().widgets.hovered.bg_fill); + } + + let mut child_ui = ui.new_child(egui::UiBuilder::new().max_rect(rect)); + child_ui.horizontal(|ui| { + ui.add_space(4.0); + ui.add(&mut ProfilePic::new(img_cache, jobs, profile_url).size(48.0)); ui.add_space(8.0); ui.add( egui::Label::new( - RichText::new(tr!( - i18n, - "Contact", - "Badge indicating this profile is in contacts" - )) - .size(12.0) - .color(ui.visuals().weak_text_color()), + RichText::new(name_str) + .size(16.0) + .color(ui.visuals().text_color()), ) .selectable(false), ); + if options.show_contact_badge { + ui.add_space(8.0); + let badge_text = tr!( + i18n, + "Contact", + "Badge indicating this profile is in contacts" + ); + ui.add( + egui::Label::new( + RichText::new(badge_text) + .size(12.0) + .color(ui.visuals().weak_text_color()), + ) + .selectable(false), + ); + } + }); + + // Draw X button on the right + if options.show_x_button { + let x_button_size = 32.0; + let x_size = 12.0; + let x_rect = egui::Rect::from_min_size( + egui::Pos2::new(rect.right() - x_button_size, rect.top()), + egui::vec2(x_button_size, rect.height()), + ); + let x_center = x_rect.center(); + let painter = ui.painter(); + painter.line_segment( + [ + egui::Pos2::new(x_center.x - x_size / 2.0, x_center.y - x_size / 2.0), + egui::Pos2::new(x_center.x + x_size / 2.0, x_center.y + x_size / 2.0), + ], + Stroke::new(1.5, ui.visuals().text_color()), + ); + painter.line_segment( + [ + egui::Pos2::new(x_center.x + x_size / 2.0, x_center.y - x_size / 2.0), + egui::Pos2::new(x_center.x - x_size / 2.0, x_center.y + x_size / 2.0), + ], + Stroke::new(1.5, ui.visuals().text_color()), + ); } - }); - resp.clicked() + resp + } +} + +/// Render a profile row with picture and name, optionally showing a contact badge. Returns true if clicked. +pub fn profile_row( + ui: &mut egui::Ui, + profile: Option<&ProfileRecord<'_>>, + is_contact: bool, + img_cache: &mut Images, + jobs: &MediaJobSender, + i18n: &mut Localization, +) -> bool { + let options = ProfileRowOptions::new().contact_badge(is_contact); + ui.add(profile_row_widget(profile, img_cache, jobs, i18n, options)) + .clicked() } pub struct ContactsListView<'a, 'txn> { @@ -157,3 +239,58 @@ impl<'a, 'txn> ContactsListView<'a, 'txn> { DragResponse::output(action) } } + +/// A profile search result. +pub struct ProfileSearchResult { + /// The public key bytes of the matched profile. + pub pk: [u8; 32], + /// Whether this profile is in the user's contacts. + pub is_contact: bool, +} + +/// Searches for profiles matching `query`, prioritizing contacts first and deduplicating. +/// Contacts that match appear first, followed by non-contact results. +/// Returns up to `max_results` matches. +pub fn search_profiles( + ndb: &Ndb, + txn: &Transaction, + query: &str, + contacts_state: &ContactState, + max_results: usize, +) -> Vec<ProfileSearchResult> { + let contacts_set = match contacts_state { + ContactState::Received { contacts, .. } => Some(contacts), + _ => None, + }; + + // Get ndb search results and partition into contacts and non-contacts + let mut contact_results: Vec<ProfileSearchResult> = Vec::new(); + let mut other_results: Vec<ProfileSearchResult> = Vec::new(); + let mut seen: HashSet<&[u8; 32]> = HashSet::new(); + + if let Ok(pks) = ndb.search_profile(txn, query, max_results as u32) { + for pk_bytes in pks { + // Skip duplicates + if seen.contains(pk_bytes) { + continue; + } + seen.insert(pk_bytes); + + let is_contact = contacts_set.is_some_and(|c| c.contains(pk_bytes)); + let result = ProfileSearchResult { + pk: *pk_bytes, + is_contact, + }; + if is_contact { + contact_results.push(result); + } else { + other_results.push(result); + } + } + } + + // Combine: contacts first, then others + contact_results.extend(other_results); + contact_results.truncate(max_results); + contact_results +} diff --git a/crates/notedeck_ui/src/lib.rs b/crates/notedeck_ui/src/lib.rs @@ -17,14 +17,20 @@ mod username; pub mod widgets; pub use anim::{rolling_number, AnimationHelper, PulseAlpha}; -pub use contacts_list::{profile_row, ContactsListAction, ContactsListView}; +pub use contacts_list::{ + profile_row, profile_row_widget, search_profiles, ContactsListAction, ContactsListView, + ProfileRowOptions, ProfileSearchResult, +}; pub use debug::debug_slider; pub use icons::{expanding_button, ICON_EXPANSION_MULTIPLE, ICON_WIDTH}; pub use mention::Mention; pub use note::{NoteContents, NoteOptions, NoteView}; pub use profile::{ProfilePic, ProfilePreview}; pub use username::Username; -pub use widgets::{side_panel_active_bg, side_panel_icon_tint}; +pub use widgets::{ + search_input_box, search_input_frame, side_panel_active_bg, side_panel_icon_tint, + SEARCH_INPUT_HEIGHT, +}; use egui::{Label, Margin, Pos2, RichText}; diff --git a/crates/notedeck_ui/src/widgets.rs b/crates/notedeck_ui/src/widgets.rs @@ -1,5 +1,6 @@ use crate::anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}; -use egui::{emath::GuiRounding, Pos2, Stroke}; +use crate::icons::search_icon; +use egui::{emath::GuiRounding, Align, Color32, CornerRadius, Pos2, RichText, Stroke, TextEdit}; use notedeck::NotedeckTextStyle; pub fn x_button(rect: egui::Rect) -> impl egui::Widget { @@ -95,3 +96,53 @@ pub fn side_panel_icon_tint(ui: &egui::Ui) -> egui::Color32 { egui::Color32::BLACK } } + +/// Returns a styled Frame for search input boxes with rounded corners. +pub fn search_input_frame(dark_mode: bool) -> egui::Frame { + egui::Frame { + inner_margin: egui::Margin::symmetric(8, 0), + outer_margin: egui::Margin::ZERO, + corner_radius: CornerRadius::same(18), + shadow: Default::default(), + fill: if dark_mode { + Color32::from_rgb(30, 30, 30) + } else { + Color32::from_rgb(240, 240, 240) + }, + stroke: if dark_mode { + Stroke::new(1.0, Color32::from_rgb(60, 60, 60)) + } else { + Stroke::new(1.0, Color32::from_rgb(200, 200, 200)) + }, + } +} + +/// The standard height for search input boxes. +pub const SEARCH_INPUT_HEIGHT: f32 = 34.0; + +/// A styled search input box with rounded corners and search icon. +pub fn search_input_box<'a>(query: &'a mut String, hint_text: &'a str) -> impl egui::Widget + 'a { + move |ui: &mut egui::Ui| -> egui::Response { + ui.horizontal(|ui| { + search_input_frame(ui.visuals().dark_mode) + .show(ui, |ui| { + ui.with_layout(egui::Layout::left_to_right(Align::Center), |ui| { + ui.spacing_mut().item_spacing = egui::vec2(8.0, 0.0); + + ui.add(search_icon(16.0, SEARCH_INPUT_HEIGHT)); + + ui.add_sized( + [ui.available_width(), SEARCH_INPUT_HEIGHT], + TextEdit::singleline(query) + .hint_text(RichText::new(hint_text).weak()) + .margin(egui::vec2(0.0, 8.0)) + .frame(false), + ) + }) + .inner + }) + .inner + }) + .response + } +}