notedeck

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

side_panel.rs (24754B)


      1 use egui::{
      2     vec2, Button, Color32, InnerResponse, Label, Layout, Margin, RichText, ScrollArea, Separator,
      3     Stroke, ThemePreference, Widget,
      4 };
      5 use tracing::{error, info};
      6 
      7 use crate::{
      8     accounts::AccountsRoute,
      9     app::{get_active_columns_mut, get_decks_mut},
     10     app_style::DECK_ICON_SIZE,
     11     colors,
     12     decks::{DecksAction, DecksCache},
     13     nav::SwitchingAction,
     14     route::Route,
     15     support::Support,
     16 };
     17 
     18 use notedeck::{Accounts, Images, NotedeckTextStyle, ThemeHandler, UserAccount};
     19 
     20 use super::{
     21     anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE},
     22     configure_deck::deck_icon,
     23     profile::preview::get_account_url,
     24     ProfilePic, View,
     25 };
     26 
     27 pub static SIDE_PANEL_WIDTH: f32 = 68.0;
     28 static ICON_WIDTH: f32 = 40.0;
     29 
     30 pub struct DesktopSidePanel<'a> {
     31     ndb: &'a nostrdb::Ndb,
     32     img_cache: &'a mut Images,
     33     selected_account: Option<&'a UserAccount>,
     34     decks_cache: &'a DecksCache,
     35 }
     36 
     37 impl View for DesktopSidePanel<'_> {
     38     fn ui(&mut self, ui: &mut egui::Ui) {
     39         self.show(ui);
     40     }
     41 }
     42 
     43 #[derive(Debug, Eq, PartialEq, Clone, Copy)]
     44 pub enum SidePanelAction {
     45     Panel,
     46     Account,
     47     Settings,
     48     Columns,
     49     ComposeNote,
     50     Search,
     51     ExpandSidePanel,
     52     Support,
     53     NewDeck,
     54     SwitchDeck(usize),
     55     EditDeck(usize),
     56     SaveTheme(ThemePreference),
     57 }
     58 
     59 pub struct SidePanelResponse {
     60     pub response: egui::Response,
     61     pub action: SidePanelAction,
     62 }
     63 
     64 impl SidePanelResponse {
     65     fn new(action: SidePanelAction, response: egui::Response) -> Self {
     66         SidePanelResponse { action, response }
     67     }
     68 }
     69 
     70 impl<'a> DesktopSidePanel<'a> {
     71     pub fn new(
     72         ndb: &'a nostrdb::Ndb,
     73         img_cache: &'a mut Images,
     74         selected_account: Option<&'a UserAccount>,
     75         decks_cache: &'a DecksCache,
     76     ) -> Self {
     77         Self {
     78             ndb,
     79             img_cache,
     80             selected_account,
     81             decks_cache,
     82         }
     83     }
     84 
     85     pub fn show(&mut self, ui: &mut egui::Ui) -> SidePanelResponse {
     86         let mut frame = egui::Frame::new().inner_margin(Margin::same(8));
     87 
     88         if !ui.visuals().dark_mode {
     89             frame = frame.fill(colors::ALMOST_WHITE);
     90         }
     91 
     92         frame.show(ui, |ui| self.show_inner(ui)).inner
     93     }
     94 
     95     fn show_inner(&mut self, ui: &mut egui::Ui) -> SidePanelResponse {
     96         let dark_mode = ui.ctx().style().visuals.dark_mode;
     97 
     98         let inner = ui
     99             .vertical(|ui| {
    100                 let top_resp = ui
    101                     .with_layout(Layout::top_down(egui::Align::Center), |ui| {
    102                         // macos needs a bit of space to make room for window
    103                         // minimize/close buttons
    104                         if cfg!(target_os = "macos") {
    105                             ui.add_space(24.0);
    106                         }
    107 
    108                         let expand_resp = ui.add(expand_side_panel_button());
    109                         ui.add_space(4.0);
    110                         ui.add(milestone_name());
    111                         ui.add_space(16.0);
    112                         let is_interactive = self
    113                             .selected_account
    114                             .is_some_and(|s| s.secret_key.is_some());
    115                         let compose_resp = ui.add(compose_note_button(is_interactive, dark_mode));
    116                         let compose_resp = if is_interactive {
    117                             compose_resp
    118                         } else {
    119                             compose_resp.on_hover_cursor(egui::CursorIcon::NotAllowed)
    120                         };
    121                         let search_resp = ui.add(search_button());
    122                         let column_resp = ui.add(add_column_button(dark_mode));
    123 
    124                         ui.add(Separator::default().horizontal().spacing(8.0).shrink(4.0));
    125 
    126                         ui.add_space(8.0);
    127                         ui.add(egui::Label::new(
    128                             RichText::new("DECKS")
    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());
    134 
    135                         let decks_inner = ScrollArea::vertical()
    136                             .max_height(ui.available_height() - (3.0 * (ICON_WIDTH + 12.0)))
    137                             .show(ui, |ui| {
    138                                 show_decks(ui, self.decks_cache, self.selected_account)
    139                             })
    140                             .inner;
    141                         if expand_resp.clicked() {
    142                             Some(InnerResponse::new(
    143                                 SidePanelAction::ExpandSidePanel,
    144                                 expand_resp,
    145                             ))
    146                         } else if compose_resp.clicked() {
    147                             Some(InnerResponse::new(
    148                                 SidePanelAction::ComposeNote,
    149                                 compose_resp,
    150                             ))
    151                         } else if search_resp.clicked() {
    152                             Some(InnerResponse::new(SidePanelAction::Search, search_resp))
    153                         } else if column_resp.clicked() {
    154                             Some(InnerResponse::new(SidePanelAction::Columns, column_resp))
    155                         } else if add_deck_resp.clicked() {
    156                             Some(InnerResponse::new(SidePanelAction::NewDeck, add_deck_resp))
    157                         } else if decks_inner.response.secondary_clicked() {
    158                             info!("decks inner secondary click");
    159                             if let Some(clicked_index) = decks_inner.inner {
    160                                 Some(InnerResponse::new(
    161                                     SidePanelAction::EditDeck(clicked_index),
    162                                     decks_inner.response,
    163                                 ))
    164                             } else {
    165                                 None
    166                             }
    167                         } else if decks_inner.response.clicked() {
    168                             if let Some(clicked_index) = decks_inner.inner {
    169                                 Some(InnerResponse::new(
    170                                     SidePanelAction::SwitchDeck(clicked_index),
    171                                     decks_inner.response,
    172                                 ))
    173                             } else {
    174                                 None
    175                             }
    176                         } else {
    177                             None
    178                         }
    179                     })
    180                     .inner;
    181 
    182                 ui.add(Separator::default().horizontal().spacing(8.0).shrink(4.0));
    183                 let (pfp_resp, bottom_resp) = ui
    184                     .with_layout(Layout::bottom_up(egui::Align::Center), |ui| {
    185                         let pfp_resp = self.pfp_button(ui);
    186                         let settings_resp = ui.add(settings_button(dark_mode));
    187 
    188                         let save_theme = if let Some((theme, resp)) = match ui.ctx().theme() {
    189                             egui::Theme::Dark => {
    190                                 let resp = ui
    191                                     .add(Button::new("☀").frame(false))
    192                                     .on_hover_text("Switch to light mode");
    193                                 if resp.clicked() {
    194                                     Some((ThemePreference::Light, resp))
    195                                 } else {
    196                                     None
    197                                 }
    198                             }
    199                             egui::Theme::Light => {
    200                                 let resp = ui
    201                                     .add(Button::new("🌙").frame(false))
    202                                     .on_hover_text("Switch to dark mode");
    203                                 if resp.clicked() {
    204                                     Some((ThemePreference::Dark, resp))
    205                                 } else {
    206                                     None
    207                                 }
    208                             }
    209                         } {
    210                             ui.ctx().set_theme(theme);
    211                             Some((theme, resp))
    212                         } else {
    213                             None
    214                         };
    215 
    216                         let support_resp = ui.add(support_button());
    217 
    218                         let optional_inner = if pfp_resp.clicked() {
    219                             Some(egui::InnerResponse::new(
    220                                 SidePanelAction::Account,
    221                                 pfp_resp.clone(),
    222                             ))
    223                         } else if settings_resp.clicked() || settings_resp.hovered() {
    224                             Some(egui::InnerResponse::new(
    225                                 SidePanelAction::Settings,
    226                                 settings_resp,
    227                             ))
    228                         } else if support_resp.clicked() {
    229                             Some(egui::InnerResponse::new(
    230                                 SidePanelAction::Support,
    231                                 support_resp,
    232                             ))
    233                         } else if let Some((theme, resp)) = save_theme {
    234                             Some(egui::InnerResponse::new(
    235                                 SidePanelAction::SaveTheme(theme),
    236                                 resp,
    237                             ))
    238                         } else {
    239                             None
    240                         };
    241 
    242                         (pfp_resp, optional_inner)
    243                     })
    244                     .inner;
    245 
    246                 if let Some(bottom_inner) = bottom_resp {
    247                     bottom_inner
    248                 } else if let Some(top_inner) = top_resp {
    249                     top_inner
    250                 } else {
    251                     egui::InnerResponse::new(SidePanelAction::Panel, pfp_resp)
    252                 }
    253             })
    254             .inner;
    255 
    256         SidePanelResponse::new(inner.inner, inner.response)
    257     }
    258 
    259     fn pfp_button(&mut self, ui: &mut egui::Ui) -> egui::Response {
    260         let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget
    261         let helper = AnimationHelper::new(ui, "pfp-button", vec2(max_size, max_size));
    262 
    263         let min_pfp_size = ICON_WIDTH;
    264         let cur_pfp_size = helper.scale_1d_pos(min_pfp_size);
    265 
    266         let txn = nostrdb::Transaction::new(self.ndb).expect("should be able to create txn");
    267         let profile_url = get_account_url(&txn, self.ndb, self.selected_account);
    268 
    269         let widget = ProfilePic::new(self.img_cache, profile_url).size(cur_pfp_size);
    270 
    271         ui.put(helper.get_animation_rect(), widget);
    272 
    273         helper.take_animation_response()
    274     }
    275 
    276     pub fn perform_action(
    277         decks_cache: &mut DecksCache,
    278         accounts: &Accounts,
    279         support: &mut Support,
    280         theme_handler: &mut ThemeHandler,
    281         action: SidePanelAction,
    282     ) -> Option<SwitchingAction> {
    283         let router = get_active_columns_mut(accounts, decks_cache).get_first_router();
    284         let mut switching_response = None;
    285         match action {
    286             SidePanelAction::Panel => {} // TODO
    287             SidePanelAction::Account => {
    288                 if router
    289                     .routes()
    290                     .iter()
    291                     .any(|r| r == &Route::Accounts(AccountsRoute::Accounts))
    292                 {
    293                     // return if we are already routing to accounts
    294                     router.go_back();
    295                 } else {
    296                     router.route_to(Route::accounts());
    297                 }
    298             }
    299             SidePanelAction::Settings => {
    300                 if router.routes().iter().any(|r| r == &Route::Relays) {
    301                     // return if we are already routing to accounts
    302                     router.go_back();
    303                 } else {
    304                     router.route_to(Route::relays());
    305                 }
    306             }
    307             SidePanelAction::Columns => {
    308                 if router
    309                     .routes()
    310                     .iter()
    311                     .any(|r| matches!(r, Route::AddColumn(_)))
    312                 {
    313                     router.go_back();
    314                 } else {
    315                     get_active_columns_mut(accounts, decks_cache).new_column_picker();
    316                 }
    317             }
    318             SidePanelAction::ComposeNote => {
    319                 if router.routes().iter().any(|r| r == &Route::ComposeNote) {
    320                     router.go_back();
    321                 } else {
    322                     router.route_to(Route::ComposeNote);
    323                 }
    324             }
    325             SidePanelAction::Search => {
    326                 // TODO
    327                 if router.top() == &Route::Search {
    328                     router.go_back();
    329                 } else {
    330                     router.route_to(Route::Search);
    331                 }
    332             }
    333             SidePanelAction::ExpandSidePanel => {
    334                 // TODO
    335                 info!("Clicked expand side panel button");
    336             }
    337             SidePanelAction::Support => {
    338                 if router.routes().iter().any(|r| r == &Route::Support) {
    339                     router.go_back();
    340                 } else {
    341                     support.refresh();
    342                     router.route_to(Route::Support);
    343                 }
    344             }
    345             SidePanelAction::NewDeck => {
    346                 if router.routes().iter().any(|r| r == &Route::NewDeck) {
    347                     router.go_back();
    348                 } else {
    349                     router.route_to(Route::NewDeck);
    350                 }
    351             }
    352             SidePanelAction::SwitchDeck(index) => {
    353                 switching_response = Some(crate::nav::SwitchingAction::Decks(DecksAction::Switch(
    354                     index,
    355                 )))
    356             }
    357             SidePanelAction::EditDeck(index) => {
    358                 if router.routes().iter().any(|r| r == &Route::EditDeck(index)) {
    359                     router.go_back();
    360                 } else {
    361                     switching_response = Some(crate::nav::SwitchingAction::Decks(
    362                         DecksAction::Switch(index),
    363                     ));
    364                     if let Some(edit_deck) = get_decks_mut(accounts, decks_cache)
    365                         .decks_mut()
    366                         .get_mut(index)
    367                     {
    368                         edit_deck
    369                             .columns_mut()
    370                             .get_first_router()
    371                             .route_to(Route::EditDeck(index));
    372                     } else {
    373                         error!("Cannot push EditDeck route to index {}", index);
    374                     }
    375                 }
    376             }
    377             SidePanelAction::SaveTheme(theme) => {
    378                 theme_handler.save(theme);
    379             }
    380         }
    381         switching_response
    382     }
    383 }
    384 
    385 fn settings_button(dark_mode: bool) -> impl Widget {
    386     move |ui: &mut egui::Ui| {
    387         let img_size = 24.0;
    388         let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget
    389         let img_data = if dark_mode {
    390             egui::include_image!("../../../../assets/icons/settings_dark_4x.png")
    391         } else {
    392             egui::include_image!("../../../../assets/icons/settings_light_4x.png")
    393         };
    394         let img = egui::Image::new(img_data).max_width(img_size);
    395 
    396         let helper = AnimationHelper::new(ui, "settings-button", vec2(max_size, max_size));
    397 
    398         let cur_img_size = helper.scale_1d_pos(img_size);
    399         img.paint_at(
    400             ui,
    401             helper
    402                 .get_animation_rect()
    403                 .shrink((max_size - cur_img_size) / 2.0),
    404         );
    405 
    406         helper.take_animation_response()
    407     }
    408 }
    409 
    410 fn add_column_button(dark_mode: bool) -> impl Widget {
    411     move |ui: &mut egui::Ui| {
    412         let img_size = 24.0;
    413         let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget
    414 
    415         let img_data = if dark_mode {
    416             egui::include_image!("../../../../assets/icons/add_column_dark_4x.png")
    417         } else {
    418             egui::include_image!("../../../../assets/icons/add_column_light_4x.png")
    419         };
    420 
    421         let img = egui::Image::new(img_data).max_width(img_size);
    422 
    423         let helper = AnimationHelper::new(ui, "add-column-button", vec2(max_size, max_size));
    424 
    425         let cur_img_size = helper.scale_1d_pos(img_size);
    426         img.paint_at(
    427             ui,
    428             helper
    429                 .get_animation_rect()
    430                 .shrink((max_size - cur_img_size) / 2.0),
    431         );
    432 
    433         helper.take_animation_response()
    434     }
    435 }
    436 
    437 fn compose_note_button(interactive: bool, dark_mode: bool) -> impl Widget {
    438     move |ui: &mut egui::Ui| -> egui::Response {
    439         let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget
    440 
    441         let min_outer_circle_diameter = 40.0;
    442         let min_plus_sign_size = 14.0; // length of the plus sign
    443         let min_line_width = 2.25; // width of the plus sign
    444 
    445         let helper = if interactive {
    446             AnimationHelper::new(ui, "note-compose-button", vec2(max_size, max_size))
    447         } else {
    448             AnimationHelper::no_animation(ui, vec2(max_size, max_size))
    449         };
    450 
    451         let painter = ui.painter_at(helper.get_animation_rect());
    452 
    453         let use_background_radius = helper.scale_radius(min_outer_circle_diameter);
    454         let use_line_width = helper.scale_1d_pos(min_line_width);
    455         let use_edge_circle_radius = helper.scale_radius(min_line_width);
    456 
    457         let fill_color = if interactive {
    458             colors::PINK
    459         } else {
    460             ui.visuals().noninteractive().bg_fill
    461         };
    462 
    463         painter.circle_filled(helper.center(), use_background_radius, fill_color);
    464 
    465         let min_half_plus_sign_size = min_plus_sign_size / 2.0;
    466         let north_edge = helper.scale_from_center(0.0, min_half_plus_sign_size);
    467         let south_edge = helper.scale_from_center(0.0, -min_half_plus_sign_size);
    468         let west_edge = helper.scale_from_center(-min_half_plus_sign_size, 0.0);
    469         let east_edge = helper.scale_from_center(min_half_plus_sign_size, 0.0);
    470 
    471         let icon_color = if !dark_mode && !interactive {
    472             Color32::BLACK
    473         } else {
    474             Color32::WHITE
    475         };
    476 
    477         painter.line_segment(
    478             [north_edge, south_edge],
    479             Stroke::new(use_line_width, icon_color),
    480         );
    481         painter.line_segment(
    482             [west_edge, east_edge],
    483             Stroke::new(use_line_width, icon_color),
    484         );
    485         painter.circle_filled(north_edge, use_edge_circle_radius, Color32::WHITE);
    486         painter.circle_filled(south_edge, use_edge_circle_radius, Color32::WHITE);
    487         painter.circle_filled(west_edge, use_edge_circle_radius, Color32::WHITE);
    488         painter.circle_filled(east_edge, use_edge_circle_radius, Color32::WHITE);
    489 
    490         helper.take_animation_response()
    491     }
    492 }
    493 
    494 pub fn search_button() -> impl Widget {
    495     |ui: &mut egui::Ui| -> egui::Response {
    496         let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget
    497         let min_line_width_circle = 1.5; // width of the magnifying glass
    498         let min_line_width_handle = 1.5;
    499         let helper = AnimationHelper::new(ui, "search-button", vec2(max_size, max_size));
    500 
    501         let painter = ui.painter_at(helper.get_animation_rect());
    502 
    503         let cur_line_width_circle = helper.scale_1d_pos(min_line_width_circle);
    504         let cur_line_width_handle = helper.scale_1d_pos(min_line_width_handle);
    505         let min_outer_circle_radius = helper.scale_radius(15.0);
    506         let cur_outer_circle_radius = helper.scale_1d_pos(min_outer_circle_radius);
    507         let min_handle_length = 7.0;
    508         let cur_handle_length = helper.scale_1d_pos(min_handle_length);
    509 
    510         let circle_center = helper.scale_from_center(-2.0, -2.0);
    511 
    512         let handle_vec = vec2(
    513             std::f32::consts::FRAC_1_SQRT_2,
    514             std::f32::consts::FRAC_1_SQRT_2,
    515         );
    516 
    517         let handle_pos_1 = circle_center + (handle_vec * (cur_outer_circle_radius - 3.0));
    518         let handle_pos_2 =
    519             circle_center + (handle_vec * (cur_outer_circle_radius + cur_handle_length));
    520 
    521         let circle_stroke = Stroke::new(cur_line_width_circle, colors::MID_GRAY);
    522         let handle_stroke = Stroke::new(cur_line_width_handle, colors::MID_GRAY);
    523 
    524         painter.line_segment([handle_pos_1, handle_pos_2], handle_stroke);
    525         painter.circle(
    526             circle_center,
    527             min_outer_circle_radius,
    528             ui.style().visuals.widgets.inactive.weak_bg_fill,
    529             circle_stroke,
    530         );
    531 
    532         helper.take_animation_response()
    533     }
    534 }
    535 
    536 // TODO: convert to responsive button when expanded side panel impl is finished
    537 fn expand_side_panel_button() -> impl Widget {
    538     |ui: &mut egui::Ui| -> egui::Response {
    539         let img_size = 40.0;
    540         let img_data = egui::include_image!("../../../../assets/damus_rounded_80.png");
    541         let img = egui::Image::new(img_data).max_width(img_size);
    542 
    543         ui.add(img)
    544     }
    545 }
    546 
    547 fn support_button() -> impl Widget {
    548     |ui: &mut egui::Ui| -> egui::Response {
    549         let img_size = 16.0;
    550 
    551         let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget
    552         let img_data = if ui.visuals().dark_mode {
    553             egui::include_image!("../../../../assets/icons/help_icon_dark_4x.png")
    554         } else {
    555             egui::include_image!("../../../../assets/icons/help_icon_inverted_4x.png")
    556         };
    557         let img = egui::Image::new(img_data).max_width(img_size);
    558 
    559         let helper = AnimationHelper::new(ui, "help-button", vec2(max_size, max_size));
    560 
    561         let cur_img_size = helper.scale_1d_pos(img_size);
    562         img.paint_at(
    563             ui,
    564             helper
    565                 .get_animation_rect()
    566                 .shrink((max_size - cur_img_size) / 2.0),
    567         );
    568 
    569         helper.take_animation_response()
    570     }
    571 }
    572 
    573 fn add_deck_button() -> impl Widget {
    574     |ui: &mut egui::Ui| -> egui::Response {
    575         let img_size = 40.0;
    576 
    577         let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget
    578         let img_data = egui::include_image!("../../../../assets/icons/new_deck_icon_4x_dark.png");
    579         let img = egui::Image::new(img_data).max_width(img_size);
    580 
    581         let helper = AnimationHelper::new(ui, "new-deck-icon", vec2(max_size, max_size));
    582 
    583         let cur_img_size = helper.scale_1d_pos(img_size);
    584         img.paint_at(
    585             ui,
    586             helper
    587                 .get_animation_rect()
    588                 .shrink((max_size - cur_img_size) / 2.0),
    589         );
    590 
    591         helper.take_animation_response()
    592     }
    593 }
    594 
    595 fn show_decks<'a>(
    596     ui: &mut egui::Ui,
    597     decks_cache: &'a DecksCache,
    598     selected_account: Option<&'a UserAccount>,
    599 ) -> InnerResponse<Option<usize>> {
    600     let show_decks_id = ui.id().with("show-decks");
    601     let account_id = if let Some(acc) = selected_account {
    602         acc.pubkey
    603     } else {
    604         *decks_cache.get_fallback_pubkey()
    605     };
    606     let (cur_decks, account_id) = (
    607         decks_cache.decks(&account_id),
    608         show_decks_id.with(account_id),
    609     );
    610     let active_index = cur_decks.active_index();
    611 
    612     let (_, mut resp) = ui.allocate_exact_size(vec2(0.0, 0.0), egui::Sense::click());
    613     let mut clicked_index = None;
    614     for (index, deck) in cur_decks.decks().iter().enumerate() {
    615         let highlight = index == active_index;
    616         let deck_icon_resp = ui
    617             .add(deck_icon(
    618                 account_id.with(index),
    619                 Some(deck.icon),
    620                 DECK_ICON_SIZE,
    621                 40.0,
    622                 highlight,
    623             ))
    624             .on_hover_text_at_pointer(&deck.name);
    625         if deck_icon_resp.clicked() || deck_icon_resp.secondary_clicked() {
    626             clicked_index = Some(index);
    627         }
    628         resp = resp.union(deck_icon_resp);
    629     }
    630     InnerResponse::new(clicked_index, resp)
    631 }
    632 
    633 fn milestone_name() -> impl Widget {
    634     |ui: &mut egui::Ui| -> egui::Response {
    635         ui.vertical_centered(|ui| {
    636             let font = egui::FontId::new(
    637                 notedeck::fonts::get_font_size(
    638                     ui.ctx(),
    639                     &NotedeckTextStyle::Tiny,
    640                 ),
    641                 egui::FontFamily::Name(notedeck::fonts::NamedFontFamily::Bold.as_str().into()),
    642             );
    643             ui.add(Label::new(
    644                 RichText::new("ALPHA")
    645                     .color( ui.style().visuals.noninteractive().fg_stroke.color)
    646                     .font(font),
    647             ).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)
    648         })
    649             .inner
    650     }
    651 }