side_panel.rs (24404B)
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, ImageCache, 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 ImageCache, 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 ImageCache, 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)); 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 } 329 SidePanelAction::ExpandSidePanel => { 330 // TODO 331 info!("Clicked expand side panel button"); 332 } 333 SidePanelAction::Support => { 334 if router.routes().iter().any(|&r| r == Route::Support) { 335 router.go_back(); 336 } else { 337 support.refresh(); 338 router.route_to(Route::Support); 339 } 340 } 341 SidePanelAction::NewDeck => { 342 if router.routes().iter().any(|&r| r == Route::NewDeck) { 343 router.go_back(); 344 } else { 345 router.route_to(Route::NewDeck); 346 } 347 } 348 SidePanelAction::SwitchDeck(index) => { 349 switching_response = Some(crate::nav::SwitchingAction::Decks(DecksAction::Switch( 350 index, 351 ))) 352 } 353 SidePanelAction::EditDeck(index) => { 354 if router.routes().iter().any(|&r| r == Route::EditDeck(index)) { 355 router.go_back(); 356 } else { 357 switching_response = Some(crate::nav::SwitchingAction::Decks( 358 DecksAction::Switch(index), 359 )); 360 if let Some(edit_deck) = get_decks_mut(accounts, decks_cache) 361 .decks_mut() 362 .get_mut(index) 363 { 364 edit_deck 365 .columns_mut() 366 .get_first_router() 367 .route_to(Route::EditDeck(index)); 368 } else { 369 error!("Cannot push EditDeck route to index {}", index); 370 } 371 } 372 } 373 SidePanelAction::SaveTheme(theme) => { 374 theme_handler.save(theme); 375 } 376 } 377 switching_response 378 } 379 } 380 381 fn settings_button(dark_mode: bool) -> impl Widget { 382 move |ui: &mut egui::Ui| { 383 let img_size = 24.0; 384 let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget 385 let img_data = if dark_mode { 386 egui::include_image!("../../../../assets/icons/settings_dark_4x.png") 387 } else { 388 egui::include_image!("../../../../assets/icons/settings_light_4x.png") 389 }; 390 let img = egui::Image::new(img_data).max_width(img_size); 391 392 let helper = AnimationHelper::new(ui, "settings-button", vec2(max_size, max_size)); 393 394 let cur_img_size = helper.scale_1d_pos(img_size); 395 img.paint_at( 396 ui, 397 helper 398 .get_animation_rect() 399 .shrink((max_size - cur_img_size) / 2.0), 400 ); 401 402 helper.take_animation_response() 403 } 404 } 405 406 fn add_column_button(dark_mode: bool) -> impl Widget { 407 move |ui: &mut egui::Ui| { 408 let img_size = 24.0; 409 let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget 410 411 let img_data = if dark_mode { 412 egui::include_image!("../../../../assets/icons/add_column_dark_4x.png") 413 } else { 414 egui::include_image!("../../../../assets/icons/add_column_light_4x.png") 415 }; 416 417 let img = egui::Image::new(img_data).max_width(img_size); 418 419 let helper = AnimationHelper::new(ui, "add-column-button", vec2(max_size, max_size)); 420 421 let cur_img_size = helper.scale_1d_pos(img_size); 422 img.paint_at( 423 ui, 424 helper 425 .get_animation_rect() 426 .shrink((max_size - cur_img_size) / 2.0), 427 ); 428 429 helper.take_animation_response() 430 } 431 } 432 433 fn compose_note_button(interactive: bool) -> impl Widget { 434 move |ui: &mut egui::Ui| -> egui::Response { 435 let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget 436 437 let min_outer_circle_diameter = 40.0; 438 let min_plus_sign_size = 14.0; // length of the plus sign 439 let min_line_width = 2.25; // width of the plus sign 440 441 let helper = if interactive { 442 AnimationHelper::new(ui, "note-compose-button", vec2(max_size, max_size)) 443 } else { 444 AnimationHelper::no_animation(ui, vec2(max_size, max_size)) 445 }; 446 447 let painter = ui.painter_at(helper.get_animation_rect()); 448 449 let use_background_radius = helper.scale_radius(min_outer_circle_diameter); 450 let use_line_width = helper.scale_1d_pos(min_line_width); 451 let use_edge_circle_radius = helper.scale_radius(min_line_width); 452 453 let fill_color = if interactive { 454 colors::PINK 455 } else { 456 ui.visuals().noninteractive().bg_fill 457 }; 458 459 painter.circle_filled(helper.center(), use_background_radius, fill_color); 460 461 let min_half_plus_sign_size = min_plus_sign_size / 2.0; 462 let north_edge = helper.scale_from_center(0.0, min_half_plus_sign_size); 463 let south_edge = helper.scale_from_center(0.0, -min_half_plus_sign_size); 464 let west_edge = helper.scale_from_center(-min_half_plus_sign_size, 0.0); 465 let east_edge = helper.scale_from_center(min_half_plus_sign_size, 0.0); 466 467 painter.line_segment( 468 [north_edge, south_edge], 469 Stroke::new(use_line_width, Color32::WHITE), 470 ); 471 painter.line_segment( 472 [west_edge, east_edge], 473 Stroke::new(use_line_width, Color32::WHITE), 474 ); 475 painter.circle_filled(north_edge, use_edge_circle_radius, Color32::WHITE); 476 painter.circle_filled(south_edge, use_edge_circle_radius, Color32::WHITE); 477 painter.circle_filled(west_edge, use_edge_circle_radius, Color32::WHITE); 478 painter.circle_filled(east_edge, use_edge_circle_radius, Color32::WHITE); 479 480 helper.take_animation_response() 481 } 482 } 483 484 #[allow(unused)] 485 fn search_button() -> impl Widget { 486 |ui: &mut egui::Ui| -> egui::Response { 487 let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget 488 let min_line_width_circle = 1.5; // width of the magnifying glass 489 let min_line_width_handle = 1.5; 490 let helper = AnimationHelper::new(ui, "search-button", vec2(max_size, max_size)); 491 492 let painter = ui.painter_at(helper.get_animation_rect()); 493 494 let cur_line_width_circle = helper.scale_1d_pos(min_line_width_circle); 495 let cur_line_width_handle = helper.scale_1d_pos(min_line_width_handle); 496 let min_outer_circle_radius = helper.scale_radius(15.0); 497 let cur_outer_circle_radius = helper.scale_1d_pos(min_outer_circle_radius); 498 let min_handle_length = 7.0; 499 let cur_handle_length = helper.scale_1d_pos(min_handle_length); 500 501 let circle_center = helper.scale_from_center(-2.0, -2.0); 502 503 let handle_vec = vec2( 504 std::f32::consts::FRAC_1_SQRT_2, 505 std::f32::consts::FRAC_1_SQRT_2, 506 ); 507 508 let handle_pos_1 = circle_center + (handle_vec * (cur_outer_circle_radius - 3.0)); 509 let handle_pos_2 = 510 circle_center + (handle_vec * (cur_outer_circle_radius + cur_handle_length)); 511 512 let circle_stroke = Stroke::new(cur_line_width_circle, colors::MID_GRAY); 513 let handle_stroke = Stroke::new(cur_line_width_handle, colors::MID_GRAY); 514 515 painter.line_segment([handle_pos_1, handle_pos_2], handle_stroke); 516 painter.circle( 517 circle_center, 518 min_outer_circle_radius, 519 ui.style().visuals.widgets.inactive.weak_bg_fill, 520 circle_stroke, 521 ); 522 523 helper.take_animation_response() 524 } 525 } 526 527 // TODO: convert to responsive button when expanded side panel impl is finished 528 fn expand_side_panel_button() -> impl Widget { 529 |ui: &mut egui::Ui| -> egui::Response { 530 let img_size = 40.0; 531 let img_data = egui::include_image!("../../../../assets/damus_rounded_80.png"); 532 let img = egui::Image::new(img_data).max_width(img_size); 533 534 ui.add(img) 535 } 536 } 537 538 fn support_button() -> impl Widget { 539 |ui: &mut egui::Ui| -> egui::Response { 540 let img_size = 16.0; 541 542 let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget 543 let img_data = if ui.visuals().dark_mode { 544 egui::include_image!("../../../../assets/icons/help_icon_dark_4x.png") 545 } else { 546 egui::include_image!("../../../../assets/icons/help_icon_inverted_4x.png") 547 }; 548 let img = egui::Image::new(img_data).max_width(img_size); 549 550 let helper = AnimationHelper::new(ui, "help-button", vec2(max_size, max_size)); 551 552 let cur_img_size = helper.scale_1d_pos(img_size); 553 img.paint_at( 554 ui, 555 helper 556 .get_animation_rect() 557 .shrink((max_size - cur_img_size) / 2.0), 558 ); 559 560 helper.take_animation_response() 561 } 562 } 563 564 fn add_deck_button() -> impl Widget { 565 |ui: &mut egui::Ui| -> egui::Response { 566 let img_size = 40.0; 567 568 let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget 569 let img_data = egui::include_image!("../../../../assets/icons/new_deck_icon_4x_dark.png"); 570 let img = egui::Image::new(img_data).max_width(img_size); 571 572 let helper = AnimationHelper::new(ui, "new-deck-icon", vec2(max_size, max_size)); 573 574 let cur_img_size = helper.scale_1d_pos(img_size); 575 img.paint_at( 576 ui, 577 helper 578 .get_animation_rect() 579 .shrink((max_size - cur_img_size) / 2.0), 580 ); 581 582 helper.take_animation_response() 583 } 584 } 585 586 fn show_decks<'a>( 587 ui: &mut egui::Ui, 588 decks_cache: &'a DecksCache, 589 selected_account: Option<&'a UserAccount>, 590 ) -> InnerResponse<Option<usize>> { 591 let show_decks_id = ui.id().with("show-decks"); 592 let account_id = if let Some(acc) = selected_account { 593 acc.pubkey 594 } else { 595 *decks_cache.get_fallback_pubkey() 596 }; 597 let (cur_decks, account_id) = ( 598 decks_cache.decks(&account_id), 599 show_decks_id.with(account_id), 600 ); 601 let active_index = cur_decks.active_index(); 602 603 let (_, mut resp) = ui.allocate_exact_size(vec2(0.0, 0.0), egui::Sense::click()); 604 let mut clicked_index = None; 605 for (index, deck) in cur_decks.decks().iter().enumerate() { 606 let highlight = index == active_index; 607 let deck_icon_resp = ui.add(deck_icon( 608 account_id.with(index), 609 Some(deck.icon), 610 DECK_ICON_SIZE, 611 40.0, 612 highlight, 613 )); 614 if deck_icon_resp.clicked() || deck_icon_resp.secondary_clicked() { 615 clicked_index = Some(index); 616 } 617 resp = resp.union(deck_icon_resp); 618 } 619 InnerResponse::new(clicked_index, resp) 620 } 621 622 fn milestone_name() -> impl Widget { 623 |ui: &mut egui::Ui| -> egui::Response { 624 ui.vertical_centered(|ui| { 625 let font = egui::FontId::new( 626 notedeck::fonts::get_font_size( 627 ui.ctx(), 628 &NotedeckTextStyle::Tiny, 629 ), 630 egui::FontFamily::Name(notedeck::fonts::NamedFontFamily::Bold.as_str().into()), 631 ); 632 ui.add(Label::new( 633 RichText::new("ALPHA") 634 .color( ui.style().visuals.noninteractive().fg_stroke.color) 635 .font(font), 636 ).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) 637 }) 638 .inner 639 } 640 }