notedeck

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

settings.rs (28576B)


      1 use egui::{
      2     vec2, Button, Color32, ComboBox, CornerRadius, FontId, Frame, Layout, Margin, RichText,
      3     ScrollArea, TextEdit, ThemePreference,
      4 };
      5 use egui_extras::{Size, StripBuilder};
      6 use enostr::NoteId;
      7 use nostrdb::Transaction;
      8 use notedeck::{
      9     tr, ui::richtext_small, DragResponse, Images, LanguageIdentifier, Localization, NoteContext,
     10     NotedeckTextStyle, Settings, SettingsHandler, DEFAULT_MAX_HASHTAGS_PER_NOTE,
     11     DEFAULT_NOTE_BODY_FONT_SIZE,
     12 };
     13 use notedeck_ui::{
     14     app_images::{copy_to_clipboard_dark_image, copy_to_clipboard_image},
     15     AnimationHelper, NoteOptions, NoteView,
     16 };
     17 
     18 use crate::{nav::RouterAction, ui::account_login_view::eye_button, Damus, Route};
     19 
     20 const PREVIEW_NOTE_ID: &str = "note1edjc8ggj07hwv77g2405uh6j2jkk5aud22gktxrvc2wnre4vdwgqzlv2gw";
     21 
     22 const MIN_ZOOM: f32 = 0.5;
     23 const MAX_ZOOM: f32 = 3.0;
     24 const ZOOM_STEP: f32 = 0.1;
     25 const RESET_ZOOM: f32 = 1.0;
     26 
     27 pub enum SettingsAction {
     28     SetZoomFactor(f32),
     29     SetTheme(ThemePreference),
     30     SetLocale(LanguageIdentifier),
     31     SetRepliestNewestFirst(bool),
     32     SetNoteBodyFontSize(f32),
     33     SetAnimateNavTransitions(bool),
     34     SetMaxHashtagsPerNote(usize),
     35     OpenRelays,
     36     OpenCacheFolder,
     37     ClearCacheFolder,
     38 }
     39 
     40 impl SettingsAction {
     41     pub fn process_settings_action<'a>(
     42         self,
     43         app: &mut Damus,
     44         settings: &'a mut SettingsHandler,
     45         i18n: &'a mut Localization,
     46         img_cache: &mut Images,
     47         ctx: &egui::Context,
     48         accounts: &mut notedeck::Accounts,
     49     ) -> Option<RouterAction> {
     50         let mut route_action: Option<RouterAction> = None;
     51 
     52         match self {
     53             Self::OpenRelays => {
     54                 route_action = Some(RouterAction::route_to(Route::Relays));
     55             }
     56             Self::SetZoomFactor(zoom_factor) => {
     57                 ctx.set_zoom_factor(zoom_factor);
     58                 settings.set_zoom_factor(zoom_factor);
     59             }
     60             Self::SetTheme(theme) => {
     61                 ctx.set_theme(theme);
     62                 settings.set_theme(theme);
     63             }
     64             Self::SetLocale(language) => {
     65                 if i18n.set_locale(language.clone()).is_ok() {
     66                     settings.set_locale(language.to_string());
     67                 }
     68             }
     69             Self::SetRepliestNewestFirst(value) => {
     70                 app.note_options.set(NoteOptions::RepliesNewestFirst, value);
     71                 settings.set_show_replies_newest_first(value);
     72             }
     73             Self::OpenCacheFolder => {
     74                 use opener;
     75                 let _ = opener::open(img_cache.base_path.clone());
     76             }
     77             Self::ClearCacheFolder => {
     78                 let _ = img_cache.clear_folder_contents();
     79             }
     80             Self::SetNoteBodyFontSize(size) => {
     81                 let mut style = (*ctx.style()).clone();
     82                 style.text_styles.insert(
     83                     NotedeckTextStyle::NoteBody.text_style(),
     84                     FontId::proportional(size),
     85                 );
     86                 ctx.set_style(style);
     87 
     88                 settings.set_note_body_font_size(size);
     89             }
     90 
     91             Self::SetAnimateNavTransitions(value) => {
     92                 settings.set_animate_nav_transitions(value);
     93             }
     94 
     95             Self::SetMaxHashtagsPerNote(value) => {
     96                 settings.set_max_hashtags_per_note(value);
     97                 accounts.update_max_hashtags_per_note(value);
     98             }
     99         }
    100         route_action
    101     }
    102 }
    103 
    104 pub struct SettingsView<'a> {
    105     settings: &'a mut Settings,
    106     note_context: &'a mut NoteContext<'a>,
    107     note_options: &'a mut NoteOptions,
    108 }
    109 
    110 fn settings_group<S>(ui: &mut egui::Ui, title: S, contents: impl FnOnce(&mut egui::Ui))
    111 where
    112     S: Into<String>,
    113 {
    114     Frame::group(ui.style())
    115         .fill(ui.style().visuals.widgets.open.bg_fill)
    116         .inner_margin(10.0)
    117         .show(ui, |ui| {
    118             ui.label(RichText::new(title).text_style(NotedeckTextStyle::Body.text_style()));
    119             ui.separator();
    120 
    121             ui.vertical(|ui| {
    122                 ui.spacing_mut().item_spacing = vec2(10.0, 10.0);
    123 
    124                 contents(ui)
    125             });
    126         });
    127 }
    128 
    129 impl<'a> SettingsView<'a> {
    130     pub fn new(
    131         settings: &'a mut Settings,
    132         note_context: &'a mut NoteContext<'a>,
    133         note_options: &'a mut NoteOptions,
    134     ) -> Self {
    135         Self {
    136             settings,
    137             note_context,
    138             note_options,
    139         }
    140     }
    141 
    142     /// Get the localized name for a language identifier
    143     fn get_selected_language_name(&mut self) -> String {
    144         if let Ok(lang_id) = self.settings.locale.parse::<LanguageIdentifier>() {
    145             self.note_context
    146                 .i18n
    147                 .get_locale_native_name(&lang_id)
    148                 .map(|s| s.to_owned())
    149                 .unwrap_or_else(|| lang_id.to_string())
    150         } else {
    151             self.settings.locale.clone()
    152         }
    153     }
    154 
    155     pub fn appearance_section(&mut self, ui: &mut egui::Ui) -> Option<SettingsAction> {
    156         let mut action = None;
    157         let title = tr!(
    158             self.note_context.i18n,
    159             "Appearance",
    160             "Label for appearance settings section",
    161         );
    162         settings_group(ui, title, |ui| {
    163             ui.horizontal_wrapped(|ui| {
    164                 ui.label(richtext_small(tr!(
    165                     self.note_context.i18n,
    166                     "Font size:",
    167                     "Label for font size, Appearance settings section",
    168                 )));
    169 
    170                 if ui
    171                     .add(
    172                         egui::Slider::new(&mut self.settings.note_body_font_size, 8.0..=32.0)
    173                             .text(""),
    174                     )
    175                     .changed()
    176                 {
    177                     action = Some(SettingsAction::SetNoteBodyFontSize(
    178                         self.settings.note_body_font_size,
    179                     ));
    180                 };
    181 
    182                 if ui
    183                     .button(richtext_small(tr!(
    184                         self.note_context.i18n,
    185                         "Reset",
    186                         "Label for reset note body font size, Appearance settings section",
    187                     )))
    188                     .clicked()
    189                 {
    190                     action = Some(SettingsAction::SetNoteBodyFontSize(
    191                         DEFAULT_NOTE_BODY_FONT_SIZE,
    192                     ));
    193                 }
    194             });
    195 
    196             let txn = Transaction::new(self.note_context.ndb).unwrap();
    197 
    198             if let Some(note_id) = NoteId::from_bech(PREVIEW_NOTE_ID) {
    199                 if let Ok(preview_note) =
    200                     self.note_context.ndb.get_note_by_id(&txn, note_id.bytes())
    201                 {
    202                     notedeck_ui::padding(8.0, ui, |ui| {
    203                         if notedeck::ui::is_narrow(ui.ctx()) {
    204                             ui.set_max_width(ui.available_width());
    205 
    206                             NoteView::new(self.note_context, &preview_note, *self.note_options)
    207                                 .actionbar(false)
    208                                 .options_button(false)
    209                                 .show(ui);
    210                         }
    211                     });
    212                     ui.separator();
    213                 }
    214             }
    215 
    216             let current_zoom = ui.ctx().zoom_factor();
    217 
    218             ui.horizontal_wrapped(|ui| {
    219                 ui.label(richtext_small(tr!(
    220                     self.note_context.i18n,
    221                     "Zoom Level:",
    222                     "Label for zoom level, Appearance settings section",
    223                 )));
    224 
    225                 let min_reached = current_zoom <= MIN_ZOOM;
    226                 let max_reached = current_zoom >= MAX_ZOOM;
    227 
    228                 if ui
    229                     .add_enabled(
    230                         !min_reached,
    231                         Button::new(
    232                             RichText::new("-").text_style(NotedeckTextStyle::Small.text_style()),
    233                         ),
    234                     )
    235                     .clicked()
    236                 {
    237                     let new_zoom = (current_zoom - ZOOM_STEP).max(MIN_ZOOM);
    238                     action = Some(SettingsAction::SetZoomFactor(new_zoom));
    239                 };
    240 
    241                 ui.label(
    242                     RichText::new(format!("{:.0}%", current_zoom * 100.0))
    243                         .text_style(NotedeckTextStyle::Small.text_style()),
    244                 );
    245 
    246                 if ui
    247                     .add_enabled(
    248                         !max_reached,
    249                         Button::new(
    250                             RichText::new("+").text_style(NotedeckTextStyle::Small.text_style()),
    251                         ),
    252                     )
    253                     .clicked()
    254                 {
    255                     let new_zoom = (current_zoom + ZOOM_STEP).min(MAX_ZOOM);
    256                     action = Some(SettingsAction::SetZoomFactor(new_zoom));
    257                 };
    258 
    259                 if ui
    260                     .button(richtext_small(tr!(
    261                         self.note_context.i18n,
    262                         "Reset",
    263                         "Label for reset zoom level, Appearance settings section",
    264                     )))
    265                     .clicked()
    266                 {
    267                     action = Some(SettingsAction::SetZoomFactor(RESET_ZOOM));
    268                 }
    269             });
    270 
    271             ui.horizontal_wrapped(|ui| {
    272                 ui.label(richtext_small(tr!(
    273                     self.note_context.i18n,
    274                     "Language:",
    275                     "Label for language, Appearance settings section",
    276                 )));
    277 
    278                 //
    279                 ComboBox::from_label("")
    280                     .selected_text(self.get_selected_language_name())
    281                     .show_ui(ui, |ui| {
    282                         for lang in self.note_context.i18n.get_available_locales() {
    283                             let name = self
    284                                 .note_context
    285                                 .i18n
    286                                 .get_locale_native_name(lang)
    287                                 .map(|s| s.to_owned())
    288                                 .unwrap_or_else(|| lang.to_string());
    289                             if ui
    290                                 .selectable_value(&mut self.settings.locale, lang.to_string(), name)
    291                                 .clicked()
    292                             {
    293                                 action = Some(SettingsAction::SetLocale(lang.to_owned()))
    294                             }
    295                         }
    296                     });
    297             });
    298 
    299             ui.horizontal_wrapped(|ui| {
    300                 ui.label(richtext_small(tr!(
    301                     self.note_context.i18n,
    302                     "Theme:",
    303                     "Label for theme, Appearance settings section",
    304                 )));
    305 
    306                 if ui
    307                     .selectable_value(
    308                         &mut self.settings.theme,
    309                         ThemePreference::Light,
    310                         richtext_small(tr!(
    311                             self.note_context.i18n,
    312                             "Light",
    313                             "Label for Theme Light, Appearance settings section",
    314                         )),
    315                     )
    316                     .clicked()
    317                 {
    318                     action = Some(SettingsAction::SetTheme(ThemePreference::Light));
    319                 }
    320 
    321                 if ui
    322                     .selectable_value(
    323                         &mut self.settings.theme,
    324                         ThemePreference::Dark,
    325                         richtext_small(tr!(
    326                             self.note_context.i18n,
    327                             "Dark",
    328                             "Label for Theme Dark, Appearance settings section",
    329                         )),
    330                     )
    331                     .clicked()
    332                 {
    333                     action = Some(SettingsAction::SetTheme(ThemePreference::Dark));
    334                 }
    335             });
    336         });
    337 
    338         action
    339     }
    340 
    341     pub fn storage_section(&mut self, ui: &mut egui::Ui) -> Option<SettingsAction> {
    342         let id = ui.id();
    343         let mut action: Option<SettingsAction> = None;
    344         let title = tr!(
    345             self.note_context.i18n,
    346             "Storage",
    347             "Label for storage settings section"
    348         );
    349         settings_group(ui, title, |ui| {
    350             ui.horizontal_wrapped(|ui| {
    351                 let static_imgs_size = self
    352                     .note_context
    353                     .img_cache
    354                     .static_imgs
    355                     .cache_size
    356                     .lock()
    357                     .unwrap();
    358 
    359                 let gifs_size = self.note_context.img_cache.gifs.cache_size.lock().unwrap();
    360 
    361                 ui.label(
    362                     RichText::new(format!(
    363                         "{} {}",
    364                         tr!(
    365                             self.note_context.i18n,
    366                             "Image cache size:",
    367                             "Label for Image cache size, Storage settings section"
    368                         ),
    369                         format_size(
    370                             [static_imgs_size, gifs_size]
    371                                 .iter()
    372                                 .fold(0_u64, |acc, cur| acc + cur.unwrap_or_default())
    373                         )
    374                     ))
    375                     .text_style(NotedeckTextStyle::Small.text_style()),
    376                 );
    377 
    378                 ui.end_row();
    379 
    380                 if !notedeck::ui::is_compiled_as_mobile()
    381                     && ui
    382                         .button(richtext_small(tr!(
    383                             self.note_context.i18n,
    384                             "View folder",
    385                             "Label for view folder button, Storage settings section",
    386                         )))
    387                         .clicked()
    388                 {
    389                     action = Some(SettingsAction::OpenCacheFolder);
    390                 }
    391 
    392                 let clearcache_resp = ui.button(
    393                     richtext_small(tr!(
    394                         self.note_context.i18n,
    395                         "Clear cache",
    396                         "Label for clear cache button, Storage settings section",
    397                     ))
    398                     .color(Color32::LIGHT_RED),
    399                 );
    400 
    401                 let id_clearcache = id.with("clear_cache");
    402                 if clearcache_resp.clicked() {
    403                     ui.data_mut(|d| d.insert_temp(id_clearcache, true));
    404                 }
    405 
    406                 if ui.data_mut(|d| *d.get_temp_mut_or_default(id_clearcache)) {
    407                     let mut confirm_pressed = false;
    408                     clearcache_resp.show_tooltip_ui(|ui| {
    409                         let confirm_resp = ui.button(tr!(
    410                             self.note_context.i18n,
    411                             "Confirm",
    412                             "Label for confirm clear cache, Storage settings section"
    413                         ));
    414                         if confirm_resp.clicked() {
    415                             confirm_pressed = true;
    416                         }
    417 
    418                         if confirm_resp.clicked()
    419                             || ui
    420                                 .button(tr!(
    421                                     self.note_context.i18n,
    422                                     "Cancel",
    423                                     "Label for cancel clear cache, Storage settings section"
    424                                 ))
    425                                 .clicked()
    426                         {
    427                             ui.data_mut(|d| d.insert_temp(id_clearcache, false));
    428                         }
    429                     });
    430 
    431                     if confirm_pressed {
    432                         action = Some(SettingsAction::ClearCacheFolder);
    433                     } else if !confirm_pressed && clearcache_resp.clicked_elsewhere() {
    434                         ui.data_mut(|d| d.insert_temp(id_clearcache, false));
    435                     }
    436                 };
    437             });
    438         });
    439 
    440         action
    441     }
    442 
    443     fn other_options_section(&mut self, ui: &mut egui::Ui) -> Option<SettingsAction> {
    444         let mut action = None;
    445 
    446         let title = tr!(
    447             self.note_context.i18n,
    448             "Others",
    449             "Label for others settings section"
    450         );
    451         settings_group(ui, title, |ui| {
    452             ui.horizontal_wrapped(|ui| {
    453                 ui.label(richtext_small(tr!(
    454                     self.note_context.i18n,
    455                     "Sort replies newest first:",
    456                     "Label for Sort replies newest first, others settings section",
    457                 )));
    458 
    459                 if ui
    460                     .toggle_value(
    461                         &mut self.settings.show_replies_newest_first,
    462                         RichText::new(tr!(
    463                             self.note_context.i18n,
    464                             "On",
    465                             "Setting to turn on sorting replies so that the newest are shown first"
    466                         ))
    467                         .text_style(NotedeckTextStyle::Small.text_style()),
    468                     )
    469                     .changed()
    470                 {
    471                     action = Some(SettingsAction::SetRepliestNewestFirst(
    472                         self.settings.show_replies_newest_first,
    473                     ));
    474                 }
    475             });
    476 
    477             ui.horizontal_wrapped(|ui| {
    478                 ui.label(richtext_small("Animate view transitions:"));
    479 
    480                 if ui
    481                     .toggle_value(
    482                         &mut self.settings.animate_nav_transitions,
    483                         RichText::new("On").text_style(NotedeckTextStyle::Small.text_style()),
    484                     )
    485                     .changed()
    486                 {
    487                     action = Some(SettingsAction::SetAnimateNavTransitions(
    488                         self.settings.animate_nav_transitions,
    489                     ));
    490                 }
    491             });
    492 
    493             ui.horizontal_wrapped(|ui| {
    494                 ui.label(richtext_small(tr!(
    495                     self.note_context.i18n,
    496                     "Max hashtags per note:",
    497                     "Label for max hashtags per note, others settings section",
    498                 )));
    499 
    500                 if ui
    501                     .add(
    502                         egui::Slider::new(&mut self.settings.max_hashtags_per_note, 0..=20)
    503                             .text("")
    504                             .step_by(1.0),
    505                     )
    506                     .changed()
    507                 {
    508                     action = Some(SettingsAction::SetMaxHashtagsPerNote(
    509                         self.settings.max_hashtags_per_note,
    510                     ));
    511                 };
    512 
    513                 if ui
    514                     .button(richtext_small(tr!(
    515                         self.note_context.i18n,
    516                         "Reset",
    517                         "Label for reset max hashtags per note, others settings section",
    518                     )))
    519                     .clicked()
    520                 {
    521                     action = Some(SettingsAction::SetMaxHashtagsPerNote(
    522                         DEFAULT_MAX_HASHTAGS_PER_NOTE,
    523                     ));
    524                 }
    525             });
    526 
    527             ui.horizontal_wrapped(|ui| {
    528                 let text = if self.settings.max_hashtags_per_note == 0 {
    529                     tr!(
    530                         self.note_context.i18n,
    531                         "Hashtag filter disabled",
    532                         "Info text when hashtag filter is disabled (set to 0)"
    533                     )
    534                 } else {
    535                     format!(
    536                         "Hide posts with more than {} hashtags",
    537                         self.settings.max_hashtags_per_note
    538                     )
    539                 };
    540                 ui.label(
    541                     richtext_small(&text).color(ui.visuals().gray_out(ui.visuals().text_color())),
    542                 );
    543             });
    544         });
    545 
    546         action
    547     }
    548 
    549     fn keys_section(&mut self, ui: &mut egui::Ui) {
    550         let title = tr!(
    551             self.note_context.i18n,
    552             "Keys",
    553             "label for keys setting section"
    554         );
    555 
    556         settings_group(ui, title, |ui| {
    557             ui.horizontal_wrapped(|ui| {
    558                 ui.label(
    559                     richtext_small(tr!(
    560                         self.note_context.i18n,
    561                         "PUBLIC ACCOUNT ID",
    562                         "label describing public key"
    563                     ))
    564                     .color(ui.visuals().gray_out(ui.visuals().text_color())),
    565                 );
    566             });
    567 
    568             let copy_img = if ui.visuals().dark_mode {
    569                 copy_to_clipboard_image()
    570             } else {
    571                 copy_to_clipboard_dark_image()
    572             };
    573             let copy_max_size = vec2(16.0, 16.0);
    574 
    575             if let Some(npub) = self.note_context.accounts.selected_account_pubkey().npub() {
    576                 item_frame(ui).show(ui, |ui| {
    577                     StripBuilder::new(ui)
    578                         .size(Size::exact(24.0))
    579                         .cell_layout(Layout::left_to_right(egui::Align::Center))
    580                         .vertical(|mut strip| {
    581                             strip.strip(|builder| {
    582                                 builder
    583                                     .size(Size::remainder())
    584                                     .size(Size::exact(16.0))
    585                                     .cell_layout(Layout::left_to_right(egui::Align::Center))
    586                                     .horizontal(|mut strip| {
    587                                         strip.cell(|ui| {
    588                                             ui.horizontal_wrapped(|ui| {
    589                                                 ui.label(richtext_small(&npub));
    590                                             });
    591                                         });
    592 
    593                                         strip.cell(|ui| {
    594                                             let helper = AnimationHelper::new(
    595                                                 ui,
    596                                                 "copy-to-clipboard-npub",
    597                                                 copy_max_size,
    598                                             );
    599 
    600                                             copy_img.paint_at(ui, helper.scaled_rect());
    601 
    602                                             if helper.take_animation_response().clicked() {
    603                                                 ui.ctx().copy_text(npub);
    604                                             }
    605                                         });
    606                                     });
    607                             });
    608                         });
    609                 });
    610             }
    611 
    612             let Some(filled) = self.note_context.accounts.selected_filled() else {
    613                 return;
    614             };
    615             let Some(mut nsec) = bech32::encode::<bech32::Bech32>(
    616                 bech32::Hrp::parse_unchecked("nsec"),
    617                 &filled.secret_key.secret_bytes(),
    618             )
    619             .ok() else {
    620                 return;
    621             };
    622 
    623             ui.horizontal_wrapped(|ui| {
    624                 ui.label(
    625                     richtext_small(tr!(
    626                         self.note_context.i18n,
    627                         "SECRET ACCOUNT LOGIN KEY",
    628                         "label describing secret key"
    629                     ))
    630                     .color(ui.visuals().gray_out(ui.visuals().text_color())),
    631                 );
    632             });
    633 
    634             let is_password_id = ui.id().with("is-password");
    635             let is_password = ui
    636                 .ctx()
    637                 .data_mut(|d| d.get_temp(is_password_id))
    638                 .unwrap_or(true);
    639 
    640             item_frame(ui).show(ui, |ui| {
    641                 StripBuilder::new(ui)
    642                     .size(Size::exact(24.0))
    643                     .cell_layout(Layout::left_to_right(egui::Align::Center))
    644                     .vertical(|mut strip| {
    645                         strip.strip(|builder| {
    646                             builder
    647                                 .size(Size::remainder())
    648                                 .size(Size::exact(48.0))
    649                                 .cell_layout(Layout::left_to_right(egui::Align::Center))
    650                                 .horizontal(|mut strip| {
    651                                     strip.cell(|ui| {
    652                                         if is_password {
    653                                             ui.add(
    654                                                 TextEdit::singleline(&mut nsec)
    655                                                     .password(is_password)
    656                                                     .interactive(false)
    657                                                     .frame(false),
    658                                             );
    659                                         } else {
    660                                             ui.horizontal_wrapped(|ui| {
    661                                                 ui.label(richtext_small(&nsec));
    662                                             });
    663                                         }
    664                                     });
    665 
    666                                     strip.cell(|ui| {
    667                                         let helper = AnimationHelper::new(
    668                                             ui,
    669                                             "copy-to-clipboard-nsec",
    670                                             copy_max_size,
    671                                         );
    672 
    673                                         copy_img.paint_at(ui, helper.scaled_rect());
    674 
    675                                         if helper.take_animation_response().clicked() {
    676                                             ui.ctx().copy_text(nsec);
    677                                         }
    678 
    679                                         if eye_button(ui, is_password).clicked() {
    680                                             ui.ctx().data_mut(|d| {
    681                                                 d.insert_temp(is_password_id, !is_password)
    682                                             });
    683                                         }
    684                                     });
    685                                 });
    686                         });
    687                     });
    688             });
    689         });
    690     }
    691 
    692     fn manage_relays_section(&mut self, ui: &mut egui::Ui) -> Option<SettingsAction> {
    693         let mut action = None;
    694 
    695         if ui
    696             .add_sized(
    697                 [ui.available_width(), 30.0],
    698                 Button::new(richtext_small(tr!(
    699                     self.note_context.i18n,
    700                     "Configure relays",
    701                     "Label for configure relays, settings section",
    702                 ))),
    703             )
    704             .clicked()
    705         {
    706             action = Some(SettingsAction::OpenRelays);
    707         }
    708 
    709         action
    710     }
    711 
    712     pub fn ui(&mut self, ui: &mut egui::Ui) -> DragResponse<SettingsAction> {
    713         let scroll_out = Frame::default()
    714             .inner_margin(Margin::symmetric(10, 10))
    715             .show(ui, |ui| {
    716                 ScrollArea::vertical().show(ui, |ui| {
    717                     let mut action = None;
    718                     if let Some(new_action) = self.appearance_section(ui) {
    719                         action = Some(new_action);
    720                     }
    721 
    722                     ui.add_space(5.0);
    723 
    724                     if let Some(new_action) = self.storage_section(ui) {
    725                         action = Some(new_action);
    726                     }
    727 
    728                     ui.add_space(5.0);
    729 
    730                     self.keys_section(ui);
    731 
    732                     ui.add_space(5.0);
    733 
    734                     if let Some(new_action) = self.other_options_section(ui) {
    735                         action = Some(new_action);
    736                     }
    737 
    738                     ui.add_space(10.0);
    739 
    740                     if let Some(new_action) = self.manage_relays_section(ui) {
    741                         action = Some(new_action);
    742                     }
    743                     action
    744                 })
    745             })
    746             .inner;
    747 
    748         DragResponse::scroll(scroll_out)
    749     }
    750 }
    751 
    752 pub fn format_size(size_bytes: u64) -> String {
    753     const KB: f64 = 1024.0;
    754     const MB: f64 = KB * 1024.0;
    755     const GB: f64 = MB * 1024.0;
    756 
    757     let size = size_bytes as f64;
    758 
    759     if size < KB {
    760         format!("{size:.0} Bytes")
    761     } else if size < MB {
    762         format!("{:.1} KB", size / KB)
    763     } else if size < GB {
    764         format!("{:.1} MB", size / MB)
    765     } else {
    766         format!("{:.2} GB", size / GB)
    767     }
    768 }
    769 
    770 fn item_frame(ui: &egui::Ui) -> egui::Frame {
    771     Frame::new()
    772         .inner_margin(Margin::same(8))
    773         .corner_radius(CornerRadius::same(8))
    774         .fill(ui.visuals().panel_fill)
    775 }