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