diff --git a/src/config.rs b/src/config.rs index e392fa8..30ba112 100644 --- a/src/config.rs +++ b/src/config.rs @@ -773,6 +773,7 @@ impl ConfigManager { ("navigation_wrap", false, None, None), ("check_for_updates_on_startup", true, None, None), ("recent_documents_to_show", false, Some(DEFAULT_RECENT_DOCUMENTS_TO_SHOW), None), + ("max_line_length", false, Some(0), None), ("sleep_timer_duration", false, Some(30), None), ("language", false, None, Some("")), ("active_document", false, None, Some("")), diff --git a/src/document.rs b/src/document.rs index 9d34a94..af7e0e3 100644 --- a/src/document.rs +++ b/src/document.rs @@ -171,6 +171,17 @@ impl DocumentBuffer { pub fn newline_positions(&self) -> &[usize] { &self.newline_char_positions } + + pub fn apply_line_wrapping(&mut self, max_width: usize) { + use crate::text::wrap_content; + self.content = wrap_content(&self.content, max_width); + self.newline_char_positions.clear(); + for (i, c) in self.content.chars().enumerate() { + if c == '\n' { + self.newline_char_positions.push(i); + } + } + } } impl Default for DocumentBuffer { diff --git a/src/session.rs b/src/session.rs index 3585d71..0d987eb 100644 --- a/src/session.rs +++ b/src/session.rs @@ -123,7 +123,7 @@ impl DocumentSession { /// # Errors /// /// Returns an error if the document cannot be parsed. - pub fn new(file_path: &str, password: &str, forced_extension: &str) -> Result { + pub fn new(file_path: &str, password: &str, forced_extension: &str, max_line_length: usize) -> Result { let mut context = ParserContext::new(file_path.to_string()); if !password.is_empty() { context = context.with_password(password.to_string()); @@ -132,7 +132,11 @@ impl DocumentSession { context = context.with_forced_extension(forced_extension.to_string()); } let parser_flags = parser::get_parser_flags_for_context(&context); - let doc = parser::parse_document(&context).map_err(|e| e.to_string())?; + let mut doc = parser::parse_document(&context).map_err(|e| e.to_string())?; + if max_line_length > 0 { + doc.buffer.apply_line_wrapping(max_line_length); + doc.compute_stats(); + } Ok(Self { handle: DocumentHandle::new(doc), file_path: file_path.to_string(), diff --git a/src/text.rs b/src/text.rs index d86a5e7..f639f4a 100644 --- a/src/text.rs +++ b/src/text.rs @@ -109,6 +109,39 @@ pub const fn is_space_like(ch: char) -> bool { ch.is_whitespace() || matches!(ch, '\u{00A0}' | '\u{200B}') } +#[must_use] +pub fn wrap_content(content: &str, max_width: usize) -> String { + let mut result = String::with_capacity(content.len()); + for (i, line) in content.split('\n').enumerate() { + if i > 0 { + result.push('\n'); + } + if line.chars().count() <= max_width { + result.push_str(line); + continue; + } + let mut current_len = 0usize; + let mut first_word = true; + for word in line.split(' ') { + let word_len = word.chars().count(); + if first_word { + result.push_str(word); + current_len = word_len; + first_word = false; + } else if current_len + 1 + word_len <= max_width { + result.push(' '); + result.push_str(word); + current_len += 1 + word_len; + } else { + result.push('\n'); + result.push_str(word); + current_len = word_len; + } + } + } + result +} + pub fn format_list_item(number: i32, list_type: &str) -> String { match list_type { "a" => to_alpha(number, false), @@ -283,4 +316,61 @@ mod tests { fn display_len_plain_newline_counts_as_one_unit() { assert_eq!(display_len("\n"), 1); } + + #[test] + fn wrap_content_short_line_unchanged() { + assert_eq!(wrap_content("Hello world", 20), "Hello world"); + } + + #[test] + fn wrap_content_exact_width_unchanged() { + assert_eq!(wrap_content("Hello world", 11), "Hello world"); + } + + #[test] + fn wrap_content_breaks_at_word_boundary() { + assert_eq!(wrap_content("Hello world, this is a test", 15), "Hello world,\nthis is a test"); + } + + #[test] + fn wrap_content_preserves_existing_newlines() { + assert_eq!(wrap_content("Short\nAlso short", 20), "Short\nAlso short"); + } + + #[test] + fn wrap_content_wraps_each_paragraph_independently() { + let input = "Hello world, this is long\nAnother long paragraph here"; + let expected = "Hello world,\nthis is long\nAnother long\nparagraph here"; + assert_eq!(wrap_content(input, 15), expected); + } + + #[test] + fn wrap_content_long_word_kept_intact() { + assert_eq!(wrap_content("Supercalifragilisticexpialidocious end", 10), "Supercalifragilisticexpialidocious\nend"); + } + + #[test] + fn wrap_content_preserves_char_count() { + let input = "Hello world, this is a very long line that should be wrapped at some point"; + let result = wrap_content(input, 30); + assert_eq!(input.chars().count(), result.chars().count()); + } + + #[test] + fn wrap_content_empty_string() { + assert_eq!(wrap_content("", 100), ""); + } + + #[test] + fn wrap_content_multiple_wraps() { + let input = "one two three four five six seven eight nine ten"; + let result = wrap_content(input, 15); + for line in result.split('\n') { + // Each line should be <= 15 chars, unless a single word exceeds it + let words: Vec<&str> = line.split(' ').collect(); + if words.len() > 1 { + assert!(line.chars().count() <= 15, "Line too long: {line}"); + } + } + } } diff --git a/src/ui/dialogs.rs b/src/ui/dialogs.rs index 03f91c7..f976572 100644 --- a/src/ui/dialogs.rs +++ b/src/ui/dialogs.rs @@ -48,6 +48,7 @@ type NavigationHandler = Box bool>; pub struct OptionsDialogResult { pub flags: OptionsDialogFlags, pub recent_documents_to_show: i32, + pub max_line_length: i32, pub language: String, pub update_channel: crate::config::UpdateChannel, } @@ -82,6 +83,7 @@ struct OptionsDialogUi { check_for_updates_check: CheckBox, bookmark_sounds_check: CheckBox, recent_docs_ctrl: SpinCtrl, + max_line_length_ctrl: SpinCtrl, language_combo: ComboBox, update_channel_combo: ComboBox, language_codes: Vec, @@ -102,7 +104,7 @@ pub fn show_options_dialog(parent: &Frame, config: &ConfigManager) -> Option crate::config::UpdateChannel::Dev, _ => crate::config::UpdateChannel::Stable, }; - Some(OptionsDialogResult { flags, recent_documents_to_show: ui.recent_docs_ctrl.value(), language, update_channel }) + Some(OptionsDialogResult { flags, recent_documents_to_show: ui.recent_docs_ctrl.value(), max_line_length: ui.max_line_length_ctrl.value(), language, update_channel }) } fn build_options_dialog_ui(parent: &Frame, config: &ConfigManager) -> OptionsDialogUi { @@ -114,15 +116,23 @@ fn build_options_dialog_ui(parent: &Frame, config: &ConfigManager) -> OptionsDia let reading_sizer = BoxSizer::builder(Orientation::Vertical).build(); let restore_docs_check = CheckBox::builder(&general_panel).with_label(&t("&Restore previously opened documents on startup")).build(); + restore_docs_check.set_name(&t("Restore previously opened documents on startup")); let word_wrap_check = CheckBox::builder(&reading_panel).with_label(&t("&Word wrap")).build(); + word_wrap_check.set_name(&t("Word wrap")); let minimize_to_tray_check = CheckBox::builder(&general_panel).with_label(&t("&Minimize to system tray")).build(); + minimize_to_tray_check.set_name(&t("Minimize to system tray")); let start_maximized_check = CheckBox::builder(&general_panel).with_label(&t("&Start maximized")).build(); + start_maximized_check.set_name(&t("Start maximized")); let compact_go_menu_check = CheckBox::builder(&reading_panel).with_label(&t("Show compact &go menu")).build(); + compact_go_menu_check.set_name(&t("Show compact go menu")); let navigation_wrap_check = CheckBox::builder(&reading_panel).with_label(&t("&Wrap navigation")).build(); + navigation_wrap_check.set_name(&t("Wrap navigation")); let bookmark_sounds_check = CheckBox::builder(&reading_panel).with_label(&t("Play &sounds on bookmarks and notes")).build(); + bookmark_sounds_check.set_name(&t("Play sounds on bookmarks and notes")); let check_for_updates_check = CheckBox::builder(&general_panel).with_label(&t("Check for &updates on startup")).build(); + check_for_updates_check.set_name(&t("Check for updates on startup")); let option_padding = 5; for check in [&restore_docs_check, &start_maximized_check, &minimize_to_tray_check, &check_for_updates_check] { general_sizer.add(check, 0, SizerFlag::All, option_padding); @@ -130,6 +140,14 @@ fn build_options_dialog_ui(parent: &Frame, config: &ConfigManager) -> OptionsDia for check in [&word_wrap_check, &navigation_wrap_check, &compact_go_menu_check, &bookmark_sounds_check] { reading_sizer.add(check, 0, SizerFlag::All, option_padding); } + let max_line_length_label = + StaticText::builder(&reading_panel).with_label(&t("Ma&ximum line length (0 for entire line):")).build(); + let max_line_length_ctrl = SpinCtrl::builder(&reading_panel).with_range(0, 500).build(); + max_line_length_ctrl.set_name(&t("Maximum line length (0 for entire line)")); + let max_line_length_sizer = BoxSizer::builder(Orientation::Horizontal).build(); + max_line_length_sizer.add(&max_line_length_label, 0, SizerFlag::AlignCenterVertical | SizerFlag::Right, DIALOG_PADDING); + max_line_length_sizer.add(&max_line_length_ctrl, 0, SizerFlag::AlignCenterVertical, 0); + reading_sizer.add_sizer(&max_line_length_sizer, 0, SizerFlag::All, option_padding); let max_recent_docs = 100; let recent_docs_label = StaticText::builder(&general_panel).with_label(&t("Number of &recent documents to show:")).build(); @@ -173,6 +191,7 @@ fn build_options_dialog_ui(parent: &Frame, config: &ConfigManager) -> OptionsDia bookmark_sounds_check.set_value(config.get_app_bool("bookmark_sounds", true)); check_for_updates_check.set_value(config.get_app_bool("check_for_updates_on_startup", true)); recent_docs_ctrl.set_value(config.get_app_int("recent_documents_to_show", 25).clamp(0, max_recent_docs)); + max_line_length_ctrl.set_value(config.get_app_int("max_line_length", 0).clamp(0, 500)); let stored_language = config.get_app_string("language", ""); let current_language = if stored_language.is_empty() { TranslationManager::instance().lock().unwrap().current_language() @@ -204,6 +223,7 @@ fn build_options_dialog_ui(parent: &Frame, config: &ConfigManager) -> OptionsDia check_for_updates_check, bookmark_sounds_check, recent_docs_ctrl, + max_line_length_ctrl, language_combo, update_channel_combo, language_codes, diff --git a/src/ui/document_manager.rs b/src/ui/document_manager.rs index edb6a46..ea00ad4 100644 --- a/src/ui/document_manager.rs +++ b/src/ui/document_manager.rs @@ -71,17 +71,18 @@ impl DocumentManager { self.notebook.set_selection(index); return true; } - let (password, forced_extension) = { + let (password, forced_extension, max_line_length) = { let config = self.config.lock().unwrap(); let path_str = path.to_string_lossy(); config.import_document_settings(&path_str); let forced_extension = config.get_document_format(&path_str); let password = config.get_document_password(&path_str); + let max_line_length = usize::try_from(config.get_app_int("max_line_length", 0)).unwrap_or(0); drop(config); - (password, forced_extension) + (password, forced_extension, max_line_length) }; let path_str = path.to_string_lossy().to_string(); - match DocumentSession::new(&path_str, &password, &forced_extension) { + match DocumentSession::new(&path_str, &password, &forced_extension, max_line_length) { Ok(session) => self.add_session_tab(self_rc, path, session, &password), Err(err) => { if err.starts_with(PASSWORD_REQUIRED_ERROR_PREFIX) { @@ -93,7 +94,7 @@ impl DocumentManager { show_error_dialog(&self.notebook, &t("Password is required."), &t("Error")); return false; }; - match DocumentSession::new(&path_str, &password, &forced_extension) { + match DocumentSession::new(&path_str, &password, &forced_extension, max_line_length) { Ok(session) => self.add_session_tab(self_rc, path, session, &password), Err(retry_error) => { let message = build_document_load_error_message(path, &retry_error); @@ -416,6 +417,19 @@ impl DocumentManager { } } + pub fn apply_max_line_length(&mut self, self_rc: &Rc>) { + let paths: Vec = self.tabs.iter().map(|tab| tab.file_path.clone()).collect(); + self.save_all_positions(); + while !self.tabs.is_empty() { + let _page = self.notebook.get_page(0); + self.notebook.remove_page(0); + self.tabs.remove(0); + } + for path in &paths { + self.open_file(self_rc, path); + } + } + fn build_text_ctrl(panel: Panel, word_wrap: bool, self_rc: &Rc>) -> TextCtrl { let style = TextCtrlStyle::MultiLine | TextCtrlStyle::ReadOnly diff --git a/src/ui/main_window.rs b/src/ui/main_window.rs index 073ee47..fc5e3b9 100644 --- a/src/ui/main_window.rs +++ b/src/ui/main_window.rs @@ -1102,9 +1102,9 @@ impl MainWindow { let Some(options) = options else { return; }; - let (old_word_wrap, old_compact_menu) = { + let (old_word_wrap, old_compact_menu, old_max_line_length) = { let cfg = config.lock().unwrap(); - (cfg.get_app_bool("word_wrap", false), cfg.get_app_bool("compact_go_menu", true)) + (cfg.get_app_bool("word_wrap", false), cfg.get_app_bool("compact_go_menu", true), cfg.get_app_int("max_line_length", 0)) }; let cfg = config.lock().unwrap(); cfg.set_app_bool( @@ -1122,6 +1122,7 @@ impl MainWindow { ); cfg.set_app_bool("bookmark_sounds", options.flags.contains(OptionsDialogFlags::BOOKMARK_SOUNDS)); cfg.set_app_int("recent_documents_to_show", options.recent_documents_to_show); + cfg.set_app_int("max_line_length", options.max_line_length); cfg.set_app_string("language", &options.language); cfg.set_update_channel(options.update_channel); cfg.flush(); @@ -1133,6 +1134,12 @@ impl MainWindow { dm_ref.apply_word_wrap(&dm_for_wrap, options_word_wrap); dm_ref.restore_focus(); } + if old_max_line_length != options.max_line_length { + let dm_for_wrap = Rc::clone(&dm); + let mut dm_ref = dm.lock().unwrap(); + dm_ref.apply_max_line_length(&dm_for_wrap); + dm_ref.restore_focus(); + } let options_compact_menu = options.flags.contains(OptionsDialogFlags::COMPACT_GO_MENU); if current_language != options.language || old_compact_menu != options_compact_menu { if current_language != options.language {