notedeck

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

side_panel.rs (24404B)


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