settings.rs (22789B)
1 use egui::{ 2 vec2, Button, Color32, ComboBox, FontId, Frame, Margin, RichText, ScrollArea, ThemePreference, 3 }; 4 use enostr::NoteId; 5 use nostrdb::Transaction; 6 use notedeck::{ 7 tr, 8 ui::{is_narrow, richtext_small}, 9 Images, JobsCache, LanguageIdentifier, Localization, NoteContext, NotedeckTextStyle, Settings, 10 SettingsHandler, DEFAULT_NOTE_BODY_FONT_SIZE, 11 }; 12 use notedeck_ui::{NoteOptions, NoteView}; 13 use strum::Display; 14 15 use crate::{nav::RouterAction, Damus, Route}; 16 17 const PREVIEW_NOTE_ID: &str = "note1edjc8ggj07hwv77g2405uh6j2jkk5aud22gktxrvc2wnre4vdwgqzlv2gw"; 18 19 const MIN_ZOOM: f32 = 0.5; 20 const MAX_ZOOM: f32 = 3.0; 21 const ZOOM_STEP: f32 = 0.1; 22 const RESET_ZOOM: f32 = 1.0; 23 24 #[derive(Clone, Copy, PartialEq, Eq, Display)] 25 pub enum ShowSourceClientOption { 26 Hide, 27 Top, 28 Bottom, 29 } 30 31 impl From<ShowSourceClientOption> for String { 32 fn from(show_option: ShowSourceClientOption) -> Self { 33 match show_option { 34 ShowSourceClientOption::Hide => "hide".to_string(), 35 ShowSourceClientOption::Top => "top".to_string(), 36 ShowSourceClientOption::Bottom => "bottom".to_string(), 37 } 38 } 39 } 40 41 impl From<NoteOptions> for ShowSourceClientOption { 42 fn from(note_options: NoteOptions) -> Self { 43 if note_options.contains(NoteOptions::ClientNameTop) { 44 ShowSourceClientOption::Top 45 } else if note_options.contains(NoteOptions::ClientNameBottom) { 46 ShowSourceClientOption::Bottom 47 } else { 48 ShowSourceClientOption::Hide 49 } 50 } 51 } 52 53 impl From<String> for ShowSourceClientOption { 54 fn from(s: String) -> Self { 55 match s.to_lowercase().as_str() { 56 "hide" => Self::Hide, 57 "top" => Self::Top, 58 "bottom" => Self::Bottom, 59 _ => Self::Hide, // default fallback 60 } 61 } 62 } 63 64 impl ShowSourceClientOption { 65 pub fn set_note_options(self, note_options: &mut NoteOptions) { 66 match self { 67 Self::Hide => { 68 note_options.set(NoteOptions::ClientNameTop, false); 69 note_options.set(NoteOptions::ClientNameBottom, false); 70 } 71 Self::Bottom => { 72 note_options.set(NoteOptions::ClientNameTop, false); 73 note_options.set(NoteOptions::ClientNameBottom, true); 74 } 75 Self::Top => { 76 note_options.set(NoteOptions::ClientNameTop, true); 77 note_options.set(NoteOptions::ClientNameBottom, false); 78 } 79 } 80 } 81 82 fn label(&self, i18n: &mut Localization) -> String { 83 match self { 84 Self::Hide => tr!( 85 i18n, 86 "Hide", 87 "Option in settings section to hide the source client label in note display" 88 ), 89 Self::Top => tr!( 90 i18n, 91 "Top", 92 "Option in settings section to show the source client label at the top of the note" 93 ), 94 Self::Bottom => tr!( 95 i18n, 96 "Bottom", 97 "Option in settings section to show the source client label at the bottom of the note" 98 ), 99 } 100 } 101 } 102 103 pub enum SettingsAction { 104 SetZoomFactor(f32), 105 SetTheme(ThemePreference), 106 SetShowSourceClient(ShowSourceClientOption), 107 SetLocale(LanguageIdentifier), 108 SetRepliestNewestFirst(bool), 109 SetNoteBodyFontSize(f32), 110 OpenRelays, 111 OpenCacheFolder, 112 ClearCacheFolder, 113 } 114 115 impl SettingsAction { 116 pub fn process_settings_action<'a>( 117 self, 118 app: &mut Damus, 119 settings: &'a mut SettingsHandler, 120 i18n: &'a mut Localization, 121 img_cache: &mut Images, 122 ctx: &egui::Context, 123 ) -> Option<RouterAction> { 124 let mut route_action: Option<RouterAction> = None; 125 126 match self { 127 Self::OpenRelays => { 128 route_action = Some(RouterAction::route_to(Route::Relays)); 129 } 130 Self::SetZoomFactor(zoom_factor) => { 131 ctx.set_zoom_factor(zoom_factor); 132 settings.set_zoom_factor(zoom_factor); 133 } 134 Self::SetShowSourceClient(option) => { 135 option.set_note_options(&mut app.note_options); 136 137 settings.set_show_source_client(option); 138 } 139 Self::SetTheme(theme) => { 140 ctx.set_theme(theme); 141 settings.set_theme(theme); 142 } 143 Self::SetLocale(language) => { 144 if i18n.set_locale(language.clone()).is_ok() { 145 settings.set_locale(language.to_string()); 146 } 147 } 148 Self::SetRepliestNewestFirst(value) => { 149 app.note_options.set(NoteOptions::RepliesNewestFirst, value); 150 settings.set_show_replies_newest_first(value); 151 } 152 Self::OpenCacheFolder => { 153 use opener; 154 let _ = opener::open(img_cache.base_path.clone()); 155 } 156 Self::ClearCacheFolder => { 157 let _ = img_cache.clear_folder_contents(); 158 } 159 Self::SetNoteBodyFontSize(size) => { 160 let mut style = (*ctx.style()).clone(); 161 style.text_styles.insert( 162 NotedeckTextStyle::NoteBody.text_style(), 163 FontId::proportional(size), 164 ); 165 ctx.set_style(style); 166 167 settings.set_note_body_font_size(size); 168 } 169 } 170 route_action 171 } 172 } 173 174 pub struct SettingsView<'a> { 175 settings: &'a mut Settings, 176 note_context: &'a mut NoteContext<'a>, 177 note_options: &'a mut NoteOptions, 178 jobs: &'a mut JobsCache, 179 } 180 181 fn settings_group<S>(ui: &mut egui::Ui, title: S, contents: impl FnOnce(&mut egui::Ui)) 182 where 183 S: Into<String>, 184 { 185 Frame::group(ui.style()) 186 .fill(ui.style().visuals.widgets.open.bg_fill) 187 .inner_margin(10.0) 188 .show(ui, |ui| { 189 ui.label(RichText::new(title).text_style(NotedeckTextStyle::Body.text_style())); 190 ui.separator(); 191 192 ui.vertical(|ui| { 193 ui.spacing_mut().item_spacing = vec2(10.0, 10.0); 194 195 contents(ui) 196 }); 197 }); 198 } 199 200 impl<'a> SettingsView<'a> { 201 pub fn new( 202 settings: &'a mut Settings, 203 note_context: &'a mut NoteContext<'a>, 204 note_options: &'a mut NoteOptions, 205 jobs: &'a mut JobsCache, 206 ) -> Self { 207 Self { 208 settings, 209 note_context, 210 note_options, 211 jobs, 212 } 213 } 214 215 /// Get the localized name for a language identifier 216 fn get_selected_language_name(&mut self) -> String { 217 if let Ok(lang_id) = self.settings.locale.parse::<LanguageIdentifier>() { 218 self.note_context 219 .i18n 220 .get_locale_native_name(&lang_id) 221 .map(|s| s.to_owned()) 222 .unwrap_or_else(|| lang_id.to_string()) 223 } else { 224 self.settings.locale.clone() 225 } 226 } 227 228 pub fn appearance_section(&mut self, ui: &mut egui::Ui) -> Option<SettingsAction> { 229 let mut action = None; 230 let title = tr!( 231 self.note_context.i18n, 232 "Appearance", 233 "Label for appearance settings section", 234 ); 235 settings_group(ui, title, |ui| { 236 ui.horizontal(|ui| { 237 ui.label(richtext_small(tr!( 238 self.note_context.i18n, 239 "Font size:", 240 "Label for font size, Appearance settings section", 241 ))); 242 243 if ui 244 .add( 245 egui::Slider::new(&mut self.settings.note_body_font_size, 8.0..=32.0) 246 .text(""), 247 ) 248 .changed() 249 { 250 action = Some(SettingsAction::SetNoteBodyFontSize( 251 self.settings.note_body_font_size, 252 )); 253 }; 254 255 if ui 256 .button(richtext_small(tr!( 257 self.note_context.i18n, 258 "Reset", 259 "Label for reset note body font size, Appearance settings section", 260 ))) 261 .clicked() 262 { 263 action = Some(SettingsAction::SetNoteBodyFontSize( 264 DEFAULT_NOTE_BODY_FONT_SIZE, 265 )); 266 } 267 }); 268 269 let txn = Transaction::new(self.note_context.ndb).unwrap(); 270 271 if let Some(note_id) = NoteId::from_bech(PREVIEW_NOTE_ID) { 272 if let Ok(preview_note) = 273 self.note_context.ndb.get_note_by_id(&txn, note_id.bytes()) 274 { 275 notedeck_ui::padding(8.0, ui, |ui| { 276 if is_narrow(ui.ctx()) { 277 ui.set_max_width(ui.available_width()); 278 279 NoteView::new( 280 self.note_context, 281 &preview_note, 282 *self.note_options, 283 self.jobs, 284 ) 285 .actionbar(false) 286 .options_button(false) 287 .show(ui); 288 } 289 }); 290 ui.separator(); 291 } 292 } 293 294 let current_zoom = ui.ctx().zoom_factor(); 295 296 ui.horizontal(|ui| { 297 ui.label(richtext_small(tr!( 298 self.note_context.i18n, 299 "Zoom Level:", 300 "Label for zoom level, Appearance settings section", 301 ))); 302 303 let min_reached = current_zoom <= MIN_ZOOM; 304 let max_reached = current_zoom >= MAX_ZOOM; 305 306 if ui 307 .add_enabled( 308 !min_reached, 309 Button::new( 310 RichText::new("-").text_style(NotedeckTextStyle::Small.text_style()), 311 ), 312 ) 313 .clicked() 314 { 315 let new_zoom = (current_zoom - ZOOM_STEP).max(MIN_ZOOM); 316 action = Some(SettingsAction::SetZoomFactor(new_zoom)); 317 }; 318 319 ui.label( 320 RichText::new(format!("{:.0}%", current_zoom * 100.0)) 321 .text_style(NotedeckTextStyle::Small.text_style()), 322 ); 323 324 if ui 325 .add_enabled( 326 !max_reached, 327 Button::new( 328 RichText::new("+").text_style(NotedeckTextStyle::Small.text_style()), 329 ), 330 ) 331 .clicked() 332 { 333 let new_zoom = (current_zoom + ZOOM_STEP).min(MAX_ZOOM); 334 action = Some(SettingsAction::SetZoomFactor(new_zoom)); 335 }; 336 337 if ui 338 .button(richtext_small(tr!( 339 self.note_context.i18n, 340 "Reset", 341 "Label for reset zoom level, Appearance settings section", 342 ))) 343 .clicked() 344 { 345 action = Some(SettingsAction::SetZoomFactor(RESET_ZOOM)); 346 } 347 }); 348 349 ui.horizontal(|ui| { 350 ui.label(richtext_small(tr!( 351 self.note_context.i18n, 352 "Language:", 353 "Label for language, Appearance settings section", 354 ))); 355 356 // 357 ComboBox::from_label("") 358 .selected_text(self.get_selected_language_name()) 359 .show_ui(ui, |ui| { 360 for lang in self.note_context.i18n.get_available_locales() { 361 let name = self 362 .note_context 363 .i18n 364 .get_locale_native_name(lang) 365 .map(|s| s.to_owned()) 366 .unwrap_or_else(|| lang.to_string()); 367 if ui 368 .selectable_value(&mut self.settings.locale, lang.to_string(), name) 369 .clicked() 370 { 371 action = Some(SettingsAction::SetLocale(lang.to_owned())) 372 } 373 } 374 }); 375 }); 376 377 ui.horizontal(|ui| { 378 ui.label(richtext_small(tr!( 379 self.note_context.i18n, 380 "Theme:", 381 "Label for theme, Appearance settings section", 382 ))); 383 384 if ui 385 .selectable_value( 386 &mut self.settings.theme, 387 ThemePreference::Light, 388 richtext_small(tr!( 389 self.note_context.i18n, 390 "Light", 391 "Label for Theme Light, Appearance settings section", 392 )), 393 ) 394 .clicked() 395 { 396 action = Some(SettingsAction::SetTheme(ThemePreference::Light)); 397 } 398 399 if ui 400 .selectable_value( 401 &mut self.settings.theme, 402 ThemePreference::Dark, 403 richtext_small(tr!( 404 self.note_context.i18n, 405 "Dark", 406 "Label for Theme Dark, Appearance settings section", 407 )), 408 ) 409 .clicked() 410 { 411 action = Some(SettingsAction::SetTheme(ThemePreference::Dark)); 412 } 413 }); 414 }); 415 416 action 417 } 418 419 pub fn storage_section(&mut self, ui: &mut egui::Ui) -> Option<SettingsAction> { 420 let id = ui.id(); 421 let mut action: Option<SettingsAction> = None; 422 let title = tr!( 423 self.note_context.i18n, 424 "Storage", 425 "Label for storage settings section" 426 ); 427 settings_group(ui, title, |ui| { 428 ui.horizontal_wrapped(|ui| { 429 let static_imgs_size = self 430 .note_context 431 .img_cache 432 .static_imgs 433 .cache_size 434 .lock() 435 .unwrap(); 436 437 let gifs_size = self.note_context.img_cache.gifs.cache_size.lock().unwrap(); 438 439 ui.label( 440 RichText::new(format!( 441 "{} {}", 442 tr!( 443 self.note_context.i18n, 444 "Image cache size:", 445 "Label for Image cache size, Storage settings section" 446 ), 447 format_size( 448 [static_imgs_size, gifs_size] 449 .iter() 450 .fold(0_u64, |acc, cur| acc + cur.unwrap_or_default()) 451 ) 452 )) 453 .text_style(NotedeckTextStyle::Small.text_style()), 454 ); 455 456 ui.end_row(); 457 458 if !notedeck::ui::is_compiled_as_mobile() 459 && ui 460 .button(richtext_small(tr!( 461 self.note_context.i18n, 462 "View folder", 463 "Label for view folder button, Storage settings section", 464 ))) 465 .clicked() 466 { 467 action = Some(SettingsAction::OpenCacheFolder); 468 } 469 470 let clearcache_resp = ui.button( 471 richtext_small(tr!( 472 self.note_context.i18n, 473 "Clear cache", 474 "Label for clear cache button, Storage settings section", 475 )) 476 .color(Color32::LIGHT_RED), 477 ); 478 479 let id_clearcache = id.with("clear_cache"); 480 if clearcache_resp.clicked() { 481 ui.data_mut(|d| d.insert_temp(id_clearcache, true)); 482 } 483 484 if ui.data_mut(|d| *d.get_temp_mut_or_default(id_clearcache)) { 485 let mut confirm_pressed = false; 486 clearcache_resp.show_tooltip_ui(|ui| { 487 let confirm_resp = ui.button(tr!( 488 self.note_context.i18n, 489 "Confirm", 490 "Label for confirm clear cache, Storage settings section" 491 )); 492 if confirm_resp.clicked() { 493 confirm_pressed = true; 494 } 495 496 if confirm_resp.clicked() 497 || ui 498 .button(tr!( 499 self.note_context.i18n, 500 "Cancel", 501 "Label for cancel clear cache, Storage settings section" 502 )) 503 .clicked() 504 { 505 ui.data_mut(|d| d.insert_temp(id_clearcache, false)); 506 } 507 }); 508 509 if confirm_pressed { 510 action = Some(SettingsAction::ClearCacheFolder); 511 } else if !confirm_pressed && clearcache_resp.clicked_elsewhere() { 512 ui.data_mut(|d| d.insert_temp(id_clearcache, false)); 513 } 514 }; 515 }); 516 }); 517 518 action 519 } 520 521 fn other_options_section(&mut self, ui: &mut egui::Ui) -> Option<SettingsAction> { 522 let mut action = None; 523 524 let title = tr!( 525 self.note_context.i18n, 526 "Others", 527 "Label for others settings section" 528 ); 529 settings_group(ui, title, |ui| { 530 ui.horizontal(|ui| { 531 ui.label(richtext_small(tr!( 532 self.note_context.i18n, 533 "Sort replies newest first:", 534 "Label for Sort replies newest first, others settings section", 535 ))); 536 537 if ui 538 .toggle_value( 539 &mut self.settings.show_replies_newest_first, 540 RichText::new(tr!( 541 self.note_context.i18n, 542 "On", 543 "Setting to turn on sorting replies so that the newest are shown first" 544 )) 545 .text_style(NotedeckTextStyle::Small.text_style()), 546 ) 547 .changed() 548 { 549 action = Some(SettingsAction::SetRepliestNewestFirst( 550 self.settings.show_replies_newest_first, 551 )); 552 } 553 }); 554 555 ui.horizontal_wrapped(|ui| { 556 ui.label(richtext_small(tr!( 557 self.note_context.i18n, 558 "Source client:", 559 "Label for Source client, others settings section", 560 ))); 561 562 for option in [ 563 ShowSourceClientOption::Hide, 564 ShowSourceClientOption::Top, 565 ShowSourceClientOption::Bottom, 566 ] { 567 let mut current: ShowSourceClientOption = 568 self.settings.show_source_client.clone().into(); 569 570 if ui 571 .selectable_value( 572 &mut current, 573 option, 574 RichText::new(option.label(self.note_context.i18n)) 575 .text_style(NotedeckTextStyle::Small.text_style()), 576 ) 577 .changed() 578 { 579 action = Some(SettingsAction::SetShowSourceClient(option)); 580 } 581 } 582 }); 583 }); 584 585 action 586 } 587 588 fn manage_relays_section(&mut self, ui: &mut egui::Ui) -> Option<SettingsAction> { 589 let mut action = None; 590 591 if ui 592 .add_sized( 593 [ui.available_width(), 30.0], 594 Button::new(richtext_small(tr!( 595 self.note_context.i18n, 596 "Configure relays", 597 "Label for configure relays, settings section", 598 ))), 599 ) 600 .clicked() 601 { 602 action = Some(SettingsAction::OpenRelays); 603 } 604 605 action 606 } 607 608 pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<SettingsAction> { 609 let mut action: Option<SettingsAction> = None; 610 611 Frame::default() 612 .inner_margin(Margin::symmetric(10, 10)) 613 .show(ui, |ui| { 614 ScrollArea::vertical().show(ui, |ui| { 615 if let Some(new_action) = self.appearance_section(ui) { 616 action = Some(new_action); 617 } 618 619 ui.add_space(5.0); 620 621 if let Some(new_action) = self.storage_section(ui) { 622 action = Some(new_action); 623 } 624 625 ui.add_space(5.0); 626 627 if let Some(new_action) = self.other_options_section(ui) { 628 action = Some(new_action); 629 } 630 631 ui.add_space(10.0); 632 633 if let Some(new_action) = self.manage_relays_section(ui) { 634 action = Some(new_action); 635 } 636 }); 637 }); 638 639 action 640 } 641 } 642 643 pub fn format_size(size_bytes: u64) -> String { 644 const KB: f64 = 1024.0; 645 const MB: f64 = KB * 1024.0; 646 const GB: f64 = MB * 1024.0; 647 648 let size = size_bytes as f64; 649 650 if size < KB { 651 format!("{size:.0} Bytes") 652 } else if size < MB { 653 format!("{:.1} KB", size / KB) 654 } else if size < GB { 655 format!("{:.1} MB", size / MB) 656 } else { 657 format!("{:.2} GB", size / GB) 658 } 659 }