notedeck

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

side_panel.rs (18621B)


      1 use egui::{
      2     vec2, Color32, InnerResponse, Label, Layout, Margin, RichText, Separator, Stroke, Widget,
      3 };
      4 use tracing::info;
      5 
      6 use crate::{
      7     accounts::AccountsRoute,
      8     app_style, colors,
      9     column::{Column, Columns},
     10     imgcache::ImageCache,
     11     route::Route,
     12     support::Support,
     13     user_account::UserAccount,
     14     Damus,
     15 };
     16 
     17 use super::{
     18     anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE},
     19     profile::preview::get_account_url,
     20     ProfilePic, View,
     21 };
     22 
     23 pub static SIDE_PANEL_WIDTH: f32 = 68.0;
     24 static ICON_WIDTH: f32 = 40.0;
     25 
     26 pub struct DesktopSidePanel<'a> {
     27     ndb: &'a nostrdb::Ndb,
     28     img_cache: &'a mut ImageCache,
     29     selected_account: Option<&'a UserAccount>,
     30 }
     31 
     32 impl View for DesktopSidePanel<'_> {
     33     fn ui(&mut self, ui: &mut egui::Ui) {
     34         self.show(ui);
     35     }
     36 }
     37 
     38 #[derive(Debug, Eq, PartialEq, Clone, Copy)]
     39 pub enum SidePanelAction {
     40     Panel,
     41     Account,
     42     Settings,
     43     Columns,
     44     ComposeNote,
     45     Search,
     46     ExpandSidePanel,
     47     Support,
     48 }
     49 
     50 pub struct SidePanelResponse {
     51     pub response: egui::Response,
     52     pub action: SidePanelAction,
     53 }
     54 
     55 impl SidePanelResponse {
     56     fn new(action: SidePanelAction, response: egui::Response) -> Self {
     57         SidePanelResponse { action, response }
     58     }
     59 }
     60 
     61 impl<'a> DesktopSidePanel<'a> {
     62     pub fn new(
     63         ndb: &'a nostrdb::Ndb,
     64         img_cache: &'a mut ImageCache,
     65         selected_account: Option<&'a UserAccount>,
     66     ) -> Self {
     67         Self {
     68             ndb,
     69             img_cache,
     70             selected_account,
     71         }
     72     }
     73 
     74     pub fn show(&mut self, ui: &mut egui::Ui) -> SidePanelResponse {
     75         let mut frame = egui::Frame::none().inner_margin(Margin::same(8.0));
     76 
     77         if !ui.visuals().dark_mode {
     78             frame = frame.fill(colors::ALMOST_WHITE);
     79         }
     80 
     81         frame.show(ui, |ui| self.show_inner(ui)).inner
     82     }
     83 
     84     fn show_inner(&mut self, ui: &mut egui::Ui) -> SidePanelResponse {
     85         let dark_mode = ui.ctx().style().visuals.dark_mode;
     86 
     87         let inner = ui
     88             .vertical(|ui| {
     89                 let top_resp = ui
     90                     .with_layout(Layout::top_down(egui::Align::Center), |ui| {
     91                         // macos needs a bit of space to make room for window
     92                         // minimize/close buttons
     93                         if cfg!(target_os = "macos") {
     94                             ui.add_space(24.0);
     95                         }
     96 
     97                         let expand_resp = ui.add(expand_side_panel_button());
     98                         ui.add_space(4.0);
     99                         ui.add(milestone_name());
    100                         ui.add_space(16.0);
    101                         let is_interactive = self
    102                             .selected_account
    103                             .is_some_and(|s| s.secret_key.is_some());
    104                         let compose_resp = ui.add(compose_note_button(is_interactive));
    105                         let compose_resp = if is_interactive {
    106                             compose_resp
    107                         } else {
    108                             compose_resp.on_hover_cursor(egui::CursorIcon::NotAllowed)
    109                         };
    110                         // let search_resp = ui.add(search_button());
    111                         let column_resp = ui.add(add_column_button(dark_mode));
    112 
    113                         ui.add(Separator::default().horizontal().spacing(8.0).shrink(4.0));
    114 
    115                         if expand_resp.clicked() {
    116                             Some(InnerResponse::new(
    117                                 SidePanelAction::ExpandSidePanel,
    118                                 expand_resp,
    119                             ))
    120                         } else if compose_resp.clicked() {
    121                             Some(InnerResponse::new(
    122                                 SidePanelAction::ComposeNote,
    123                                 compose_resp,
    124                             ))
    125                         // } else if search_resp.clicked() {
    126                         //     Some(InnerResponse::new(SidePanelAction::Search, search_resp))
    127                         } else if column_resp.clicked() {
    128                             Some(InnerResponse::new(SidePanelAction::Columns, column_resp))
    129                         } else {
    130                             None
    131                         }
    132                     })
    133                     .inner;
    134 
    135                 let (pfp_resp, bottom_resp) = ui
    136                     .with_layout(Layout::bottom_up(egui::Align::Center), |ui| {
    137                         let pfp_resp = self.pfp_button(ui);
    138                         let settings_resp = ui.add(settings_button(dark_mode));
    139 
    140                         if let Some(new_visuals) = app_style::user_requested_visuals_change(
    141                             super::is_oled(),
    142                             ui.ctx().style().visuals.dark_mode,
    143                             ui,
    144                         ) {
    145                             ui.ctx().set_visuals(new_visuals)
    146                         }
    147 
    148                         let support_resp = ui.add(support_button());
    149 
    150                         let optional_inner = if pfp_resp.clicked() {
    151                             Some(egui::InnerResponse::new(
    152                                 SidePanelAction::Account,
    153                                 pfp_resp.clone(),
    154                             ))
    155                         } else if settings_resp.clicked() || settings_resp.hovered() {
    156                             Some(egui::InnerResponse::new(
    157                                 SidePanelAction::Settings,
    158                                 settings_resp,
    159                             ))
    160                         } else if support_resp.clicked() {
    161                             Some(egui::InnerResponse::new(
    162                                 SidePanelAction::Support,
    163                                 support_resp,
    164                             ))
    165                         } else {
    166                             None
    167                         };
    168 
    169                         (pfp_resp, optional_inner)
    170                     })
    171                     .inner;
    172 
    173                 if let Some(bottom_inner) = bottom_resp {
    174                     bottom_inner
    175                 } else if let Some(top_inner) = top_resp {
    176                     top_inner
    177                 } else {
    178                     egui::InnerResponse::new(SidePanelAction::Panel, pfp_resp)
    179                 }
    180             })
    181             .inner;
    182 
    183         SidePanelResponse::new(inner.inner, inner.response)
    184     }
    185 
    186     fn pfp_button(&mut self, ui: &mut egui::Ui) -> egui::Response {
    187         let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget
    188         let helper = AnimationHelper::new(ui, "pfp-button", vec2(max_size, max_size));
    189 
    190         let min_pfp_size = ICON_WIDTH;
    191         let cur_pfp_size = helper.scale_1d_pos(min_pfp_size);
    192 
    193         let txn = nostrdb::Transaction::new(self.ndb).expect("should be able to create txn");
    194         let profile_url = get_account_url(&txn, self.ndb, self.selected_account);
    195 
    196         let widget = ProfilePic::new(self.img_cache, profile_url).size(cur_pfp_size);
    197 
    198         ui.put(helper.get_animation_rect(), widget);
    199 
    200         helper.take_animation_response()
    201     }
    202 
    203     pub fn perform_action(columns: &mut Columns, support: &mut Support, action: SidePanelAction) {
    204         let router = columns.get_first_router();
    205         match action {
    206             SidePanelAction::Panel => {} // TODO
    207             SidePanelAction::Account => {
    208                 if router
    209                     .routes()
    210                     .iter()
    211                     .any(|&r| r == Route::Accounts(AccountsRoute::Accounts))
    212                 {
    213                     // return if we are already routing to accounts
    214                     router.go_back();
    215                 } else {
    216                     router.route_to(Route::accounts());
    217                 }
    218             }
    219             SidePanelAction::Settings => {
    220                 if router.routes().iter().any(|&r| r == Route::Relays) {
    221                     // return if we are already routing to accounts
    222                     router.go_back();
    223                 } else {
    224                     router.route_to(Route::relays());
    225                 }
    226             }
    227             SidePanelAction::Columns => {
    228                 if router
    229                     .routes()
    230                     .iter()
    231                     .any(|&r| matches!(r, Route::AddColumn(_)))
    232                 {
    233                     router.go_back();
    234                 } else {
    235                     columns.new_column_picker();
    236                 }
    237             }
    238             SidePanelAction::ComposeNote => {
    239                 if router.routes().iter().any(|&r| r == Route::ComposeNote) {
    240                     router.go_back();
    241                 } else {
    242                     router.route_to(Route::ComposeNote);
    243                 }
    244             }
    245             SidePanelAction::Search => {
    246                 // TODO
    247                 info!("Clicked search button");
    248             }
    249             SidePanelAction::ExpandSidePanel => {
    250                 // TODO
    251                 info!("Clicked expand side panel button");
    252             }
    253             SidePanelAction::Support => {
    254                 if router.routes().iter().any(|&r| r == Route::Support) {
    255                     router.go_back();
    256                 } else {
    257                     support.refresh();
    258                     router.route_to(Route::Support);
    259                 }
    260             }
    261         }
    262     }
    263 }
    264 
    265 fn settings_button(dark_mode: bool) -> impl Widget {
    266     move |ui: &mut egui::Ui| {
    267         let img_size = 24.0;
    268         let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget
    269         let img_data = if dark_mode {
    270             egui::include_image!("../../assets/icons/settings_dark_4x.png")
    271         } else {
    272             egui::include_image!("../../assets/icons/settings_light_4x.png")
    273         };
    274         let img = egui::Image::new(img_data).max_width(img_size);
    275 
    276         let helper = AnimationHelper::new(ui, "settings-button", vec2(max_size, max_size));
    277 
    278         let cur_img_size = helper.scale_1d_pos(img_size);
    279         img.paint_at(
    280             ui,
    281             helper
    282                 .get_animation_rect()
    283                 .shrink((max_size - cur_img_size) / 2.0),
    284         );
    285 
    286         helper.take_animation_response()
    287     }
    288 }
    289 
    290 fn add_column_button(dark_mode: bool) -> impl Widget {
    291     move |ui: &mut egui::Ui| {
    292         let img_size = 24.0;
    293         let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget
    294 
    295         let img_data = if dark_mode {
    296             egui::include_image!("../../assets/icons/add_column_dark_4x.png")
    297         } else {
    298             egui::include_image!("../../assets/icons/add_column_light_4x.png")
    299         };
    300 
    301         let img = egui::Image::new(img_data).max_width(img_size);
    302 
    303         let helper = AnimationHelper::new(ui, "add-column-button", vec2(max_size, max_size));
    304 
    305         let cur_img_size = helper.scale_1d_pos(img_size);
    306         img.paint_at(
    307             ui,
    308             helper
    309                 .get_animation_rect()
    310                 .shrink((max_size - cur_img_size) / 2.0),
    311         );
    312 
    313         helper.take_animation_response()
    314     }
    315 }
    316 
    317 fn compose_note_button(interactive: bool) -> impl Widget {
    318     move |ui: &mut egui::Ui| -> egui::Response {
    319         let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget
    320 
    321         let min_outer_circle_diameter = 40.0;
    322         let min_plus_sign_size = 14.0; // length of the plus sign
    323         let min_line_width = 2.25; // width of the plus sign
    324 
    325         let helper = if interactive {
    326             AnimationHelper::new(ui, "note-compose-button", vec2(max_size, max_size))
    327         } else {
    328             AnimationHelper::no_animation(ui, vec2(max_size, max_size))
    329         };
    330 
    331         let painter = ui.painter_at(helper.get_animation_rect());
    332 
    333         let use_background_radius = helper.scale_radius(min_outer_circle_diameter);
    334         let use_line_width = helper.scale_1d_pos(min_line_width);
    335         let use_edge_circle_radius = helper.scale_radius(min_line_width);
    336 
    337         let fill_color = if interactive {
    338             colors::PINK
    339         } else {
    340             ui.visuals().noninteractive().bg_fill
    341         };
    342 
    343         painter.circle_filled(helper.center(), use_background_radius, fill_color);
    344 
    345         let min_half_plus_sign_size = min_plus_sign_size / 2.0;
    346         let north_edge = helper.scale_from_center(0.0, min_half_plus_sign_size);
    347         let south_edge = helper.scale_from_center(0.0, -min_half_plus_sign_size);
    348         let west_edge = helper.scale_from_center(-min_half_plus_sign_size, 0.0);
    349         let east_edge = helper.scale_from_center(min_half_plus_sign_size, 0.0);
    350 
    351         painter.line_segment(
    352             [north_edge, south_edge],
    353             Stroke::new(use_line_width, Color32::WHITE),
    354         );
    355         painter.line_segment(
    356             [west_edge, east_edge],
    357             Stroke::new(use_line_width, Color32::WHITE),
    358         );
    359         painter.circle_filled(north_edge, use_edge_circle_radius, Color32::WHITE);
    360         painter.circle_filled(south_edge, use_edge_circle_radius, Color32::WHITE);
    361         painter.circle_filled(west_edge, use_edge_circle_radius, Color32::WHITE);
    362         painter.circle_filled(east_edge, use_edge_circle_radius, Color32::WHITE);
    363 
    364         helper.take_animation_response()
    365     }
    366 }
    367 
    368 #[allow(unused)]
    369 fn search_button() -> impl Widget {
    370     |ui: &mut egui::Ui| -> egui::Response {
    371         let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget
    372         let min_line_width_circle = 1.5; // width of the magnifying glass
    373         let min_line_width_handle = 1.5;
    374         let helper = AnimationHelper::new(ui, "search-button", vec2(max_size, max_size));
    375 
    376         let painter = ui.painter_at(helper.get_animation_rect());
    377 
    378         let cur_line_width_circle = helper.scale_1d_pos(min_line_width_circle);
    379         let cur_line_width_handle = helper.scale_1d_pos(min_line_width_handle);
    380         let min_outer_circle_radius = helper.scale_radius(15.0);
    381         let cur_outer_circle_radius = helper.scale_1d_pos(min_outer_circle_radius);
    382         let min_handle_length = 7.0;
    383         let cur_handle_length = helper.scale_1d_pos(min_handle_length);
    384 
    385         let circle_center = helper.scale_from_center(-2.0, -2.0);
    386 
    387         let handle_vec = vec2(
    388             std::f32::consts::FRAC_1_SQRT_2,
    389             std::f32::consts::FRAC_1_SQRT_2,
    390         );
    391 
    392         let handle_pos_1 = circle_center + (handle_vec * (cur_outer_circle_radius - 3.0));
    393         let handle_pos_2 =
    394             circle_center + (handle_vec * (cur_outer_circle_radius + cur_handle_length));
    395 
    396         let circle_stroke = Stroke::new(cur_line_width_circle, colors::MID_GRAY);
    397         let handle_stroke = Stroke::new(cur_line_width_handle, colors::MID_GRAY);
    398 
    399         painter.line_segment([handle_pos_1, handle_pos_2], handle_stroke);
    400         painter.circle(
    401             circle_center,
    402             min_outer_circle_radius,
    403             ui.style().visuals.widgets.inactive.weak_bg_fill,
    404             circle_stroke,
    405         );
    406 
    407         helper.take_animation_response()
    408     }
    409 }
    410 
    411 // TODO: convert to responsive button when expanded side panel impl is finished
    412 fn expand_side_panel_button() -> impl Widget {
    413     |ui: &mut egui::Ui| -> egui::Response {
    414         let img_size = 40.0;
    415         let img_data = egui::include_image!("../../assets/damus_rounded_80.png");
    416         let img = egui::Image::new(img_data).max_width(img_size);
    417 
    418         ui.add(img)
    419     }
    420 }
    421 
    422 fn support_button() -> impl Widget {
    423     |ui: &mut egui::Ui| -> egui::Response {
    424         let img_size = 16.0;
    425 
    426         let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget
    427         let img_data = if ui.visuals().dark_mode {
    428             egui::include_image!("../../assets/icons/help_icon_dark_4x.png")
    429         } else {
    430             egui::include_image!("../../assets/icons/help_icon_inverted_4x.png")
    431         };
    432         let img = egui::Image::new(img_data).max_width(img_size);
    433 
    434         let helper = AnimationHelper::new(ui, "help-button", vec2(max_size, max_size));
    435 
    436         let cur_img_size = helper.scale_1d_pos(img_size);
    437         img.paint_at(
    438             ui,
    439             helper
    440                 .get_animation_rect()
    441                 .shrink((max_size - cur_img_size) / 2.0),
    442         );
    443 
    444         helper.take_animation_response()
    445     }
    446 }
    447 
    448 fn milestone_name() -> impl Widget {
    449     |ui: &mut egui::Ui| -> egui::Response {
    450         ui.vertical_centered(|ui| {
    451             let font = egui::FontId::new(
    452                 crate::app_style::get_font_size(
    453                     ui.ctx(),
    454                     &crate::app_style::NotedeckTextStyle::Tiny,
    455                 ),
    456                 egui::FontFamily::Name(crate::fonts::NamedFontFamily::Bold.as_str().into()),
    457             );
    458             ui.add(Label::new(
    459                 RichText::new("ALPHA")
    460                     .color(crate::colors::GRAY_SECONDARY)
    461                     .font(font),
    462             ).selectable(false)).on_hover_text("Notedeck is an alpha product. Expect bugs and contact us when you run into issues.").on_hover_cursor(egui::CursorIcon::Help)
    463         })
    464         .inner
    465     }
    466 }
    467 
    468 mod preview {
    469 
    470     use egui_extras::{Size, StripBuilder};
    471 
    472     use crate::{
    473         test_data,
    474         ui::{Preview, PreviewConfig},
    475     };
    476 
    477     use super::*;
    478 
    479     pub struct DesktopSidePanelPreview {
    480         app: Damus,
    481     }
    482 
    483     impl DesktopSidePanelPreview {
    484         fn new() -> Self {
    485             let mut app = test_data::test_app();
    486             app.columns.add_column(Column::new(vec![Route::accounts()]));
    487             DesktopSidePanelPreview { app }
    488         }
    489     }
    490 
    491     impl View for DesktopSidePanelPreview {
    492         fn ui(&mut self, ui: &mut egui::Ui) {
    493             StripBuilder::new(ui)
    494                 .size(Size::exact(SIDE_PANEL_WIDTH))
    495                 .sizes(Size::remainder(), 0)
    496                 .clip(true)
    497                 .horizontal(|mut strip| {
    498                     strip.cell(|ui| {
    499                         let mut panel = DesktopSidePanel::new(
    500                             &self.app.ndb,
    501                             &mut self.app.img_cache,
    502                             self.app.accounts.get_selected_account(),
    503                         );
    504                         let response = panel.show(ui);
    505 
    506                         DesktopSidePanel::perform_action(
    507                             &mut self.app.columns,
    508                             &mut self.app.support,
    509                             response.action,
    510                         );
    511                     });
    512                 });
    513         }
    514     }
    515 
    516     impl Preview for DesktopSidePanel<'_> {
    517         type Prev = DesktopSidePanelPreview;
    518 
    519         fn preview(_cfg: PreviewConfig) -> Self::Prev {
    520             DesktopSidePanelPreview::new()
    521         }
    522     }
    523 }