notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

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: &notedeck::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: &notedeck::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 }