chrome.rs (56861B)
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::enostr::OutboxSession; 17 use notedeck::fonts::get_font_size; 18 use notedeck::name::get_display_name; 19 use notedeck::ui::is_compiled_as_mobile; 20 use notedeck::AppResponse; 21 use notedeck::DrawerRouter; 22 use notedeck::Error; 23 use notedeck::SoftKeyboardContext; 24 use notedeck::{ 25 tr, App, AppAction, AppContext, Localization, Notedeck, NotedeckOptions, NotedeckTextStyle, 26 UserAccount, WalletType, 27 }; 28 use notedeck_columns::{timeline::TimelineKind, Damus}; 29 use notedeck_dave::{Dave, DaveAvatar}; 30 31 #[cfg(feature = "messages")] 32 use notedeck_messages::MessagesApp; 33 34 #[cfg(feature = "dashboard")] 35 use notedeck_dashboard::Dashboard; 36 37 #[cfg(feature = "clndash")] 38 use notedeck_ui::expanding_button; 39 40 use notedeck_ui::{app_images, galley_centered_pos, ProfilePic}; 41 use std::collections::HashMap; 42 43 #[derive(Default)] 44 pub struct Chrome { 45 active: i32, 46 options: ChromeOptions, 47 apps: Vec<NotedeckApp>, 48 49 /// Track which apps have been opened (activated) at least once. 50 /// Only opened apps receive `update()` calls each frame. 51 opened: Vec<bool>, 52 53 /// The state of the soft keyboard animation 54 soft_kb_anim_state: AnimState, 55 56 pub repaint_causes: HashMap<egui::RepaintCause, u64>, 57 nav: DrawerRouter, 58 } 59 60 #[derive(Clone)] 61 enum ChromeRoute { 62 Chrome, 63 App, 64 } 65 66 pub enum ChromePanelAction { 67 Support, 68 Settings, 69 Account, 70 Wallet, 71 SaveTheme(ThemePreference), 72 Profile(notedeck::enostr::Pubkey), 73 } 74 75 bitflags! { 76 #[repr(transparent)] 77 #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] 78 pub struct SidebarOptions: u8 { 79 const Compact = 1 << 0; 80 } 81 } 82 83 impl ChromePanelAction { 84 fn columns_navigate(ctx: &mut AppContext, chrome: &mut Chrome, route: notedeck_columns::Route) { 85 chrome.switch_to_columns(); 86 87 if let Some(c) = chrome.get_columns_app().and_then(|columns| { 88 columns 89 .decks_cache 90 .selected_column_mut(ctx.i18n, ctx.accounts) 91 }) { 92 if c.router().routes().iter().any(|r| r == &route) { 93 // return if we are already routing to accounts 94 c.router_mut().go_back(); 95 } else { 96 c.router_mut().route_to(route); 97 //c..route_to(Route::relays()); 98 } 99 }; 100 } 101 102 #[profiling::function] 103 fn process(&self, ctx: &mut AppContext, chrome: &mut Chrome, ui: &mut egui::Ui) { 104 match self { 105 Self::SaveTheme(theme) => { 106 ui.ctx().set_theme(*theme); 107 ctx.settings.set_theme(*theme); 108 } 109 110 Self::Support => { 111 Self::columns_navigate(ctx, chrome, notedeck_columns::Route::Support); 112 } 113 114 Self::Account => { 115 Self::columns_navigate(ctx, chrome, notedeck_columns::Route::accounts()); 116 } 117 118 Self::Settings => { 119 Self::columns_navigate(ctx, chrome, notedeck_columns::Route::Settings); 120 } 121 122 Self::Wallet => { 123 Self::columns_navigate( 124 ctx, 125 chrome, 126 notedeck_columns::Route::Wallet(WalletType::Auto), 127 ); 128 } 129 Self::Profile(pk) => { 130 columns_route_to_profile(pk, chrome, ctx, ui); 131 } 132 } 133 } 134 } 135 136 /// Some people have been running notedeck in debug, let's catch that! 137 fn stop_debug_mode(options: NotedeckOptions) { 138 if !options.contains(NotedeckOptions::Tests) 139 && cfg!(debug_assertions) 140 && !options.contains(NotedeckOptions::Debug) 141 { 142 println!("--- WELCOME TO DAMUS NOTEDECK! ---"); 143 println!( 144 "It looks like are running notedeck in debug mode, unless you are a developer, this is not likely what you want." 145 ); 146 println!("If you are a developer, run `cargo run -- --debug` to skip this message."); 147 println!("For everyone else, try again with `cargo run --release`. Enjoy!"); 148 println!("---------------------------------"); 149 panic!(); 150 } 151 } 152 153 impl Chrome { 154 /// Create a new chrome with the default app setup 155 pub fn new_with_apps( 156 cc: &CreationContext, 157 app_args: &[String], 158 notedeck: &mut Notedeck, 159 outbox_session: OutboxSession, 160 ) -> Result<Self, Error> { 161 stop_debug_mode(notedeck.options()); 162 163 let notedeck_ref = &mut notedeck.notedeck_ref(&cc.egui_ctx, Some(outbox_session)); 164 let dave = Dave::new( 165 cc.wgpu_render_state.as_ref(), 166 notedeck_ref.app_ctx.ndb.clone(), 167 cc.egui_ctx.clone(), 168 notedeck_ref.app_ctx.path, 169 ); 170 #[cfg(feature = "wasm")] 171 let wasm_dir = notedeck_ref 172 .app_ctx 173 .path 174 .path(notedeck::DataPathType::Cache) 175 .join("wasm_apps"); 176 let mut chrome = Chrome::default(); 177 178 if !app_args.iter().any(|arg| arg == "--no-columns-app") { 179 let columns = Damus::new(&mut notedeck_ref.app_ctx, app_args); 180 notedeck_ref 181 .internals 182 .check_args(columns.unrecognized_args())?; 183 chrome.add_app(NotedeckApp::Columns(Box::new(columns))); 184 } 185 186 chrome.add_app(NotedeckApp::Dave(Box::new(dave))); 187 188 #[cfg(feature = "messages")] 189 chrome.add_app(NotedeckApp::Messages(Box::new(MessagesApp::new()))); 190 191 #[cfg(feature = "dashboard")] 192 chrome.add_app(NotedeckApp::Dashboard(Box::new(Dashboard::default()))); 193 194 #[cfg(feature = "notebook")] 195 chrome.add_app(NotedeckApp::Notebook(Box::default())); 196 197 #[cfg(feature = "clndash")] 198 chrome.add_app(NotedeckApp::ClnDash(Box::default())); 199 200 #[cfg(feature = "nostrverse")] 201 chrome.add_app(NotedeckApp::Nostrverse(Box::new( 202 notedeck_nostrverse::NostrverseApp::demo(cc.wgpu_render_state.as_ref()), 203 ))); 204 205 #[cfg(feature = "wasm")] 206 { 207 tracing::info!("looking for WASM apps in: {}", wasm_dir.display()); 208 if wasm_dir.is_dir() { 209 if let Ok(entries) = std::fs::read_dir(&wasm_dir) { 210 for entry in entries.flatten() { 211 let path = entry.path(); 212 if path.extension().is_some_and(|e| e == "wasm") { 213 match notedeck_wasm::WasmApp::from_file(&path) { 214 Ok(app) => { 215 let name = app.name().to_string(); 216 tracing::info!( 217 "loaded WASM app '{}': {}", 218 name, 219 path.display() 220 ); 221 chrome.add_app(NotedeckApp::Other(name, Box::new(app))); 222 } 223 Err(e) => { 224 tracing::error!( 225 "failed to load WASM app {}: {e}", 226 path.display() 227 ); 228 } 229 } 230 } 231 } 232 } 233 } else { 234 tracing::info!("WASM apps directory not found: {}", wasm_dir.display()); 235 } 236 } 237 238 chrome.set_active(0); 239 240 Ok(chrome) 241 } 242 243 pub fn toggle(&mut self) { 244 if self.nav.drawer_focused { 245 self.nav.close(); 246 } else { 247 self.nav.open(); 248 } 249 } 250 251 pub fn add_app(&mut self, app: NotedeckApp) { 252 self.apps.push(app); 253 self.opened.push(false); 254 } 255 256 fn get_columns_app(&mut self) -> Option<&mut Damus> { 257 for app in &mut self.apps { 258 if let NotedeckApp::Columns(cols) = app { 259 return Some(cols); 260 } 261 } 262 263 None 264 } 265 266 fn switch_to_columns(&mut self) { 267 for (i, app) in self.apps.iter().enumerate() { 268 if let NotedeckApp::Columns(_) = app { 269 self.active = i as i32; 270 if let Some(opened) = self.opened.get_mut(i) { 271 *opened = true; 272 } 273 } 274 } 275 } 276 277 fn get_dave_app(&mut self) -> Option<&mut Dave> { 278 for app in &mut self.apps { 279 if let NotedeckApp::Dave(dave) = app { 280 return Some(dave); 281 } 282 } 283 None 284 } 285 286 fn switch_to_dave(&mut self) { 287 for (i, app) in self.apps.iter().enumerate() { 288 if let NotedeckApp::Dave(_) = app { 289 self.active = i as i32; 290 if let Some(opened) = self.opened.get_mut(i) { 291 *opened = true; 292 } 293 } 294 } 295 } 296 297 #[cfg(feature = "messages")] 298 fn switch_to_messages(&mut self) { 299 for (i, app) in self.apps.iter().enumerate() { 300 if let NotedeckApp::Messages(_) = app { 301 self.active = i as i32; 302 if let Some(opened) = self.opened.get_mut(i) { 303 *opened = true; 304 } 305 } 306 } 307 } 308 309 fn process_toolbar_action(&mut self, action: ChromeToolbarAction, ctx: &mut AppContext) { 310 match action { 311 ChromeToolbarAction::Home => { 312 self.switch_to_columns(); 313 if let Some(columns) = self.get_columns_app() { 314 columns.navigate_home(ctx); 315 } 316 } 317 #[cfg(feature = "messages")] 318 ChromeToolbarAction::Chat => { 319 self.switch_to_messages(); 320 } 321 ChromeToolbarAction::Search => { 322 self.switch_to_columns(); 323 if let Some(columns) = self.get_columns_app() { 324 columns.navigate_search(ctx); 325 } 326 } 327 ChromeToolbarAction::Notifications => { 328 self.switch_to_columns(); 329 if let Some(columns) = self.get_columns_app() { 330 columns.navigate_notifications(ctx); 331 } 332 } 333 } 334 } 335 336 /// Returns which ChromeToolbarAction is currently "active" based on 337 /// the active app and its route. Used to highlight the current tab. 338 fn active_toolbar_tab(&self, accounts: ¬edeck::Accounts) -> Option<ChromeToolbarAction> { 339 let active_app = &self.apps[self.active as usize]; 340 match active_app { 341 #[cfg(feature = "messages")] 342 NotedeckApp::Messages(_) => Some(ChromeToolbarAction::Chat), 343 NotedeckApp::Columns(columns) => match columns.active_toolbar_tab(accounts) { 344 Some(0) => Some(ChromeToolbarAction::Home), 345 Some(1) => Some(ChromeToolbarAction::Search), 346 Some(2) => Some(ChromeToolbarAction::Notifications), 347 _ => None, 348 }, 349 _ => None, 350 } 351 } 352 353 pub fn set_active(&mut self, app: i32) { 354 self.active = app; 355 if let Some(opened) = self.opened.get_mut(app as usize) { 356 *opened = true; 357 } 358 } 359 360 /// The chrome side panel 361 #[profiling::function] 362 fn panel( 363 &mut self, 364 app_ctx: &mut AppContext, 365 ui: &mut egui::Ui, 366 amt_keyboard_open: f32, 367 ) -> Option<ChromePanelAction> { 368 let drawer = NavDrawer::new(&ChromeRoute::App, &ChromeRoute::Chrome) 369 .navigating(self.nav.navigating) 370 .returning(self.nav.returning) 371 .drawer_focused(self.nav.drawer_focused) 372 .drag(is_compiled_as_mobile()) 373 .opened_offset(240.0); 374 375 let resp = drawer.show_mut(ui, |ui, route| match route { 376 ChromeRoute::Chrome => { 377 ui.painter().rect_filled( 378 ui.available_rect_before_wrap(), 379 CornerRadius::ZERO, 380 if ui.visuals().dark_mode { 381 egui::Color32::BLACK 382 } else { 383 egui::Color32::WHITE 384 }, 385 ); 386 egui::Frame::new() 387 .inner_margin(Margin::same(16)) 388 .show(ui, |ui| { 389 let options = if amt_keyboard_open > 0.0 { 390 SidebarOptions::Compact 391 } else { 392 SidebarOptions::default() 393 }; 394 395 let response = ui 396 .with_layout(Layout::top_down(egui::Align::Min), |ui| { 397 topdown_sidebar(self, app_ctx, ui, options) 398 }) 399 .inner; 400 401 ui.with_layout(Layout::bottom_up(egui::Align::Center), |ui| { 402 ui.add(milestone_name(app_ctx.i18n)); 403 }); 404 405 RouteResponse { 406 response, 407 can_take_drag_from: Vec::new(), 408 } 409 }) 410 .inner 411 } 412 ChromeRoute::App => { 413 let resp = self.apps[self.active as usize].render(app_ctx, ui); 414 415 if let Some(action) = resp.action { 416 chrome_handle_app_action(self, app_ctx, action, ui); 417 } 418 419 RouteResponse { 420 response: None, 421 can_take_drag_from: resp.can_take_drag_from, 422 } 423 } 424 }); 425 426 if let Some(action) = resp.action { 427 if matches!(action, NavAction::Returned(_)) { 428 self.nav.closed(); 429 } else if let NavAction::Navigating = action { 430 self.nav.navigating = false; 431 } else if let NavAction::Navigated = action { 432 self.nav.opened(); 433 } 434 } 435 436 resp.drawer_response? 437 } 438 439 /// Show the side menu or bar, depending on if we're on a narrow 440 /// or wide screen. 441 /// 442 /// The side menu should hover over the screen, while the side bar 443 /// is collapsible but persistent on the screen. 444 #[profiling::function] 445 fn show(&mut self, ctx: &mut AppContext, ui: &mut egui::Ui) -> Option<ChromePanelAction> { 446 ui.spacing_mut().item_spacing.x = 0.0; 447 448 let skb_anim = 449 keyboard_visibility(ui, ctx, &mut self.options, &mut self.soft_kb_anim_state); 450 451 let virtual_keyboard = self.options.contains(ChromeOptions::VirtualKeyboard); 452 let keyboard_height = if self.options.contains(ChromeOptions::KeyboardVisibility) { 453 skb_anim.anim_height 454 } else { 455 0.0 456 }; 457 458 let is_narrow = notedeck::ui::is_narrow(ui.ctx()); 459 let toolbar_height = if is_narrow && ctx.settings.welcome_completed() { 460 toolbar_visibility_height(skb_anim.skb_rect, ui) 461 } else { 462 0.0 463 }; 464 465 let (unseen_notifications, active_toolbar_tab) = if is_narrow { 466 let unseen = self 467 .get_columns_app() 468 .map(|c| c.has_unseen_notifications(ctx.accounts)) 469 .unwrap_or(false); 470 let active = self.active_toolbar_tab(ctx.accounts); 471 (unseen, active) 472 } else { 473 (false, None) 474 }; 475 476 // if the soft keyboard is open, shrink the chrome contents 477 let mut action: Option<ChromePanelAction> = None; 478 let mut toolbar_action: Option<ChromeToolbarAction> = None; 479 // build a strip to carve out the soft keyboard inset 480 let prev_spacing = ui.spacing().item_spacing; 481 ui.spacing_mut().item_spacing.y = 0.0; 482 StripBuilder::new(ui) 483 .size(Size::remainder()) 484 .size(Size::exact(toolbar_height)) 485 .size(Size::exact(keyboard_height)) 486 .vertical(|mut strip| { 487 // the actual content, shifted up because of the soft keyboard 488 strip.cell(|ui| { 489 ui.spacing_mut().item_spacing = prev_spacing; 490 action = self.panel(ctx, ui, keyboard_height); 491 }); 492 493 // mobile toolbar 494 strip.cell(|ui| { 495 if toolbar_height > 0.0 { 496 toolbar_action = 497 chrome_toolbar(ui, unseen_notifications, active_toolbar_tab); 498 } 499 }); 500 501 // the filler space taken up by the soft keyboard 502 strip.cell(|ui| { 503 // keyboard-visibility virtual keyboard 504 if virtual_keyboard && keyboard_height > 0.0 { 505 virtual_keyboard_ui(ui, ui.available_rect_before_wrap()) 506 } 507 }); 508 }); 509 510 // hovering virtual keyboard 511 if virtual_keyboard { 512 if let Some(mut kb_rect) = skb_anim.skb_rect { 513 let kb_height = if self.options.contains(ChromeOptions::KeyboardVisibility) { 514 keyboard_height 515 } else { 516 400.0 517 }; 518 kb_rect.min.y = kb_rect.max.y - kb_height; 519 tracing::debug!("hovering virtual kb_height:{keyboard_height} kb_rect:{kb_rect}"); 520 virtual_keyboard_ui(ui, kb_rect) 521 } 522 } 523 524 if let Some(tb_action) = toolbar_action { 525 self.process_toolbar_action(tb_action, ctx); 526 } 527 528 action 529 } 530 } 531 532 impl notedeck::App for Chrome { 533 fn update(&mut self, ctx: &mut notedeck::AppContext, egui_ctx: &egui::Context) { 534 // Update opened apps every frame so background processing 535 // (relay pools, subscriptions, etc.) stays alive. 536 // Apps that haven't been opened yet are skipped. 537 for (i, app) in self.apps.iter_mut().enumerate() { 538 if self.opened.get(i).copied().unwrap_or(false) { 539 app.update(ctx, egui_ctx); 540 } 541 } 542 } 543 544 fn render(&mut self, ctx: &mut notedeck::AppContext, ui: &mut egui::Ui) -> AppResponse { 545 #[cfg(feature = "tracy")] 546 { 547 ui.ctx().request_repaint(); 548 } 549 550 if let Some(action) = self.show(ctx, ui) { 551 action.process(ctx, self, ui); 552 self.nav.close(); 553 } 554 555 // Toggle the side menu on Escape if no app consumed the key 556 if ui 557 .ctx() 558 .input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) 559 { 560 self.toggle(); 561 } 562 563 // TODO: unify this constant with the columns side panel width. ui crate? 564 AppResponse::none() 565 } 566 } 567 568 const TOOLBAR_HEIGHT: f32 = 48.0; 569 570 #[derive(Debug, Eq, PartialEq)] 571 enum ChromeToolbarAction { 572 Home, 573 #[cfg(feature = "messages")] 574 Chat, 575 Search, 576 Notifications, 577 } 578 579 /// Compute the animated toolbar height, auto-hiding on scroll and 580 /// when the soft keyboard is open. 581 fn toolbar_visibility_height(skb_rect: Option<Rect>, ui: &mut Ui) -> f32 { 582 let toolbar_visible_id = egui::Id::new("chrome_toolbar_visible"); 583 584 let scroll_delta = scroll_delta(ui.ctx()); 585 let velocity_threshold = 1.0; 586 587 if scroll_delta > velocity_threshold { 588 ui.ctx() 589 .data_mut(|d| d.insert_temp(toolbar_visible_id, true)); 590 } else if scroll_delta < -velocity_threshold { 591 ui.ctx() 592 .data_mut(|d| d.insert_temp(toolbar_visible_id, false)); 593 } 594 595 let toolbar_visible = ui 596 .ctx() 597 .data(|d| d.get_temp::<bool>(toolbar_visible_id)) 598 .unwrap_or(true); 599 600 let toolbar_anim = ui 601 .ctx() 602 .animate_bool_responsive(toolbar_visible_id.with("anim"), toolbar_visible); 603 604 if skb_rect.is_none() { 605 TOOLBAR_HEIGHT * toolbar_anim 606 } else { 607 0.0 608 } 609 } 610 611 /// Detect vertical scroll intent from mouse wheel, trackpad, or touch drag. 612 fn scroll_delta(ctx: &egui::Context) -> f32 { 613 ctx.input(|i| { 614 let sd = i.smooth_scroll_delta.y; 615 if sd.abs() > 0.5 { 616 return sd; 617 } 618 if i.pointer.is_decidedly_dragging() { 619 return i.pointer.velocity().y; 620 } 621 0.0 622 }) 623 } 624 625 /// Render the Chrome mobile toolbar (Home, Chat, Search, Notifications). 626 fn chrome_toolbar( 627 ui: &mut Ui, 628 unseen_notifications: bool, 629 active_tab: Option<ChromeToolbarAction>, 630 ) -> Option<ChromeToolbarAction> { 631 use egui_tabs::{TabColor, Tabs}; 632 use notedeck_ui::icons::{home_button, notifications_button, search_button}; 633 634 let rect = ui.available_rect_before_wrap(); 635 ui.painter().hline( 636 rect.x_range(), 637 rect.top(), 638 ui.visuals().widgets.noninteractive.bg_stroke, 639 ); 640 641 if !ui.visuals().dark_mode { 642 ui.painter().rect( 643 rect, 644 0, 645 notedeck_ui::colors::ALMOST_WHITE, 646 egui::Stroke::new(0.0, Color32::TRANSPARENT), 647 egui::StrokeKind::Inside, 648 ); 649 } 650 651 let has_chat = cfg!(feature = "messages"); 652 let mut next_index = 0; 653 let home_index = next_index; 654 next_index += 1; 655 let chat_index = if has_chat { 656 let i = next_index; 657 next_index += 1; 658 Some(i) 659 } else { 660 None 661 }; 662 let search_index = next_index; 663 next_index += 1; 664 let notif_index = next_index; 665 let tab_count = notif_index + 1; 666 667 let actual_height = ui.available_height(); 668 let rs = Tabs::new(tab_count) 669 .selected(0) 670 .hover_bg(TabColor::none()) 671 .selected_fg(TabColor::none()) 672 .selected_bg(TabColor::none()) 673 .height(actual_height) 674 .layout(Layout::centered_and_justified(egui::Direction::TopDown)) 675 .show(ui, |ui, state| { 676 let index = state.index(); 677 let btn_size: f32 = 20.0; 678 679 if index == home_index { 680 let active = active_tab == Some(ChromeToolbarAction::Home); 681 if home_button(ui, btn_size, active).clicked() { 682 return Some(ChromeToolbarAction::Home); 683 } 684 } else if Some(index) == chat_index { 685 #[cfg(feature = "messages")] 686 { 687 let active = active_tab == Some(ChromeToolbarAction::Chat); 688 if notedeck_ui::icons::chat_button(ui, btn_size, active).clicked() { 689 return Some(ChromeToolbarAction::Chat); 690 } 691 } 692 } else if index == search_index { 693 let active = active_tab == Some(ChromeToolbarAction::Search); 694 if ui 695 .add(search_button(ui.visuals().text_color(), 2.0, active)) 696 .clicked() 697 { 698 return Some(ChromeToolbarAction::Search); 699 } 700 } else if index == notif_index { 701 let active = active_tab == Some(ChromeToolbarAction::Notifications); 702 if notifications_button(ui, btn_size, active, unseen_notifications).clicked() { 703 return Some(ChromeToolbarAction::Notifications); 704 } 705 } 706 707 None 708 }) 709 .inner(); 710 711 for maybe_r in rs { 712 if maybe_r.inner.is_some() { 713 return maybe_r.inner; 714 } 715 } 716 717 None 718 } 719 720 fn milestone_name<'a>(i18n: &'a mut Localization) -> impl Widget + 'a { 721 let text = if notedeck::ui::is_compiled_as_mobile() { 722 tr!( 723 i18n, 724 "Damus Android BETA", 725 "Damus android beta version label" 726 ) 727 } else { 728 tr!( 729 i18n, 730 "Damus Notedeck BETA", 731 "Damus notedeck beta version label" 732 ) 733 }; 734 735 |ui: &mut egui::Ui| -> egui::Response { 736 let font = egui::FontId::new( 737 notedeck::fonts::get_font_size(ui.ctx(), &NotedeckTextStyle::Tiny), 738 egui::FontFamily::Name(notedeck::fonts::NamedFontFamily::Bold.as_str().into()), 739 ); 740 ui.add( 741 Label::new( 742 RichText::new(text) 743 .color(ui.style().visuals.noninteractive().fg_stroke.color) 744 .font(font), 745 ) 746 .selectable(false), 747 ) 748 .on_hover_text(tr!( 749 i18n, 750 "Notedeck is a beta product. Expect bugs and contact us when you run into issues.", 751 "Beta product warning message" 752 )) 753 .on_hover_cursor(egui::CursorIcon::Help) 754 } 755 } 756 757 #[cfg(feature = "clndash")] 758 fn clndash_button(ui: &mut egui::Ui) -> egui::Response { 759 notedeck_ui::expanding_button( 760 "clndash-button", 761 24.0, 762 app_images::cln_image(), 763 app_images::cln_image(), 764 ui, 765 false, 766 ) 767 } 768 769 #[cfg(feature = "notebook")] 770 fn notebook_button(ui: &mut egui::Ui) -> egui::Response { 771 notedeck_ui::expanding_button( 772 "notebook-button", 773 40.0, 774 app_images::algo_image(), 775 app_images::algo_image(), 776 ui, 777 false, 778 ) 779 } 780 781 fn dave_button(avatar: Option<&mut DaveAvatar>, ui: &mut egui::Ui, rect: Rect) -> egui::Response { 782 if let Some(avatar) = avatar { 783 avatar.render(rect, ui) 784 } else { 785 // plain icon if wgpu device not available?? 786 ui.label("fixme") 787 } 788 } 789 790 pub fn get_profile_url_owned(profile: Option<ProfileRecord<'_>>) -> &str { 791 if let Some(url) = profile.and_then(|pr| pr.record().profile().and_then(|p| p.picture())) { 792 url 793 } else { 794 notedeck::profile::no_pfp_url() 795 } 796 } 797 798 pub fn get_account_url<'a>( 799 txn: &'a nostrdb::Transaction, 800 ndb: &nostrdb::Ndb, 801 account: &UserAccount, 802 ) -> &'a str { 803 if let Ok(profile) = ndb.get_profile_by_pubkey(txn, account.key.pubkey.bytes()) { 804 get_profile_url_owned(Some(profile)) 805 } else { 806 get_profile_url_owned(None) 807 } 808 } 809 810 fn chrome_handle_app_action( 811 chrome: &mut Chrome, 812 ctx: &mut AppContext, 813 action: AppAction, 814 ui: &mut egui::Ui, 815 ) { 816 match action { 817 AppAction::ToggleChrome => { 818 chrome.toggle(); 819 } 820 821 AppAction::Note(note_action) => { 822 // Intercept SummarizeThread — route to Dave instead of Columns 823 if let notedeck::NoteAction::Context(ref context) = note_action { 824 if let notedeck::NoteContextSelection::SummarizeThread(note_id) = context.action { 825 chrome.switch_to_dave(); 826 if let Some(dave) = chrome.get_dave_app() { 827 dave.summarize_thread(note_id); 828 } 829 return; 830 } 831 } 832 833 chrome.switch_to_columns(); 834 let Some(columns) = chrome.get_columns_app() else { 835 return; 836 }; 837 838 let txn = Transaction::new(ctx.ndb).unwrap(); 839 840 let cols = columns 841 .decks_cache 842 .active_columns_mut(ctx.i18n, ctx.accounts) 843 .unwrap(); 844 let m_action = notedeck_columns::actionbar::execute_and_process_note_action( 845 note_action, 846 ctx.ndb, 847 cols, 848 0, 849 &mut columns.timeline_cache, 850 &mut columns.threads, 851 ctx.note_cache, 852 &mut ctx.remote, 853 &txn, 854 ctx.unknown_ids, 855 ctx.accounts, 856 ctx.global_wallet, 857 ctx.zaps, 858 ctx.img_cache, 859 &mut columns.view_state, 860 ctx.media_jobs.sender(), 861 ui, 862 ); 863 864 if let Some(action) = m_action { 865 let col = cols.selected_mut(); 866 867 action.process_router_action(&mut col.router, &mut col.sheet_router); 868 } 869 } 870 } 871 } 872 873 fn columns_route_to_profile( 874 pk: ¬edeck::enostr::Pubkey, 875 chrome: &mut Chrome, 876 ctx: &mut AppContext, 877 ui: &mut egui::Ui, 878 ) { 879 chrome.switch_to_columns(); 880 let Some(columns) = chrome.get_columns_app() else { 881 return; 882 }; 883 884 let cols = columns 885 .decks_cache 886 .active_columns_mut(ctx.i18n, ctx.accounts) 887 .unwrap(); 888 889 let router = cols.get_selected_router(); 890 if router.routes().iter().any(|r| { 891 matches!( 892 r, 893 notedeck_columns::Route::Timeline(TimelineKind::Profile(_)) 894 ) 895 }) { 896 router.go_back(); 897 return; 898 } 899 900 let txn = Transaction::new(ctx.ndb).unwrap(); 901 let m_action = notedeck_columns::actionbar::execute_and_process_note_action( 902 notedeck::NoteAction::Profile(*pk), 903 ctx.ndb, 904 cols, 905 0, 906 &mut columns.timeline_cache, 907 &mut columns.threads, 908 ctx.note_cache, 909 &mut ctx.remote, 910 &txn, 911 ctx.unknown_ids, 912 ctx.accounts, 913 ctx.global_wallet, 914 ctx.zaps, 915 ctx.img_cache, 916 &mut columns.view_state, 917 ctx.media_jobs.sender(), 918 ui, 919 ); 920 921 if let Some(action) = m_action { 922 let col = cols.selected_mut(); 923 924 action.process_router_action(&mut col.router, &mut col.sheet_router); 925 } 926 } 927 928 /// The section of the chrome sidebar that starts at the 929 /// bottom and goes up 930 fn topdown_sidebar( 931 chrome: &mut Chrome, 932 ctx: &mut AppContext, 933 ui: &mut egui::Ui, 934 options: SidebarOptions, 935 ) -> Option<ChromePanelAction> { 936 let previous_spacing = ui.spacing().item_spacing; 937 ui.spacing_mut().item_spacing.y = 12.0; 938 939 let loc = &mut ctx.i18n; 940 941 // macos needs a bit of space to make room for window 942 // minimize/close buttons 943 if cfg!(target_os = "macos") { 944 ui.add_space(8.0); 945 } 946 947 let txn = Transaction::new(ctx.ndb).expect("should be able to create txn"); 948 let profile = ctx 949 .ndb 950 .get_profile_by_pubkey(&txn, ctx.accounts.get_selected_account().key.pubkey.bytes()); 951 952 let disp_name = get_display_name(profile.as_ref().ok()); 953 let name = if let Some(username) = disp_name.username { 954 format!("@{username}") 955 } else { 956 disp_name.username_or_displayname().to_owned() 957 }; 958 959 let selected_acc = ctx.accounts.get_selected_account(); 960 let profile_url = get_account_url(&txn, ctx.ndb, selected_acc); 961 if let Ok(profile) = profile { 962 get_profile_url_owned(Some(profile)) 963 } else { 964 get_profile_url_owned(None) 965 }; 966 967 let pfp_resp = ui 968 .add(&mut ProfilePic::new(ctx.img_cache, ctx.media_jobs.sender(), profile_url).size(64.0)); 969 970 ui.horizontal_wrapped(|ui| { 971 ui.add(egui::Label::new( 972 RichText::new(name) 973 .color(ui.visuals().weak_text_color()) 974 .size(16.0), 975 )); 976 }); 977 978 if let Some(npub) = selected_acc.key.pubkey.npub() { 979 if ui.add(copy_npub(&npub, 200.0)).clicked() { 980 ui.ctx().copy_text(npub); 981 } 982 } 983 984 // we skip this whole function in compact mode 985 if options.contains(SidebarOptions::Compact) { 986 return if pfp_resp.clicked() { 987 Some(ChromePanelAction::Profile( 988 ctx.accounts.get_selected_account().key.pubkey, 989 )) 990 } else { 991 None 992 }; 993 } 994 995 let mut action = None; 996 997 let theme = ui.ctx().theme(); 998 999 StripBuilder::new(ui) 1000 .sizes(Size::exact(40.0), 6) 1001 .clip(true) 1002 .vertical(|mut strip| { 1003 strip.strip(|b| { 1004 if drawer_item( 1005 b, 1006 |ui| { 1007 let profile_img = if ui.visuals().dark_mode { 1008 app_images::profile_image() 1009 } else { 1010 app_images::profile_image().tint(ui.visuals().text_color()) 1011 } 1012 .max_size(ui.available_size()); 1013 ui.add(profile_img); 1014 }, 1015 tr!(loc, "Profile", "Button to go to the user's profile"), 1016 ) 1017 .clicked() 1018 { 1019 action = Some(ChromePanelAction::Profile( 1020 ctx.accounts.get_selected_account().key.pubkey, 1021 )); 1022 } 1023 }); 1024 1025 strip.strip(|b| { 1026 if drawer_item( 1027 b, 1028 |ui| { 1029 let account_img = if ui.visuals().dark_mode { 1030 app_images::accounts_image() 1031 } else { 1032 app_images::accounts_image().tint(ui.visuals().text_color()) 1033 } 1034 .max_size(ui.available_size()); 1035 ui.add(account_img); 1036 }, 1037 tr!(loc, "Accounts", "Button to go to the accounts view"), 1038 ) 1039 .clicked() 1040 { 1041 action = Some(ChromePanelAction::Account); 1042 } 1043 }); 1044 1045 strip.strip(|b| { 1046 if drawer_item( 1047 b, 1048 |ui| { 1049 let img = if ui.visuals().dark_mode { 1050 app_images::wallet_dark_image() 1051 } else { 1052 app_images::wallet_light_image() 1053 }; 1054 1055 ui.add(img); 1056 }, 1057 tr!(loc, "Wallet", "Button to go to the wallet view"), 1058 ) 1059 .clicked() 1060 { 1061 action = Some(ChromePanelAction::Wallet); 1062 } 1063 }); 1064 1065 strip.strip(|b| { 1066 if drawer_item( 1067 b, 1068 |ui| { 1069 ui.add(if ui.visuals().dark_mode { 1070 app_images::settings_dark_image() 1071 } else { 1072 app_images::settings_light_image() 1073 }); 1074 }, 1075 tr!(loc, "Settings", "Button to go to the settings view"), 1076 ) 1077 .clicked() 1078 { 1079 action = Some(ChromePanelAction::Settings); 1080 } 1081 }); 1082 1083 strip.strip(|b| { 1084 if drawer_item( 1085 b, 1086 |ui| { 1087 let c = match theme { 1088 egui::Theme::Dark => "🔆", 1089 egui::Theme::Light => "🌒", 1090 }; 1091 1092 let painter = ui.painter(); 1093 let galley = painter.layout_no_wrap( 1094 c.to_owned(), 1095 NotedeckTextStyle::Heading3.get_font_id(ui.ctx()), 1096 ui.visuals().text_color(), 1097 ); 1098 1099 painter.galley( 1100 galley_centered_pos(&galley, ui.available_rect_before_wrap().center()), 1101 galley, 1102 ui.visuals().text_color(), 1103 ); 1104 }, 1105 tr!(loc, "Theme", "Button to change the theme (light or dark)"), 1106 ) 1107 .clicked() 1108 { 1109 match theme { 1110 egui::Theme::Dark => { 1111 action = Some(ChromePanelAction::SaveTheme(ThemePreference::Light)); 1112 } 1113 egui::Theme::Light => { 1114 action = Some(ChromePanelAction::SaveTheme(ThemePreference::Dark)); 1115 } 1116 } 1117 } 1118 }); 1119 1120 strip.strip(|b| { 1121 if drawer_item( 1122 b, 1123 |ui| { 1124 ui.add(if ui.visuals().dark_mode { 1125 app_images::help_dark_image() 1126 } else { 1127 app_images::help_light_image() 1128 }); 1129 }, 1130 tr!(loc, "Support", "Button to go to the support view"), 1131 ) 1132 .clicked() 1133 { 1134 action = Some(ChromePanelAction::Support); 1135 } 1136 }); 1137 }); 1138 1139 for (i, app) in chrome.apps.iter_mut().enumerate() { 1140 if chrome.active == i as i32 { 1141 continue; 1142 } 1143 1144 let text = match &app { 1145 NotedeckApp::Dave(_) => tr!(loc, "Dave", "Button to go to the Dave app"), 1146 NotedeckApp::Columns(_) => tr!(loc, "Columns", "Button to go to the Columns app"), 1147 1148 #[cfg(feature = "messages")] 1149 NotedeckApp::Messages(_) => { 1150 tr!(loc, "Messaging", "Button to go to the messaging app") 1151 } 1152 1153 #[cfg(feature = "dashboard")] 1154 NotedeckApp::Dashboard(_) => { 1155 tr!(loc, "Dashboard", "Button to go to the dashboard app") 1156 } 1157 1158 #[cfg(feature = "notebook")] 1159 NotedeckApp::Notebook(_) => { 1160 tr!(loc, "Notebook", "Button to go to the Notebook app") 1161 } 1162 1163 #[cfg(feature = "clndash")] 1164 NotedeckApp::ClnDash(_) => tr!(loc, "ClnDash", "Button to go to the ClnDash app"), 1165 1166 #[cfg(feature = "nostrverse")] 1167 NotedeckApp::Nostrverse(_) => { 1168 tr!(loc, "Nostrverse", "Button to go to the Nostrverse app") 1169 } 1170 1171 NotedeckApp::Other(name, _) => { 1172 tr!(loc, name.as_str(), "Button to go to a WASM app") 1173 } 1174 }; 1175 1176 StripBuilder::new(ui) 1177 .size(Size::exact(40.0)) 1178 .clip(true) 1179 .vertical(|mut strip| { 1180 strip.strip(|b| { 1181 let resp = drawer_item( 1182 b, 1183 |ui| match app { 1184 NotedeckApp::Columns(_columns_app) => { 1185 ui.add(app_images::columns_image()); 1186 } 1187 1188 NotedeckApp::Dave(dave) => { 1189 dave_button( 1190 dave.avatar_mut(), 1191 ui, 1192 Rect::from_center_size( 1193 ui.available_rect_before_wrap().center(), 1194 vec2(30.0, 30.0), 1195 ), 1196 ); 1197 } 1198 1199 #[cfg(feature = "dashboard")] 1200 NotedeckApp::Dashboard(_columns_app) => { 1201 ui.add(app_images::algo_image()); 1202 } 1203 1204 #[cfg(feature = "messages")] 1205 NotedeckApp::Messages(_dms) => { 1206 ui.add(app_images::new_message_image()); 1207 } 1208 1209 #[cfg(feature = "clndash")] 1210 NotedeckApp::ClnDash(_clndash) => { 1211 clndash_button(ui); 1212 } 1213 1214 #[cfg(feature = "notebook")] 1215 NotedeckApp::Notebook(_notebook) => { 1216 notebook_button(ui); 1217 } 1218 1219 #[cfg(feature = "nostrverse")] 1220 NotedeckApp::Nostrverse(_nostrverse) => { 1221 ui.add(app_images::universe_image()); 1222 } 1223 1224 NotedeckApp::Other(_name, _other) => { 1225 ui.label("W"); 1226 } 1227 }, 1228 text, 1229 ) 1230 .on_hover_cursor(egui::CursorIcon::PointingHand); 1231 1232 if resp.clicked() { 1233 chrome.active = i as i32; 1234 if let Some(opened) = chrome.opened.get_mut(i) { 1235 *opened = true; 1236 } 1237 chrome.nav.close(); 1238 } 1239 }) 1240 }); 1241 } 1242 1243 if ctx.args.options.contains(NotedeckOptions::Debug) { 1244 let r = ui 1245 .weak(format!("{}", ctx.frame_history.fps() as i32)) 1246 .union(ui.weak(format!( 1247 "{:10.1}", 1248 ctx.frame_history.mean_frame_time() * 1e3 1249 ))) 1250 .on_hover_cursor(egui::CursorIcon::PointingHand); 1251 1252 if r.clicked() { 1253 chrome.options.toggle(ChromeOptions::RepaintDebug); 1254 } 1255 1256 if chrome.options.contains(ChromeOptions::RepaintDebug) { 1257 for cause in ui.ctx().repaint_causes() { 1258 chrome 1259 .repaint_causes 1260 .entry(cause) 1261 .and_modify(|rc| { 1262 *rc += 1; 1263 }) 1264 .or_insert(1); 1265 } 1266 repaint_causes_window(ui, &chrome.repaint_causes) 1267 } 1268 1269 #[cfg(feature = "memory")] 1270 { 1271 let mem_use = re_memory::MemoryUse::capture(); 1272 if let Some(counted) = mem_use.counted { 1273 if ui 1274 .label(format!("{}", format_bytes(counted as f64))) 1275 .on_hover_cursor(egui::CursorIcon::PointingHand) 1276 .clicked() 1277 { 1278 chrome.options.toggle(ChromeOptions::MemoryDebug); 1279 } 1280 } 1281 if let Some(resident) = mem_use.resident { 1282 ui.weak(format!("{}", format_bytes(resident as f64))); 1283 } 1284 1285 if chrome.options.contains(ChromeOptions::MemoryDebug) { 1286 egui::Window::new("Memory Debug").show(ui.ctx(), memory_debug_ui); 1287 } 1288 } 1289 } 1290 1291 ui.spacing_mut().item_spacing = previous_spacing; 1292 1293 action 1294 } 1295 1296 fn drawer_item(builder: StripBuilder, icon: impl FnOnce(&mut Ui), text: String) -> egui::Response { 1297 builder 1298 .cell_layout(Layout::left_to_right(egui::Align::Center)) 1299 .sense(Sense::click()) 1300 .size(Size::exact(24.0)) 1301 .size(Size::exact(8.0)) // free space 1302 .size(Size::remainder()) 1303 .horizontal(|mut strip| { 1304 strip.cell(icon); 1305 1306 strip.empty(); 1307 1308 strip.cell(|ui| { 1309 ui.add(drawer_label(ui.ctx(), &text)); 1310 }); 1311 }) 1312 .on_hover_cursor(egui::CursorIcon::PointingHand) 1313 } 1314 1315 fn drawer_label(ctx: &egui::Context, text: &str) -> egui::Label { 1316 egui::Label::new(RichText::new(text).size(get_font_size(ctx, &NotedeckTextStyle::Heading2))) 1317 .selectable(false) 1318 } 1319 1320 fn copy_npub<'a>(npub: &'a String, width: f32) -> impl Widget + use<'a> { 1321 move |ui: &mut egui::Ui| -> egui::Response { 1322 let size = vec2(width, 24.0); 1323 let (rect, mut resp) = ui.allocate_exact_size(size, egui::Sense::click()); 1324 resp = resp.on_hover_cursor(egui::CursorIcon::Copy); 1325 1326 let painter = ui.painter_at(rect); 1327 1328 painter.rect_filled( 1329 rect, 1330 CornerRadius::same(32), 1331 if resp.hovered() { 1332 ui.visuals().widgets.active.bg_fill 1333 } else { 1334 // ui.visuals().panel_fill 1335 ui.visuals().widgets.inactive.bg_fill 1336 }, 1337 ); 1338 1339 let text = 1340 Label::new(RichText::new(npub).size(get_font_size(ui.ctx(), &NotedeckTextStyle::Tiny))) 1341 .truncate() 1342 .selectable(false); 1343 1344 let (label_rect, copy_rect) = { 1345 let rect = rect.shrink(4.0); 1346 let (l, r) = rect.split_left_right_at_x(rect.right() - 24.0); 1347 (l, r.shrink2(vec2(4.0, 0.0))) 1348 }; 1349 1350 app_images::copy_to_clipboard_image() 1351 .tint(ui.visuals().text_color()) 1352 .maintain_aspect_ratio(true) 1353 // .max_size(vec2(24.0, 24.0)) 1354 .paint_at(ui, copy_rect); 1355 1356 ui.put(label_rect, text); 1357 1358 resp 1359 } 1360 } 1361 1362 #[cfg(feature = "memory")] 1363 fn memory_debug_ui(ui: &mut egui::Ui) { 1364 let Some(stats) = &re_memory::accounting_allocator::tracking_stats() else { 1365 ui.label("re_memory::accounting_allocator::set_tracking_callstacks(true); not set!!"); 1366 return; 1367 }; 1368 1369 egui::ScrollArea::vertical().show(ui, |ui| { 1370 ui.label(format!( 1371 "track_size_threshold {}", 1372 stats.track_size_threshold 1373 )); 1374 ui.label(format!( 1375 "untracked {} {}", 1376 stats.untracked.count, 1377 format_bytes(stats.untracked.size as f64) 1378 )); 1379 ui.label(format!( 1380 "stochastically_tracked {} {}", 1381 stats.stochastically_tracked.count, 1382 format_bytes(stats.stochastically_tracked.size as f64), 1383 )); 1384 ui.label(format!( 1385 "fully_tracked {} {}", 1386 stats.fully_tracked.count, 1387 format_bytes(stats.fully_tracked.size as f64) 1388 )); 1389 ui.label(format!( 1390 "overhead {} {}", 1391 stats.overhead.count, 1392 format_bytes(stats.overhead.size as f64) 1393 )); 1394 1395 ui.separator(); 1396 1397 for (i, callstack) in stats.top_callstacks.iter().enumerate() { 1398 let full_bt = format!("{}", callstack.readable_backtrace); 1399 let mut lines = full_bt.lines().skip(5); 1400 let bt_header = lines.nth(0).map_or("??", |v| v); 1401 let header = format!( 1402 "#{} {bt_header} {}x {}", 1403 i + 1, 1404 callstack.extant.count, 1405 format_bytes(callstack.extant.size as f64) 1406 ); 1407 1408 egui::CollapsingHeader::new(header) 1409 .id_salt(("mem_cs", i)) 1410 .show(ui, |ui| { 1411 ui.label(lines.collect::<Vec<_>>().join("\n")); 1412 }); 1413 } 1414 }); 1415 } 1416 1417 /// Pretty format a number of bytes by using SI notation (base2), e.g. 1418 /// 1419 /// ``` 1420 /// # use re_format::format_bytes; 1421 /// assert_eq!(format_bytes(123.0), "123 B"); 1422 /// assert_eq!(format_bytes(12_345.0), "12.1 KiB"); 1423 /// assert_eq!(format_bytes(1_234_567.0), "1.2 MiB"); 1424 /// assert_eq!(format_bytes(123_456_789.0), "118 MiB"); 1425 /// ``` 1426 #[cfg(feature = "memory")] 1427 pub fn format_bytes(number_of_bytes: f64) -> String { 1428 /// The minus character: <https://www.compart.com/en/unicode/U+2212> 1429 /// Looks slightly different from the normal hyphen `-`. 1430 const MINUS: char = '−'; 1431 1432 if number_of_bytes < 0.0 { 1433 format!("{MINUS}{}", format_bytes(-number_of_bytes)) 1434 } else if number_of_bytes == 0.0 { 1435 "0 B".to_owned() 1436 } else if number_of_bytes < 1.0 { 1437 format!("{number_of_bytes} B") 1438 } else if number_of_bytes < 20.0 { 1439 let is_integer = number_of_bytes.round() == number_of_bytes; 1440 if is_integer { 1441 format!("{number_of_bytes:.0} B") 1442 } else { 1443 format!("{number_of_bytes:.1} B") 1444 } 1445 } else if number_of_bytes < 10.0_f64.exp2() { 1446 format!("{number_of_bytes:.0} B") 1447 } else if number_of_bytes < 20.0_f64.exp2() { 1448 let decimals = (10.0 * number_of_bytes < 20.0_f64.exp2()) as usize; 1449 format!("{:.*} KiB", decimals, number_of_bytes / 10.0_f64.exp2()) 1450 } else if number_of_bytes < 30.0_f64.exp2() { 1451 let decimals = (10.0 * number_of_bytes < 30.0_f64.exp2()) as usize; 1452 format!("{:.*} MiB", decimals, number_of_bytes / 20.0_f64.exp2()) 1453 } else { 1454 let decimals = (10.0 * number_of_bytes < 40.0_f64.exp2()) as usize; 1455 format!("{:.*} GiB", decimals, number_of_bytes / 30.0_f64.exp2()) 1456 } 1457 } 1458 1459 fn repaint_causes_window(ui: &mut egui::Ui, causes: &HashMap<egui::RepaintCause, u64>) { 1460 egui::Window::new("Repaint Causes").show(ui.ctx(), |ui| { 1461 use egui_extras::{Column, TableBuilder}; 1462 TableBuilder::new(ui) 1463 .column(Column::auto().at_least(600.0).resizable(true)) 1464 .column(Column::auto().at_least(50.0).resizable(true)) 1465 .column(Column::auto().at_least(50.0).resizable(true)) 1466 .column(Column::remainder()) 1467 .header(20.0, |mut header| { 1468 header.col(|ui| { 1469 ui.heading("file"); 1470 }); 1471 header.col(|ui| { 1472 ui.heading("line"); 1473 }); 1474 header.col(|ui| { 1475 ui.heading("count"); 1476 }); 1477 header.col(|ui| { 1478 ui.heading("reason"); 1479 }); 1480 }) 1481 .body(|mut body| { 1482 for (cause, hits) in causes.iter() { 1483 body.row(30.0, |mut row| { 1484 row.col(|ui| { 1485 ui.label(cause.file.to_string()); 1486 }); 1487 row.col(|ui| { 1488 ui.label(format!("{}", cause.line)); 1489 }); 1490 row.col(|ui| { 1491 ui.label(format!("{hits}")); 1492 }); 1493 row.col(|ui| { 1494 ui.label(format!("{}", &cause.reason)); 1495 }); 1496 }); 1497 } 1498 }); 1499 }); 1500 } 1501 1502 fn virtual_keyboard_ui(ui: &mut egui::Ui, rect: egui::Rect) { 1503 let painter = ui.painter_at(rect); 1504 1505 painter.rect_filled(rect, 0.0, Color32::from_black_alpha(200)); 1506 1507 ui.put(rect, |ui: &mut egui::Ui| { 1508 ui.centered_and_justified(|ui| { 1509 ui.label("This is a keyboard"); 1510 }) 1511 .response 1512 }); 1513 } 1514 1515 struct SoftKeyboardAnim { 1516 skb_rect: Option<Rect>, 1517 anim_height: f32, 1518 } 1519 1520 #[derive(Copy, Default, Clone, Eq, PartialEq, Debug)] 1521 enum AnimState { 1522 /// It finished opening 1523 Opened, 1524 1525 /// We started to open 1526 StartOpen, 1527 1528 /// We started to close 1529 StartClose, 1530 1531 /// We finished openning 1532 FinishedOpen, 1533 1534 /// We finished to close 1535 FinishedClose, 1536 1537 /// It finished closing 1538 #[default] 1539 Closed, 1540 1541 /// We are animating towards open 1542 Opening, 1543 1544 /// We are animating towards close 1545 Closing, 1546 } 1547 1548 impl SoftKeyboardAnim { 1549 /// Advance the FSM based on current (anim_height) vs target (skb_rect.height()). 1550 /// Start*/Finished* are one-tick edge states used for signaling. 1551 fn changed(&self, state: AnimState) -> AnimState { 1552 const EPS: f32 = 0.01; 1553 1554 let target = self.skb_rect.map_or(0.0, |r| r.height()); 1555 let current = self.anim_height; 1556 1557 let done = (current - target).abs() <= EPS; 1558 let going_up = target > current + EPS; 1559 let going_down = current > target + EPS; 1560 let target_is_closed = target <= EPS; 1561 1562 match state { 1563 // Resting states: emit a Start* edge only when a move is requested, 1564 // and pick direction by the sign of (target - current). 1565 AnimState::Opened => { 1566 if done { 1567 AnimState::Opened 1568 } else if going_up { 1569 AnimState::StartOpen 1570 } else { 1571 AnimState::StartClose 1572 } 1573 } 1574 AnimState::Closed => { 1575 if done { 1576 AnimState::Closed 1577 } else if going_up { 1578 AnimState::StartOpen 1579 } else { 1580 AnimState::StartClose 1581 } 1582 } 1583 1584 // Edge → flow 1585 AnimState::StartOpen => AnimState::Opening, 1586 AnimState::StartClose => AnimState::Closing, 1587 1588 // Flow states: finish when we hit the target; if the target jumps across, 1589 // emit the opposite Start* to signal a reversal. 1590 AnimState::Opening => { 1591 if done { 1592 if target_is_closed { 1593 AnimState::FinishedClose 1594 } else { 1595 AnimState::FinishedOpen 1596 } 1597 } else if going_down { 1598 // target moved below current mid-flight → reversal 1599 AnimState::StartClose 1600 } else { 1601 AnimState::Opening 1602 } 1603 } 1604 AnimState::Closing => { 1605 if done { 1606 if target_is_closed { 1607 AnimState::FinishedClose 1608 } else { 1609 AnimState::FinishedOpen 1610 } 1611 } else if going_up { 1612 // target moved above current mid-flight → reversal 1613 AnimState::StartOpen 1614 } else { 1615 AnimState::Closing 1616 } 1617 } 1618 1619 // Finish edges collapse to the stable resting states on the next tick. 1620 AnimState::FinishedOpen => AnimState::Opened, 1621 AnimState::FinishedClose => AnimState::Closed, 1622 } 1623 } 1624 } 1625 1626 /// How "open" the softkeyboard is. This is an animated value 1627 fn soft_keyboard_anim( 1628 ui: &mut egui::Ui, 1629 ctx: &mut AppContext, 1630 chrome_options: &mut ChromeOptions, 1631 ) -> SoftKeyboardAnim { 1632 let skb_ctx = if chrome_options.contains(ChromeOptions::VirtualKeyboard) { 1633 SoftKeyboardContext::Virtual 1634 } else { 1635 SoftKeyboardContext::Platform { 1636 ppp: ui.ctx().pixels_per_point(), 1637 } 1638 }; 1639 1640 // move screen up if virtual keyboard intersects with input_rect 1641 let screen_rect = ui.ctx().screen_rect(); 1642 let mut skb_rect: Option<Rect> = None; 1643 1644 let keyboard_height = 1645 if let Some(vkb_rect) = ctx.soft_keyboard_rect(screen_rect, skb_ctx.clone()) { 1646 skb_rect = Some(vkb_rect); 1647 vkb_rect.height() 1648 } else { 1649 0.0 1650 }; 1651 1652 let anim_height = 1653 ui.ctx() 1654 .animate_value_with_time(egui::Id::new("keyboard_anim"), keyboard_height, 0.1); 1655 1656 SoftKeyboardAnim { 1657 anim_height, 1658 skb_rect, 1659 } 1660 } 1661 1662 fn try_toggle_virtual_keyboard( 1663 ctx: &egui::Context, 1664 options: NotedeckOptions, 1665 chrome_options: &mut ChromeOptions, 1666 ) { 1667 // handle virtual keyboard toggle here because why not 1668 if options.contains(NotedeckOptions::Debug) && ctx.input(|i| i.key_pressed(egui::Key::F1)) { 1669 chrome_options.toggle(ChromeOptions::VirtualKeyboard); 1670 } 1671 } 1672 1673 /// All the logic which handles our keyboard visibility 1674 fn keyboard_visibility( 1675 ui: &mut egui::Ui, 1676 ctx: &mut AppContext, 1677 options: &mut ChromeOptions, 1678 soft_kb_anim_state: &mut AnimState, 1679 ) -> SoftKeyboardAnim { 1680 try_toggle_virtual_keyboard(ui.ctx(), ctx.args.options, options); 1681 1682 let soft_kb_anim = soft_keyboard_anim(ui, ctx, options); 1683 1684 let prev_state = *soft_kb_anim_state; 1685 let current_state = soft_kb_anim.changed(prev_state); 1686 *soft_kb_anim_state = current_state; 1687 1688 if prev_state != current_state { 1689 tracing::debug!("soft kb state {prev_state:?} -> {current_state:?}"); 1690 } 1691 1692 match current_state { 1693 // we finished 1694 AnimState::FinishedOpen => {} 1695 1696 // on first open, we setup our scroll target 1697 AnimState::StartOpen => { 1698 // when we first open the keyboard, check to see if the target soft 1699 // keyboard rect (the height at full open) intersects with any 1700 // input response rects from last frame 1701 // 1702 // If we do, then we set a bit that we need keyboard visibility. 1703 // We will use this bit to resize the screen based on the soft 1704 // keyboard animation state 1705 if let Some(skb_rect) = soft_kb_anim.skb_rect { 1706 if let Some(input_rect) = notedeck_ui::input_rect(ui) { 1707 options.set( 1708 ChromeOptions::KeyboardVisibility, 1709 input_rect.intersects(skb_rect), 1710 ) 1711 } 1712 } 1713 } 1714 1715 AnimState::FinishedClose => { 1716 // clear last input box position state 1717 notedeck_ui::clear_input_rect(ui); 1718 } 1719 1720 AnimState::Closing => {} 1721 AnimState::Opened => {} 1722 AnimState::Closed => {} 1723 AnimState::Opening => {} 1724 AnimState::StartClose => {} 1725 }; 1726 1727 soft_kb_anim 1728 }