From b670ea35cdcdeb611f20f1bf205e8c2a288a8819 Mon Sep 17 00:00:00 2001 From: Victor Caparica Date: Thu, 26 Mar 2026 14:36:33 -0300 Subject: [PATCH] Add maximum line length option for screenreader accessibility Adds a configurable maximum line length setting to the Options dialog (Reading tab). When set, long lines are wrapped at word boundaries so screenreaders read manageable chunks instead of entire paragraphs at once. The default value of 0 preserves the current behavior (entire line). The wrapping replaces spaces with newlines, preserving character count so bookmarks, positions, and navigation remain accurate. Also sets explicit accessible names on all Options dialog controls to ensure screenreaders announce checkbox labels correctly. --- src/config.rs | 1 + src/document.rs | 11 +++++ src/session.rs | 8 +++- src/text.rs | 90 ++++++++++++++++++++++++++++++++++++++ src/ui/dialogs.rs | 22 +++++++++- src/ui/document_manager.rs | 22 ++++++++-- src/ui/main_window.rs | 11 ++++- 7 files changed, 156 insertions(+), 9 deletions(-) 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 {