notedeck

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

chrome.rs (44338B)


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