notedeck

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

side_panel.rs (24805B)


      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::none().inner_margin(Margin::same(8.0));
     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                 info!("Clicked search button");
    328                 if router.top() == &Route::Search {
    329                     router.go_back();
    330                 } else {
    331                     router.route_to(Route::Search);
    332                 }
    333             }
    334             SidePanelAction::ExpandSidePanel => {
    335                 // TODO
    336                 info!("Clicked expand side panel button");
    337             }
    338             SidePanelAction::Support => {
    339                 if router.routes().iter().any(|r| r == &Route::Support) {
    340                     router.go_back();
    341                 } else {
    342                     support.refresh();
    343                     router.route_to(Route::Support);
    344                 }
    345             }
    346             SidePanelAction::NewDeck => {
    347                 if router.routes().iter().any(|r| r == &Route::NewDeck) {
    348                     router.go_back();
    349                 } else {
    350                     router.route_to(Route::NewDeck);
    351                 }
    352             }
    353             SidePanelAction::SwitchDeck(index) => {
    354                 switching_response = Some(crate::nav::SwitchingAction::Decks(DecksAction::Switch(
    355                     index,
    356                 )))
    357             }
    358             SidePanelAction::EditDeck(index) => {
    359                 if router.routes().iter().any(|r| r == &Route::EditDeck(index)) {
    360                     router.go_back();
    361                 } else {
    362                     switching_response = Some(crate::nav::SwitchingAction::Decks(
    363                         DecksAction::Switch(index),
    364                     ));
    365                     if let Some(edit_deck) = get_decks_mut(accounts, decks_cache)
    366                         .decks_mut()
    367                         .get_mut(index)
    368                     {
    369                         edit_deck
    370                             .columns_mut()
    371                             .get_first_router()
    372                             .route_to(Route::EditDeck(index));
    373                     } else {
    374                         error!("Cannot push EditDeck route to index {}", index);
    375                     }
    376                 }
    377             }
    378             SidePanelAction::SaveTheme(theme) => {
    379                 theme_handler.save(theme);
    380             }
    381         }
    382         switching_response
    383     }
    384 }
    385 
    386 fn settings_button(dark_mode: bool) -> impl Widget {
    387     move |ui: &mut egui::Ui| {
    388         let img_size = 24.0;
    389         let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget
    390         let img_data = if dark_mode {
    391             egui::include_image!("../../../../assets/icons/settings_dark_4x.png")
    392         } else {
    393             egui::include_image!("../../../../assets/icons/settings_light_4x.png")
    394         };
    395         let img = egui::Image::new(img_data).max_width(img_size);
    396 
    397         let helper = AnimationHelper::new(ui, "settings-button", vec2(max_size, max_size));
    398 
    399         let cur_img_size = helper.scale_1d_pos(img_size);
    400         img.paint_at(
    401             ui,
    402             helper
    403                 .get_animation_rect()
    404                 .shrink((max_size - cur_img_size) / 2.0),
    405         );
    406 
    407         helper.take_animation_response()
    408     }
    409 }
    410 
    411 fn add_column_button(dark_mode: bool) -> impl Widget {
    412     move |ui: &mut egui::Ui| {
    413         let img_size = 24.0;
    414         let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget
    415 
    416         let img_data = if dark_mode {
    417             egui::include_image!("../../../../assets/icons/add_column_dark_4x.png")
    418         } else {
    419             egui::include_image!("../../../../assets/icons/add_column_light_4x.png")
    420         };
    421 
    422         let img = egui::Image::new(img_data).max_width(img_size);
    423 
    424         let helper = AnimationHelper::new(ui, "add-column-button", vec2(max_size, max_size));
    425 
    426         let cur_img_size = helper.scale_1d_pos(img_size);
    427         img.paint_at(
    428             ui,
    429             helper
    430                 .get_animation_rect()
    431                 .shrink((max_size - cur_img_size) / 2.0),
    432         );
    433 
    434         helper.take_animation_response()
    435     }
    436 }
    437 
    438 fn compose_note_button(interactive: bool, dark_mode: bool) -> impl Widget {
    439     move |ui: &mut egui::Ui| -> egui::Response {
    440         let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget
    441 
    442         let min_outer_circle_diameter = 40.0;
    443         let min_plus_sign_size = 14.0; // length of the plus sign
    444         let min_line_width = 2.25; // width of the plus sign
    445 
    446         let helper = if interactive {
    447             AnimationHelper::new(ui, "note-compose-button", vec2(max_size, max_size))
    448         } else {
    449             AnimationHelper::no_animation(ui, vec2(max_size, max_size))
    450         };
    451 
    452         let painter = ui.painter_at(helper.get_animation_rect());
    453 
    454         let use_background_radius = helper.scale_radius(min_outer_circle_diameter);
    455         let use_line_width = helper.scale_1d_pos(min_line_width);
    456         let use_edge_circle_radius = helper.scale_radius(min_line_width);
    457 
    458         let fill_color = if interactive {
    459             colors::PINK
    460         } else {
    461             ui.visuals().noninteractive().bg_fill
    462         };
    463 
    464         painter.circle_filled(helper.center(), use_background_radius, fill_color);
    465 
    466         let min_half_plus_sign_size = min_plus_sign_size / 2.0;
    467         let north_edge = helper.scale_from_center(0.0, min_half_plus_sign_size);
    468         let south_edge = helper.scale_from_center(0.0, -min_half_plus_sign_size);
    469         let west_edge = helper.scale_from_center(-min_half_plus_sign_size, 0.0);
    470         let east_edge = helper.scale_from_center(min_half_plus_sign_size, 0.0);
    471 
    472         let icon_color = if !dark_mode && !interactive {
    473             Color32::BLACK
    474         } else {
    475             Color32::WHITE
    476         };
    477 
    478         painter.line_segment(
    479             [north_edge, south_edge],
    480             Stroke::new(use_line_width, icon_color),
    481         );
    482         painter.line_segment(
    483             [west_edge, east_edge],
    484             Stroke::new(use_line_width, icon_color),
    485         );
    486         painter.circle_filled(north_edge, use_edge_circle_radius, Color32::WHITE);
    487         painter.circle_filled(south_edge, use_edge_circle_radius, Color32::WHITE);
    488         painter.circle_filled(west_edge, use_edge_circle_radius, Color32::WHITE);
    489         painter.circle_filled(east_edge, use_edge_circle_radius, Color32::WHITE);
    490 
    491         helper.take_animation_response()
    492     }
    493 }
    494 
    495 pub fn search_button() -> impl Widget {
    496     |ui: &mut egui::Ui| -> egui::Response {
    497         let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget
    498         let min_line_width_circle = 1.5; // width of the magnifying glass
    499         let min_line_width_handle = 1.5;
    500         let helper = AnimationHelper::new(ui, "search-button", vec2(max_size, max_size));
    501 
    502         let painter = ui.painter_at(helper.get_animation_rect());
    503 
    504         let cur_line_width_circle = helper.scale_1d_pos(min_line_width_circle);
    505         let cur_line_width_handle = helper.scale_1d_pos(min_line_width_handle);
    506         let min_outer_circle_radius = helper.scale_radius(15.0);
    507         let cur_outer_circle_radius = helper.scale_1d_pos(min_outer_circle_radius);
    508         let min_handle_length = 7.0;
    509         let cur_handle_length = helper.scale_1d_pos(min_handle_length);
    510 
    511         let circle_center = helper.scale_from_center(-2.0, -2.0);
    512 
    513         let handle_vec = vec2(
    514             std::f32::consts::FRAC_1_SQRT_2,
    515             std::f32::consts::FRAC_1_SQRT_2,
    516         );
    517 
    518         let handle_pos_1 = circle_center + (handle_vec * (cur_outer_circle_radius - 3.0));
    519         let handle_pos_2 =
    520             circle_center + (handle_vec * (cur_outer_circle_radius + cur_handle_length));
    521 
    522         let circle_stroke = Stroke::new(cur_line_width_circle, colors::MID_GRAY);
    523         let handle_stroke = Stroke::new(cur_line_width_handle, colors::MID_GRAY);
    524 
    525         painter.line_segment([handle_pos_1, handle_pos_2], handle_stroke);
    526         painter.circle(
    527             circle_center,
    528             min_outer_circle_radius,
    529             ui.style().visuals.widgets.inactive.weak_bg_fill,
    530             circle_stroke,
    531         );
    532 
    533         helper.take_animation_response()
    534     }
    535 }
    536 
    537 // TODO: convert to responsive button when expanded side panel impl is finished
    538 fn expand_side_panel_button() -> impl Widget {
    539     |ui: &mut egui::Ui| -> egui::Response {
    540         let img_size = 40.0;
    541         let img_data = egui::include_image!("../../../../assets/damus_rounded_80.png");
    542         let img = egui::Image::new(img_data).max_width(img_size);
    543 
    544         ui.add(img)
    545     }
    546 }
    547 
    548 fn support_button() -> impl Widget {
    549     |ui: &mut egui::Ui| -> egui::Response {
    550         let img_size = 16.0;
    551 
    552         let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget
    553         let img_data = if ui.visuals().dark_mode {
    554             egui::include_image!("../../../../assets/icons/help_icon_dark_4x.png")
    555         } else {
    556             egui::include_image!("../../../../assets/icons/help_icon_inverted_4x.png")
    557         };
    558         let img = egui::Image::new(img_data).max_width(img_size);
    559 
    560         let helper = AnimationHelper::new(ui, "help-button", vec2(max_size, max_size));
    561 
    562         let cur_img_size = helper.scale_1d_pos(img_size);
    563         img.paint_at(
    564             ui,
    565             helper
    566                 .get_animation_rect()
    567                 .shrink((max_size - cur_img_size) / 2.0),
    568         );
    569 
    570         helper.take_animation_response()
    571     }
    572 }
    573 
    574 fn add_deck_button() -> impl Widget {
    575     |ui: &mut egui::Ui| -> egui::Response {
    576         let img_size = 40.0;
    577 
    578         let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget
    579         let img_data = egui::include_image!("../../../../assets/icons/new_deck_icon_4x_dark.png");
    580         let img = egui::Image::new(img_data).max_width(img_size);
    581 
    582         let helper = AnimationHelper::new(ui, "new-deck-icon", vec2(max_size, max_size));
    583 
    584         let cur_img_size = helper.scale_1d_pos(img_size);
    585         img.paint_at(
    586             ui,
    587             helper
    588                 .get_animation_rect()
    589                 .shrink((max_size - cur_img_size) / 2.0),
    590         );
    591 
    592         helper.take_animation_response()
    593     }
    594 }
    595 
    596 fn show_decks<'a>(
    597     ui: &mut egui::Ui,
    598     decks_cache: &'a DecksCache,
    599     selected_account: Option<&'a UserAccount>,
    600 ) -> InnerResponse<Option<usize>> {
    601     let show_decks_id = ui.id().with("show-decks");
    602     let account_id = if let Some(acc) = selected_account {
    603         acc.pubkey
    604     } else {
    605         *decks_cache.get_fallback_pubkey()
    606     };
    607     let (cur_decks, account_id) = (
    608         decks_cache.decks(&account_id),
    609         show_decks_id.with(account_id),
    610     );
    611     let active_index = cur_decks.active_index();
    612 
    613     let (_, mut resp) = ui.allocate_exact_size(vec2(0.0, 0.0), egui::Sense::click());
    614     let mut clicked_index = None;
    615     for (index, deck) in cur_decks.decks().iter().enumerate() {
    616         let highlight = index == active_index;
    617         let deck_icon_resp = ui
    618             .add(deck_icon(
    619                 account_id.with(index),
    620                 Some(deck.icon),
    621                 DECK_ICON_SIZE,
    622                 40.0,
    623                 highlight,
    624             ))
    625             .on_hover_text_at_pointer(&deck.name);
    626         if deck_icon_resp.clicked() || deck_icon_resp.secondary_clicked() {
    627             clicked_index = Some(index);
    628         }
    629         resp = resp.union(deck_icon_resp);
    630     }
    631     InnerResponse::new(clicked_index, resp)
    632 }
    633 
    634 fn milestone_name() -> impl Widget {
    635     |ui: &mut egui::Ui| -> egui::Response {
    636         ui.vertical_centered(|ui| {
    637             let font = egui::FontId::new(
    638                 notedeck::fonts::get_font_size(
    639                     ui.ctx(),
    640                     &NotedeckTextStyle::Tiny,
    641                 ),
    642                 egui::FontFamily::Name(notedeck::fonts::NamedFontFamily::Bold.as_str().into()),
    643             );
    644             ui.add(Label::new(
    645                 RichText::new("ALPHA")
    646                     .color( ui.style().visuals.noninteractive().fg_stroke.color)
    647                     .font(font),
    648             ).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)
    649         })
    650             .inner
    651     }
    652 }