side_panel.rs (29717B)
1 use egui::{ 2 vec2, CursorIcon, InnerResponse, Label, Layout, Margin, RichText, ScrollArea, Separator, 3 Stroke, 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 enostr::RelayStatus; 16 use notedeck::{ 17 tr, Accounts, Localization, MediaJobSender, NotedeckTextStyle, RelayInspectApi, UserAccount, 18 }; 19 use notedeck_ui::{ 20 anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, 21 app_images, colors, ProfilePic, View, 22 }; 23 24 use super::configure_deck::deck_icon; 25 26 pub static SIDE_PANEL_WIDTH: f32 = 68.0; 27 static ICON_WIDTH: f32 = 40.0; 28 29 pub struct DesktopSidePanel<'r, 'a> { 30 selected_account: &'a UserAccount, 31 decks_cache: &'a DecksCache, 32 i18n: &'a mut Localization, 33 ndb: &'a nostrdb::Ndb, 34 img_cache: &'a mut notedeck::Images, 35 jobs: &'a MediaJobSender, 36 current_route: Option<&'a Route>, 37 relay_inspect: RelayInspectApi<'r, 'a>, 38 } 39 40 impl View for DesktopSidePanel<'_, '_> { 41 fn ui(&mut self, ui: &mut egui::Ui) { 42 self.show(ui); 43 } 44 } 45 46 #[derive(Debug, Eq, PartialEq, Clone, Copy)] 47 pub enum SidePanelAction { 48 Home, 49 Columns, 50 ComposeNote, 51 Search, 52 ExpandSidePanel, 53 NewDeck, 54 SwitchDeck(usize), 55 EditDeck(usize), 56 Wallet, 57 Profile, 58 Settings, 59 Relays, 60 Accounts, 61 Support, 62 } 63 64 pub struct SidePanelResponse { 65 pub response: egui::Response, 66 pub action: SidePanelAction, 67 } 68 69 impl SidePanelResponse { 70 fn new(action: SidePanelAction, response: egui::Response) -> Self { 71 SidePanelResponse { action, response } 72 } 73 } 74 75 impl<'r, 'a> DesktopSidePanel<'r, 'a> { 76 #[allow(clippy::too_many_arguments)] 77 pub fn new( 78 selected_account: &'a UserAccount, 79 decks_cache: &'a DecksCache, 80 i18n: &'a mut Localization, 81 ndb: &'a nostrdb::Ndb, 82 img_cache: &'a mut notedeck::Images, 83 jobs: &'a MediaJobSender, 84 current_route: Option<&'a Route>, 85 relay_inspect: RelayInspectApi<'r, 'a>, 86 ) -> Self { 87 Self { 88 selected_account, 89 decks_cache, 90 i18n, 91 ndb, 92 img_cache, 93 jobs, 94 current_route, 95 relay_inspect, 96 } 97 } 98 99 pub fn show(&mut self, ui: &mut egui::Ui) -> Option<SidePanelResponse> { 100 let frame = 101 egui::Frame::new().inner_margin(Margin::same(notedeck_ui::constants::FRAME_MARGIN)); 102 103 if !ui.visuals().dark_mode { 104 let rect = ui.available_rect_before_wrap(); 105 ui.painter().rect( 106 rect, 107 0, 108 colors::ALMOST_WHITE, 109 egui::Stroke::new(0.0, egui::Color32::TRANSPARENT), 110 egui::StrokeKind::Inside, 111 ); 112 } 113 114 frame.show(ui, |ui| self.show_inner(ui)).inner 115 } 116 117 fn show_inner(&mut self, ui: &mut egui::Ui) -> Option<SidePanelResponse> { 118 let avatar_size = 40.0; 119 let bottom_padding = 8.0; 120 let connectivity_indicator_height = 48.0; 121 let is_read_only = self.selected_account.key.secret_key.is_none(); 122 let read_only_label_height = if is_read_only { 16.0 } else { 0.0 }; 123 let avatar_section_height = 124 avatar_size + bottom_padding + read_only_label_height + connectivity_indicator_height; 125 126 ui.vertical(|ui| { 127 #[cfg(target_os = "macos")] 128 ui.add_space(32.0); 129 130 let available_for_scroll = ui.available_height() - avatar_section_height; 131 132 let scroll_out = ScrollArea::vertical() 133 .max_height(available_for_scroll) 134 .show(ui, |ui| { 135 ui.with_layout(Layout::top_down(egui::Align::Center), |ui| { 136 let home_resp = ui.add(home_button()); 137 let compose_resp = ui 138 .add(crate::ui::post::compose_note_button(ui.visuals().dark_mode)) 139 .on_hover_cursor(egui::CursorIcon::PointingHand); 140 let search_resp = ui.add(search_button(self.current_route)); 141 let settings_resp = ui.add(settings_button(self.current_route)); 142 let wallet_resp = ui.add(wallet_button(self.current_route)); 143 144 let profile_resp = ui.add(profile_button( 145 self.current_route, 146 self.selected_account.key.pubkey, 147 )); 148 149 let support_resp = ui.add(support_button(self.current_route)); 150 151 ui.add(Separator::default().horizontal().spacing(8.0).shrink(4.0)); 152 153 ui.add_space(8.0); 154 ui.add(egui::Label::new( 155 RichText::new(tr!( 156 self.i18n, 157 "DECKS", 158 "Label for decks section in side panel" 159 )) 160 .size(11.0) 161 .color(ui.visuals().noninteractive().fg_stroke.color), 162 )); 163 ui.add_space(8.0); 164 165 let column_resp = ui.add(add_column_button()); 166 let add_deck_resp = ui.add(add_deck_button(self.i18n)); 167 168 let decks_inner = show_decks(ui, self.decks_cache, self.selected_account); 169 170 ( 171 home_resp, 172 compose_resp, 173 search_resp, 174 column_resp, 175 settings_resp, 176 profile_resp, 177 wallet_resp, 178 support_resp, 179 add_deck_resp, 180 decks_inner, 181 ) 182 }) 183 }); 184 185 let ( 186 home_resp, 187 compose_resp, 188 search_resp, 189 column_resp, 190 settings_resp, 191 profile_resp, 192 wallet_resp, 193 support_resp, 194 add_deck_resp, 195 decks_inner, 196 ) = scroll_out.inner.inner; 197 198 let remaining = ui.available_height(); 199 if remaining > avatar_section_height { 200 ui.add_space(remaining - avatar_section_height); 201 } 202 203 // Connectivity indicator 204 let connectivity_resp = ui 205 .with_layout(Layout::top_down(egui::Align::Center), |ui| { 206 connectivity_indicator(ui, &self.relay_inspect, self.current_route) 207 }) 208 .inner; 209 210 let pfp_resp = ui 211 .with_layout(Layout::top_down(egui::Align::Center), |ui| { 212 let is_read_only = self.selected_account.key.secret_key.is_none(); 213 214 if is_read_only { 215 ui.add( 216 Label::new( 217 RichText::new(tr!( 218 self.i18n, 219 "Read only", 220 "Label for read-only profile mode" 221 )) 222 .size(notedeck::fonts::get_font_size( 223 ui.ctx(), 224 &NotedeckTextStyle::Tiny, 225 )) 226 .color(ui.visuals().warn_fg_color), 227 ) 228 .selectable(false), 229 ); 230 ui.add_space(4.0); 231 } 232 233 let txn = nostrdb::Transaction::new(self.ndb).ok(); 234 let profile_url = if let Some(ref txn) = txn { 235 if let Ok(profile) = self 236 .ndb 237 .get_profile_by_pubkey(txn, self.selected_account.key.pubkey.bytes()) 238 { 239 notedeck::profile::get_profile_url(Some(&profile)) 240 } else { 241 notedeck::profile::no_pfp_url() 242 } 243 } else { 244 notedeck::profile::no_pfp_url() 245 }; 246 247 let resp = ui 248 .add( 249 &mut ProfilePic::new(self.img_cache, self.jobs, profile_url) 250 .size(avatar_size) 251 .sense(egui::Sense::click()), 252 ) 253 .on_hover_cursor(egui::CursorIcon::PointingHand); 254 255 // Draw border if Accounts route is active 256 let is_accounts_active = self 257 .current_route 258 .is_some_and(|r| matches!(r, Route::Accounts(_))); 259 if is_accounts_active { 260 let rect = resp.rect; 261 let radius = avatar_size / 2.0; 262 ui.painter().circle_stroke( 263 rect.center(), 264 radius + 2.0, 265 Stroke::new(1.5, ui.visuals().text_color()), 266 ); 267 } 268 269 resp 270 }) 271 .inner; 272 273 if connectivity_resp.clicked() { 274 Some(SidePanelResponse::new( 275 SidePanelAction::Relays, 276 connectivity_resp, 277 )) 278 } else if home_resp.clicked() { 279 Some(SidePanelResponse::new(SidePanelAction::Home, home_resp)) 280 } else if pfp_resp.clicked() { 281 Some(SidePanelResponse::new(SidePanelAction::Accounts, pfp_resp)) 282 } else if compose_resp.clicked() { 283 Some(SidePanelResponse::new( 284 SidePanelAction::ComposeNote, 285 compose_resp, 286 )) 287 } else if search_resp.clicked() { 288 Some(SidePanelResponse::new(SidePanelAction::Search, search_resp)) 289 } else if column_resp.clicked() { 290 Some(SidePanelResponse::new( 291 SidePanelAction::Columns, 292 column_resp, 293 )) 294 } else if settings_resp.clicked() { 295 Some(SidePanelResponse::new( 296 SidePanelAction::Settings, 297 settings_resp, 298 )) 299 } else if profile_resp.clicked() { 300 Some(SidePanelResponse::new( 301 SidePanelAction::Profile, 302 profile_resp, 303 )) 304 } else if wallet_resp.clicked() { 305 Some(SidePanelResponse::new(SidePanelAction::Wallet, wallet_resp)) 306 } else if support_resp.clicked() { 307 Some(SidePanelResponse::new( 308 SidePanelAction::Support, 309 support_resp, 310 )) 311 } else if add_deck_resp.clicked() { 312 Some(SidePanelResponse::new( 313 SidePanelAction::NewDeck, 314 add_deck_resp, 315 )) 316 } else if decks_inner.response.secondary_clicked() { 317 info!("decks inner secondary click"); 318 if let Some(clicked_index) = decks_inner.inner { 319 Some(SidePanelResponse::new( 320 SidePanelAction::EditDeck(clicked_index), 321 decks_inner.response, 322 )) 323 } else { 324 None 325 } 326 } else if decks_inner.response.clicked() { 327 if let Some(clicked_index) = decks_inner.inner { 328 Some(SidePanelResponse::new( 329 SidePanelAction::SwitchDeck(clicked_index), 330 decks_inner.response, 331 )) 332 } else { 333 None 334 } 335 } else { 336 None 337 } 338 }) 339 .inner 340 } 341 342 pub fn perform_action( 343 decks_cache: &mut DecksCache, 344 accounts: &Accounts, 345 action: SidePanelAction, 346 i18n: &mut Localization, 347 ) -> Option<SwitchingAction> { 348 let router = get_active_columns_mut(i18n, accounts, decks_cache).get_selected_router(); 349 let mut switching_response = None; 350 match action { 351 SidePanelAction::Home => { 352 let pubkey = accounts.get_selected_account().key.pubkey; 353 let home_route = 354 Route::timeline(crate::timeline::TimelineKind::contact_list(pubkey)); 355 356 if router.top() == &home_route { 357 // TODO: implement scroll to top when already on home route 358 } else { 359 router.route_to(home_route); 360 } 361 } 362 SidePanelAction::Columns => { 363 if router 364 .routes() 365 .iter() 366 .any(|r| matches!(r, Route::AddColumn(_))) 367 { 368 router.go_back(); 369 } else { 370 get_active_columns_mut(i18n, accounts, decks_cache).new_column_picker(); 371 } 372 } 373 SidePanelAction::ComposeNote => { 374 let can_post = accounts.get_selected_account().key.secret_key.is_some(); 375 376 if !can_post { 377 router.route_to(Route::accounts()); 378 } else if router.routes().iter().any(|r| r == &Route::ComposeNote) { 379 router.go_back(); 380 } else { 381 router.route_to(Route::ComposeNote); 382 } 383 } 384 SidePanelAction::Search => { 385 if router.top() == &Route::Search { 386 router.go_back(); 387 } else { 388 router.route_to(Route::Search); 389 } 390 } 391 SidePanelAction::ExpandSidePanel => { 392 info!("Clicked expand side panel button"); 393 } 394 SidePanelAction::NewDeck => { 395 if router.routes().iter().any(|r| r == &Route::NewDeck) { 396 router.go_back(); 397 } else { 398 router.route_to(Route::NewDeck); 399 } 400 } 401 SidePanelAction::SwitchDeck(index) => { 402 switching_response = Some(crate::nav::SwitchingAction::Decks(DecksAction::Switch( 403 index, 404 ))) 405 } 406 SidePanelAction::EditDeck(index) => { 407 if router.routes().iter().any(|r| r == &Route::EditDeck(index)) { 408 router.go_back(); 409 } else { 410 switching_response = Some(crate::nav::SwitchingAction::Decks( 411 DecksAction::Switch(index), 412 )); 413 if let Some(edit_deck) = get_decks_mut(i18n, accounts, decks_cache) 414 .decks_mut() 415 .get_mut(index) 416 { 417 edit_deck 418 .columns_mut() 419 .get_selected_router() 420 .route_to(Route::EditDeck(index)); 421 } else { 422 error!("Cannot push EditDeck route to index {}", index); 423 } 424 } 425 } 426 SidePanelAction::Wallet => 's: { 427 if router 428 .routes() 429 .iter() 430 .any(|r| matches!(r, Route::Wallet(_))) 431 { 432 router.go_back(); 433 break 's; 434 } 435 436 router.route_to(Route::Wallet(notedeck::WalletType::Auto)); 437 } 438 SidePanelAction::Profile => { 439 let pubkey = accounts.get_selected_account().key.pubkey; 440 if router.routes().iter().any(|r| r == &Route::profile(pubkey)) { 441 router.go_back(); 442 } else { 443 router.route_to(Route::profile(pubkey)); 444 } 445 } 446 SidePanelAction::Settings => { 447 if router.routes().iter().any(|r| r == &Route::Settings) { 448 router.go_back(); 449 } else { 450 router.route_to(Route::Settings); 451 } 452 } 453 SidePanelAction::Relays => { 454 if router.routes().iter().any(|r| r == &Route::Relays) { 455 router.go_back(); 456 } else { 457 router.route_to(Route::relays()); 458 } 459 } 460 SidePanelAction::Accounts => { 461 if router 462 .routes() 463 .iter() 464 .any(|r| matches!(r, Route::Accounts(_))) 465 { 466 router.go_back(); 467 } else { 468 router.route_to(Route::accounts()); 469 } 470 } 471 SidePanelAction::Support => { 472 if router.routes().iter().any(|r| r == &Route::Support) { 473 router.go_back(); 474 } else { 475 router.route_to(Route::Support); 476 } 477 } 478 } 479 switching_response 480 } 481 } 482 483 fn add_column_button() -> impl Widget { 484 move |ui: &mut egui::Ui| { 485 let img_size = 24.0; 486 let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget 487 488 let img = if ui.visuals().dark_mode { 489 app_images::add_column_dark_image() 490 } else { 491 app_images::add_column_light_image() 492 }; 493 494 let helper = AnimationHelper::new(ui, "add-column-button", vec2(max_size, max_size)); 495 496 let cur_img_size = helper.scale_1d_pos(img_size); 497 img.paint_at( 498 ui, 499 helper 500 .get_animation_rect() 501 .shrink((max_size - cur_img_size) / 2.0), 502 ); 503 504 helper 505 .take_animation_response() 506 .on_hover_cursor(CursorIcon::PointingHand) 507 .on_hover_text("Add new column") 508 } 509 } 510 511 pub fn search_button_impl(color: egui::Color32, line_width: f32, is_active: bool) -> impl Widget { 512 notedeck_ui::icons::search_button(color, line_width, is_active) 513 } 514 515 pub fn search_button(current_route: Option<&Route>) -> impl Widget + '_ { 516 let is_active = matches!(current_route, Some(Route::Search)); 517 move |ui: &mut egui::Ui| { 518 let icon_color = notedeck_ui::side_panel_icon_tint(ui); 519 search_button_impl(icon_color, 1.5, is_active).ui(ui) 520 } 521 } 522 523 // TODO: convert to responsive button when expanded side panel impl is finished 524 525 fn add_deck_button<'a>(i18n: &'a mut Localization) -> impl Widget + 'a { 526 |ui: &mut egui::Ui| -> egui::Response { 527 let img_size = 40.0; 528 529 let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget 530 let img = app_images::new_deck_image().max_width(img_size); 531 532 let helper = AnimationHelper::new(ui, "new-deck-icon", vec2(max_size, max_size)); 533 534 let cur_img_size = helper.scale_1d_pos(img_size); 535 img.paint_at( 536 ui, 537 helper 538 .get_animation_rect() 539 .shrink((max_size - cur_img_size) / 2.0), 540 ); 541 542 helper 543 .take_animation_response() 544 .on_hover_cursor(CursorIcon::PointingHand) 545 .on_hover_text(tr!( 546 i18n, 547 "Add new deck", 548 "Tooltip text for adding a new deck button" 549 )) 550 } 551 } 552 553 fn show_decks<'a>( 554 ui: &mut egui::Ui, 555 decks_cache: &'a DecksCache, 556 selected_account: &'a UserAccount, 557 ) -> InnerResponse<Option<usize>> { 558 let show_decks_id = ui.id().with("show-decks"); 559 let account_id = selected_account.key.pubkey; 560 let (cur_decks, account_id) = ( 561 decks_cache.decks(&account_id), 562 show_decks_id.with(account_id), 563 ); 564 let active_index = cur_decks.active_index(); 565 566 let (_, mut resp) = ui.allocate_exact_size(vec2(0.0, 0.0), egui::Sense::click()); 567 let mut clicked_index = None; 568 for (index, deck) in cur_decks.decks().iter().enumerate() { 569 let highlight = index == active_index; 570 let deck_icon_resp = ui 571 .add(deck_icon( 572 account_id.with(index), 573 Some(deck.icon), 574 DECK_ICON_SIZE, 575 40.0, 576 highlight, 577 )) 578 .on_hover_text_at_pointer(&deck.name) 579 .on_hover_cursor(CursorIcon::PointingHand); 580 if deck_icon_resp.clicked() || deck_icon_resp.secondary_clicked() { 581 clicked_index = Some(index); 582 } 583 resp = resp.union(deck_icon_resp); 584 } 585 InnerResponse::new(clicked_index, resp) 586 } 587 588 fn settings_button(current_route: Option<&Route>) -> impl Widget + '_ { 589 let is_active = matches!(current_route, Some(Route::Settings)); 590 move |ui: &mut egui::Ui| { 591 let img_size = 24.0; 592 let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; 593 let helper = AnimationHelper::new(ui, "settings-button", vec2(max_size, max_size)); 594 595 let painter = ui.painter_at(helper.get_animation_rect()); 596 if is_active { 597 let circle_radius = max_size / 2.0; 598 painter.circle( 599 helper.get_animation_rect().center(), 600 circle_radius, 601 notedeck_ui::side_panel_active_bg(ui), 602 Stroke::NONE, 603 ); 604 } 605 606 let img = if ui.visuals().dark_mode { 607 app_images::settings_dark_image() 608 } else { 609 app_images::settings_light_image() 610 }; 611 let cur_img_size = helper.scale_1d_pos(img_size); 612 img.paint_at( 613 ui, 614 helper 615 .get_animation_rect() 616 .shrink((max_size - cur_img_size) / 2.0), 617 ); 618 helper 619 .take_animation_response() 620 .on_hover_cursor(CursorIcon::PointingHand) 621 .on_hover_text("Settings") 622 } 623 } 624 625 fn profile_button(current_route: Option<&Route>, pubkey: enostr::Pubkey) -> impl Widget + '_ { 626 let is_active = matches!( 627 current_route, 628 Some(Route::Timeline(crate::timeline::TimelineKind::Profile(pk))) if *pk == pubkey 629 ); 630 move |ui: &mut egui::Ui| { 631 let img_size = 24.0; 632 let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; 633 let helper = AnimationHelper::new(ui, "profile-button", vec2(max_size, max_size)); 634 635 let painter = ui.painter_at(helper.get_animation_rect()); 636 if is_active { 637 let circle_radius = max_size / 2.0; 638 painter.circle( 639 helper.get_animation_rect().center(), 640 circle_radius, 641 notedeck_ui::side_panel_active_bg(ui), 642 Stroke::NONE, 643 ); 644 } 645 646 let img = app_images::profile_image().tint(notedeck_ui::side_panel_icon_tint(ui)); 647 let cur_img_size = helper.scale_1d_pos(img_size); 648 img.paint_at( 649 ui, 650 helper 651 .get_animation_rect() 652 .shrink((max_size - cur_img_size) / 2.0), 653 ); 654 helper 655 .take_animation_response() 656 .on_hover_cursor(CursorIcon::PointingHand) 657 .on_hover_text("Profile") 658 } 659 } 660 661 fn wallet_button(current_route: Option<&Route>) -> impl Widget + '_ { 662 let is_active = matches!(current_route, Some(Route::Wallet(_))); 663 move |ui: &mut egui::Ui| { 664 let img_size = 24.0; 665 let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; 666 let helper = AnimationHelper::new(ui, "wallet-button", vec2(max_size, max_size)); 667 668 let painter = ui.painter_at(helper.get_animation_rect()); 669 if is_active { 670 let circle_radius = max_size / 2.0; 671 painter.circle( 672 helper.get_animation_rect().center(), 673 circle_radius, 674 notedeck_ui::side_panel_active_bg(ui), 675 Stroke::NONE, 676 ); 677 } 678 679 let img = if ui.visuals().dark_mode { 680 app_images::wallet_dark_image() 681 } else { 682 app_images::wallet_light_image() 683 }; 684 let cur_img_size = helper.scale_1d_pos(img_size); 685 img.paint_at( 686 ui, 687 helper 688 .get_animation_rect() 689 .shrink((max_size - cur_img_size) / 2.0), 690 ); 691 helper 692 .take_animation_response() 693 .on_hover_cursor(CursorIcon::PointingHand) 694 .on_hover_text("Wallet") 695 } 696 } 697 698 fn support_button(current_route: Option<&Route>) -> impl Widget + '_ { 699 let is_active = matches!(current_route, Some(Route::Support)); 700 move |ui: &mut egui::Ui| { 701 let img_size = 24.0; 702 let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; 703 let helper = AnimationHelper::new(ui, "support-button", vec2(max_size, max_size)); 704 705 let painter = ui.painter_at(helper.get_animation_rect()); 706 if is_active { 707 let circle_radius = max_size / 2.0; 708 painter.circle( 709 helper.get_animation_rect().center(), 710 circle_radius, 711 notedeck_ui::side_panel_active_bg(ui), 712 Stroke::NONE, 713 ); 714 } 715 716 let img = if ui.visuals().dark_mode { 717 app_images::help_dark_image() 718 } else { 719 app_images::help_light_image() 720 }; 721 let cur_img_size = helper.scale_1d_pos(img_size); 722 img.paint_at( 723 ui, 724 helper 725 .get_animation_rect() 726 .shrink((max_size - cur_img_size) / 2.0), 727 ); 728 helper 729 .take_animation_response() 730 .on_hover_cursor(CursorIcon::PointingHand) 731 .on_hover_text("Support") 732 } 733 } 734 735 fn home_button() -> impl Widget { 736 |ui: &mut egui::Ui| { 737 let img_size = 32.0; 738 let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; 739 let helper = AnimationHelper::new(ui, "home-button", vec2(max_size, max_size)); 740 741 let img = app_images::damus_image(); 742 let cur_img_size = helper.scale_1d_pos(img_size); 743 img.paint_at( 744 ui, 745 helper 746 .get_animation_rect() 747 .shrink((max_size - cur_img_size) / 2.0), 748 ); 749 helper 750 .take_animation_response() 751 .on_hover_cursor(CursorIcon::PointingHand) 752 .on_hover_text("Home") 753 } 754 } 755 fn connectivity_indicator( 756 ui: &mut egui::Ui, 757 relay_inspect: &RelayInspectApi<'_, '_>, 758 _current_route: Option<&Route>, 759 ) -> egui::Response { 760 let relay_infos = relay_inspect.relay_infos(); 761 let connected_count = relay_infos 762 .iter() 763 .filter(|info| matches!(info.status, RelayStatus::Connected)) 764 .count(); 765 let total_count = relay_infos.len(); 766 767 // Calculate connectivity ratio (0.0 to 1.0) 768 let ratio = if total_count > 0 { 769 connected_count as f32 / total_count as f32 770 } else { 771 0.0 772 }; 773 774 // Color based on ratio: red (0%) -> yellow (50%) -> green (100%) 775 let active_color = if ratio < 0.5 { 776 // Red to yellow: interpolate from red (255,102,102) to yellow (255,204,102) 777 let t = ratio * 2.0; 778 egui::Color32::from_rgb(0xFF, 0x66 + (0x66 as f32 * t) as u8, 0x66) 779 } else { 780 // Yellow to green: interpolate from yellow (255,204,102) to green (102,204,102) 781 let t = (ratio - 0.5) * 2.0; 782 egui::Color32::from_rgb(0xFF - (0x99 as f32 * t) as u8, 0xCC, 0x66) 783 }; 784 785 let inactive_color = if ui.visuals().dark_mode { 786 egui::Color32::from_rgb(60, 60, 60) 787 } else { 788 egui::Color32::from_rgb(200, 200, 200) 789 }; 790 791 let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; 792 let helper = AnimationHelper::new(ui, "connectivity-indicator", vec2(max_size, max_size)); 793 794 let painter = ui.painter_at(helper.get_animation_rect()); 795 let rect = helper.get_animation_rect(); 796 let center = rect.center(); 797 798 let bar_width = 3.0; 799 let bar_spacing = 2.0; 800 let num_bars = 4; 801 802 let base_y = center.y + 6.0; 803 let total_width = (num_bars as f32) * bar_width + ((num_bars - 1) as f32) * bar_spacing; 804 let start_x = center.x - total_width / 2.0; 805 806 // Progressive bar heights (short to tall) 807 let bar_heights = [4.0, 7.0, 10.0, 13.0]; 808 809 // Determine how many bars should be active based on ratio 810 let active_bars = if connected_count == 0 { 811 0 812 } else { 813 // At least 1 bar if connected, scale up to 4 814 ((ratio * (num_bars as f32)).ceil() as usize) 815 .max(1) 816 .min(num_bars) 817 }; 818 819 for (i, &height) in bar_heights.iter().enumerate() { 820 let x = start_x + (i as f32) * (bar_width + bar_spacing); 821 let bar_rect = 822 egui::Rect::from_min_size(egui::pos2(x, base_y - height), vec2(bar_width, height)); 823 824 let color = if i < active_bars { 825 active_color 826 } else { 827 inactive_color 828 }; 829 painter.rect_filled(bar_rect, 1.0, color); 830 } 831 832 helper 833 .take_animation_response() 834 .on_hover_cursor(CursorIcon::PointingHand) 835 .on_hover_text(format!( 836 "{}/{} relays connected", 837 connected_count, total_count 838 )) 839 }