side_panel.rs (24805B)
1 use egui::{ 2 vec2, Button, Color32, InnerResponse, Label, Layout, Margin, RichText, ScrollArea, Separator, 3 Stroke, ThemePreference, Widget, 4 }; 5 use tracing::{error, info}; 6 7 use crate::{ 8 accounts::AccountsRoute, 9 app::{get_active_columns_mut, get_decks_mut}, 10 app_style::DECK_ICON_SIZE, 11 colors, 12 decks::{DecksAction, DecksCache}, 13 nav::SwitchingAction, 14 route::Route, 15 support::Support, 16 }; 17 18 use notedeck::{Accounts, Images, NotedeckTextStyle, ThemeHandler, UserAccount}; 19 20 use super::{ 21 anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, 22 configure_deck::deck_icon, 23 profile::preview::get_account_url, 24 ProfilePic, View, 25 }; 26 27 pub static SIDE_PANEL_WIDTH: f32 = 68.0; 28 static ICON_WIDTH: f32 = 40.0; 29 30 pub struct DesktopSidePanel<'a> { 31 ndb: &'a nostrdb::Ndb, 32 img_cache: &'a mut Images, 33 selected_account: Option<&'a UserAccount>, 34 decks_cache: &'a DecksCache, 35 } 36 37 impl View for DesktopSidePanel<'_> { 38 fn ui(&mut self, ui: &mut egui::Ui) { 39 self.show(ui); 40 } 41 } 42 43 #[derive(Debug, Eq, PartialEq, Clone, Copy)] 44 pub enum SidePanelAction { 45 Panel, 46 Account, 47 Settings, 48 Columns, 49 ComposeNote, 50 Search, 51 ExpandSidePanel, 52 Support, 53 NewDeck, 54 SwitchDeck(usize), 55 EditDeck(usize), 56 SaveTheme(ThemePreference), 57 } 58 59 pub struct SidePanelResponse { 60 pub response: egui::Response, 61 pub action: SidePanelAction, 62 } 63 64 impl SidePanelResponse { 65 fn new(action: SidePanelAction, response: egui::Response) -> Self { 66 SidePanelResponse { action, response } 67 } 68 } 69 70 impl<'a> DesktopSidePanel<'a> { 71 pub fn new( 72 ndb: &'a nostrdb::Ndb, 73 img_cache: &'a mut Images, 74 selected_account: Option<&'a UserAccount>, 75 decks_cache: &'a DecksCache, 76 ) -> Self { 77 Self { 78 ndb, 79 img_cache, 80 selected_account, 81 decks_cache, 82 } 83 } 84 85 pub fn show(&mut self, ui: &mut egui::Ui) -> SidePanelResponse { 86 let mut frame = egui::Frame::none().inner_margin(Margin::same(8.0)); 87 88 if !ui.visuals().dark_mode { 89 frame = frame.fill(colors::ALMOST_WHITE); 90 } 91 92 frame.show(ui, |ui| self.show_inner(ui)).inner 93 } 94 95 fn show_inner(&mut self, ui: &mut egui::Ui) -> SidePanelResponse { 96 let dark_mode = ui.ctx().style().visuals.dark_mode; 97 98 let inner = ui 99 .vertical(|ui| { 100 let top_resp = ui 101 .with_layout(Layout::top_down(egui::Align::Center), |ui| { 102 // macos needs a bit of space to make room for window 103 // minimize/close buttons 104 if cfg!(target_os = "macos") { 105 ui.add_space(24.0); 106 } 107 108 let expand_resp = ui.add(expand_side_panel_button()); 109 ui.add_space(4.0); 110 ui.add(milestone_name()); 111 ui.add_space(16.0); 112 let is_interactive = self 113 .selected_account 114 .is_some_and(|s| s.secret_key.is_some()); 115 let compose_resp = ui.add(compose_note_button(is_interactive, dark_mode)); 116 let compose_resp = if is_interactive { 117 compose_resp 118 } else { 119 compose_resp.on_hover_cursor(egui::CursorIcon::NotAllowed) 120 }; 121 let search_resp = ui.add(search_button()); 122 let column_resp = ui.add(add_column_button(dark_mode)); 123 124 ui.add(Separator::default().horizontal().spacing(8.0).shrink(4.0)); 125 126 ui.add_space(8.0); 127 ui.add(egui::Label::new( 128 RichText::new("DECKS") 129 .size(11.0) 130 .color(ui.visuals().noninteractive().fg_stroke.color), 131 )); 132 ui.add_space(8.0); 133 let add_deck_resp = ui.add(add_deck_button()); 134 135 let decks_inner = ScrollArea::vertical() 136 .max_height(ui.available_height() - (3.0 * (ICON_WIDTH + 12.0))) 137 .show(ui, |ui| { 138 show_decks(ui, self.decks_cache, self.selected_account) 139 }) 140 .inner; 141 if expand_resp.clicked() { 142 Some(InnerResponse::new( 143 SidePanelAction::ExpandSidePanel, 144 expand_resp, 145 )) 146 } else if compose_resp.clicked() { 147 Some(InnerResponse::new( 148 SidePanelAction::ComposeNote, 149 compose_resp, 150 )) 151 } else if search_resp.clicked() { 152 Some(InnerResponse::new(SidePanelAction::Search, search_resp)) 153 } else if column_resp.clicked() { 154 Some(InnerResponse::new(SidePanelAction::Columns, column_resp)) 155 } else if add_deck_resp.clicked() { 156 Some(InnerResponse::new(SidePanelAction::NewDeck, add_deck_resp)) 157 } else if decks_inner.response.secondary_clicked() { 158 info!("decks inner secondary click"); 159 if let Some(clicked_index) = decks_inner.inner { 160 Some(InnerResponse::new( 161 SidePanelAction::EditDeck(clicked_index), 162 decks_inner.response, 163 )) 164 } else { 165 None 166 } 167 } else if decks_inner.response.clicked() { 168 if let Some(clicked_index) = decks_inner.inner { 169 Some(InnerResponse::new( 170 SidePanelAction::SwitchDeck(clicked_index), 171 decks_inner.response, 172 )) 173 } else { 174 None 175 } 176 } else { 177 None 178 } 179 }) 180 .inner; 181 182 ui.add(Separator::default().horizontal().spacing(8.0).shrink(4.0)); 183 let (pfp_resp, bottom_resp) = ui 184 .with_layout(Layout::bottom_up(egui::Align::Center), |ui| { 185 let pfp_resp = self.pfp_button(ui); 186 let settings_resp = ui.add(settings_button(dark_mode)); 187 188 let save_theme = if let Some((theme, resp)) = match ui.ctx().theme() { 189 egui::Theme::Dark => { 190 let resp = ui 191 .add(Button::new("☀").frame(false)) 192 .on_hover_text("Switch to light mode"); 193 if resp.clicked() { 194 Some((ThemePreference::Light, resp)) 195 } else { 196 None 197 } 198 } 199 egui::Theme::Light => { 200 let resp = ui 201 .add(Button::new("🌙").frame(false)) 202 .on_hover_text("Switch to dark mode"); 203 if resp.clicked() { 204 Some((ThemePreference::Dark, resp)) 205 } else { 206 None 207 } 208 } 209 } { 210 ui.ctx().set_theme(theme); 211 Some((theme, resp)) 212 } else { 213 None 214 }; 215 216 let support_resp = ui.add(support_button()); 217 218 let optional_inner = if pfp_resp.clicked() { 219 Some(egui::InnerResponse::new( 220 SidePanelAction::Account, 221 pfp_resp.clone(), 222 )) 223 } else if settings_resp.clicked() || settings_resp.hovered() { 224 Some(egui::InnerResponse::new( 225 SidePanelAction::Settings, 226 settings_resp, 227 )) 228 } else if support_resp.clicked() { 229 Some(egui::InnerResponse::new( 230 SidePanelAction::Support, 231 support_resp, 232 )) 233 } else if let Some((theme, resp)) = save_theme { 234 Some(egui::InnerResponse::new( 235 SidePanelAction::SaveTheme(theme), 236 resp, 237 )) 238 } else { 239 None 240 }; 241 242 (pfp_resp, optional_inner) 243 }) 244 .inner; 245 246 if let Some(bottom_inner) = bottom_resp { 247 bottom_inner 248 } else if let Some(top_inner) = top_resp { 249 top_inner 250 } else { 251 egui::InnerResponse::new(SidePanelAction::Panel, pfp_resp) 252 } 253 }) 254 .inner; 255 256 SidePanelResponse::new(inner.inner, inner.response) 257 } 258 259 fn pfp_button(&mut self, ui: &mut egui::Ui) -> egui::Response { 260 let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget 261 let helper = AnimationHelper::new(ui, "pfp-button", vec2(max_size, max_size)); 262 263 let min_pfp_size = ICON_WIDTH; 264 let cur_pfp_size = helper.scale_1d_pos(min_pfp_size); 265 266 let txn = nostrdb::Transaction::new(self.ndb).expect("should be able to create txn"); 267 let profile_url = get_account_url(&txn, self.ndb, self.selected_account); 268 269 let widget = ProfilePic::new(self.img_cache, profile_url).size(cur_pfp_size); 270 271 ui.put(helper.get_animation_rect(), widget); 272 273 helper.take_animation_response() 274 } 275 276 pub fn perform_action( 277 decks_cache: &mut DecksCache, 278 accounts: &Accounts, 279 support: &mut Support, 280 theme_handler: &mut ThemeHandler, 281 action: SidePanelAction, 282 ) -> Option<SwitchingAction> { 283 let router = get_active_columns_mut(accounts, decks_cache).get_first_router(); 284 let mut switching_response = None; 285 match action { 286 SidePanelAction::Panel => {} // TODO 287 SidePanelAction::Account => { 288 if router 289 .routes() 290 .iter() 291 .any(|r| r == &Route::Accounts(AccountsRoute::Accounts)) 292 { 293 // return if we are already routing to accounts 294 router.go_back(); 295 } else { 296 router.route_to(Route::accounts()); 297 } 298 } 299 SidePanelAction::Settings => { 300 if router.routes().iter().any(|r| r == &Route::Relays) { 301 // return if we are already routing to accounts 302 router.go_back(); 303 } else { 304 router.route_to(Route::relays()); 305 } 306 } 307 SidePanelAction::Columns => { 308 if router 309 .routes() 310 .iter() 311 .any(|r| matches!(r, Route::AddColumn(_))) 312 { 313 router.go_back(); 314 } else { 315 get_active_columns_mut(accounts, decks_cache).new_column_picker(); 316 } 317 } 318 SidePanelAction::ComposeNote => { 319 if router.routes().iter().any(|r| r == &Route::ComposeNote) { 320 router.go_back(); 321 } else { 322 router.route_to(Route::ComposeNote); 323 } 324 } 325 SidePanelAction::Search => { 326 // TODO 327 info!("Clicked search button"); 328 if router.top() == &Route::Search { 329 router.go_back(); 330 } else { 331 router.route_to(Route::Search); 332 } 333 } 334 SidePanelAction::ExpandSidePanel => { 335 // TODO 336 info!("Clicked expand side panel button"); 337 } 338 SidePanelAction::Support => { 339 if router.routes().iter().any(|r| r == &Route::Support) { 340 router.go_back(); 341 } else { 342 support.refresh(); 343 router.route_to(Route::Support); 344 } 345 } 346 SidePanelAction::NewDeck => { 347 if router.routes().iter().any(|r| r == &Route::NewDeck) { 348 router.go_back(); 349 } else { 350 router.route_to(Route::NewDeck); 351 } 352 } 353 SidePanelAction::SwitchDeck(index) => { 354 switching_response = Some(crate::nav::SwitchingAction::Decks(DecksAction::Switch( 355 index, 356 ))) 357 } 358 SidePanelAction::EditDeck(index) => { 359 if router.routes().iter().any(|r| r == &Route::EditDeck(index)) { 360 router.go_back(); 361 } else { 362 switching_response = Some(crate::nav::SwitchingAction::Decks( 363 DecksAction::Switch(index), 364 )); 365 if let Some(edit_deck) = get_decks_mut(accounts, decks_cache) 366 .decks_mut() 367 .get_mut(index) 368 { 369 edit_deck 370 .columns_mut() 371 .get_first_router() 372 .route_to(Route::EditDeck(index)); 373 } else { 374 error!("Cannot push EditDeck route to index {}", index); 375 } 376 } 377 } 378 SidePanelAction::SaveTheme(theme) => { 379 theme_handler.save(theme); 380 } 381 } 382 switching_response 383 } 384 } 385 386 fn settings_button(dark_mode: bool) -> impl Widget { 387 move |ui: &mut egui::Ui| { 388 let img_size = 24.0; 389 let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget 390 let img_data = if dark_mode { 391 egui::include_image!("../../../../assets/icons/settings_dark_4x.png") 392 } else { 393 egui::include_image!("../../../../assets/icons/settings_light_4x.png") 394 }; 395 let img = egui::Image::new(img_data).max_width(img_size); 396 397 let helper = AnimationHelper::new(ui, "settings-button", vec2(max_size, max_size)); 398 399 let cur_img_size = helper.scale_1d_pos(img_size); 400 img.paint_at( 401 ui, 402 helper 403 .get_animation_rect() 404 .shrink((max_size - cur_img_size) / 2.0), 405 ); 406 407 helper.take_animation_response() 408 } 409 } 410 411 fn add_column_button(dark_mode: bool) -> impl Widget { 412 move |ui: &mut egui::Ui| { 413 let img_size = 24.0; 414 let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget 415 416 let img_data = if dark_mode { 417 egui::include_image!("../../../../assets/icons/add_column_dark_4x.png") 418 } else { 419 egui::include_image!("../../../../assets/icons/add_column_light_4x.png") 420 }; 421 422 let img = egui::Image::new(img_data).max_width(img_size); 423 424 let helper = AnimationHelper::new(ui, "add-column-button", vec2(max_size, max_size)); 425 426 let cur_img_size = helper.scale_1d_pos(img_size); 427 img.paint_at( 428 ui, 429 helper 430 .get_animation_rect() 431 .shrink((max_size - cur_img_size) / 2.0), 432 ); 433 434 helper.take_animation_response() 435 } 436 } 437 438 fn compose_note_button(interactive: bool, dark_mode: bool) -> impl Widget { 439 move |ui: &mut egui::Ui| -> egui::Response { 440 let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget 441 442 let min_outer_circle_diameter = 40.0; 443 let min_plus_sign_size = 14.0; // length of the plus sign 444 let min_line_width = 2.25; // width of the plus sign 445 446 let helper = if interactive { 447 AnimationHelper::new(ui, "note-compose-button", vec2(max_size, max_size)) 448 } else { 449 AnimationHelper::no_animation(ui, vec2(max_size, max_size)) 450 }; 451 452 let painter = ui.painter_at(helper.get_animation_rect()); 453 454 let use_background_radius = helper.scale_radius(min_outer_circle_diameter); 455 let use_line_width = helper.scale_1d_pos(min_line_width); 456 let use_edge_circle_radius = helper.scale_radius(min_line_width); 457 458 let fill_color = if interactive { 459 colors::PINK 460 } else { 461 ui.visuals().noninteractive().bg_fill 462 }; 463 464 painter.circle_filled(helper.center(), use_background_radius, fill_color); 465 466 let min_half_plus_sign_size = min_plus_sign_size / 2.0; 467 let north_edge = helper.scale_from_center(0.0, min_half_plus_sign_size); 468 let south_edge = helper.scale_from_center(0.0, -min_half_plus_sign_size); 469 let west_edge = helper.scale_from_center(-min_half_plus_sign_size, 0.0); 470 let east_edge = helper.scale_from_center(min_half_plus_sign_size, 0.0); 471 472 let icon_color = if !dark_mode && !interactive { 473 Color32::BLACK 474 } else { 475 Color32::WHITE 476 }; 477 478 painter.line_segment( 479 [north_edge, south_edge], 480 Stroke::new(use_line_width, icon_color), 481 ); 482 painter.line_segment( 483 [west_edge, east_edge], 484 Stroke::new(use_line_width, icon_color), 485 ); 486 painter.circle_filled(north_edge, use_edge_circle_radius, Color32::WHITE); 487 painter.circle_filled(south_edge, use_edge_circle_radius, Color32::WHITE); 488 painter.circle_filled(west_edge, use_edge_circle_radius, Color32::WHITE); 489 painter.circle_filled(east_edge, use_edge_circle_radius, Color32::WHITE); 490 491 helper.take_animation_response() 492 } 493 } 494 495 pub fn search_button() -> impl Widget { 496 |ui: &mut egui::Ui| -> egui::Response { 497 let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget 498 let min_line_width_circle = 1.5; // width of the magnifying glass 499 let min_line_width_handle = 1.5; 500 let helper = AnimationHelper::new(ui, "search-button", vec2(max_size, max_size)); 501 502 let painter = ui.painter_at(helper.get_animation_rect()); 503 504 let cur_line_width_circle = helper.scale_1d_pos(min_line_width_circle); 505 let cur_line_width_handle = helper.scale_1d_pos(min_line_width_handle); 506 let min_outer_circle_radius = helper.scale_radius(15.0); 507 let cur_outer_circle_radius = helper.scale_1d_pos(min_outer_circle_radius); 508 let min_handle_length = 7.0; 509 let cur_handle_length = helper.scale_1d_pos(min_handle_length); 510 511 let circle_center = helper.scale_from_center(-2.0, -2.0); 512 513 let handle_vec = vec2( 514 std::f32::consts::FRAC_1_SQRT_2, 515 std::f32::consts::FRAC_1_SQRT_2, 516 ); 517 518 let handle_pos_1 = circle_center + (handle_vec * (cur_outer_circle_radius - 3.0)); 519 let handle_pos_2 = 520 circle_center + (handle_vec * (cur_outer_circle_radius + cur_handle_length)); 521 522 let circle_stroke = Stroke::new(cur_line_width_circle, colors::MID_GRAY); 523 let handle_stroke = Stroke::new(cur_line_width_handle, colors::MID_GRAY); 524 525 painter.line_segment([handle_pos_1, handle_pos_2], handle_stroke); 526 painter.circle( 527 circle_center, 528 min_outer_circle_radius, 529 ui.style().visuals.widgets.inactive.weak_bg_fill, 530 circle_stroke, 531 ); 532 533 helper.take_animation_response() 534 } 535 } 536 537 // TODO: convert to responsive button when expanded side panel impl is finished 538 fn expand_side_panel_button() -> impl Widget { 539 |ui: &mut egui::Ui| -> egui::Response { 540 let img_size = 40.0; 541 let img_data = egui::include_image!("../../../../assets/damus_rounded_80.png"); 542 let img = egui::Image::new(img_data).max_width(img_size); 543 544 ui.add(img) 545 } 546 } 547 548 fn support_button() -> impl Widget { 549 |ui: &mut egui::Ui| -> egui::Response { 550 let img_size = 16.0; 551 552 let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget 553 let img_data = if ui.visuals().dark_mode { 554 egui::include_image!("../../../../assets/icons/help_icon_dark_4x.png") 555 } else { 556 egui::include_image!("../../../../assets/icons/help_icon_inverted_4x.png") 557 }; 558 let img = egui::Image::new(img_data).max_width(img_size); 559 560 let helper = AnimationHelper::new(ui, "help-button", vec2(max_size, max_size)); 561 562 let cur_img_size = helper.scale_1d_pos(img_size); 563 img.paint_at( 564 ui, 565 helper 566 .get_animation_rect() 567 .shrink((max_size - cur_img_size) / 2.0), 568 ); 569 570 helper.take_animation_response() 571 } 572 } 573 574 fn add_deck_button() -> impl Widget { 575 |ui: &mut egui::Ui| -> egui::Response { 576 let img_size = 40.0; 577 578 let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget 579 let img_data = egui::include_image!("../../../../assets/icons/new_deck_icon_4x_dark.png"); 580 let img = egui::Image::new(img_data).max_width(img_size); 581 582 let helper = AnimationHelper::new(ui, "new-deck-icon", vec2(max_size, max_size)); 583 584 let cur_img_size = helper.scale_1d_pos(img_size); 585 img.paint_at( 586 ui, 587 helper 588 .get_animation_rect() 589 .shrink((max_size - cur_img_size) / 2.0), 590 ); 591 592 helper.take_animation_response() 593 } 594 } 595 596 fn show_decks<'a>( 597 ui: &mut egui::Ui, 598 decks_cache: &'a DecksCache, 599 selected_account: Option<&'a UserAccount>, 600 ) -> InnerResponse<Option<usize>> { 601 let show_decks_id = ui.id().with("show-decks"); 602 let account_id = if let Some(acc) = selected_account { 603 acc.pubkey 604 } else { 605 *decks_cache.get_fallback_pubkey() 606 }; 607 let (cur_decks, account_id) = ( 608 decks_cache.decks(&account_id), 609 show_decks_id.with(account_id), 610 ); 611 let active_index = cur_decks.active_index(); 612 613 let (_, mut resp) = ui.allocate_exact_size(vec2(0.0, 0.0), egui::Sense::click()); 614 let mut clicked_index = None; 615 for (index, deck) in cur_decks.decks().iter().enumerate() { 616 let highlight = index == active_index; 617 let deck_icon_resp = ui 618 .add(deck_icon( 619 account_id.with(index), 620 Some(deck.icon), 621 DECK_ICON_SIZE, 622 40.0, 623 highlight, 624 )) 625 .on_hover_text_at_pointer(&deck.name); 626 if deck_icon_resp.clicked() || deck_icon_resp.secondary_clicked() { 627 clicked_index = Some(index); 628 } 629 resp = resp.union(deck_icon_resp); 630 } 631 InnerResponse::new(clicked_index, resp) 632 } 633 634 fn milestone_name() -> impl Widget { 635 |ui: &mut egui::Ui| -> egui::Response { 636 ui.vertical_centered(|ui| { 637 let font = egui::FontId::new( 638 notedeck::fonts::get_font_size( 639 ui.ctx(), 640 &NotedeckTextStyle::Tiny, 641 ), 642 egui::FontFamily::Name(notedeck::fonts::NamedFontFamily::Bold.as_str().into()), 643 ); 644 ui.add(Label::new( 645 RichText::new("ALPHA") 646 .color( ui.style().visuals.noninteractive().fg_stroke.color) 647 .font(font), 648 ).selectable(false)).on_hover_text("Notedeck is an alpha product. Expect bugs and contact us when you run into issues.").on_hover_cursor(egui::CursorIcon::Help) 649 }) 650 .inner 651 } 652 }