notedeck

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

side_panel.rs (18085B)


      1 use egui::{
      2     vec2, CursorIcon, InnerResponse, Layout, Margin, RichText, ScrollArea, Separator, Stroke,
      3     Widget,
      4 };
      5 use tracing::{error, info};
      6 
      7 use crate::{
      8     app::{get_active_columns_mut, get_decks_mut},
      9     app_style::DECK_ICON_SIZE,
     10     decks::{DecksAction, DecksCache},
     11     nav::SwitchingAction,
     12     route::Route,
     13 };
     14 
     15 use notedeck::{tr, Accounts, Localization, MediaJobSender, UserAccount};
     16 use notedeck_ui::{
     17     anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE},
     18     app_images, colors, ProfilePic, View,
     19 };
     20 
     21 use super::configure_deck::deck_icon;
     22 
     23 pub static SIDE_PANEL_WIDTH: f32 = 68.0;
     24 static ICON_WIDTH: f32 = 40.0;
     25 
     26 pub struct DesktopSidePanel<'a> {
     27     selected_account: &'a UserAccount,
     28     decks_cache: &'a DecksCache,
     29     i18n: &'a mut Localization,
     30     ndb: &'a nostrdb::Ndb,
     31     img_cache: &'a mut notedeck::Images,
     32     jobs: &'a MediaJobSender,
     33 }
     34 
     35 impl View for DesktopSidePanel<'_> {
     36     fn ui(&mut self, ui: &mut egui::Ui) {
     37         self.show(ui);
     38     }
     39 }
     40 
     41 #[derive(Debug, Eq, PartialEq, Clone, Copy)]
     42 pub enum SidePanelAction {
     43     Columns,
     44     ComposeNote,
     45     Search,
     46     ExpandSidePanel,
     47     NewDeck,
     48     SwitchDeck(usize),
     49     EditDeck(usize),
     50     Wallet,
     51     ProfileAvatar,
     52 }
     53 
     54 pub struct SidePanelResponse {
     55     pub response: egui::Response,
     56     pub action: SidePanelAction,
     57 }
     58 
     59 impl SidePanelResponse {
     60     fn new(action: SidePanelAction, response: egui::Response) -> Self {
     61         SidePanelResponse { action, response }
     62     }
     63 }
     64 
     65 impl<'a> DesktopSidePanel<'a> {
     66     pub fn new(
     67         selected_account: &'a UserAccount,
     68         decks_cache: &'a DecksCache,
     69         i18n: &'a mut Localization,
     70         ndb: &'a nostrdb::Ndb,
     71         img_cache: &'a mut notedeck::Images,
     72         jobs: &'a MediaJobSender,
     73     ) -> Self {
     74         Self {
     75             selected_account,
     76             decks_cache,
     77             i18n,
     78             ndb,
     79             img_cache,
     80             jobs,
     81         }
     82     }
     83 
     84     pub fn show(&mut self, ui: &mut egui::Ui) -> Option<SidePanelResponse> {
     85         let frame =
     86             egui::Frame::new().inner_margin(Margin::same(notedeck_ui::constants::FRAME_MARGIN));
     87 
     88         if !ui.visuals().dark_mode {
     89             let rect = ui.available_rect_before_wrap();
     90             ui.painter().rect(
     91                 rect,
     92                 0,
     93                 colors::ALMOST_WHITE,
     94                 egui::Stroke::new(0.0, egui::Color32::TRANSPARENT),
     95                 egui::StrokeKind::Inside,
     96             );
     97         }
     98 
     99         frame.show(ui, |ui| self.show_inner(ui)).inner
    100     }
    101 
    102     fn show_inner(&mut self, ui: &mut egui::Ui) -> Option<SidePanelResponse> {
    103         let dark_mode = ui.ctx().style().visuals.dark_mode;
    104 
    105         let inner = ui
    106             .vertical(|ui| {
    107                 ui.with_layout(Layout::top_down(egui::Align::Center), |ui| {
    108                     // macos needs a bit of space to make room for window
    109                     // minimize/close buttons
    110                     if cfg!(target_os = "macos") {
    111                         ui.add_space(24.0);
    112                     }
    113 
    114                     let compose_resp = ui
    115                         .add(crate::ui::post::compose_note_button(dark_mode))
    116                         .on_hover_cursor(egui::CursorIcon::PointingHand);
    117                     let search_resp = ui.add(search_button());
    118                     let column_resp = ui.add(add_column_button());
    119 
    120                     ui.add(Separator::default().horizontal().spacing(8.0).shrink(4.0));
    121 
    122                     ui.add_space(8.0);
    123                     ui.add(egui::Label::new(
    124                         RichText::new(tr!(
    125                             self.i18n,
    126                             "DECKS",
    127                             "Label for decks section in side panel"
    128                         ))
    129                         .size(11.0)
    130                         .color(ui.visuals().noninteractive().fg_stroke.color),
    131                     ));
    132                     ui.add_space(8.0);
    133                     let add_deck_resp = ui.add(add_deck_button(self.i18n));
    134 
    135                     let avatar_size = 40.0;
    136                     let bottom_padding = 8.0;
    137                     let avatar_section_height = avatar_size + bottom_padding;
    138 
    139                     let available_for_decks = ui.available_height() - avatar_section_height;
    140 
    141                     let decks_inner = ScrollArea::vertical()
    142                         .max_height(available_for_decks)
    143                         .show(ui, |ui| {
    144                             show_decks(ui, self.decks_cache, self.selected_account)
    145                         })
    146                         .inner;
    147 
    148                     let remaining = ui.available_height();
    149                     if remaining > avatar_section_height {
    150                         ui.add_space(remaining - avatar_section_height);
    151                     }
    152 
    153                     let txn = nostrdb::Transaction::new(self.ndb).ok();
    154                     let profile_url = if let Some(ref txn) = txn {
    155                         if let Ok(profile) = self
    156                             .ndb
    157                             .get_profile_by_pubkey(txn, self.selected_account.key.pubkey.bytes())
    158                         {
    159                             notedeck::profile::get_profile_url(Some(&profile))
    160                         } else {
    161                             notedeck::profile::no_pfp_url()
    162                         }
    163                     } else {
    164                         notedeck::profile::no_pfp_url()
    165                     };
    166 
    167                     let pfp_resp = ui
    168                         .add(
    169                             &mut ProfilePic::new(self.img_cache, self.jobs, profile_url)
    170                                 .size(avatar_size)
    171                                 .sense(egui::Sense::click()),
    172                         )
    173                         .on_hover_cursor(egui::CursorIcon::PointingHand);
    174 
    175                     /*
    176                     if expand_resp.clicked() {
    177                         Some(InnerResponse::new(
    178                             SidePanelAction::ExpandSidePanel,
    179                             expand_resp,
    180                         ))
    181                     */
    182                     if pfp_resp.clicked() {
    183                         Some(InnerResponse::new(SidePanelAction::ProfileAvatar, pfp_resp))
    184                     } else if compose_resp.clicked() {
    185                         Some(InnerResponse::new(
    186                             SidePanelAction::ComposeNote,
    187                             compose_resp,
    188                         ))
    189                     } else if search_resp.clicked() {
    190                         Some(InnerResponse::new(SidePanelAction::Search, search_resp))
    191                     } else if column_resp.clicked() {
    192                         Some(InnerResponse::new(SidePanelAction::Columns, column_resp))
    193                     } else if add_deck_resp.clicked() {
    194                         Some(InnerResponse::new(SidePanelAction::NewDeck, add_deck_resp))
    195                     } else if decks_inner.response.secondary_clicked() {
    196                         info!("decks inner secondary click");
    197                         if let Some(clicked_index) = decks_inner.inner {
    198                             Some(InnerResponse::new(
    199                                 SidePanelAction::EditDeck(clicked_index),
    200                                 decks_inner.response,
    201                             ))
    202                         } else {
    203                             None
    204                         }
    205                     } else if decks_inner.response.clicked() {
    206                         if let Some(clicked_index) = decks_inner.inner {
    207                             Some(InnerResponse::new(
    208                                 SidePanelAction::SwitchDeck(clicked_index),
    209                                 decks_inner.response,
    210                             ))
    211                         } else {
    212                             None
    213                         }
    214                     } else {
    215                         None
    216                     }
    217                 })
    218                 .inner
    219             })
    220             .inner;
    221 
    222         if let Some(inner) = inner {
    223             Some(SidePanelResponse::new(inner.inner, inner.response))
    224         } else {
    225             None
    226         }
    227     }
    228 
    229     pub fn perform_action(
    230         decks_cache: &mut DecksCache,
    231         accounts: &Accounts,
    232         action: SidePanelAction,
    233         i18n: &mut Localization,
    234     ) -> Option<SwitchingAction> {
    235         let router = get_active_columns_mut(i18n, accounts, decks_cache).get_selected_router();
    236         let mut switching_response = None;
    237         match action {
    238             /*
    239             SidePanelAction::Panel => {} // TODO
    240             SidePanelAction::Account => {
    241                 if router
    242                     .routes()
    243                     .iter()
    244                     .any(|r| r == &Route::Accounts(AccountsRoute::Accounts))
    245                 {
    246                     // return if we are already routing to accounts
    247                     router.go_back();
    248                 } else {
    249                     router.route_to(Route::accounts());
    250                 }
    251             }
    252             SidePanelAction::Settings => {
    253                 if router.routes().iter().any(|r| r == &Route::Relays) {
    254                     // return if we are already routing to accounts
    255                     router.go_back();
    256                 } else {
    257                     router.route_to(Route::relays());
    258                 }
    259             }
    260             SidePanelAction::Support => {
    261                 if router.routes().iter().any(|r| r == &Route::Support) {
    262                     router.go_back();
    263                 } else {
    264                     support.refresh();
    265                     router.route_to(Route::Support);
    266                 }
    267             }
    268             */
    269             SidePanelAction::Columns => {
    270                 if router
    271                     .routes()
    272                     .iter()
    273                     .any(|r| matches!(r, Route::AddColumn(_)))
    274                 {
    275                     router.go_back();
    276                 } else {
    277                     get_active_columns_mut(i18n, accounts, decks_cache).new_column_picker();
    278                 }
    279             }
    280             SidePanelAction::ComposeNote => {
    281                 let can_post = accounts.get_selected_account().key.secret_key.is_some();
    282 
    283                 if !can_post {
    284                     router.route_to(Route::accounts());
    285                 } else if router.routes().iter().any(|r| r == &Route::ComposeNote) {
    286                     router.go_back();
    287                 } else {
    288                     router.route_to(Route::ComposeNote);
    289                 }
    290             }
    291             SidePanelAction::Search => {
    292                 // TODO
    293                 if router.top() == &Route::Search {
    294                     router.go_back();
    295                 } else {
    296                     router.route_to(Route::Search);
    297                 }
    298             }
    299             SidePanelAction::ExpandSidePanel => {
    300                 // TODO
    301                 info!("Clicked expand side panel button");
    302             }
    303             SidePanelAction::NewDeck => {
    304                 if router.routes().iter().any(|r| r == &Route::NewDeck) {
    305                     router.go_back();
    306                 } else {
    307                     router.route_to(Route::NewDeck);
    308                 }
    309             }
    310             SidePanelAction::SwitchDeck(index) => {
    311                 switching_response = Some(crate::nav::SwitchingAction::Decks(DecksAction::Switch(
    312                     index,
    313                 )))
    314             }
    315             SidePanelAction::EditDeck(index) => {
    316                 if router.routes().iter().any(|r| r == &Route::EditDeck(index)) {
    317                     router.go_back();
    318                 } else {
    319                     switching_response = Some(crate::nav::SwitchingAction::Decks(
    320                         DecksAction::Switch(index),
    321                     ));
    322                     if let Some(edit_deck) = get_decks_mut(i18n, accounts, decks_cache)
    323                         .decks_mut()
    324                         .get_mut(index)
    325                     {
    326                         edit_deck
    327                             .columns_mut()
    328                             .get_selected_router()
    329                             .route_to(Route::EditDeck(index));
    330                     } else {
    331                         error!("Cannot push EditDeck route to index {}", index);
    332                     }
    333                 }
    334             }
    335             SidePanelAction::Wallet => 's: {
    336                 if router
    337                     .routes()
    338                     .iter()
    339                     .any(|r| matches!(r, Route::Wallet(_)))
    340                 {
    341                     router.go_back();
    342                     break 's;
    343                 }
    344 
    345                 router.route_to(Route::Wallet(notedeck::WalletType::Auto));
    346             }
    347             SidePanelAction::ProfileAvatar => {
    348                 let pubkey = accounts.get_selected_account().key.pubkey;
    349                 if router.routes().iter().any(|r| r == &Route::profile(pubkey)) {
    350                     router.go_back();
    351                 } else {
    352                     router.route_to(Route::profile(pubkey));
    353                 }
    354             }
    355         }
    356         switching_response
    357     }
    358 }
    359 
    360 fn add_column_button() -> impl Widget {
    361     move |ui: &mut egui::Ui| {
    362         let img_size = 24.0;
    363         let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget
    364 
    365         let img = if ui.visuals().dark_mode {
    366             app_images::add_column_dark_image()
    367         } else {
    368             app_images::add_column_light_image()
    369         };
    370 
    371         let helper = AnimationHelper::new(ui, "add-column-button", vec2(max_size, max_size));
    372 
    373         let cur_img_size = helper.scale_1d_pos(img_size);
    374         img.paint_at(
    375             ui,
    376             helper
    377                 .get_animation_rect()
    378                 .shrink((max_size - cur_img_size) / 2.0),
    379         );
    380 
    381         helper
    382             .take_animation_response()
    383             .on_hover_cursor(CursorIcon::PointingHand)
    384             .on_hover_text("Add new column")
    385     }
    386 }
    387 
    388 pub fn search_button_impl(color: egui::Color32, line_width: f32) -> impl Widget {
    389     move |ui: &mut egui::Ui| -> egui::Response {
    390         let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget
    391         let min_line_width_circle = line_width; // width of the magnifying glass
    392         let min_line_width_handle = line_width;
    393         let helper = AnimationHelper::new(ui, "search-button", vec2(max_size, max_size));
    394 
    395         let painter = ui.painter_at(helper.get_animation_rect());
    396 
    397         let cur_line_width_circle = helper.scale_1d_pos(min_line_width_circle);
    398         let cur_line_width_handle = helper.scale_1d_pos(min_line_width_handle);
    399         let min_outer_circle_radius = helper.scale_radius(15.0);
    400         let cur_outer_circle_radius = helper.scale_1d_pos(min_outer_circle_radius);
    401         let min_handle_length = 7.0;
    402         let cur_handle_length = helper.scale_1d_pos(min_handle_length);
    403 
    404         let circle_center = helper.scale_from_center(-2.0, -2.0);
    405 
    406         let handle_vec = vec2(
    407             std::f32::consts::FRAC_1_SQRT_2,
    408             std::f32::consts::FRAC_1_SQRT_2,
    409         );
    410 
    411         let handle_pos_1 = circle_center + (handle_vec * (cur_outer_circle_radius - 3.0));
    412         let handle_pos_2 =
    413             circle_center + (handle_vec * (cur_outer_circle_radius + cur_handle_length));
    414 
    415         let circle_stroke = Stroke::new(cur_line_width_circle, color);
    416         let handle_stroke = Stroke::new(cur_line_width_handle, color);
    417 
    418         painter.line_segment([handle_pos_1, handle_pos_2], handle_stroke);
    419         painter.circle(
    420             circle_center,
    421             min_outer_circle_radius,
    422             ui.style().visuals.widgets.inactive.weak_bg_fill,
    423             circle_stroke,
    424         );
    425 
    426         helper
    427             .take_animation_response()
    428             .on_hover_cursor(CursorIcon::PointingHand)
    429             .on_hover_text("Open search")
    430     }
    431 }
    432 
    433 pub fn search_button() -> impl Widget {
    434     search_button_impl(colors::MID_GRAY, 1.5)
    435 }
    436 
    437 // TODO: convert to responsive button when expanded side panel impl is finished
    438 
    439 fn add_deck_button<'a>(i18n: &'a mut Localization) -> impl Widget + 'a {
    440     |ui: &mut egui::Ui| -> egui::Response {
    441         let img_size = 40.0;
    442 
    443         let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget
    444         let img = app_images::new_deck_image().max_width(img_size);
    445 
    446         let helper = AnimationHelper::new(ui, "new-deck-icon", vec2(max_size, max_size));
    447 
    448         let cur_img_size = helper.scale_1d_pos(img_size);
    449         img.paint_at(
    450             ui,
    451             helper
    452                 .get_animation_rect()
    453                 .shrink((max_size - cur_img_size) / 2.0),
    454         );
    455 
    456         helper
    457             .take_animation_response()
    458             .on_hover_cursor(CursorIcon::PointingHand)
    459             .on_hover_text(tr!(
    460                 i18n,
    461                 "Add new deck",
    462                 "Tooltip text for adding a new deck button"
    463             ))
    464     }
    465 }
    466 
    467 fn show_decks<'a>(
    468     ui: &mut egui::Ui,
    469     decks_cache: &'a DecksCache,
    470     selected_account: &'a UserAccount,
    471 ) -> InnerResponse<Option<usize>> {
    472     let show_decks_id = ui.id().with("show-decks");
    473     let account_id = selected_account.key.pubkey;
    474     let (cur_decks, account_id) = (
    475         decks_cache.decks(&account_id),
    476         show_decks_id.with(account_id),
    477     );
    478     let active_index = cur_decks.active_index();
    479 
    480     let (_, mut resp) = ui.allocate_exact_size(vec2(0.0, 0.0), egui::Sense::click());
    481     let mut clicked_index = None;
    482     for (index, deck) in cur_decks.decks().iter().enumerate() {
    483         let highlight = index == active_index;
    484         let deck_icon_resp = ui
    485             .add(deck_icon(
    486                 account_id.with(index),
    487                 Some(deck.icon),
    488                 DECK_ICON_SIZE,
    489                 40.0,
    490                 highlight,
    491             ))
    492             .on_hover_text_at_pointer(&deck.name)
    493             .on_hover_cursor(CursorIcon::PointingHand);
    494         if deck_icon_resp.clicked() || deck_icon_resp.secondary_clicked() {
    495             clicked_index = Some(index);
    496         }
    497         resp = resp.union(deck_icon_resp);
    498     }
    499     InnerResponse::new(clicked_index, resp)
    500 }