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