chrome.rs (44338B)
1 // Entry point for wasm 2 //#[cfg(target_arch = "wasm32")] 3 //use wasm_bindgen::prelude::*; 4 use crate::app::NotedeckApp; 5 use crate::ChromeOptions; 6 use bitflags::bitflags; 7 use eframe::CreationContext; 8 use egui::{ 9 vec2, Color32, CornerRadius, Label, Layout, Margin, Rect, RichText, Sense, ThemePreference, Ui, 10 Widget, 11 }; 12 use egui_extras::{Size, StripBuilder}; 13 use egui_nav::RouteResponse; 14 use egui_nav::{NavAction, NavDrawer}; 15 use nostrdb::{ProfileRecord, Transaction}; 16 use notedeck::fonts::get_font_size; 17 use notedeck::name::get_display_name; 18 use notedeck::ui::is_compiled_as_mobile; 19 use notedeck::AppResponse; 20 use notedeck::DrawerRouter; 21 use notedeck::Error; 22 use notedeck::SoftKeyboardContext; 23 use notedeck::{ 24 tr, App, AppAction, AppContext, Localization, Notedeck, NotedeckOptions, NotedeckTextStyle, 25 UserAccount, WalletType, 26 }; 27 use notedeck_columns::{timeline::TimelineKind, Damus}; 28 use notedeck_dave::{Dave, DaveAvatar}; 29 30 #[cfg(feature = "messages")] 31 use notedeck_messages::MessagesApp; 32 33 #[cfg(feature = "dashboard")] 34 use notedeck_dashboard::Dashboard; 35 36 #[cfg(feature = "clndash")] 37 use notedeck_ui::expanding_button; 38 39 use notedeck_ui::{app_images, galley_centered_pos, ProfilePic}; 40 use std::collections::HashMap; 41 42 #[derive(Default)] 43 pub struct Chrome { 44 active: i32, 45 options: ChromeOptions, 46 apps: Vec<NotedeckApp>, 47 48 /// The state of the soft keyboard animation 49 soft_kb_anim_state: AnimState, 50 51 pub repaint_causes: HashMap<egui::RepaintCause, u64>, 52 nav: DrawerRouter, 53 } 54 55 #[derive(Clone)] 56 enum ChromeRoute { 57 Chrome, 58 App, 59 } 60 61 pub enum ChromePanelAction { 62 Support, 63 Settings, 64 Account, 65 Wallet, 66 SaveTheme(ThemePreference), 67 Profile(notedeck::enostr::Pubkey), 68 } 69 70 bitflags! { 71 #[repr(transparent)] 72 #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] 73 pub struct SidebarOptions: u8 { 74 const Compact = 1 << 0; 75 } 76 } 77 78 impl ChromePanelAction { 79 fn columns_navigate(ctx: &mut AppContext, chrome: &mut Chrome, route: notedeck_columns::Route) { 80 chrome.switch_to_columns(); 81 82 if let Some(c) = chrome.get_columns_app().and_then(|columns| { 83 columns 84 .decks_cache 85 .selected_column_mut(ctx.i18n, ctx.accounts) 86 }) { 87 if c.router().routes().iter().any(|r| r == &route) { 88 // return if we are already routing to accounts 89 c.router_mut().go_back(); 90 } else { 91 c.router_mut().route_to(route); 92 //c..route_to(Route::relays()); 93 } 94 }; 95 } 96 97 #[profiling::function] 98 fn process(&self, ctx: &mut AppContext, chrome: &mut Chrome, ui: &mut egui::Ui) { 99 match self { 100 Self::SaveTheme(theme) => { 101 ui.ctx().set_theme(*theme); 102 ctx.settings.set_theme(*theme); 103 } 104 105 Self::Support => { 106 Self::columns_navigate(ctx, chrome, notedeck_columns::Route::Support); 107 } 108 109 Self::Account => { 110 Self::columns_navigate(ctx, chrome, notedeck_columns::Route::accounts()); 111 } 112 113 Self::Settings => { 114 Self::columns_navigate(ctx, chrome, notedeck_columns::Route::Settings); 115 } 116 117 Self::Wallet => { 118 Self::columns_navigate( 119 ctx, 120 chrome, 121 notedeck_columns::Route::Wallet(WalletType::Auto), 122 ); 123 } 124 Self::Profile(pk) => { 125 columns_route_to_profile(pk, chrome, ctx, ui); 126 } 127 } 128 } 129 } 130 131 /// Some people have been running notedeck in debug, let's catch that! 132 fn stop_debug_mode(options: NotedeckOptions) { 133 if !options.contains(NotedeckOptions::Tests) 134 && cfg!(debug_assertions) 135 && !options.contains(NotedeckOptions::Debug) 136 { 137 println!("--- WELCOME TO DAMUS NOTEDECK! ---"); 138 println!( 139 "It looks like are running notedeck in debug mode, unless you are a developer, this is not likely what you want." 140 ); 141 println!("If you are a developer, run `cargo run -- --debug` to skip this message."); 142 println!("For everyone else, try again with `cargo run --release`. Enjoy!"); 143 println!("---------------------------------"); 144 panic!(); 145 } 146 } 147 148 impl Chrome { 149 /// Create a new chrome with the default app setup 150 pub fn new_with_apps( 151 cc: &CreationContext, 152 app_args: &[String], 153 notedeck: &mut Notedeck, 154 ) -> Result<Self, Error> { 155 stop_debug_mode(notedeck.options()); 156 157 let context = &mut notedeck.app_context(); 158 let dave = Dave::new( 159 cc.wgpu_render_state.as_ref(), 160 context.ndb.clone(), 161 cc.egui_ctx.clone(), 162 ); 163 let mut chrome = Chrome::default(); 164 165 if !app_args.iter().any(|arg| arg == "--no-columns-app") { 166 let columns = Damus::new(context, app_args); 167 notedeck.check_args(columns.unrecognized_args())?; 168 chrome.add_app(NotedeckApp::Columns(Box::new(columns))); 169 } 170 171 chrome.add_app(NotedeckApp::Dave(Box::new(dave))); 172 173 #[cfg(feature = "messages")] 174 chrome.add_app(NotedeckApp::Messages(Box::new(MessagesApp::new()))); 175 176 #[cfg(feature = "dashboard")] 177 chrome.add_app(NotedeckApp::Dashboard(Box::new(Dashboard::default()))); 178 179 #[cfg(feature = "notebook")] 180 chrome.add_app(NotedeckApp::Notebook(Box::default())); 181 182 #[cfg(feature = "clndash")] 183 chrome.add_app(NotedeckApp::ClnDash(Box::default())); 184 185 chrome.set_active(0); 186 187 Ok(chrome) 188 } 189 190 pub fn toggle(&mut self) { 191 if self.nav.drawer_focused { 192 self.nav.close(); 193 } else { 194 self.nav.open(); 195 } 196 } 197 198 pub fn add_app(&mut self, app: NotedeckApp) { 199 self.apps.push(app); 200 } 201 202 fn get_columns_app(&mut self) -> Option<&mut Damus> { 203 for app in &mut self.apps { 204 if let NotedeckApp::Columns(cols) = app { 205 return Some(cols); 206 } 207 } 208 209 None 210 } 211 212 fn switch_to_columns(&mut self) { 213 for (i, app) in self.apps.iter().enumerate() { 214 if let NotedeckApp::Columns(_) = app { 215 self.active = i as i32; 216 } 217 } 218 } 219 220 pub fn set_active(&mut self, app: i32) { 221 self.active = app; 222 } 223 224 /// The chrome side panel 225 #[profiling::function] 226 fn panel( 227 &mut self, 228 app_ctx: &mut AppContext, 229 ui: &mut egui::Ui, 230 amt_keyboard_open: f32, 231 ) -> Option<ChromePanelAction> { 232 let drawer = NavDrawer::new(&ChromeRoute::App, &ChromeRoute::Chrome) 233 .navigating(self.nav.navigating) 234 .returning(self.nav.returning) 235 .drawer_focused(self.nav.drawer_focused) 236 .drag(is_compiled_as_mobile()) 237 .opened_offset(240.0); 238 239 let resp = drawer.show_mut(ui, |ui, route| match route { 240 ChromeRoute::Chrome => { 241 ui.painter().rect_filled( 242 ui.available_rect_before_wrap(), 243 CornerRadius::ZERO, 244 if ui.visuals().dark_mode { 245 egui::Color32::BLACK 246 } else { 247 egui::Color32::WHITE 248 }, 249 ); 250 egui::Frame::new() 251 .inner_margin(Margin::same(16)) 252 .show(ui, |ui| { 253 let options = if amt_keyboard_open > 0.0 { 254 SidebarOptions::Compact 255 } else { 256 SidebarOptions::default() 257 }; 258 259 let response = ui 260 .with_layout(Layout::top_down(egui::Align::Min), |ui| { 261 topdown_sidebar(self, app_ctx, ui, options) 262 }) 263 .inner; 264 265 ui.with_layout(Layout::bottom_up(egui::Align::Center), |ui| { 266 ui.add(milestone_name(app_ctx.i18n)); 267 }); 268 269 RouteResponse { 270 response, 271 can_take_drag_from: Vec::new(), 272 } 273 }) 274 .inner 275 } 276 ChromeRoute::App => { 277 let resp = self.apps[self.active as usize].update(app_ctx, ui); 278 279 if let Some(action) = resp.action { 280 chrome_handle_app_action(self, app_ctx, action, ui); 281 } 282 283 RouteResponse { 284 response: None, 285 can_take_drag_from: resp.can_take_drag_from, 286 } 287 } 288 }); 289 290 if let Some(action) = resp.action { 291 if matches!(action, NavAction::Returned(_)) { 292 self.nav.closed(); 293 } else if let NavAction::Navigating = action { 294 self.nav.navigating = false; 295 } else if let NavAction::Navigated = action { 296 self.nav.opened(); 297 } 298 } 299 300 resp.drawer_response? 301 } 302 303 /// Show the side menu or bar, depending on if we're on a narrow 304 /// or wide screen. 305 /// 306 /// The side menu should hover over the screen, while the side bar 307 /// is collapsible but persistent on the screen. 308 #[profiling::function] 309 fn show(&mut self, ctx: &mut AppContext, ui: &mut egui::Ui) -> Option<ChromePanelAction> { 310 ui.spacing_mut().item_spacing.x = 0.0; 311 312 let skb_anim = 313 keyboard_visibility(ui, ctx, &mut self.options, &mut self.soft_kb_anim_state); 314 315 let virtual_keyboard = self.options.contains(ChromeOptions::VirtualKeyboard); 316 let keyboard_height = if self.options.contains(ChromeOptions::KeyboardVisibility) { 317 skb_anim.anim_height 318 } else { 319 0.0 320 }; 321 322 // if the soft keyboard is open, shrink the chrome contents 323 let mut action: Option<ChromePanelAction> = None; 324 // build a strip to carve out the soft keyboard inset 325 let prev_spacing = ui.spacing().item_spacing; 326 ui.spacing_mut().item_spacing.y = 0.0; 327 StripBuilder::new(ui) 328 .size(Size::remainder()) 329 .size(Size::exact(keyboard_height)) 330 .vertical(|mut strip| { 331 // the actual content, shifted up because of the soft keyboard 332 strip.cell(|ui| { 333 ui.spacing_mut().item_spacing = prev_spacing; 334 action = self.panel(ctx, ui, keyboard_height); 335 }); 336 337 // the filler space taken up by the soft keyboard 338 strip.cell(|ui| { 339 // keyboard-visibility virtual keyboard 340 if virtual_keyboard && keyboard_height > 0.0 { 341 virtual_keyboard_ui(ui, ui.available_rect_before_wrap()) 342 } 343 }); 344 }); 345 346 // hovering virtual keyboard 347 if virtual_keyboard { 348 if let Some(mut kb_rect) = skb_anim.skb_rect { 349 let kb_height = if self.options.contains(ChromeOptions::KeyboardVisibility) { 350 keyboard_height 351 } else { 352 400.0 353 }; 354 kb_rect.min.y = kb_rect.max.y - kb_height; 355 tracing::debug!("hovering virtual kb_height:{keyboard_height} kb_rect:{kb_rect}"); 356 virtual_keyboard_ui(ui, kb_rect) 357 } 358 } 359 360 action 361 } 362 } 363 364 impl notedeck::App for Chrome { 365 fn update(&mut self, ctx: &mut notedeck::AppContext, ui: &mut egui::Ui) -> AppResponse { 366 #[cfg(feature = "tracy")] 367 { 368 ui.ctx().request_repaint(); 369 } 370 371 if let Some(action) = self.show(ctx, ui) { 372 action.process(ctx, self, ui); 373 self.nav.close(); 374 } 375 // TODO: unify this constant with the columns side panel width. ui crate? 376 AppResponse::none() 377 } 378 } 379 380 fn milestone_name<'a>(i18n: &'a mut Localization) -> impl Widget + 'a { 381 let text = if notedeck::ui::is_compiled_as_mobile() { 382 tr!( 383 i18n, 384 "Damus Android BETA", 385 "Damus android beta version label" 386 ) 387 } else { 388 tr!( 389 i18n, 390 "Damus Notedeck BETA", 391 "Damus notedeck beta version label" 392 ) 393 }; 394 395 |ui: &mut egui::Ui| -> egui::Response { 396 let font = egui::FontId::new( 397 notedeck::fonts::get_font_size(ui.ctx(), &NotedeckTextStyle::Tiny), 398 egui::FontFamily::Name(notedeck::fonts::NamedFontFamily::Bold.as_str().into()), 399 ); 400 ui.add( 401 Label::new( 402 RichText::new(text) 403 .color(ui.style().visuals.noninteractive().fg_stroke.color) 404 .font(font), 405 ) 406 .selectable(false), 407 ) 408 .on_hover_text(tr!( 409 i18n, 410 "Notedeck is a beta product. Expect bugs and contact us when you run into issues.", 411 "Beta product warning message" 412 )) 413 .on_hover_cursor(egui::CursorIcon::Help) 414 } 415 } 416 417 #[cfg(feature = "clndash")] 418 fn clndash_button(ui: &mut egui::Ui) -> egui::Response { 419 notedeck_ui::expanding_button( 420 "clndash-button", 421 24.0, 422 app_images::cln_image(), 423 app_images::cln_image(), 424 ui, 425 false, 426 ) 427 } 428 429 #[cfg(feature = "notebook")] 430 fn notebook_button(ui: &mut egui::Ui) -> egui::Response { 431 notedeck_ui::expanding_button( 432 "notebook-button", 433 40.0, 434 app_images::algo_image(), 435 app_images::algo_image(), 436 ui, 437 false, 438 ) 439 } 440 441 fn dave_button(avatar: Option<&mut DaveAvatar>, ui: &mut egui::Ui, rect: Rect) -> egui::Response { 442 if let Some(avatar) = avatar { 443 avatar.render(rect, ui) 444 } else { 445 // plain icon if wgpu device not available?? 446 ui.label("fixme") 447 } 448 } 449 450 pub fn get_profile_url_owned(profile: Option<ProfileRecord<'_>>) -> &str { 451 if let Some(url) = profile.and_then(|pr| pr.record().profile().and_then(|p| p.picture())) { 452 url 453 } else { 454 notedeck::profile::no_pfp_url() 455 } 456 } 457 458 pub fn get_account_url<'a>( 459 txn: &'a nostrdb::Transaction, 460 ndb: &nostrdb::Ndb, 461 account: &UserAccount, 462 ) -> &'a str { 463 if let Ok(profile) = ndb.get_profile_by_pubkey(txn, account.key.pubkey.bytes()) { 464 get_profile_url_owned(Some(profile)) 465 } else { 466 get_profile_url_owned(None) 467 } 468 } 469 470 fn chrome_handle_app_action( 471 chrome: &mut Chrome, 472 ctx: &mut AppContext, 473 action: AppAction, 474 ui: &mut egui::Ui, 475 ) { 476 match action { 477 AppAction::ToggleChrome => { 478 chrome.toggle(); 479 } 480 481 AppAction::Note(note_action) => { 482 chrome.switch_to_columns(); 483 let Some(columns) = chrome.get_columns_app() else { 484 return; 485 }; 486 487 let txn = Transaction::new(ctx.ndb).unwrap(); 488 489 let cols = columns 490 .decks_cache 491 .active_columns_mut(ctx.i18n, ctx.accounts) 492 .unwrap(); 493 let m_action = notedeck_columns::actionbar::execute_and_process_note_action( 494 note_action, 495 ctx.ndb, 496 cols, 497 0, 498 &mut columns.timeline_cache, 499 &mut columns.threads, 500 ctx.note_cache, 501 ctx.pool, 502 &txn, 503 ctx.unknown_ids, 504 ctx.accounts, 505 ctx.global_wallet, 506 ctx.zaps, 507 ctx.img_cache, 508 &mut columns.view_state, 509 ctx.media_jobs.sender(), 510 ui, 511 ); 512 513 if let Some(action) = m_action { 514 let col = cols.selected_mut(); 515 516 action.process_router_action(&mut col.router, &mut col.sheet_router); 517 } 518 } 519 } 520 } 521 522 fn columns_route_to_profile( 523 pk: ¬edeck::enostr::Pubkey, 524 chrome: &mut Chrome, 525 ctx: &mut AppContext, 526 ui: &mut egui::Ui, 527 ) { 528 chrome.switch_to_columns(); 529 let Some(columns) = chrome.get_columns_app() else { 530 return; 531 }; 532 533 let cols = columns 534 .decks_cache 535 .active_columns_mut(ctx.i18n, ctx.accounts) 536 .unwrap(); 537 538 let router = cols.get_selected_router(); 539 if router.routes().iter().any(|r| { 540 matches!( 541 r, 542 notedeck_columns::Route::Timeline(TimelineKind::Profile(_)) 543 ) 544 }) { 545 router.go_back(); 546 return; 547 } 548 549 let txn = Transaction::new(ctx.ndb).unwrap(); 550 let m_action = notedeck_columns::actionbar::execute_and_process_note_action( 551 notedeck::NoteAction::Profile(*pk), 552 ctx.ndb, 553 cols, 554 0, 555 &mut columns.timeline_cache, 556 &mut columns.threads, 557 ctx.note_cache, 558 ctx.pool, 559 &txn, 560 ctx.unknown_ids, 561 ctx.accounts, 562 ctx.global_wallet, 563 ctx.zaps, 564 ctx.img_cache, 565 &mut columns.view_state, 566 ctx.media_jobs.sender(), 567 ui, 568 ); 569 570 if let Some(action) = m_action { 571 let col = cols.selected_mut(); 572 573 action.process_router_action(&mut col.router, &mut col.sheet_router); 574 } 575 } 576 577 /// The section of the chrome sidebar that starts at the 578 /// bottom and goes up 579 fn topdown_sidebar( 580 chrome: &mut Chrome, 581 ctx: &mut AppContext, 582 ui: &mut egui::Ui, 583 options: SidebarOptions, 584 ) -> Option<ChromePanelAction> { 585 let previous_spacing = ui.spacing().item_spacing; 586 ui.spacing_mut().item_spacing.y = 12.0; 587 588 let loc = &mut ctx.i18n; 589 590 // macos needs a bit of space to make room for window 591 // minimize/close buttons 592 if cfg!(target_os = "macos") { 593 ui.add_space(8.0); 594 } 595 596 let txn = Transaction::new(ctx.ndb).expect("should be able to create txn"); 597 let profile = ctx 598 .ndb 599 .get_profile_by_pubkey(&txn, ctx.accounts.get_selected_account().key.pubkey.bytes()); 600 601 let disp_name = get_display_name(profile.as_ref().ok()); 602 let name = if let Some(username) = disp_name.username { 603 format!("@{username}") 604 } else { 605 disp_name.username_or_displayname().to_owned() 606 }; 607 608 let selected_acc = ctx.accounts.get_selected_account(); 609 let profile_url = get_account_url(&txn, ctx.ndb, selected_acc); 610 if let Ok(profile) = profile { 611 get_profile_url_owned(Some(profile)) 612 } else { 613 get_profile_url_owned(None) 614 }; 615 616 let pfp_resp = ui 617 .add(&mut ProfilePic::new(ctx.img_cache, ctx.media_jobs.sender(), profile_url).size(64.0)); 618 619 ui.horizontal_wrapped(|ui| { 620 ui.add(egui::Label::new( 621 RichText::new(name) 622 .color(ui.visuals().weak_text_color()) 623 .size(16.0), 624 )); 625 }); 626 627 if let Some(npub) = selected_acc.key.pubkey.npub() { 628 if ui.add(copy_npub(&npub, 200.0)).clicked() { 629 ui.ctx().copy_text(npub); 630 } 631 } 632 633 // we skip this whole function in compact mode 634 if options.contains(SidebarOptions::Compact) { 635 return if pfp_resp.clicked() { 636 Some(ChromePanelAction::Profile( 637 ctx.accounts.get_selected_account().key.pubkey, 638 )) 639 } else { 640 None 641 }; 642 } 643 644 let mut action = None; 645 646 let theme = ui.ctx().theme(); 647 648 StripBuilder::new(ui) 649 .sizes(Size::exact(40.0), 6) 650 .clip(true) 651 .vertical(|mut strip| { 652 strip.strip(|b| { 653 if drawer_item( 654 b, 655 |ui| { 656 let profile_img = if ui.visuals().dark_mode { 657 app_images::profile_image() 658 } else { 659 app_images::profile_image().tint(ui.visuals().text_color()) 660 } 661 .max_size(ui.available_size()); 662 ui.add(profile_img); 663 }, 664 tr!(loc, "Profile", "Button to go to the user's profile"), 665 ) 666 .clicked() 667 { 668 action = Some(ChromePanelAction::Profile( 669 ctx.accounts.get_selected_account().key.pubkey, 670 )); 671 } 672 }); 673 674 strip.strip(|b| { 675 if drawer_item( 676 b, 677 |ui| { 678 let account_img = if ui.visuals().dark_mode { 679 app_images::accounts_image() 680 } else { 681 app_images::accounts_image().tint(ui.visuals().text_color()) 682 } 683 .max_size(ui.available_size()); 684 ui.add(account_img); 685 }, 686 tr!(loc, "Accounts", "Button to go to the accounts view"), 687 ) 688 .clicked() 689 { 690 action = Some(ChromePanelAction::Account); 691 } 692 }); 693 694 strip.strip(|b| { 695 if drawer_item( 696 b, 697 |ui| { 698 let img = if ui.visuals().dark_mode { 699 app_images::wallet_dark_image() 700 } else { 701 app_images::wallet_light_image() 702 }; 703 704 ui.add(img); 705 }, 706 tr!(loc, "Wallet", "Button to go to the wallet view"), 707 ) 708 .clicked() 709 { 710 action = Some(ChromePanelAction::Wallet); 711 } 712 }); 713 714 strip.strip(|b| { 715 if drawer_item( 716 b, 717 |ui| { 718 ui.add(if ui.visuals().dark_mode { 719 app_images::settings_dark_image() 720 } else { 721 app_images::settings_light_image() 722 }); 723 }, 724 tr!(loc, "Settings", "Button to go to the settings view"), 725 ) 726 .clicked() 727 { 728 action = Some(ChromePanelAction::Settings); 729 } 730 }); 731 732 strip.strip(|b| { 733 if drawer_item( 734 b, 735 |ui| { 736 let c = match theme { 737 egui::Theme::Dark => "🔆", 738 egui::Theme::Light => "🌒", 739 }; 740 741 let painter = ui.painter(); 742 let galley = painter.layout_no_wrap( 743 c.to_owned(), 744 NotedeckTextStyle::Heading3.get_font_id(ui.ctx()), 745 ui.visuals().text_color(), 746 ); 747 748 painter.galley( 749 galley_centered_pos(&galley, ui.available_rect_before_wrap().center()), 750 galley, 751 ui.visuals().text_color(), 752 ); 753 }, 754 tr!(loc, "Theme", "Button to change the theme (light or dark)"), 755 ) 756 .clicked() 757 { 758 match theme { 759 egui::Theme::Dark => { 760 action = Some(ChromePanelAction::SaveTheme(ThemePreference::Light)); 761 } 762 egui::Theme::Light => { 763 action = Some(ChromePanelAction::SaveTheme(ThemePreference::Dark)); 764 } 765 } 766 } 767 }); 768 769 strip.strip(|b| { 770 if drawer_item( 771 b, 772 |ui| { 773 ui.add(if ui.visuals().dark_mode { 774 app_images::help_dark_image() 775 } else { 776 app_images::help_light_image() 777 }); 778 }, 779 tr!(loc, "Support", "Button to go to the support view"), 780 ) 781 .clicked() 782 { 783 action = Some(ChromePanelAction::Support); 784 } 785 }); 786 }); 787 788 for (i, app) in chrome.apps.iter_mut().enumerate() { 789 if chrome.active == i as i32 { 790 continue; 791 } 792 793 let text = match &app { 794 NotedeckApp::Dave(_) => tr!(loc, "Dave", "Button to go to the Dave app"), 795 NotedeckApp::Columns(_) => tr!(loc, "Columns", "Button to go to the Columns app"), 796 797 #[cfg(feature = "messages")] 798 NotedeckApp::Messages(_) => { 799 tr!(loc, "Messaging", "Button to go to the messaging app") 800 } 801 802 #[cfg(feature = "dashboard")] 803 NotedeckApp::Dashboard(_) => { 804 tr!(loc, "Dashboard", "Button to go to the dashboard app") 805 } 806 807 #[cfg(feature = "notebook")] 808 NotedeckApp::Notebook(_) => { 809 tr!(loc, "Notebook", "Button to go to the Notebook app") 810 } 811 812 #[cfg(feature = "clndash")] 813 NotedeckApp::ClnDash(_) => tr!(loc, "ClnDash", "Button to go to the ClnDash app"), 814 NotedeckApp::Other(_) => tr!(loc, "Other", "Button to go to the Other app"), 815 }; 816 817 StripBuilder::new(ui) 818 .size(Size::exact(40.0)) 819 .clip(true) 820 .vertical(|mut strip| { 821 strip.strip(|b| { 822 let resp = drawer_item( 823 b, 824 |ui| { 825 match app { 826 NotedeckApp::Columns(_columns_app) => { 827 ui.add(app_images::columns_image()); 828 } 829 830 NotedeckApp::Dave(dave) => { 831 dave_button( 832 dave.avatar_mut(), 833 ui, 834 Rect::from_center_size( 835 ui.available_rect_before_wrap().center(), 836 vec2(30.0, 30.0), 837 ), 838 ); 839 } 840 841 #[cfg(feature = "dashboard")] 842 NotedeckApp::Dashboard(_columns_app) => { 843 ui.add(app_images::algo_image()); 844 } 845 846 #[cfg(feature = "messages")] 847 NotedeckApp::Messages(_dms) => { 848 ui.add(app_images::new_message_image()); 849 } 850 851 #[cfg(feature = "clndash")] 852 NotedeckApp::ClnDash(_clndash) => { 853 clndash_button(ui); 854 } 855 856 #[cfg(feature = "notebook")] 857 NotedeckApp::Notebook(_notebook) => { 858 notebook_button(ui); 859 } 860 861 NotedeckApp::Other(_other) => { 862 // app provides its own button rendering ui? 863 panic!("TODO: implement other apps") 864 } 865 } 866 }, 867 text, 868 ) 869 .on_hover_cursor(egui::CursorIcon::PointingHand); 870 871 if resp.clicked() { 872 chrome.active = i as i32; 873 chrome.nav.close(); 874 } 875 }) 876 }); 877 } 878 879 if ctx.args.options.contains(NotedeckOptions::Debug) { 880 let r = ui 881 .weak(format!("{}", ctx.frame_history.fps() as i32)) 882 .union(ui.weak(format!( 883 "{:10.1}", 884 ctx.frame_history.mean_frame_time() * 1e3 885 ))) 886 .on_hover_cursor(egui::CursorIcon::PointingHand); 887 888 if r.clicked() { 889 chrome.options.toggle(ChromeOptions::RepaintDebug); 890 } 891 892 if chrome.options.contains(ChromeOptions::RepaintDebug) { 893 for cause in ui.ctx().repaint_causes() { 894 chrome 895 .repaint_causes 896 .entry(cause) 897 .and_modify(|rc| { 898 *rc += 1; 899 }) 900 .or_insert(1); 901 } 902 repaint_causes_window(ui, &chrome.repaint_causes) 903 } 904 905 #[cfg(feature = "memory")] 906 { 907 let mem_use = re_memory::MemoryUse::capture(); 908 if let Some(counted) = mem_use.counted { 909 if ui 910 .label(format!("{}", format_bytes(counted as f64))) 911 .on_hover_cursor(egui::CursorIcon::PointingHand) 912 .clicked() 913 { 914 chrome.options.toggle(ChromeOptions::MemoryDebug); 915 } 916 } 917 if let Some(resident) = mem_use.resident { 918 ui.weak(format!("{}", format_bytes(resident as f64))); 919 } 920 921 if chrome.options.contains(ChromeOptions::MemoryDebug) { 922 egui::Window::new("Memory Debug").show(ui.ctx(), memory_debug_ui); 923 } 924 } 925 } 926 927 ui.spacing_mut().item_spacing = previous_spacing; 928 929 action 930 } 931 932 fn drawer_item(builder: StripBuilder, icon: impl FnOnce(&mut Ui), text: String) -> egui::Response { 933 builder 934 .cell_layout(Layout::left_to_right(egui::Align::Center)) 935 .sense(Sense::click()) 936 .size(Size::exact(24.0)) 937 .size(Size::exact(8.0)) // free space 938 .size(Size::remainder()) 939 .horizontal(|mut strip| { 940 strip.cell(icon); 941 942 strip.empty(); 943 944 strip.cell(|ui| { 945 ui.add(drawer_label(ui.ctx(), &text)); 946 }); 947 }) 948 .on_hover_cursor(egui::CursorIcon::PointingHand) 949 } 950 951 fn drawer_label(ctx: &egui::Context, text: &str) -> egui::Label { 952 egui::Label::new(RichText::new(text).size(get_font_size(ctx, &NotedeckTextStyle::Heading2))) 953 .selectable(false) 954 } 955 956 fn copy_npub<'a>(npub: &'a String, width: f32) -> impl Widget + use<'a> { 957 move |ui: &mut egui::Ui| -> egui::Response { 958 let size = vec2(width, 24.0); 959 let (rect, mut resp) = ui.allocate_exact_size(size, egui::Sense::click()); 960 resp = resp.on_hover_cursor(egui::CursorIcon::Copy); 961 962 let painter = ui.painter_at(rect); 963 964 painter.rect_filled( 965 rect, 966 CornerRadius::same(32), 967 if resp.hovered() { 968 ui.visuals().widgets.active.bg_fill 969 } else { 970 // ui.visuals().panel_fill 971 ui.visuals().widgets.inactive.bg_fill 972 }, 973 ); 974 975 let text = 976 Label::new(RichText::new(npub).size(get_font_size(ui.ctx(), &NotedeckTextStyle::Tiny))) 977 .truncate() 978 .selectable(false); 979 980 let (label_rect, copy_rect) = { 981 let rect = rect.shrink(4.0); 982 let (l, r) = rect.split_left_right_at_x(rect.right() - 24.0); 983 (l, r.shrink2(vec2(4.0, 0.0))) 984 }; 985 986 app_images::copy_to_clipboard_image() 987 .tint(ui.visuals().text_color()) 988 .maintain_aspect_ratio(true) 989 // .max_size(vec2(24.0, 24.0)) 990 .paint_at(ui, copy_rect); 991 992 ui.put(label_rect, text); 993 994 resp 995 } 996 } 997 998 #[cfg(feature = "memory")] 999 fn memory_debug_ui(ui: &mut egui::Ui) { 1000 let Some(stats) = &re_memory::accounting_allocator::tracking_stats() else { 1001 ui.label("re_memory::accounting_allocator::set_tracking_callstacks(true); not set!!"); 1002 return; 1003 }; 1004 1005 egui::ScrollArea::vertical().show(ui, |ui| { 1006 ui.label(format!( 1007 "track_size_threshold {}", 1008 stats.track_size_threshold 1009 )); 1010 ui.label(format!( 1011 "untracked {} {}", 1012 stats.untracked.count, 1013 format_bytes(stats.untracked.size as f64) 1014 )); 1015 ui.label(format!( 1016 "stochastically_tracked {} {}", 1017 stats.stochastically_tracked.count, 1018 format_bytes(stats.stochastically_tracked.size as f64), 1019 )); 1020 ui.label(format!( 1021 "fully_tracked {} {}", 1022 stats.fully_tracked.count, 1023 format_bytes(stats.fully_tracked.size as f64) 1024 )); 1025 ui.label(format!( 1026 "overhead {} {}", 1027 stats.overhead.count, 1028 format_bytes(stats.overhead.size as f64) 1029 )); 1030 1031 ui.separator(); 1032 1033 for (i, callstack) in stats.top_callstacks.iter().enumerate() { 1034 let full_bt = format!("{}", callstack.readable_backtrace); 1035 let mut lines = full_bt.lines().skip(5); 1036 let bt_header = lines.nth(0).map_or("??", |v| v); 1037 let header = format!( 1038 "#{} {bt_header} {}x {}", 1039 i + 1, 1040 callstack.extant.count, 1041 format_bytes(callstack.extant.size as f64) 1042 ); 1043 1044 egui::CollapsingHeader::new(header) 1045 .id_salt(("mem_cs", i)) 1046 .show(ui, |ui| { 1047 ui.label(lines.collect::<Vec<_>>().join("\n")); 1048 }); 1049 } 1050 }); 1051 } 1052 1053 /// Pretty format a number of bytes by using SI notation (base2), e.g. 1054 /// 1055 /// ``` 1056 /// # use re_format::format_bytes; 1057 /// assert_eq!(format_bytes(123.0), "123 B"); 1058 /// assert_eq!(format_bytes(12_345.0), "12.1 KiB"); 1059 /// assert_eq!(format_bytes(1_234_567.0), "1.2 MiB"); 1060 /// assert_eq!(format_bytes(123_456_789.0), "118 MiB"); 1061 /// ``` 1062 #[cfg(feature = "memory")] 1063 pub fn format_bytes(number_of_bytes: f64) -> String { 1064 /// The minus character: <https://www.compart.com/en/unicode/U+2212> 1065 /// Looks slightly different from the normal hyphen `-`. 1066 const MINUS: char = '−'; 1067 1068 if number_of_bytes < 0.0 { 1069 format!("{MINUS}{}", format_bytes(-number_of_bytes)) 1070 } else if number_of_bytes == 0.0 { 1071 "0 B".to_owned() 1072 } else if number_of_bytes < 1.0 { 1073 format!("{number_of_bytes} B") 1074 } else if number_of_bytes < 20.0 { 1075 let is_integer = number_of_bytes.round() == number_of_bytes; 1076 if is_integer { 1077 format!("{number_of_bytes:.0} B") 1078 } else { 1079 format!("{number_of_bytes:.1} B") 1080 } 1081 } else if number_of_bytes < 10.0_f64.exp2() { 1082 format!("{number_of_bytes:.0} B") 1083 } else if number_of_bytes < 20.0_f64.exp2() { 1084 let decimals = (10.0 * number_of_bytes < 20.0_f64.exp2()) as usize; 1085 format!("{:.*} KiB", decimals, number_of_bytes / 10.0_f64.exp2()) 1086 } else if number_of_bytes < 30.0_f64.exp2() { 1087 let decimals = (10.0 * number_of_bytes < 30.0_f64.exp2()) as usize; 1088 format!("{:.*} MiB", decimals, number_of_bytes / 20.0_f64.exp2()) 1089 } else { 1090 let decimals = (10.0 * number_of_bytes < 40.0_f64.exp2()) as usize; 1091 format!("{:.*} GiB", decimals, number_of_bytes / 30.0_f64.exp2()) 1092 } 1093 } 1094 1095 fn repaint_causes_window(ui: &mut egui::Ui, causes: &HashMap<egui::RepaintCause, u64>) { 1096 egui::Window::new("Repaint Causes").show(ui.ctx(), |ui| { 1097 use egui_extras::{Column, TableBuilder}; 1098 TableBuilder::new(ui) 1099 .column(Column::auto().at_least(600.0).resizable(true)) 1100 .column(Column::auto().at_least(50.0).resizable(true)) 1101 .column(Column::auto().at_least(50.0).resizable(true)) 1102 .column(Column::remainder()) 1103 .header(20.0, |mut header| { 1104 header.col(|ui| { 1105 ui.heading("file"); 1106 }); 1107 header.col(|ui| { 1108 ui.heading("line"); 1109 }); 1110 header.col(|ui| { 1111 ui.heading("count"); 1112 }); 1113 header.col(|ui| { 1114 ui.heading("reason"); 1115 }); 1116 }) 1117 .body(|mut body| { 1118 for (cause, hits) in causes.iter() { 1119 body.row(30.0, |mut row| { 1120 row.col(|ui| { 1121 ui.label(cause.file.to_string()); 1122 }); 1123 row.col(|ui| { 1124 ui.label(format!("{}", cause.line)); 1125 }); 1126 row.col(|ui| { 1127 ui.label(format!("{hits}")); 1128 }); 1129 row.col(|ui| { 1130 ui.label(format!("{}", &cause.reason)); 1131 }); 1132 }); 1133 } 1134 }); 1135 }); 1136 } 1137 1138 fn virtual_keyboard_ui(ui: &mut egui::Ui, rect: egui::Rect) { 1139 let painter = ui.painter_at(rect); 1140 1141 painter.rect_filled(rect, 0.0, Color32::from_black_alpha(200)); 1142 1143 ui.put(rect, |ui: &mut egui::Ui| { 1144 ui.centered_and_justified(|ui| { 1145 ui.label("This is a keyboard"); 1146 }) 1147 .response 1148 }); 1149 } 1150 1151 struct SoftKeyboardAnim { 1152 skb_rect: Option<Rect>, 1153 anim_height: f32, 1154 } 1155 1156 #[derive(Copy, Default, Clone, Eq, PartialEq, Debug)] 1157 enum AnimState { 1158 /// It finished opening 1159 Opened, 1160 1161 /// We started to open 1162 StartOpen, 1163 1164 /// We started to close 1165 StartClose, 1166 1167 /// We finished openning 1168 FinishedOpen, 1169 1170 /// We finished to close 1171 FinishedClose, 1172 1173 /// It finished closing 1174 #[default] 1175 Closed, 1176 1177 /// We are animating towards open 1178 Opening, 1179 1180 /// We are animating towards close 1181 Closing, 1182 } 1183 1184 impl SoftKeyboardAnim { 1185 /// Advance the FSM based on current (anim_height) vs target (skb_rect.height()). 1186 /// Start*/Finished* are one-tick edge states used for signaling. 1187 fn changed(&self, state: AnimState) -> AnimState { 1188 const EPS: f32 = 0.01; 1189 1190 let target = self.skb_rect.map_or(0.0, |r| r.height()); 1191 let current = self.anim_height; 1192 1193 let done = (current - target).abs() <= EPS; 1194 let going_up = target > current + EPS; 1195 let going_down = current > target + EPS; 1196 let target_is_closed = target <= EPS; 1197 1198 match state { 1199 // Resting states: emit a Start* edge only when a move is requested, 1200 // and pick direction by the sign of (target - current). 1201 AnimState::Opened => { 1202 if done { 1203 AnimState::Opened 1204 } else if going_up { 1205 AnimState::StartOpen 1206 } else { 1207 AnimState::StartClose 1208 } 1209 } 1210 AnimState::Closed => { 1211 if done { 1212 AnimState::Closed 1213 } else if going_up { 1214 AnimState::StartOpen 1215 } else { 1216 AnimState::StartClose 1217 } 1218 } 1219 1220 // Edge → flow 1221 AnimState::StartOpen => AnimState::Opening, 1222 AnimState::StartClose => AnimState::Closing, 1223 1224 // Flow states: finish when we hit the target; if the target jumps across, 1225 // emit the opposite Start* to signal a reversal. 1226 AnimState::Opening => { 1227 if done { 1228 if target_is_closed { 1229 AnimState::FinishedClose 1230 } else { 1231 AnimState::FinishedOpen 1232 } 1233 } else if going_down { 1234 // target moved below current mid-flight → reversal 1235 AnimState::StartClose 1236 } else { 1237 AnimState::Opening 1238 } 1239 } 1240 AnimState::Closing => { 1241 if done { 1242 if target_is_closed { 1243 AnimState::FinishedClose 1244 } else { 1245 AnimState::FinishedOpen 1246 } 1247 } else if going_up { 1248 // target moved above current mid-flight → reversal 1249 AnimState::StartOpen 1250 } else { 1251 AnimState::Closing 1252 } 1253 } 1254 1255 // Finish edges collapse to the stable resting states on the next tick. 1256 AnimState::FinishedOpen => AnimState::Opened, 1257 AnimState::FinishedClose => AnimState::Closed, 1258 } 1259 } 1260 } 1261 1262 /// How "open" the softkeyboard is. This is an animated value 1263 fn soft_keyboard_anim( 1264 ui: &mut egui::Ui, 1265 ctx: &mut AppContext, 1266 chrome_options: &mut ChromeOptions, 1267 ) -> SoftKeyboardAnim { 1268 let skb_ctx = if chrome_options.contains(ChromeOptions::VirtualKeyboard) { 1269 SoftKeyboardContext::Virtual 1270 } else { 1271 SoftKeyboardContext::Platform { 1272 ppp: ui.ctx().pixels_per_point(), 1273 } 1274 }; 1275 1276 // move screen up if virtual keyboard intersects with input_rect 1277 let screen_rect = ui.ctx().screen_rect(); 1278 let mut skb_rect: Option<Rect> = None; 1279 1280 let keyboard_height = 1281 if let Some(vkb_rect) = ctx.soft_keyboard_rect(screen_rect, skb_ctx.clone()) { 1282 skb_rect = Some(vkb_rect); 1283 vkb_rect.height() 1284 } else { 1285 0.0 1286 }; 1287 1288 let anim_height = 1289 ui.ctx() 1290 .animate_value_with_time(egui::Id::new("keyboard_anim"), keyboard_height, 0.1); 1291 1292 SoftKeyboardAnim { 1293 anim_height, 1294 skb_rect, 1295 } 1296 } 1297 1298 fn try_toggle_virtual_keyboard( 1299 ctx: &egui::Context, 1300 options: NotedeckOptions, 1301 chrome_options: &mut ChromeOptions, 1302 ) { 1303 // handle virtual keyboard toggle here because why not 1304 if options.contains(NotedeckOptions::Debug) && ctx.input(|i| i.key_pressed(egui::Key::F1)) { 1305 chrome_options.toggle(ChromeOptions::VirtualKeyboard); 1306 } 1307 } 1308 1309 /// All the logic which handles our keyboard visibility 1310 fn keyboard_visibility( 1311 ui: &mut egui::Ui, 1312 ctx: &mut AppContext, 1313 options: &mut ChromeOptions, 1314 soft_kb_anim_state: &mut AnimState, 1315 ) -> SoftKeyboardAnim { 1316 try_toggle_virtual_keyboard(ui.ctx(), ctx.args.options, options); 1317 1318 let soft_kb_anim = soft_keyboard_anim(ui, ctx, options); 1319 1320 let prev_state = *soft_kb_anim_state; 1321 let current_state = soft_kb_anim.changed(prev_state); 1322 *soft_kb_anim_state = current_state; 1323 1324 if prev_state != current_state { 1325 tracing::debug!("soft kb state {prev_state:?} -> {current_state:?}"); 1326 } 1327 1328 match current_state { 1329 // we finished 1330 AnimState::FinishedOpen => {} 1331 1332 // on first open, we setup our scroll target 1333 AnimState::StartOpen => { 1334 // when we first open the keyboard, check to see if the target soft 1335 // keyboard rect (the height at full open) intersects with any 1336 // input response rects from last frame 1337 // 1338 // If we do, then we set a bit that we need keyboard visibility. 1339 // We will use this bit to resize the screen based on the soft 1340 // keyboard animation state 1341 if let Some(skb_rect) = soft_kb_anim.skb_rect { 1342 if let Some(input_rect) = notedeck_ui::input_rect(ui) { 1343 options.set( 1344 ChromeOptions::KeyboardVisibility, 1345 input_rect.intersects(skb_rect), 1346 ) 1347 } 1348 } 1349 } 1350 1351 AnimState::FinishedClose => { 1352 // clear last input box position state 1353 notedeck_ui::clear_input_rect(ui); 1354 } 1355 1356 AnimState::Closing => {} 1357 AnimState::Opened => {} 1358 AnimState::Closed => {} 1359 AnimState::Opening => {} 1360 AnimState::StartClose => {} 1361 }; 1362 1363 soft_kb_anim 1364 }