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