Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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("")),
Expand Down
11 changes: 11 additions & 0 deletions src/document.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 6 additions & 2 deletions src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self, String> {
pub fn new(file_path: &str, password: &str, forced_extension: &str, max_line_length: usize) -> Result<Self, String> {
let mut context = ParserContext::new(file_path.to_string());
if !password.is_empty() {
context = context.with_password(password.to_string());
Expand All @@ -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(),
Expand Down
90 changes: 90 additions & 0 deletions src/text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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}");
}
}
}
}
22 changes: 21 additions & 1 deletion src/ui/dialogs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ type NavigationHandler = Box<dyn Fn(&str) -> 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,
}
Expand Down Expand Up @@ -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<String>,
Expand All @@ -102,7 +104,7 @@ pub fn show_options_dialog(parent: &Frame, config: &ConfigManager) -> Option<Opt
Some(1) => 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 {
Expand All @@ -114,22 +116,38 @@ 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);
}
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();
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down
22 changes: 18 additions & 4 deletions src/ui/document_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);
Expand Down Expand Up @@ -416,6 +417,19 @@ impl DocumentManager {
}
}

pub fn apply_max_line_length(&mut self, self_rc: &Rc<Mutex<Self>>) {
let paths: Vec<PathBuf> = 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<Mutex<Self>>) -> TextCtrl {
let style = TextCtrlStyle::MultiLine
| TextCtrlStyle::ReadOnly
Expand Down
11 changes: 9 additions & 2 deletions src/ui/main_window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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();
Expand All @@ -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 {
Expand Down