header.rs (24017B)
1 use crate::column::ColumnsAction; 2 use crate::nav::RenderNavAction; 3 use crate::nav::SwitchingAction; 4 use crate::timeline::ThreadSelection; 5 use crate::{ 6 column::Columns, 7 route::Route, 8 timeline::{ColumnTitle, TimelineKind}, 9 ui::{self}, 10 }; 11 12 use egui::UiBuilder; 13 use egui::{Margin, Response, RichText, Sense, Stroke}; 14 use enostr::Pubkey; 15 use nostrdb::{Ndb, Transaction}; 16 use notedeck::tr; 17 use notedeck::{Images, Localization, MediaJobSender, NotedeckTextStyle}; 18 use notedeck_ui::app_images; 19 use notedeck_ui::header::chevron; 20 use notedeck_ui::{ 21 anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, 22 ProfilePic, 23 }; 24 25 pub struct NavTitle<'a> { 26 ndb: &'a Ndb, 27 img_cache: &'a mut Images, 28 columns: &'a Columns, 29 routes: &'a [Route], 30 col_id: usize, 31 options: u32, 32 i18n: &'a mut Localization, 33 jobs: &'a MediaJobSender, 34 } 35 36 impl<'a> NavTitle<'a> { 37 // options 38 const SHOW_MOVE: u32 = 1 << 0; 39 const SHOW_DELETE: u32 = 1 << 1; 40 41 pub fn new( 42 ndb: &'a Ndb, 43 img_cache: &'a mut Images, 44 columns: &'a Columns, 45 routes: &'a [Route], 46 col_id: usize, 47 i18n: &'a mut Localization, 48 jobs: &'a MediaJobSender, 49 ) -> Self { 50 let options = Self::SHOW_MOVE | Self::SHOW_DELETE; 51 NavTitle { 52 ndb, 53 img_cache, 54 columns, 55 routes, 56 col_id, 57 options, 58 i18n, 59 jobs, 60 } 61 } 62 63 pub fn show(&mut self, ui: &mut egui::Ui) -> Option<RenderNavAction> { 64 notedeck_ui::padding(8.0, ui, |ui| { 65 let mut rect = ui.available_rect_before_wrap(); 66 rect.set_height(48.0); 67 68 let mut child_ui = ui.new_child( 69 UiBuilder::new() 70 .max_rect(rect) 71 .layout(egui::Layout::left_to_right(egui::Align::Center)), 72 ); 73 74 let interact_rect = child_ui.interact(rect, child_ui.id().with("drag"), Sense::drag()); 75 if interact_rect.drag_started_by(egui::PointerButton::Primary) { 76 child_ui 77 .ctx() 78 .send_viewport_cmd(egui::ViewportCommand::StartDrag); 79 } 80 81 let r = self.title_bar(&mut child_ui); 82 83 ui.advance_cursor_after_rect(rect); 84 85 r 86 }) 87 .inner 88 } 89 90 fn title_bar(&mut self, ui: &mut egui::Ui) -> Option<RenderNavAction> { 91 let item_spacing = 8.0; 92 ui.spacing_mut().item_spacing.x = item_spacing; 93 94 let chev_x = 8.0; 95 let back_button_resp = prev(self.routes).map(|r| { 96 self.back_button(ui, r, egui::Vec2::new(chev_x, 15.0)) 97 .on_hover_cursor(egui::CursorIcon::PointingHand) 98 }); 99 100 if back_button_resp.is_none() { 101 // add some space where chevron would have been. this makes the ui 102 // less bumpy when navigating 103 ui.add_space(chev_x + item_spacing); 104 } 105 106 let title_resp = self.title(ui, self.routes.last().unwrap(), back_button_resp.is_some()); 107 108 if let Some(resp) = title_resp { 109 tracing::debug!("got title response {resp:?}"); 110 match resp { 111 TitleResponse::RemoveColumn => Some(RenderNavAction::RemoveColumn), 112 TitleResponse::PfpClicked => Some(RenderNavAction::PfpClicked), 113 TitleResponse::MoveColumn(to_index) => { 114 let from = self.col_id; 115 Some(RenderNavAction::SwitchingAction(SwitchingAction::Columns( 116 ColumnsAction::Switch(from, to_index), 117 ))) 118 } 119 } 120 } else if back_button_resp.is_some_and(|r| r.clicked()) { 121 tracing::debug!("render nav action back"); 122 Some(RenderNavAction::Back) 123 } else { 124 None 125 } 126 } 127 128 fn back_button( 129 &mut self, 130 ui: &mut egui::Ui, 131 prev: &Route, 132 chev_size: egui::Vec2, 133 ) -> egui::Response { 134 //let color = ui.visuals().hyperlink_color; 135 let color = ui.style().visuals.noninteractive().fg_stroke.color; 136 137 //let spacing_prev = ui.spacing().item_spacing.x; 138 //ui.spacing_mut().item_spacing.x = 0.0; 139 140 let chev_resp = chevron(ui, 2.0, chev_size, Stroke::new(2.0, color)); 141 142 //ui.spacing_mut().item_spacing.x = spacing_prev; 143 144 // NOTE(jb55): include graphic in back label as well because why 145 // not it looks cool 146 let pfp_resp = self.title_pfp(ui, prev, 32.0); 147 let column_title = prev.title(self.i18n); 148 149 let back_resp = match &column_title { 150 ColumnTitle::Simple(title) => ui.add(Self::back_label(title, color)), 151 152 ColumnTitle::NeedsDb(need_db) => { 153 let txn = Transaction::new(self.ndb).unwrap(); 154 let title = need_db.title(&txn, self.ndb); 155 ui.add(Self::back_label(title, color)) 156 } 157 }; 158 159 if let Some(pfp_resp) = pfp_resp { 160 back_resp.union(chev_resp).union(pfp_resp) 161 } else { 162 back_resp.union(chev_resp) 163 } 164 } 165 166 fn back_label(title: &str, color: egui::Color32) -> egui::Label { 167 egui::Label::new( 168 RichText::new(title.to_string()) 169 .color(color) 170 .text_style(NotedeckTextStyle::Body.text_style()), 171 ) 172 .selectable(false) 173 .sense(egui::Sense::click()) 174 } 175 176 fn delete_column_button(&self, ui: &mut egui::Ui, icon_width: f32) -> egui::Response { 177 let img_size = 16.0; 178 let max_size = icon_width * ICON_EXPANSION_MULTIPLE; 179 180 let img = (if ui.visuals().dark_mode { 181 app_images::delete_dark_image() 182 } else { 183 app_images::delete_light_image() 184 }) 185 .max_width(img_size); 186 187 let helper = 188 AnimationHelper::new(ui, "delete-column-button", egui::vec2(max_size, max_size)); 189 190 let cur_img_size = helper.scale_1d_pos_min_max(0.0, img_size); 191 192 let animation_rect = helper.get_animation_rect(); 193 let animation_resp = helper.take_animation_response(); 194 195 img.paint_at(ui, animation_rect.shrink((max_size - cur_img_size) / 2.0)); 196 197 animation_resp 198 } 199 200 fn delete_button_section(&mut self, ui: &mut egui::Ui) -> bool { 201 let id = ui.id().with("title"); 202 203 let delete_button_resp = self.delete_column_button(ui, 32.0); 204 if delete_button_resp.clicked() { 205 ui.data_mut(|d| d.insert_temp(id, true)); 206 } 207 208 if ui.data_mut(|d| *d.get_temp_mut_or_default(id)) { 209 let mut confirm_pressed = false; 210 delete_button_resp.show_tooltip_ui(|ui| { 211 let confirm_resp = ui.button(tr!( 212 self.i18n, 213 "Confirm", 214 "Button label to confirm an action" 215 )); 216 if confirm_resp.clicked() { 217 confirm_pressed = true; 218 } 219 220 if confirm_resp.clicked() 221 || ui 222 .button(tr!(self.i18n, "Cancel", "Button label to cancel an action")) 223 .clicked() 224 { 225 ui.data_mut(|d| d.insert_temp(id, false)); 226 } 227 }); 228 if !confirm_pressed && delete_button_resp.clicked_elsewhere() { 229 ui.data_mut(|d| d.insert_temp(id, false)); 230 } 231 confirm_pressed 232 } else { 233 delete_button_resp.on_hover_text(tr!( 234 self.i18n, 235 "Delete this column", 236 "Tooltip for deleting a column" 237 )); 238 false 239 } 240 } 241 242 // returns the column index to switch to, if any 243 fn move_button_section(&mut self, ui: &mut egui::Ui) -> Option<usize> { 244 let cur_id = ui.id().with("move"); 245 let mut move_resp = ui 246 .add(grab_button()) 247 .on_hover_cursor(egui::CursorIcon::PointingHand); 248 249 // showing the hover text while showing the move tooltip causes some weird visuals 250 if ui.data(|d| d.get_temp::<bool>(cur_id).is_none()) { 251 move_resp = move_resp.on_hover_text(tr!( 252 self.i18n, 253 "Moves this column to another position", 254 "Tooltip for moving a column" 255 )); 256 } 257 258 if move_resp.clicked() { 259 ui.data_mut(|d| { 260 if let Some(val) = d.get_temp::<bool>(cur_id) { 261 if val { 262 d.remove_temp::<bool>(cur_id); 263 } else { 264 d.insert_temp(cur_id, true); 265 } 266 } else { 267 d.insert_temp(cur_id, true); 268 } 269 }); 270 } 271 272 ui.data(|d| d.get_temp(cur_id)).and_then(|val| { 273 if val { 274 let resp = self.add_move_tooltip(cur_id, &move_resp); 275 if move_resp.clicked_elsewhere() || resp.is_some() { 276 ui.data_mut(|d| d.remove_temp::<bool>(cur_id)); 277 } 278 resp 279 } else { 280 None 281 } 282 }) 283 } 284 285 fn move_tooltip_col_presentation(&mut self, ui: &mut egui::Ui, col: usize) -> egui::Response { 286 ui.horizontal(|ui| { 287 self.title_presentation(ui, self.columns.column(col).router().top(), 32.0); 288 }) 289 .response 290 } 291 292 fn add_move_tooltip(&mut self, id: egui::Id, move_resp: &egui::Response) -> Option<usize> { 293 let mut inner_resp = None; 294 move_resp.show_tooltip_ui(|ui| { 295 // dnd frame color workaround 296 ui.visuals_mut().widgets.inactive.bg_stroke = Stroke::default(); 297 let x_range = ui.available_rect_before_wrap().x_range(); 298 let is_dragging = egui::DragAndDrop::payload::<usize>(ui.ctx()).is_some(); // must be outside ui.dnd_drop_zone to capture properly 299 let (_, _) = ui.dnd_drop_zone::<usize, ()>( 300 egui::Frame::new().inner_margin(Margin::same(8)), 301 |ui| { 302 let distances: Vec<(egui::Response, f32)> = 303 self.collect_column_distances(ui, id); 304 305 if let Some((closest_index, closest_resp, distance)) = 306 self.find_closest_column(&distances) 307 { 308 if is_dragging && closest_index != self.col_id { 309 if self.should_draw_hint(closest_index, distance) { 310 ui.painter().hline( 311 x_range, 312 self.calculate_hint_y( 313 &distances, 314 closest_resp, 315 closest_index, 316 distance, 317 ), 318 egui::Stroke::new(1.0, ui.visuals().text_color()), 319 ); 320 } 321 322 if ui.input(|i| i.pointer.any_released()) { 323 inner_resp = 324 Some(self.calculate_new_index(closest_index, distance)); 325 } 326 } 327 } 328 }, 329 ); 330 }); 331 inner_resp 332 } 333 334 fn collect_column_distances( 335 &mut self, 336 ui: &mut egui::Ui, 337 id: egui::Id, 338 ) -> Vec<(egui::Response, f32)> { 339 let y_margin: i8 = 4; 340 let item_frame = egui::Frame::new() 341 .corner_radius(egui::CornerRadius::same(8)) 342 .inner_margin(Margin::symmetric(8, y_margin)); 343 344 (0..self.columns.num_columns()) 345 .filter_map(|col| { 346 let item_id = id.with(col); 347 let col_resp = if col == self.col_id { 348 ui.dnd_drag_source(item_id, col, |ui| { 349 item_frame 350 .stroke(egui::Stroke::new(2.0, notedeck_ui::colors::PINK)) 351 .fill(ui.visuals().widgets.noninteractive.bg_stroke.color) 352 .show(ui, |ui| self.move_tooltip_col_presentation(ui, col)); 353 }) 354 .response 355 } else { 356 item_frame 357 .show(ui, |ui| { 358 self.move_tooltip_col_presentation(ui, col) 359 .on_hover_cursor(egui::CursorIcon::NotAllowed) 360 }) 361 .response 362 }; 363 364 ui.input(|i| i.pointer.interact_pos()).map(|pointer| { 365 let distance = pointer.y - col_resp.rect.center().y; 366 (col_resp, distance) 367 }) 368 }) 369 .collect() 370 } 371 372 fn find_closest_column( 373 &'a self, 374 distances: &'a [(egui::Response, f32)], 375 ) -> Option<(usize, &'a egui::Response, f32)> { 376 distances 377 .iter() 378 .enumerate() 379 .min_by(|(_, (_, dist1)), (_, (_, dist2))| { 380 dist1.abs().partial_cmp(&dist2.abs()).unwrap() 381 }) 382 .filter(|(index, (_, distance))| { 383 (index + 1 != self.col_id && *distance > 0.0) 384 || (index.saturating_sub(1) != self.col_id && *distance < 0.0) 385 }) 386 .map(|(index, (resp, dist))| (index, resp, *dist)) 387 } 388 389 fn should_draw_hint(&self, closest_index: usize, distance: f32) -> bool { 390 let is_above = distance < 0.0; 391 (is_above && closest_index.saturating_sub(1) != self.col_id) 392 || (!is_above && closest_index + 1 != self.col_id) 393 } 394 395 fn calculate_new_index(&self, closest_index: usize, distance: f32) -> usize { 396 let moving_up = self.col_id > closest_index; 397 match (distance < 0.0, moving_up) { 398 (true, true) | (false, false) => closest_index, 399 (true, false) => closest_index.saturating_sub(1), 400 (false, true) => closest_index + 1, 401 } 402 } 403 404 fn calculate_hint_y( 405 &self, 406 distances: &[(egui::Response, f32)], 407 closest_resp: &egui::Response, 408 closest_index: usize, 409 distance: f32, 410 ) -> f32 { 411 let y_margin = 4.0; 412 413 let offset = if distance < 0.0 { 414 distances 415 .get(closest_index.wrapping_sub(1)) 416 .map(|(above_resp, _)| (closest_resp.rect.top() - above_resp.rect.bottom()) / 2.0) 417 .unwrap_or(y_margin) 418 } else { 419 distances 420 .get(closest_index + 1) 421 .map(|(below_resp, _)| (below_resp.rect.top() - closest_resp.rect.bottom()) / 2.0) 422 .unwrap_or(y_margin) 423 }; 424 425 if distance < 0.0 { 426 closest_resp.rect.top() - offset 427 } else { 428 closest_resp.rect.bottom() + offset 429 } 430 } 431 432 fn pubkey_pfp<'txn, 'me>( 433 &'me mut self, 434 txn: &'txn Transaction, 435 pubkey: &[u8; 32], 436 pfp_size: f32, 437 ) -> Option<ProfilePic<'me, 'txn>> { 438 self.ndb 439 .get_profile_by_pubkey(txn, pubkey) 440 .as_ref() 441 .ok() 442 .and_then(move |p| { 443 ProfilePic::from_profile(self.img_cache, self.jobs, p) 444 .map(|pfp| pfp.size(pfp_size).sense(Sense::click())) 445 }) 446 } 447 448 fn timeline_pfp(&mut self, ui: &mut egui::Ui, id: &TimelineKind, pfp_size: f32) -> Response { 449 let txn = Transaction::new(self.ndb).unwrap(); 450 451 if let Some(mut pfp) = id 452 .pubkey() 453 .and_then(|pk| self.pubkey_pfp(&txn, pk.bytes(), pfp_size)) 454 { 455 ui.add(&mut pfp) 456 } else { 457 ui.add( 458 &mut ProfilePic::new(self.img_cache, self.jobs, notedeck::profile::no_pfp_url()) 459 .size(pfp_size) 460 .sense(Sense::click()), 461 ) 462 } 463 } 464 465 fn title_pfp(&mut self, ui: &mut egui::Ui, top: &Route, pfp_size: f32) -> Option<Response> { 466 match top { 467 Route::Timeline(kind) => match kind { 468 TimelineKind::Hashtag(_ht) => Some(ui.add( 469 app_images::hashtag_image().fit_to_exact_size(egui::vec2(pfp_size, pfp_size)), 470 )), 471 472 TimelineKind::Profile(pubkey) => Some(self.show_profile(ui, pubkey, pfp_size)), 473 474 TimelineKind::Search(_sq) => { 475 // TODO: show author pfp if author field set? 476 477 Some(ui.add(ui::side_panel::search_button())) 478 } 479 480 TimelineKind::Universe 481 | TimelineKind::Algo(_) 482 | TimelineKind::Notifications(_) 483 | TimelineKind::Generic(_) 484 | TimelineKind::List(_) => Some(self.timeline_pfp(ui, kind, pfp_size)), 485 }, 486 Route::Reply(_) => None, 487 Route::Quote(_) => None, 488 Route::Accounts(_as) => None, 489 Route::ComposeNote => None, 490 Route::AddColumn(_add_col_route) => None, 491 Route::Support => None, 492 Route::Relays => None, 493 Route::Settings => None, 494 Route::NewDeck => None, 495 Route::EditDeck(_) => None, 496 Route::EditProfile(pubkey) => Some(self.show_profile(ui, pubkey, pfp_size)), 497 Route::Search => Some(ui.add(ui::side_panel::search_button())), 498 Route::Wallet(_) => None, 499 Route::CustomizeZapAmount(_) => None, 500 Route::Thread(thread_selection) => { 501 Some(self.thread_pfp(ui, thread_selection, pfp_size)) 502 } 503 Route::RepostDecision(_) => None, 504 Route::Following(pubkey) => Some(self.show_profile(ui, pubkey, pfp_size)), 505 Route::FollowedBy(pubkey) => Some(self.show_profile(ui, pubkey, pfp_size)), 506 } 507 } 508 509 fn show_profile( 510 &mut self, 511 ui: &mut egui::Ui, 512 pubkey: &Pubkey, 513 pfp_size: f32, 514 ) -> egui::Response { 515 let txn = Transaction::new(self.ndb).unwrap(); 516 if let Some(mut pfp) = self.pubkey_pfp(&txn, pubkey.bytes(), pfp_size) { 517 ui.add(&mut pfp) 518 } else { 519 ui.add( 520 &mut ProfilePic::new(self.img_cache, self.jobs, notedeck::profile::no_pfp_url()) 521 .size(pfp_size) 522 .sense(Sense::click()), 523 ) 524 } 525 } 526 527 fn thread_pfp( 528 &mut self, 529 ui: &mut egui::Ui, 530 selection: &ThreadSelection, 531 pfp_size: f32, 532 ) -> egui::Response { 533 let txn = Transaction::new(self.ndb).unwrap(); 534 535 if let Ok(note) = self.ndb.get_note_by_id(&txn, selection.selected_or_root()) { 536 if let Some(mut pfp) = self.pubkey_pfp(&txn, note.pubkey(), pfp_size) { 537 return ui.add(&mut pfp); 538 } 539 } 540 541 ui.add( 542 &mut ProfilePic::new(self.img_cache, self.jobs, notedeck::profile::no_pfp_url()) 543 .size(pfp_size), 544 ) 545 } 546 547 fn title_label_value(title: &str) -> egui::Label { 548 egui::Label::new(RichText::new(title).text_style(NotedeckTextStyle::Body.text_style())) 549 .selectable(false) 550 } 551 552 fn title_label(&mut self, ui: &mut egui::Ui, top: &Route) { 553 let column_title = top.title(self.i18n); 554 555 match &column_title { 556 ColumnTitle::Simple(title) => ui.add(Self::title_label_value(title)), 557 558 ColumnTitle::NeedsDb(need_db) => { 559 let txn = Transaction::new(self.ndb).unwrap(); 560 let title = need_db.title(&txn, self.ndb); 561 ui.add(Self::title_label_value(title)) 562 } 563 }; 564 } 565 566 pub fn show_move_button(&mut self, enable: bool) -> &mut Self { 567 if enable { 568 self.options |= Self::SHOW_MOVE; 569 } else { 570 self.options &= !Self::SHOW_MOVE; 571 } 572 573 self 574 } 575 576 pub fn show_delete_button(&mut self, enable: bool) -> &mut Self { 577 if enable { 578 self.options |= Self::SHOW_DELETE; 579 } else { 580 self.options &= !Self::SHOW_DELETE; 581 } 582 583 self 584 } 585 586 fn should_show_move_button(&self) -> bool { 587 (self.options & Self::SHOW_MOVE) == Self::SHOW_MOVE 588 } 589 590 fn should_show_delete_button(&self) -> bool { 591 (self.options & Self::SHOW_DELETE) == Self::SHOW_DELETE 592 } 593 594 fn title(&mut self, ui: &mut egui::Ui, top: &Route, navigating: bool) -> Option<TitleResponse> { 595 let title_r = if !navigating { 596 self.title_presentation(ui, top, 32.0) 597 } else { 598 None 599 }; 600 601 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { 602 if navigating { 603 self.title_presentation(ui, top, 32.0) 604 } else { 605 let mut move_col: Option<usize> = None; 606 let mut remove_col = false; 607 608 if self.should_show_move_button() { 609 move_col = self.move_button_section(ui); 610 } 611 if self.should_show_delete_button() { 612 remove_col = self.delete_button_section(ui); 613 } 614 615 if let Some(col) = move_col { 616 Some(TitleResponse::MoveColumn(col)) 617 } else if remove_col { 618 Some(TitleResponse::RemoveColumn) 619 } else { 620 None 621 } 622 } 623 }) 624 .inner 625 .or(title_r) 626 } 627 628 fn title_presentation( 629 &mut self, 630 ui: &mut egui::Ui, 631 top: &Route, 632 pfp_size: f32, 633 ) -> Option<TitleResponse> { 634 let pfp_r = self 635 .title_pfp(ui, top, pfp_size) 636 .map(|r| r.on_hover_cursor(egui::CursorIcon::PointingHand)); 637 638 self.title_label(ui, top); 639 640 pfp_r.and_then(|r| { 641 if r.clicked() { 642 Some(TitleResponse::PfpClicked) 643 } else { 644 None 645 } 646 }) 647 } 648 } 649 650 #[derive(Debug)] 651 enum TitleResponse { 652 RemoveColumn, 653 PfpClicked, 654 MoveColumn(usize), 655 } 656 657 fn prev<R>(xs: &[R]) -> Option<&R> { 658 xs.get(xs.len().checked_sub(2)?) 659 } 660 661 fn grab_button() -> impl egui::Widget { 662 |ui: &mut egui::Ui| -> egui::Response { 663 let max_size = egui::vec2(20.0, 20.0); 664 let helper = AnimationHelper::new(ui, "grab", max_size); 665 let painter = ui.painter_at(helper.get_animation_rect()); 666 let min_circle_radius = 1.0; 667 let cur_circle_radius = helper.scale_1d_pos(min_circle_radius); 668 let horiz_spacing = 4.0; 669 let vert_spacing = 10.0; 670 let horiz_from_center = (horiz_spacing + min_circle_radius) / 2.0; 671 let vert_from_center = (vert_spacing + min_circle_radius) / 2.0; 672 673 let color = ui.style().visuals.noninteractive().fg_stroke.color; 674 675 let middle_left = helper.scale_from_center(-horiz_from_center, 0.0); 676 let middle_right = helper.scale_from_center(horiz_from_center, 0.0); 677 let top_left = helper.scale_from_center(-horiz_from_center, -vert_from_center); 678 let top_right = helper.scale_from_center(horiz_from_center, -vert_from_center); 679 let bottom_left = helper.scale_from_center(-horiz_from_center, vert_from_center); 680 let bottom_right = helper.scale_from_center(horiz_from_center, vert_from_center); 681 682 painter.circle_filled(middle_left, cur_circle_radius, color); 683 painter.circle_filled(middle_right, cur_circle_radius, color); 684 painter.circle_filled(top_left, cur_circle_radius, color); 685 painter.circle_filled(top_right, cur_circle_radius, color); 686 painter.circle_filled(bottom_left, cur_circle_radius, color); 687 painter.circle_filled(bottom_right, cur_circle_radius, color); 688 689 helper.take_animation_response() 690 } 691 }