notedeck

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

chrome.rs (43143B)


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