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