side_panel.rs (18085B)
1 use egui::{ 2 vec2, CursorIcon, InnerResponse, Layout, Margin, RichText, ScrollArea, Separator, Stroke, 3 Widget, 4 }; 5 use tracing::{error, info}; 6 7 use crate::{ 8 app::{get_active_columns_mut, get_decks_mut}, 9 app_style::DECK_ICON_SIZE, 10 decks::{DecksAction, DecksCache}, 11 nav::SwitchingAction, 12 route::Route, 13 }; 14 15 use notedeck::{tr, Accounts, Localization, MediaJobSender, UserAccount}; 16 use notedeck_ui::{ 17 anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, 18 app_images, colors, ProfilePic, View, 19 }; 20 21 use super::configure_deck::deck_icon; 22 23 pub static SIDE_PANEL_WIDTH: f32 = 68.0; 24 static ICON_WIDTH: f32 = 40.0; 25 26 pub struct DesktopSidePanel<'a> { 27 selected_account: &'a UserAccount, 28 decks_cache: &'a DecksCache, 29 i18n: &'a mut Localization, 30 ndb: &'a nostrdb::Ndb, 31 img_cache: &'a mut notedeck::Images, 32 jobs: &'a MediaJobSender, 33 } 34 35 impl View for DesktopSidePanel<'_> { 36 fn ui(&mut self, ui: &mut egui::Ui) { 37 self.show(ui); 38 } 39 } 40 41 #[derive(Debug, Eq, PartialEq, Clone, Copy)] 42 pub enum SidePanelAction { 43 Columns, 44 ComposeNote, 45 Search, 46 ExpandSidePanel, 47 NewDeck, 48 SwitchDeck(usize), 49 EditDeck(usize), 50 Wallet, 51 ProfileAvatar, 52 } 53 54 pub struct SidePanelResponse { 55 pub response: egui::Response, 56 pub action: SidePanelAction, 57 } 58 59 impl SidePanelResponse { 60 fn new(action: SidePanelAction, response: egui::Response) -> Self { 61 SidePanelResponse { action, response } 62 } 63 } 64 65 impl<'a> DesktopSidePanel<'a> { 66 pub fn new( 67 selected_account: &'a UserAccount, 68 decks_cache: &'a DecksCache, 69 i18n: &'a mut Localization, 70 ndb: &'a nostrdb::Ndb, 71 img_cache: &'a mut notedeck::Images, 72 jobs: &'a MediaJobSender, 73 ) -> Self { 74 Self { 75 selected_account, 76 decks_cache, 77 i18n, 78 ndb, 79 img_cache, 80 jobs, 81 } 82 } 83 84 pub fn show(&mut self, ui: &mut egui::Ui) -> Option<SidePanelResponse> { 85 let frame = 86 egui::Frame::new().inner_margin(Margin::same(notedeck_ui::constants::FRAME_MARGIN)); 87 88 if !ui.visuals().dark_mode { 89 let rect = ui.available_rect_before_wrap(); 90 ui.painter().rect( 91 rect, 92 0, 93 colors::ALMOST_WHITE, 94 egui::Stroke::new(0.0, egui::Color32::TRANSPARENT), 95 egui::StrokeKind::Inside, 96 ); 97 } 98 99 frame.show(ui, |ui| self.show_inner(ui)).inner 100 } 101 102 fn show_inner(&mut self, ui: &mut egui::Ui) -> Option<SidePanelResponse> { 103 let dark_mode = ui.ctx().style().visuals.dark_mode; 104 105 let inner = ui 106 .vertical(|ui| { 107 ui.with_layout(Layout::top_down(egui::Align::Center), |ui| { 108 // macos needs a bit of space to make room for window 109 // minimize/close buttons 110 if cfg!(target_os = "macos") { 111 ui.add_space(24.0); 112 } 113 114 let compose_resp = ui 115 .add(crate::ui::post::compose_note_button(dark_mode)) 116 .on_hover_cursor(egui::CursorIcon::PointingHand); 117 let search_resp = ui.add(search_button()); 118 let column_resp = ui.add(add_column_button()); 119 120 ui.add(Separator::default().horizontal().spacing(8.0).shrink(4.0)); 121 122 ui.add_space(8.0); 123 ui.add(egui::Label::new( 124 RichText::new(tr!( 125 self.i18n, 126 "DECKS", 127 "Label for decks section in side panel" 128 )) 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(self.i18n)); 134 135 let avatar_size = 40.0; 136 let bottom_padding = 8.0; 137 let avatar_section_height = avatar_size + bottom_padding; 138 139 let available_for_decks = ui.available_height() - avatar_section_height; 140 141 let decks_inner = ScrollArea::vertical() 142 .max_height(available_for_decks) 143 .show(ui, |ui| { 144 show_decks(ui, self.decks_cache, self.selected_account) 145 }) 146 .inner; 147 148 let remaining = ui.available_height(); 149 if remaining > avatar_section_height { 150 ui.add_space(remaining - avatar_section_height); 151 } 152 153 let txn = nostrdb::Transaction::new(self.ndb).ok(); 154 let profile_url = if let Some(ref txn) = txn { 155 if let Ok(profile) = self 156 .ndb 157 .get_profile_by_pubkey(txn, self.selected_account.key.pubkey.bytes()) 158 { 159 notedeck::profile::get_profile_url(Some(&profile)) 160 } else { 161 notedeck::profile::no_pfp_url() 162 } 163 } else { 164 notedeck::profile::no_pfp_url() 165 }; 166 167 let pfp_resp = ui 168 .add( 169 &mut ProfilePic::new(self.img_cache, self.jobs, profile_url) 170 .size(avatar_size) 171 .sense(egui::Sense::click()), 172 ) 173 .on_hover_cursor(egui::CursorIcon::PointingHand); 174 175 /* 176 if expand_resp.clicked() { 177 Some(InnerResponse::new( 178 SidePanelAction::ExpandSidePanel, 179 expand_resp, 180 )) 181 */ 182 if pfp_resp.clicked() { 183 Some(InnerResponse::new(SidePanelAction::ProfileAvatar, pfp_resp)) 184 } else if compose_resp.clicked() { 185 Some(InnerResponse::new( 186 SidePanelAction::ComposeNote, 187 compose_resp, 188 )) 189 } else if search_resp.clicked() { 190 Some(InnerResponse::new(SidePanelAction::Search, search_resp)) 191 } else if column_resp.clicked() { 192 Some(InnerResponse::new(SidePanelAction::Columns, column_resp)) 193 } else if add_deck_resp.clicked() { 194 Some(InnerResponse::new(SidePanelAction::NewDeck, add_deck_resp)) 195 } else if decks_inner.response.secondary_clicked() { 196 info!("decks inner secondary click"); 197 if let Some(clicked_index) = decks_inner.inner { 198 Some(InnerResponse::new( 199 SidePanelAction::EditDeck(clicked_index), 200 decks_inner.response, 201 )) 202 } else { 203 None 204 } 205 } else if decks_inner.response.clicked() { 206 if let Some(clicked_index) = decks_inner.inner { 207 Some(InnerResponse::new( 208 SidePanelAction::SwitchDeck(clicked_index), 209 decks_inner.response, 210 )) 211 } else { 212 None 213 } 214 } else { 215 None 216 } 217 }) 218 .inner 219 }) 220 .inner; 221 222 if let Some(inner) = inner { 223 Some(SidePanelResponse::new(inner.inner, inner.response)) 224 } else { 225 None 226 } 227 } 228 229 pub fn perform_action( 230 decks_cache: &mut DecksCache, 231 accounts: &Accounts, 232 action: SidePanelAction, 233 i18n: &mut Localization, 234 ) -> Option<SwitchingAction> { 235 let router = get_active_columns_mut(i18n, accounts, decks_cache).get_selected_router(); 236 let mut switching_response = None; 237 match action { 238 /* 239 SidePanelAction::Panel => {} // TODO 240 SidePanelAction::Account => { 241 if router 242 .routes() 243 .iter() 244 .any(|r| r == &Route::Accounts(AccountsRoute::Accounts)) 245 { 246 // return if we are already routing to accounts 247 router.go_back(); 248 } else { 249 router.route_to(Route::accounts()); 250 } 251 } 252 SidePanelAction::Settings => { 253 if router.routes().iter().any(|r| r == &Route::Relays) { 254 // return if we are already routing to accounts 255 router.go_back(); 256 } else { 257 router.route_to(Route::relays()); 258 } 259 } 260 SidePanelAction::Support => { 261 if router.routes().iter().any(|r| r == &Route::Support) { 262 router.go_back(); 263 } else { 264 support.refresh(); 265 router.route_to(Route::Support); 266 } 267 } 268 */ 269 SidePanelAction::Columns => { 270 if router 271 .routes() 272 .iter() 273 .any(|r| matches!(r, Route::AddColumn(_))) 274 { 275 router.go_back(); 276 } else { 277 get_active_columns_mut(i18n, accounts, decks_cache).new_column_picker(); 278 } 279 } 280 SidePanelAction::ComposeNote => { 281 let can_post = accounts.get_selected_account().key.secret_key.is_some(); 282 283 if !can_post { 284 router.route_to(Route::accounts()); 285 } else if router.routes().iter().any(|r| r == &Route::ComposeNote) { 286 router.go_back(); 287 } else { 288 router.route_to(Route::ComposeNote); 289 } 290 } 291 SidePanelAction::Search => { 292 // TODO 293 if router.top() == &Route::Search { 294 router.go_back(); 295 } else { 296 router.route_to(Route::Search); 297 } 298 } 299 SidePanelAction::ExpandSidePanel => { 300 // TODO 301 info!("Clicked expand side panel button"); 302 } 303 SidePanelAction::NewDeck => { 304 if router.routes().iter().any(|r| r == &Route::NewDeck) { 305 router.go_back(); 306 } else { 307 router.route_to(Route::NewDeck); 308 } 309 } 310 SidePanelAction::SwitchDeck(index) => { 311 switching_response = Some(crate::nav::SwitchingAction::Decks(DecksAction::Switch( 312 index, 313 ))) 314 } 315 SidePanelAction::EditDeck(index) => { 316 if router.routes().iter().any(|r| r == &Route::EditDeck(index)) { 317 router.go_back(); 318 } else { 319 switching_response = Some(crate::nav::SwitchingAction::Decks( 320 DecksAction::Switch(index), 321 )); 322 if let Some(edit_deck) = get_decks_mut(i18n, accounts, decks_cache) 323 .decks_mut() 324 .get_mut(index) 325 { 326 edit_deck 327 .columns_mut() 328 .get_selected_router() 329 .route_to(Route::EditDeck(index)); 330 } else { 331 error!("Cannot push EditDeck route to index {}", index); 332 } 333 } 334 } 335 SidePanelAction::Wallet => 's: { 336 if router 337 .routes() 338 .iter() 339 .any(|r| matches!(r, Route::Wallet(_))) 340 { 341 router.go_back(); 342 break 's; 343 } 344 345 router.route_to(Route::Wallet(notedeck::WalletType::Auto)); 346 } 347 SidePanelAction::ProfileAvatar => { 348 let pubkey = accounts.get_selected_account().key.pubkey; 349 if router.routes().iter().any(|r| r == &Route::profile(pubkey)) { 350 router.go_back(); 351 } else { 352 router.route_to(Route::profile(pubkey)); 353 } 354 } 355 } 356 switching_response 357 } 358 } 359 360 fn add_column_button() -> impl Widget { 361 move |ui: &mut egui::Ui| { 362 let img_size = 24.0; 363 let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget 364 365 let img = if ui.visuals().dark_mode { 366 app_images::add_column_dark_image() 367 } else { 368 app_images::add_column_light_image() 369 }; 370 371 let helper = AnimationHelper::new(ui, "add-column-button", vec2(max_size, max_size)); 372 373 let cur_img_size = helper.scale_1d_pos(img_size); 374 img.paint_at( 375 ui, 376 helper 377 .get_animation_rect() 378 .shrink((max_size - cur_img_size) / 2.0), 379 ); 380 381 helper 382 .take_animation_response() 383 .on_hover_cursor(CursorIcon::PointingHand) 384 .on_hover_text("Add new column") 385 } 386 } 387 388 pub fn search_button_impl(color: egui::Color32, line_width: f32) -> impl Widget { 389 move |ui: &mut egui::Ui| -> egui::Response { 390 let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget 391 let min_line_width_circle = line_width; // width of the magnifying glass 392 let min_line_width_handle = line_width; 393 let helper = AnimationHelper::new(ui, "search-button", vec2(max_size, max_size)); 394 395 let painter = ui.painter_at(helper.get_animation_rect()); 396 397 let cur_line_width_circle = helper.scale_1d_pos(min_line_width_circle); 398 let cur_line_width_handle = helper.scale_1d_pos(min_line_width_handle); 399 let min_outer_circle_radius = helper.scale_radius(15.0); 400 let cur_outer_circle_radius = helper.scale_1d_pos(min_outer_circle_radius); 401 let min_handle_length = 7.0; 402 let cur_handle_length = helper.scale_1d_pos(min_handle_length); 403 404 let circle_center = helper.scale_from_center(-2.0, -2.0); 405 406 let handle_vec = vec2( 407 std::f32::consts::FRAC_1_SQRT_2, 408 std::f32::consts::FRAC_1_SQRT_2, 409 ); 410 411 let handle_pos_1 = circle_center + (handle_vec * (cur_outer_circle_radius - 3.0)); 412 let handle_pos_2 = 413 circle_center + (handle_vec * (cur_outer_circle_radius + cur_handle_length)); 414 415 let circle_stroke = Stroke::new(cur_line_width_circle, color); 416 let handle_stroke = Stroke::new(cur_line_width_handle, color); 417 418 painter.line_segment([handle_pos_1, handle_pos_2], handle_stroke); 419 painter.circle( 420 circle_center, 421 min_outer_circle_radius, 422 ui.style().visuals.widgets.inactive.weak_bg_fill, 423 circle_stroke, 424 ); 425 426 helper 427 .take_animation_response() 428 .on_hover_cursor(CursorIcon::PointingHand) 429 .on_hover_text("Open search") 430 } 431 } 432 433 pub fn search_button() -> impl Widget { 434 search_button_impl(colors::MID_GRAY, 1.5) 435 } 436 437 // TODO: convert to responsive button when expanded side panel impl is finished 438 439 fn add_deck_button<'a>(i18n: &'a mut Localization) -> impl Widget + 'a { 440 |ui: &mut egui::Ui| -> egui::Response { 441 let img_size = 40.0; 442 443 let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget 444 let img = app_images::new_deck_image().max_width(img_size); 445 446 let helper = AnimationHelper::new(ui, "new-deck-icon", vec2(max_size, max_size)); 447 448 let cur_img_size = helper.scale_1d_pos(img_size); 449 img.paint_at( 450 ui, 451 helper 452 .get_animation_rect() 453 .shrink((max_size - cur_img_size) / 2.0), 454 ); 455 456 helper 457 .take_animation_response() 458 .on_hover_cursor(CursorIcon::PointingHand) 459 .on_hover_text(tr!( 460 i18n, 461 "Add new deck", 462 "Tooltip text for adding a new deck button" 463 )) 464 } 465 } 466 467 fn show_decks<'a>( 468 ui: &mut egui::Ui, 469 decks_cache: &'a DecksCache, 470 selected_account: &'a UserAccount, 471 ) -> InnerResponse<Option<usize>> { 472 let show_decks_id = ui.id().with("show-decks"); 473 let account_id = selected_account.key.pubkey; 474 let (cur_decks, account_id) = ( 475 decks_cache.decks(&account_id), 476 show_decks_id.with(account_id), 477 ); 478 let active_index = cur_decks.active_index(); 479 480 let (_, mut resp) = ui.allocate_exact_size(vec2(0.0, 0.0), egui::Sense::click()); 481 let mut clicked_index = None; 482 for (index, deck) in cur_decks.decks().iter().enumerate() { 483 let highlight = index == active_index; 484 let deck_icon_resp = ui 485 .add(deck_icon( 486 account_id.with(index), 487 Some(deck.icon), 488 DECK_ICON_SIZE, 489 40.0, 490 highlight, 491 )) 492 .on_hover_text_at_pointer(&deck.name) 493 .on_hover_cursor(CursorIcon::PointingHand); 494 if deck_icon_resp.clicked() || deck_icon_resp.secondary_clicked() { 495 clicked_index = Some(index); 496 } 497 resp = resp.union(deck_icon_resp); 498 } 499 InnerResponse::new(clicked_index, resp) 500 }