From c6a7bff78c3c46f57ff2bc6c72b4b9b277164a7a Mon Sep 17 00:00:00 2001 From: RivoLink Date: Mon, 9 Nov 2026 21:22:27 +0300 Subject: [PATCH] chore: toggle soft line breaks --- README.md | 6 +- config.toml | 8 + src/app/content.rs | 67 +++++--- src/app/flash.rs | 18 ++ src/app/io_picker.rs | 4 + src/app/mod.rs | 38 ++++- src/app/theme_picker.rs | 30 +--- src/config.rs | 2 + src/main.rs | 55 ++++-- src/markdown/mod.rs | 24 ++- src/render/mod.rs | 2 +- src/render/popup.rs | 18 +- src/render/status.rs | 25 ++- src/runtime/keyboard.rs | 10 ++ src/runtime/mod.rs | 9 + src/tests/app.rs | 18 +- src/tests/app_text_copy.rs | 1 + src/tests/common/app_helpers.rs | 1 + src/tests/config.rs | 14 ++ src/tests/markdown_blocks.rs | 6 +- src/tests/markdown_embedded.rs | 10 +- src/tests/markdown_footnotes.rs | 1 + src/tests/markdown_links.rs | 7 +- src/tests/markdown_list_blocks.rs | 2 +- src/tests/markdown_lists.rs | 5 +- src/tests/markdown_rendering.rs | 1 + src/tests/markdown_tables.rs | 13 +- src/tests/mod.rs | 1 + src/tests/render_popup.rs | 30 ++++ src/tests/soft_breaks.rs | 274 ++++++++++++++++++++++++++++++ src/tests/wrapping.rs | 6 +- 31 files changed, 605 insertions(+), 101 deletions(-) create mode 100644 src/tests/soft_breaks.rs diff --git a/README.md b/README.md index 7136253..d6a4ea0 100644 --- a/README.md +++ b/README.md @@ -288,6 +288,7 @@ file-picker-width = "75%" # width of the file picker (fuzzy + file browser) toc-filelist-width = "25%" # width of the TOC and file-list side panels main-line-numbers = false # show line numbers in the main document by default code-line-numbers = true # show line numbers inside fenced code blocks +keep-soft-breaks = true # keep source line breaks inside paragraphs tab-title-length = -1 # terminal tab title truncation (min: 20, -1: no truncation) file-history-length = 0 # recent file history length (0 disables, max: 50) hyper-link-prefix = "#" # single character before link text ("" for none) @@ -390,7 +391,7 @@ leaf --theme rose-pine README.md | Key | Action | Key | Action | |---|---|---|---| | `j` / `↓` | Scroll down | `?` | Show help popup | -| `k` / `↑` | Scroll up | `p` | Show file path | +| `k` / `↑` | Scroll up | `Shift+B` | Toggle soft breaks | | `d` / PgDn | Page down (20 lines) | `Shift+L` | Toggle line numbers | | `u` / PgUp | Page up (20 lines) | `Shift+T` | Open theme picker | | `g` / Home | Top | `Shift+E` | Open editor picker | @@ -405,7 +406,8 @@ leaf --theme rose-pine README.md | `w` | Toggle watch mode | `Double-Click` (code) | Copy code block | | `r` | Force reload (watch mode) | `Shift+Drag` | Select text | | `t` | Toggle TOC sidebar | `Option+Drag` | Select text (iTerm2) | -| `q` | Quit | `Ctrl+Double-Click` | Copy line | +| `p` | Show file path | `Ctrl+Double-Click` | Copy line | +| `q` | Quit | | | ## Features diff --git a/config.toml b/config.toml index 49f119f..3b259c3 100644 --- a/config.toml +++ b/config.toml @@ -104,6 +104,14 @@ watch = false # Default: true # code-line-numbers = true +# Keep soft line breaks inside paragraphs +# Set to false to join paragraph lines and re-wrap them to the content width. +# Can still be toggled interactively with 'Shift+B'. +# Priority: LEAF_KEEP_SOFT_BREAKS > this setting > default (true) +# Accepted env values: 1 = true, 0 = false (other values ignored) +# Default: true +# keep-soft-breaks = true + # Maximum tab title length (in characters) # Controls the tab title of the terminal: "leaf: ". # When the filename is long, it is truncated: "leaf: prefix...ext". diff --git a/src/app/content.rs b/src/app/content.rs index 9ef6c18..0f5bd40 100644 --- a/src/app/content.rs +++ b/src/app/content.rs @@ -262,18 +262,9 @@ impl App { self.mode = super::AppMode::Document; let (wrapped, is_code_file) = Self::wrap_as_code_block(src, &ext, ss); self.file_mode = is_code_file; + self.plain_text_file = Self::is_plain_text_extension(&ext); self.source = wrapped; - let theme = current_syntect_theme(themes); - let at = app_theme(); - let parsed = parse_markdown_with_width( - &self.source, - ss, - theme, - self.render_width, - &at.markdown, - self.file_mode, - self.code_line_numbers, - ); + let parsed = self.parse_current_source(ss, themes); self.invalidate_theme_previews(); self.store_current_theme_preview_from(&parsed); self.replace_content(parsed); @@ -284,10 +275,48 @@ impl App { } pub(crate) fn reparse_source(&mut self, ss: &SyntaxSet, themes: &ThemeSet) { + let old_total = self.total(); + let parsed = self.parse_current_source(ss, themes); + let new_total = parsed.lines.len(); + + if old_total > 0 { + self.scroll = ((self.scroll as f64 / old_total as f64) * new_total as f64) as usize; + let vh = self.content_area.height as usize; + self.scroll = self.scroll.min(new_total.saturating_sub(vh)); + } + + self.apply_reparsed(parsed); + } + + pub(crate) fn toggle_soft_breaks(&mut self, ss: &SyntaxSet, themes: &ThemeSet) { + let keep = !self.keep_soft_breaks(); + self.soft_breaks_override = Some(keep); + let anchor = self.source_line_at(self.scroll); + let parsed = self.parse_current_source(ss, themes); + let new_total = parsed.lines.len(); + let map = &parsed.source_line_map; + let target = map + .iter() + .copied() + .filter(|&line| line <= anchor) + .max() + .and_then(|best| map.iter().position(|&line| line == best)) + .unwrap_or(0); + let vh = self.content_area.height as usize; + self.scroll = target.min(new_total.saturating_sub(vh)); + + self.apply_reparsed(parsed); + self.set_soft_break_flash(if keep { + super::SoftBreakFlash::Kept + } else { + super::SoftBreakFlash::Joined + }); + } + + pub(super) fn parse_current_source(&self, ss: &SyntaxSet, themes: &ThemeSet) -> ParseResult { let theme = current_syntect_theme(themes); let at = app_theme(); - let old_total = self.total(); - let parsed = parse_markdown_with_width( + parse_markdown_with_width( &self.source, ss, theme, @@ -295,15 +324,11 @@ impl App { &at.markdown, self.file_mode, self.code_line_numbers, - ); - let new_total = parsed.lines.len(); - - if old_total > 0 { - self.scroll = ((self.scroll as f64 / old_total as f64) * new_total as f64) as usize; - let vh = self.content_area.height as usize; - self.scroll = self.scroll.min(new_total.saturating_sub(vh)); - } + self.keep_soft_breaks(), + ) + } + fn apply_reparsed(&mut self, parsed: ParseResult) { self.invalidate_theme_previews(); self.store_current_theme_preview_from(&parsed); self.replace_content(parsed); diff --git a/src/app/flash.rs b/src/app/flash.rs index 1589f15..1d4e7aa 100644 --- a/src/app/flash.rs +++ b/src/app/flash.rs @@ -50,6 +50,12 @@ pub(crate) enum HistoryFlash { LengthCapped { was: i32 }, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum SoftBreakFlash { + Kept, + Joined, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum DiffFlash { SearchInPreview, @@ -231,4 +237,16 @@ impl App { pub(crate) fn clear_diff_flash(&mut self) { self.diff_flash = None; } + + pub(crate) fn set_soft_break_flash(&mut self, flash: SoftBreakFlash) { + self.soft_break_flash = Some((flash, Instant::now())); + } + + pub(crate) fn soft_break_flash(&self) -> Option<(&SoftBreakFlash, &Instant)> { + self.soft_break_flash.as_ref().map(|(f, t)| (f, t)) + } + + pub(crate) fn clear_soft_break_flash(&mut self) { + self.soft_break_flash = None; + } } diff --git a/src/app/io_picker.rs b/src/app/io_picker.rs index bfffb9d..3a0f4c7 100644 --- a/src/app/io_picker.rs +++ b/src/app/io_picker.rs @@ -25,6 +25,10 @@ impl App { format!("````{ext}\n{src}\n````") } + pub(crate) fn is_plain_text_extension(ext: &str) -> bool { + !ext.is_empty() && !Self::is_markdown_extension(ext) + } + pub(crate) fn wrap_as_code_block(src: String, ext: &str, ss: &SyntaxSet) -> (String, bool) { if Self::is_markdown_extension(ext) || !Self::has_code_syntax(ext, ss) { (src, false) diff --git a/src/app/mod.rs b/src/app/mod.rs index 64158cd..79bc4c1 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -63,8 +63,8 @@ pub(crate) use diff_preview::{fetch_before, reconstruct_after_from_hunks}; mod flash; pub(crate) use flash::{ - CodeBlockFlash, DiffFlash, EditorFlash, HistoryFlash, LinkFlash, PathFlash, WatchFlash, - COPY_HIGHLIGHT_DURATION_MS, FLASH_DURATION_MS, + CodeBlockFlash, DiffFlash, EditorFlash, HistoryFlash, LinkFlash, PathFlash, SoftBreakFlash, + WatchFlash, COPY_HIGHLIGHT_DURATION_MS, FLASH_DURATION_MS, }; mod text_copy; @@ -133,6 +133,7 @@ pub(crate) struct StatusCacheKey { code_block_flash_active: bool, history_flash_active: bool, diff_flash_active: bool, + soft_break_flash_active: bool, preview_reconstruct_error: bool, mouse_capture: bool, toc_scroll_hint_visible: bool, @@ -196,6 +197,7 @@ pub(crate) struct App { pub(super) file_history_length: Option, pub(super) history_flash: Option<(HistoryFlash, std::time::Instant)>, pub(super) diff_flash: Option<(DiffFlash, std::time::Instant)>, + pub(super) soft_break_flash: Option<(SoftBreakFlash, std::time::Instant)>, pub(super) syntax_set: Option>, pub(super) diff_highlight: Option<( std::sync::Arc, @@ -238,6 +240,9 @@ pub(crate) struct App { reverse_mode: bool, pub(super) file_mode: bool, pub(super) code_line_numbers: bool, + keep_soft_breaks_pref: bool, + soft_breaks_override: Option, + pub(super) plain_text_file: bool, scroll_with_enter: bool, page_scroll_size: crate::config::PageScrollSize, max_width: Option, @@ -381,6 +386,7 @@ impl App { file_history_length: None, history_flash: None, diff_flash: None, + soft_break_flash: None, syntax_set: None, diff_highlight: None, history_error_receiver: None, @@ -429,6 +435,9 @@ impl App { reverse_mode: false, file_mode: false, code_line_numbers: true, + keep_soft_breaks_pref: true, + soft_breaks_override: None, + plain_text_file: false, scroll_with_enter: false, page_scroll_size: crate::config::PageScrollSize::default(), max_width: None, @@ -471,6 +480,26 @@ impl App { self.code_line_numbers = value; } + pub(crate) fn set_keep_soft_breaks(&mut self, value: bool) { + self.keep_soft_breaks_pref = value; + } + + pub(crate) fn set_plain_text_file(&mut self, value: bool) { + self.plain_text_file = value; + } + + pub(crate) fn default_keep_soft_breaks(pref: bool, plain_text_file: bool) -> bool { + pref || plain_text_file + } + + pub(crate) fn keep_soft_breaks(&self) -> bool { + self.soft_breaks_override + .unwrap_or(Self::default_keep_soft_breaks( + self.keep_soft_breaks_pref, + self.plain_text_file, + )) + } + pub(crate) fn set_scroll_with_enter(&mut self, value: bool) { self.scroll_with_enter = value; } @@ -909,6 +938,11 @@ impl App { .as_ref() .map(|(_, t)| t.elapsed() < Duration::from_millis(FLASH_DURATION_MS)) .unwrap_or(false), + soft_break_flash_active: self + .soft_break_flash + .as_ref() + .map(|(_, t)| t.elapsed() < Duration::from_millis(FLASH_DURATION_MS)) + .unwrap_or(false), preview_reconstruct_error: self.is_diff_preview_reconstruction_error(), mouse_capture: self.mouse_capture, toc_scroll_hint_visible: self.is_toc_scroll_hint_visible(), diff --git a/src/app/theme_picker.rs b/src/app/theme_picker.rs index 25a1766..1b57b09 100644 --- a/src/app/theme_picker.rs +++ b/src/app/theme_picker.rs @@ -1,8 +1,8 @@ use crate::{ - markdown::{parse_markdown_with_width, ParseResult}, + markdown::ParseResult, theme::{ - app_theme, current_syntect_theme, current_theme_selection, set_theme_preset, - set_theme_selection, theme_preset_index, ThemePreset, ThemeSelection, THEME_PRESETS, + current_syntect_theme, current_theme_selection, set_theme_preset, set_theme_selection, + theme_preset_index, ThemePreset, ThemeSelection, THEME_PRESETS, }, }; use syntect::{highlighting::ThemeSet, parsing::SyntaxSet}; @@ -116,17 +116,7 @@ impl App { return; } - let theme = current_syntect_theme(themes); - let at = app_theme(); - let parsed = parse_markdown_with_width( - &self.source, - ss, - theme, - self.render_width, - &at.markdown, - self.file_mode, - self.code_line_numbers, - ); + let parsed = self.parse_current_source(ss, themes); self.store_theme_preview(preset, parsed.clone()); self.replace_content(parsed); } @@ -145,17 +135,7 @@ impl App { if let Some(parsed) = self.theme_picker.original_preview.take() { self.replace_content(parsed); } else { - let theme = current_syntect_theme(themes); - let at = app_theme(); - let parsed = parse_markdown_with_width( - &self.source, - ss, - theme, - self.render_width, - &at.markdown, - self.file_mode, - self.code_line_numbers, - ); + let parsed = self.parse_current_source(ss, themes); self.replace_content(parsed); } } diff --git a/src/config.rs b/src/config.rs index 5b2ad70..a03e71d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -68,6 +68,8 @@ pub(crate) struct LeafConfig { pub(crate) file_history_length: Option, #[serde(rename = "hyper-link-prefix")] pub(crate) hyper_link_prefix: Option, + #[serde(rename = "keep-soft-breaks")] + pub(crate) keep_soft_breaks: Option, #[serde(rename = "scroll-with-enter")] pub(crate) scroll_with_enter: Option, #[serde( diff --git a/src/main.rs b/src/main.rs index df2cfdf..203e24b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,7 +30,7 @@ mod update; use app::{mode::detect_mode, App, AppConfig, AppMode}; use cli::{parse_cli, print_usage, print_version, CliOptions}; -use markdown::{hash_str, parse_markdown, parse_markdown_with_width, read_file_state}; +use markdown::{hash_str, parse_markdown_with_width, read_file_state, DEFAULT_RENDER_WIDTH}; use runtime::run; use terminal::{finish_with_restore, TerminalSession}; use theme::{ @@ -186,26 +186,30 @@ fn resolve_configured_width( config_width.map(|w| w.max(20)) } -fn resolve_main_line_numbers(config_value: Option) -> bool { - if let Ok(val) = std::env::var("LEAF_MAIN_LINE_NUMBERS") { - match val.as_str() { - "1" => return true, - "0" => return false, - _ => {} - } +fn env_bool(name: &str) -> Option { + match std::env::var(name).ok()?.as_str() { + "1" => Some(true), + "0" => Some(false), + _ => None, } - config_value.unwrap_or(false) +} + +fn resolve_main_line_numbers(config_value: Option) -> bool { + env_bool("LEAF_MAIN_LINE_NUMBERS") + .or(config_value) + .unwrap_or(false) +} + +fn resolve_keep_soft_breaks(config_value: Option) -> bool { + env_bool("LEAF_KEEP_SOFT_BREAKS") + .or(config_value) + .unwrap_or(true) } fn resolve_code_line_numbers(config_value: Option) -> bool { - if let Ok(val) = std::env::var("LEAF_CODE_LINE_NUMBERS") { - match val.as_str() { - "1" => return true, - "0" => return false, - _ => {} - } - } - config_value.unwrap_or(true) + env_bool("LEAF_CODE_LINE_NUMBERS") + .or(config_value) + .unwrap_or(true) } const LEAF_TAB_PREFIX_LEN: usize = 6; @@ -396,6 +400,7 @@ fn main() -> Result<()> { let max_width = resolve_configured_width(cli_width, user_config.width); let main_line_numbers = resolve_main_line_numbers(user_config.main_line_numbers); let code_line_numbers = resolve_code_line_numbers(user_config.code_line_numbers); + let keep_soft_breaks_pref = resolve_keep_soft_breaks(user_config.keep_soft_breaks); let tab_title_length = resolve_tab_title_length_n(user_config.tab_title_length); let tab_title_max_filename_len = tab_title_length.and_then(tab_title_n_to_max_filename_len); let file_picker_width = resolve_file_picker_width(user_config.file_picker_width); @@ -574,6 +579,8 @@ fn main() -> Result<()> { .and_then(|p| p.extension()) .and_then(|e| e.to_str()) .unwrap_or(""); + let plain_text_file = App::is_plain_text_extension(ext); + let keep_soft_breaks = App::default_keep_soft_breaks(keep_soft_breaks_pref, plain_text_file); let app_mode = match mode_hint { Some(m) => m, @@ -608,6 +615,7 @@ fn main() -> Result<()> { &at.markdown, file_mode, code_line_numbers, + keep_soft_breaks, ); while parsed.lines.last().is_some_and(|l| { @@ -628,7 +636,16 @@ fn main() -> Result<()> { let parsed = if diff_files.is_some() { crate::markdown::ParseResult::default() } else { - parse_markdown(&src, ss, &theme, &at.markdown, file_mode, code_line_numbers) + parse_markdown_with_width( + &src, + ss, + &theme, + DEFAULT_RENDER_WIDTH, + &at.markdown, + file_mode, + code_line_numbers, + keep_soft_breaks, + ) }; let crate::markdown::ParseResult { lines, @@ -700,6 +717,8 @@ fn main() -> Result<()> { app.set_editor_config(Some(resolved_editor)); app.set_line_numbers_visible(main_line_numbers); app.set_code_line_numbers(code_line_numbers); + app.set_keep_soft_breaks(keep_soft_breaks_pref); + app.set_plain_text_file(plain_text_file); app.set_scroll_with_enter(user_config.scroll_with_enter.unwrap_or(false)); app.set_config_warning(config_warning); if let Some(dir) = dir_arg { diff --git a/src/markdown/mod.rs b/src/markdown/mod.rs index f5a1778..193726d 100644 --- a/src/markdown/mod.rs +++ b/src/markdown/mod.rs @@ -108,7 +108,7 @@ pub(crate) fn hash_file_contents(path: &PathBuf) -> io::Result { std::fs::read_to_string(path).map(|contents| hash_str(&contents)) } -const DEFAULT_RENDER_WIDTH: usize = 80; +pub(crate) const DEFAULT_RENDER_WIDTH: usize = 80; fn heading_level(level: HeadingLevel) -> u8 { match level { @@ -391,6 +391,7 @@ impl From for (Vec>, Vec, Vec, Ve } } +#[cfg(test)] pub(crate) fn parse_markdown( src: &str, ss: &syntect::parsing::SyntaxSet, @@ -407,9 +408,11 @@ pub(crate) fn parse_markdown( md_theme, file_mode, code_line_numbers, + true, ) } +#[allow(clippy::too_many_arguments)] pub(crate) fn parse_markdown_with_width( src: &str, ss: &syntect::parsing::SyntaxSet, @@ -418,6 +421,7 @@ pub(crate) fn parse_markdown_with_width( theme_colors: &MarkdownTheme, file_mode: bool, code_line_numbers: bool, + keep_soft_breaks: bool, ) -> ParseResult { let original_src = src; let (src, fm_pairs) = frontmatter::extract_frontmatter(src); @@ -498,7 +502,7 @@ pub(crate) fn parse_markdown_with_width( } let mut wraps = false; - let soft_break_flushed = !in_code && matches!(ev, MdEvent::SoftBreak); + let soft_break_flushed = !in_code && keep_soft_breaks && matches!(ev, MdEvent::SoftBreak); match ev { MdEvent::Start(Tag::Table(aligns)) => { start_table(&mut table, &aligns); @@ -783,6 +787,22 @@ pub(crate) fn parse_markdown_with_width( &mut html_style_buffer, ); } + MdEvent::SoftBreak if !in_code && !keep_soft_breaks => { + let prev = normalized[..range.start].chars().next_back(); + let next = normalized[range.end..] + .chars() + .find(|c| !matches!(c, ' ' | '\t' | '>')); + push_text_event( + &mut spans, + &mut code_buf, + width::soft_break_joiner(prev, next), + in_code, + theme_colors, + blockquote_depth, + inline, + &mut html_style_buffer, + ); + } MdEvent::SoftBreak | MdEvent::HardBreak if !in_code => { flush_wrapped_spans( &mut lines, diff --git a/src/render/mod.rs b/src/render/mod.rs index 5f3e213..a58fc5e 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -130,7 +130,7 @@ pub(super) fn clear_popup_area(f: &mut Frame, area: Rect) { } pub(super) const THEME_POPUP_SIZE: (u16, u16) = (43, 10); -pub(super) const HELP_DOCUMENT_POPUP_SIZE: (u16, u16) = (53, 27); +pub(super) const HELP_DOCUMENT_POPUP_SIZE: (u16, u16) = (53, 28); pub(super) fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { let popup_width = width.min(area.width.saturating_sub(2)).max(1); diff --git a/src/render/popup.rs b/src/render/popup.rs index e1d0d27..cf5fb0e 100644 --- a/src/render/popup.rs +++ b/src/render/popup.rs @@ -213,18 +213,22 @@ fn render_help_document(f: &mut Frame, _app: &App) { Span::styled("hist", text_style), ]), Line::from(vec![ - Span::styled("p ", key_style), - Span::styled("path viewer", text_style), + Span::styled("shift+b ", key_style), + Span::styled("soft breaks", text_style), + Span::raw(" "), + Span::styled("p ", key_style), + Span::styled("path", text_style), + ]), + Line::from(vec![ + Span::styled("t ", key_style), + Span::styled("toc sidebar", text_style), Span::raw(" "), Span::styled("q ", key_style), Span::styled("quit", text_style), ]), Line::from(vec![ - Span::styled("t ", key_style), - Span::styled("toggle toc", text_style), - Span::raw(" "), - Span::styled("? ", key_style), - Span::styled("help", text_style), + Span::styled("? ", key_style), + Span::styled("help popup", text_style), ]), Line::from(""), popup_footer_line(&["esc close", "? close"], theme.ui.toc_bg), diff --git a/src/render/status.rs b/src/render/status.rs index cb3f342..53129a5 100644 --- a/src/render/status.rs +++ b/src/render/status.rs @@ -2,7 +2,7 @@ use crate::{ app::{ history::{msg_history_capped, MSG_HISTORY_WRITE_FAILED}, App, AppMode, CodeBlockFlash, DiffFlash, DiffSource, EditorFlash, HistoryFlash, LinkFlash, - PathFlash, WatchFlash, FLASH_DURATION_MS, + PathFlash, SoftBreakFlash, WatchFlash, FLASH_DURATION_MS, }, markdown::{truncate_display_width, width::spans_width}, theme::app_theme, @@ -211,6 +211,23 @@ pub(crate) fn status_percent_section(pct: u16, bar_bg: Color) -> Vec Option>> { + let (flash, started) = app.soft_break_flash()?; + if started.elapsed() >= std::time::Duration::from_millis(FLASH_DURATION_MS) { + return None; + } + let theme = app_theme(); + let bar_bg = status_bar_bg(); + let text = match flash { + SoftBreakFlash::Kept => " Soft breaks kept ", + SoftBreakFlash::Joined => " Soft breaks joined ", + }; + Some(vec![Span::styled( + text, + Style::default().fg(theme.ui.status_success_fg).bg(bar_bg), + )]) +} + fn config_flash_section(app: &App) -> Option>> { let (message, started) = app.config_flash()?; if started.elapsed() >= std::time::Duration::from_millis(FLASH_DURATION_MS) { @@ -390,6 +407,12 @@ pub(crate) fn build_status_bar(app: &App, pct: u16) -> Vec> { return join_span_sections(vec![left], outer_separator); } + if let Some(flash_section) = soft_break_flash_section(app) { + let mut left = status_brand_section(); + left.extend(flash_section); + return join_span_sections(vec![left], outer_separator); + } + if let Some(flash_section) = config_flash_section(app) { let mut left = status_brand_section(); left.extend(flash_section); diff --git a/src/runtime/keyboard.rs b/src/runtime/keyboard.rs index 84c5e9c..ef97eb2 100644 --- a/src/runtime/keyboard.rs +++ b/src/runtime/keyboard.rs @@ -639,6 +639,16 @@ fn handle_shared_keys( app.toggle_line_numbers(); Ok(SharedOutcome::Handled) } + KeyCode::Char('B') + if !key.modifiers.contains(KeyModifiers::ALT) + && !key.modifiers.contains(KeyModifiers::CONTROL) => + { + if app.is_diff_mode() { + return Ok(SharedOutcome::NotHandled); + } + app.toggle_soft_breaks(ss, themes); + Ok(SharedOutcome::Handled) + } KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => { handle_open_in_editor(terminal, app, ss, themes)?; Ok(SharedOutcome::Handled) diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index 139d2c8..8d3e97d 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -180,6 +180,8 @@ pub(crate) fn run( .and_then(|(_, started)| HISTORY_FLASH_DURATION.checked_sub(started.elapsed())), app.diff_flash() .and_then(|(_, started)| FLASH_DURATION.checked_sub(started.elapsed())), + app.soft_break_flash() + .and_then(|(_, started)| FLASH_DURATION.checked_sub(started.elapsed())), app.copy_highlight() .and_then(|(_, started)| COPY_HIGHLIGHT_DURATION.checked_sub(started.elapsed())), resize_timeout, @@ -352,6 +354,13 @@ pub(crate) fn run( } } + if let Some((_, started)) = app.soft_break_flash() { + if started.elapsed() >= FLASH_DURATION { + app.clear_soft_break_flash(); + needs_redraw = true; + } + } + if let Some((_, started)) = app.copy_highlight() { if started.elapsed() >= COPY_HIGHLIGHT_DURATION { app.clear_copy_highlight(); diff --git a/src/tests/app.rs b/src/tests/app.rs index 494c9a3..288cd12 100644 --- a/src/tests/app.rs +++ b/src/tests/app.rs @@ -770,8 +770,17 @@ fn sync_render_width_preserves_scroll_proportion() { }) .collect::>() .join("\n\n"); - let (lines, toc, _, _) = - parse_markdown_with_width(&source, &ss, &theme, 80, &test_md_theme(), false, true).into(); + let (lines, toc, _, _) = parse_markdown_with_width( + &source, + &ss, + &theme, + 80, + &test_md_theme(), + false, + true, + true, + ) + .into(); let mut app = App::new_with_source( lines, toc, @@ -836,7 +845,8 @@ fn sync_render_width_returns_false_when_clamped_width_is_unchanged() { let ts = super::test_theme_set(); let source = "One paragraph that does not matter much for this width clamp test."; let (lines, toc, _, _) = - parse_markdown_with_width(source, &ss, &theme, 20, &test_md_theme(), false, true).into(); + parse_markdown_with_width(source, &ss, &theme, 20, &test_md_theme(), false, true, true) + .into(); let mut app = App::new_with_source( lines, toc, @@ -854,7 +864,7 @@ fn sync_render_width_returns_false_when_clamped_width_is_unchanged() { assert!(!app.sync_render_width(10, &ss, ts)); assert_eq!( app.total(), - parse_markdown_with_width(source, &ss, &theme, 20, &test_md_theme(), false, true) + parse_markdown_with_width(source, &ss, &theme, 20, &test_md_theme(), false, true, true) .lines .len() ); diff --git a/src/tests/app_text_copy.rs b/src/tests/app_text_copy.rs index 4e381d9..17de351 100644 --- a/src/tests/app_text_copy.rs +++ b/src/tests/app_text_copy.rs @@ -381,6 +381,7 @@ fn code_line_copy_in_file_mode() { &test_md_theme(), true, true, + true, ); let mut app = markdown_app(&source, usize::from(WIDTH) - 3, "a.rs", Some("/src/a.rs")); app.set_file_mode(true); diff --git a/src/tests/common/app_helpers.rs b/src/tests/common/app_helpers.rs index 2334990..8598bea 100644 --- a/src/tests/common/app_helpers.rs +++ b/src/tests/common/app_helpers.rs @@ -13,6 +13,7 @@ pub(crate) fn parse_markdown_for_test(source: &str, parse_width: usize) -> Parse &test_md_theme(), false, true, + true, ) } diff --git a/src/tests/config.rs b/src/tests/config.rs index a740df6..50aaffa 100644 --- a/src/tests/config.rs +++ b/src/tests/config.rs @@ -488,3 +488,17 @@ fn validate_hyper_link_prefix_graphemes() { assert!(!is_valid_hyper_link_prefix(" ")); assert!(!is_valid_hyper_link_prefix("→→")); } + +#[test] +fn parse_keep_soft_breaks_true() { + let toml = r#"keep-soft-breaks = true"#; + let config: LeafConfig = toml::from_str(toml).unwrap(); + assert_eq!(config.keep_soft_breaks, Some(true)); +} + +#[test] +fn parse_keep_soft_breaks_missing_defaults_to_none() { + let toml = r#"theme = "ocean""#; + let config: LeafConfig = toml::from_str(toml).unwrap(); + assert_eq!(config.keep_soft_breaks, None); +} diff --git a/src/tests/markdown_blocks.rs b/src/tests/markdown_blocks.rs index ad80363..e71b855 100644 --- a/src/tests/markdown_blocks.rs +++ b/src/tests/markdown_blocks.rs @@ -175,7 +175,7 @@ fn long_blockquotes_wrap_into_multiple_prefixed_lines() { let (ss, theme) = test_assets(); let src = "> This is a long blockquote line that should wrap into multiple quoted lines at narrow widths.\n"; let (lines, _, _, _) = - parse_markdown_with_width(src, &ss, &theme, 28, &test_md_theme(), false, true).into(); + parse_markdown_with_width(src, &ss, &theme, 28, &test_md_theme(), false, true, true).into(); let rendered = rendered_non_empty_lines(&lines); let quoted: Vec<_> = rendered .into_iter() @@ -293,6 +293,7 @@ fn h2_headings_are_underlined_and_compact() { &test_md_theme(), false, true, + true, ) .into(); let rendered = rendered_non_empty_lines(&lines); @@ -312,6 +313,7 @@ fn rules_use_render_width_without_extra_blank_after() { &test_md_theme(), false, true, + true, ) .into(); let rendered = rendered_non_empty_lines(&lines); @@ -330,7 +332,7 @@ fn body_dashes_are_thematic_breaks_not_metadata_block() { let (ss, theme) = test_assets(); let src = "# Title\n\n---\n### SSL\n\n- item 1\n- item 2\n\n---\n### TLS\nend\n"; let (lines, _, _, _) = - parse_markdown_with_width(src, &ss, &theme, 40, &test_md_theme(), false, true).into(); + parse_markdown_with_width(src, &ss, &theme, 40, &test_md_theme(), false, true, true).into(); let rendered = rendered_non_empty_lines(&lines); let rule_count = rendered diff --git a/src/tests/markdown_embedded.rs b/src/tests/markdown_embedded.rs index c023e0b..f96fe89 100644 --- a/src/tests/markdown_embedded.rs +++ b/src/tests/markdown_embedded.rs @@ -234,7 +234,7 @@ fn oversized_horizontal_mermaid_reflows_vertically() { let (ss, theme) = test_assets(); let src = "```mermaid\nflowchart LR\n A[Collect request] --> B[Validate request] --> C[Store response]\n```\n"; let (lines, _, _, _) = - parse_markdown_with_width(src, &ss, &theme, 50, &test_md_theme(), false, true).into(); + parse_markdown_with_width(src, &ss, &theme, 50, &test_md_theme(), false, true, true).into(); let rendered = rendered_non_empty_lines(&lines); assert!( @@ -260,7 +260,8 @@ fn oversized_mermaid_falls_back_instead_of_wrapping_diagram_rows() { "oversized ".repeat(10) ); let (lines, _, _, _) = - parse_markdown_with_width(&src, &ss, &theme, 50, &test_md_theme(), false, true).into(); + parse_markdown_with_width(&src, &ss, &theme, 50, &test_md_theme(), false, true, true) + .into(); let rendered = rendered_non_empty_lines(&lines); assert!( @@ -276,7 +277,8 @@ fn oversized_mermaid_falls_back_instead_of_wrapping_diagram_rows() { fn mermaid_header(src: &str, width: usize) -> String { let (ss, theme) = test_assets(); let (lines, _, _, _) = - parse_markdown_with_width(src, &ss, &theme, width, &test_md_theme(), false, true).into(); + parse_markdown_with_width(src, &ss, &theme, width, &test_md_theme(), false, true, true) + .into(); rendered_non_empty_lines(&lines) .into_iter() .find(|line| line.contains("┌─ mermaid")) @@ -345,7 +347,7 @@ fn oversized_class_diagram_uses_vertical_cards() { let (ss, theme) = test_assets(); let src = "```mermaid\nclassDiagram\n class Client {\n +BackendCapabilities capabilities\n +from_profile()\n }\n class BackendAdapter {\n <>\n +invoke(OperationDef, input, deadline)\n }\n Client *-- BackendAdapter\n```\n"; let (lines, _, _, _) = - parse_markdown_with_width(src, &ss, &theme, 32, &test_md_theme(), false, true).into(); + parse_markdown_with_width(src, &ss, &theme, 32, &test_md_theme(), false, true, true).into(); let rendered = rendered_non_empty_lines(&lines); assert!( diff --git a/src/tests/markdown_footnotes.rs b/src/tests/markdown_footnotes.rs index 6c83e02..d54265b 100644 --- a/src/tests/markdown_footnotes.rs +++ b/src/tests/markdown_footnotes.rs @@ -190,6 +190,7 @@ fn footnote_link_rewrap_keeps_zero_width_clusters_in_the_label() { &test_md_theme(), false, true, + true, ); let rows: Vec = parsed.lines[6..10].iter().map(line_plain_text).collect(); assert_eq!( diff --git a/src/tests/markdown_links.rs b/src/tests/markdown_links.rs index 5ab6ddd..3e8169f 100644 --- a/src/tests/markdown_links.rs +++ b/src/tests/markdown_links.rs @@ -21,6 +21,7 @@ fn adversarial_table_fixture() -> (Vec>, Vec, Vec = parsed @@ -419,7 +421,8 @@ impl Drop for LinkMarkerGuard { fn link_texts(md: &str, width: usize) -> Vec<(String, String)> { let (ss, theme) = test_assets(); - let parsed = parse_markdown_with_width(md, &ss, &theme, width, &test_md_theme(), false, true); + let parsed = + parse_markdown_with_width(md, &ss, &theme, width, &test_md_theme(), false, true, true); parsed .link_spans .iter() diff --git a/src/tests/markdown_list_blocks.rs b/src/tests/markdown_list_blocks.rs index a10049b..e845729 100644 --- a/src/tests/markdown_list_blocks.rs +++ b/src/tests/markdown_list_blocks.rs @@ -287,7 +287,7 @@ fn blockquote_long_line_in_list_item_wraps_with_correct_indent() { let (ss, theme) = test_assets(); let md = "- one\n > This is a long blockquote line that should wrap into multiple prefixed lines at a narrow width.\n"; let (lines, _, _, _) = - parse_markdown_with_width(md, &ss, &theme, 32, &test_md_theme(), false, true).into(); + parse_markdown_with_width(md, &ss, &theme, 32, &test_md_theme(), false, true, true).into(); let rendered = rendered_non_empty_lines(&lines); let quoted: Vec<_> = rendered.iter().filter(|line| line.contains('▏')).collect(); diff --git a/src/tests/markdown_lists.rs b/src/tests/markdown_lists.rs index b4eb58c..7808fdf 100644 --- a/src/tests/markdown_lists.rs +++ b/src/tests/markdown_lists.rs @@ -163,7 +163,7 @@ fn wrapped_list_items_align_continuation_under_text() { let (ss, theme) = test_assets(); let src = "- First item with enough text to wrap when the terminal is narrow and show continuation alignment.\n8. Eighth item with enough text to wrap and keep numeric alignment readable.\n"; let (lines, _, _, _) = - parse_markdown_with_width(src, &ss, &theme, 36, &test_md_theme(), false, true).into(); + parse_markdown_with_width(src, &ss, &theme, 36, &test_md_theme(), false, true, true).into(); let rendered = rendered_non_empty_lines(&lines); assert!(rendered.iter().any(|line| line.starts_with("• First item"))); @@ -227,7 +227,8 @@ fn wrapped_list_inline_code_keeps_left_padding_in_rendered_line() { let (ss, theme) = test_assets(); let source = "- `leaf --theme ocean README.md` exercises wrapping inside a list item.\n"; let (lines, _, _, _) = - parse_markdown_with_width(source, &ss, &theme, 22, &test_md_theme(), false, true).into(); + parse_markdown_with_width(source, &ss, &theme, 22, &test_md_theme(), false, true, true) + .into(); let target = lines .iter() diff --git a/src/tests/markdown_rendering.rs b/src/tests/markdown_rendering.rs index 07c72ee..f86789d 100644 --- a/src/tests/markdown_rendering.rs +++ b/src/tests/markdown_rendering.rs @@ -34,6 +34,7 @@ fn file_mode_code_block_fills_full_render_width() { &test_md_theme(), true, true, + true, ) .into(); let buffer = render_buffer(&lines); diff --git a/src/tests/markdown_tables.rs b/src/tests/markdown_tables.rs index 097f9f4..8d1d941 100644 --- a/src/tests/markdown_tables.rs +++ b/src/tests/markdown_tables.rs @@ -8,7 +8,7 @@ fn narrow_tables_fit_render_width_and_wrap_cells() { let (ss, theme) = test_assets(); let md = "| Column | Description | Value |\n| --- | --- | ---: |\n| Width | Terminal-dependent layout behavior | 80 |\n"; let (lines, _, _, _) = - parse_markdown_with_width(md, &ss, &theme, 36, &test_md_theme(), false, true).into(); + parse_markdown_with_width(md, &ss, &theme, 36, &test_md_theme(), false, true, true).into(); let rendered = rendered_non_empty_lines(&lines); assert!(rendered.len() >= 6); @@ -178,7 +178,8 @@ fn table_long_inline_code_wraps_without_clipping() { let md = format!("| Description |\n|---|\n| `{code}` |\n"); let width = 24; let (lines, _, _, _) = - parse_markdown_with_width(&md, &ss, &theme, width, &test_md_theme(), false, true).into(); + parse_markdown_with_width(&md, &ss, &theme, width, &test_md_theme(), false, true, true) + .into(); let rendered = rendered_non_empty_lines(&lines); let theme_colors = &app_theme().markdown; @@ -217,7 +218,7 @@ fn table_link_after_text_keeps_row_width() { let (ss, theme) = test_assets(); let md = "| Name | Link |\n|------|------|\n| foo | x [alpha beta gamma delta epsilon zeta](https://example.com) and [more](https://example.com) |\n| bar | plain text here |\n"; let (lines, _, _, _) = - parse_markdown_with_width(md, &ss, &theme, 20, &test_md_theme(), false, true).into(); + parse_markdown_with_width(md, &ss, &theme, 20, &test_md_theme(), false, true, true).into(); let rendered = rendered_non_empty_lines(&lines); let widths: Vec = rendered.iter().map(|line| display_width(line)).collect(); @@ -233,7 +234,8 @@ fn table_cell_with_vs16_emoji_fits_render_width() { let md = "| A | B |\n|---|---|\n| 🍀️ text long enough to wrap | short |\n"; let width = 30; let (lines, _, _, _) = - parse_markdown_with_width(md, &ss, &theme, width, &test_md_theme(), false, true).into(); + parse_markdown_with_width(md, &ss, &theme, width, &test_md_theme(), false, true, true) + .into(); let rendered = rendered_non_empty_lines(&lines); assert!(!rendered.is_empty()); for line in &rendered { @@ -267,7 +269,8 @@ fn linked_table_inline_variants_retain_one_owner() { fn split_table_code_label_keeps_owner_on_every_row() { let (ss, theme) = test_assets(); let md = "| c |\n|---|\n| [`abcdefghijklmnopqrstuvwxyz`](https://example.test/code) |\n"; - let parsed = parse_markdown_with_width(md, &ss, &theme, 24, &test_md_theme(), false, true); + let parsed = + parse_markdown_with_width(md, &ss, &theme, 24, &test_md_theme(), false, true, true); let rows: Vec = parsed .link_spans .iter() diff --git a/src/tests/mod.rs b/src/tests/mod.rs index 1f9a147..ddc6436 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -55,6 +55,7 @@ mod render_split_body; mod render_split_frame; mod render_sticky; mod render_unified_truncate; +mod soft_breaks; mod theme; mod toc; mod update; diff --git a/src/tests/render_popup.rs b/src/tests/render_popup.rs index 11dba49..0551ff3 100644 --- a/src/tests/render_popup.rs +++ b/src/tests/render_popup.rs @@ -81,3 +81,33 @@ fn help_popup_left_border_survives_wide_chars_underneath() { let popup = popup_area(HELP_DOCUMENT_POPUP_SIZE); assert_left_border_intact(&buffer, popup); } + +#[test] +fn help_popup_lists_the_soft_breaks_toggle_above_the_footer() { + let _guard = lock_theme_test_state(); + let mut app = markdown_app("alpha", WIDTH as usize, "doc.md", None); + app.open_help(); + + let buffer = draw_app(&mut app, WIDTH, 32); + let popup = centered_rect( + HELP_DOCUMENT_POPUP_SIZE.0, + HELP_DOCUMENT_POPUP_SIZE.1, + Rect::new(0, 0, WIDTH, 32), + ); + let rows: Vec = (popup.y..popup.bottom()) + .map(|y| { + (popup.x..popup.right()) + .map(|x| buffer[(x, y)].symbol()) + .collect() + }) + .collect(); + let shift_b = rows + .iter() + .position(|row| row.contains("shift+b") && row.contains("soft breaks")) + .expect("missing shift+b row"); + let footer = rows + .iter() + .position(|row| row.contains("esc close")) + .expect("footer must stay visible"); + assert!(shift_b < footer); +} diff --git a/src/tests/soft_breaks.rs b/src/tests/soft_breaks.rs new file mode 100644 index 0000000..ebcc06f --- /dev/null +++ b/src/tests/soft_breaks.rs @@ -0,0 +1,274 @@ +use super::common::app_helpers::{draw_app, markdown_app, status_text}; +use super::{ + lock_theme_test_state, rendered_non_empty_lines, test_assets, test_md_theme, test_syntax_set, + test_theme_set, +}; +use crate::app::App; +use crate::markdown::{parse_markdown_with_width, ParseResult}; + +fn all_lines(app: &App) -> &[ratatui::text::Line<'static>] { + app.visible_lines(0, app.total()) +} + +fn parse_at(src: &str, width: usize, keep_soft_breaks: bool) -> ParseResult { + let (ss, theme) = test_assets(); + parse_markdown_with_width( + src, + &ss, + &theme, + width, + &test_md_theme(), + false, + true, + keep_soft_breaks, + ) +} + +fn parse(src: &str, keep_soft_breaks: bool) -> ParseResult { + parse_at(src, 80, keep_soft_breaks) +} + +fn rendered(src: &str, keep_soft_breaks: bool) -> Vec { + rendered_non_empty_lines(&parse(src, keep_soft_breaks).lines) +} + +#[test] +fn soft_breaks_are_joined_or_kept() { + let src = "alpha beta\ngamma delta\nepsilon"; + assert_eq!(rendered(src, false), ["alpha beta gamma delta epsilon"]); + assert_eq!( + rendered(src, true), + ["alpha beta", "gamma delta", "epsilon"] + ); +} + +#[test] +fn joined_paragraph_is_rewrapped_as_one_logical_line() { + let src = "one two three four five six\nseven eight nine ten"; + let parsed = parse_at(src, 20, false); + let rows: Vec<(String, usize)> = parsed + .lines + .iter() + .map(crate::line_plain_text) + .zip(parsed.line_number_map.iter().copied()) + .filter(|(text, _)| !text.is_empty()) + .collect(); + assert!(rows.len() > 1, "paragraph must wrap"); + let words: Vec<&str> = rows + .iter() + .flat_map(|(text, _)| text.split_whitespace()) + .collect(); + assert_eq!( + words.join(" "), + "one two three four five six seven eight nine ten" + ); + assert!( + rows.iter().all(|(text, _)| text.chars().count() <= 20), + "{rows:?}" + ); + assert!( + rows.iter().all(|(_, number)| *number == rows[0].1), + "{rows:?}" + ); +} + +#[test] +fn hard_breaks_are_kept_in_both_modes() { + for src in ["alpha \nbeta", "alpha\\\nbeta", "alpha
beta"] { + for keep in [false, true] { + assert_eq!( + rendered(src, keep), + ["alpha", "beta"], + "{src:?} keep={keep}" + ); + } + } +} + +#[test] +fn soft_breaks_between_cjk_characters_join_without_space() { + assert_eq!(rendered("中文\n中文", false), ["中文中文"]); + assert_eq!(rendered("日本語。\nかな", false), ["日本語。かな"]); + assert_eq!(rendered("中文\nabc", false), ["中文 abc"]); + assert_eq!(rendered("abc\n中文", false), ["abc 中文"]); +} + +#[test] +fn soft_breaks_between_hangul_keep_the_space() { + assert_eq!(rendered("한국\n어", false), ["한국 어"]); +} + +#[test] +fn crlf_soft_breaks_are_joined() { + assert_eq!(rendered("alpha\r\nbeta", false), ["alpha beta"]); +} + +#[test] +fn soft_breaks_in_blockquote_are_joined() { + assert_eq!(rendered("> alpha\n> beta", false), ["▏ alpha beta"]); +} + +#[test] +fn soft_breaks_in_blockquote_with_cjk_skip_the_quote_marker() { + assert_eq!(rendered("> 中文\n> 中文", false), ["▏ 中文中文"]); +} + +#[test] +fn soft_breaks_in_list_item_are_joined() { + assert_eq!(rendered("- alpha\n beta", false), ["• alpha beta"]); +} + +#[test] +fn soft_break_keeps_bold_style() { + let parsed = parse("**alpha\nbeta**", false); + let line = parsed + .lines + .iter() + .find(|line| crate::line_plain_text(line).contains("alpha")) + .unwrap(); + assert_eq!(crate::line_plain_text(line), "alpha beta"); + let bold = line.spans[0].style; + assert!(line.spans.iter().all(|span| span.style == bold), "{line:?}"); +} + +#[test] +fn soft_break_inside_link_keeps_the_link_id() { + let parsed = parse("[alpha\nbeta](https://example.com)", false); + assert_eq!(parsed.link_urls, ["https://example.com"]); + let line = &parsed.lines[parsed.link_spans[0].line_idx]; + let text = crate::line_plain_text(line); + assert!(text.contains("alpha beta"), "{text:?}"); + let covered: usize = parsed + .link_spans + .iter() + .map(|span| span.end_col - span.start_col) + .sum(); + assert!( + covered >= "alpha beta".len(), + "link must cover the joined space" + ); +} + +#[test] +fn multiline_setext_heading_is_one_heading_and_one_toc_entry() { + let parsed = parse("Title\nsuite\n===\n\nbody", false); + assert_eq!(parsed.toc.len(), 1); + assert_eq!(parsed.toc[0].title, "Title suite"); + let lines = rendered_non_empty_lines(&parsed.lines); + assert!( + lines.iter().any(|line| line.contains("Title suite")), + "{lines:?}" + ); +} + +#[test] +fn plain_text_extension_detection() { + for ext in ["txt", "log", "csv", "rs"] { + assert!(App::is_plain_text_extension(ext), "{ext}"); + } + for ext in ["", "md", "MD", "markdown"] { + assert!(!App::is_plain_text_extension(ext), "{ext}"); + } +} + +#[test] +fn effective_keep_soft_breaks_follows_preference_file_type_and_override() { + let mut app = markdown_app("alpha", 80, "doc.md", Some("/docs/doc.md")); + assert!(app.keep_soft_breaks(), "soft breaks are kept by default"); + + app.set_keep_soft_breaks(false); + assert!(!app.keep_soft_breaks(), "preference applies"); + + app.set_plain_text_file(true); + assert!(app.keep_soft_breaks(), "plain text files keep soft breaks"); + + app.toggle_soft_breaks(&test_syntax_set(), test_theme_set()); + assert!(!app.keep_soft_breaks(), "override wins over file type"); + + app.set_plain_text_file(false); + app.set_keep_soft_breaks(true); + assert!(!app.keep_soft_breaks(), "override wins over preference"); +} + +#[test] +fn toggle_soft_breaks_rerenders_and_flashes_success() { + let _guard = lock_theme_test_state(); + let src = "alpha beta\ngamma delta"; + let mut app = markdown_app(src, 80, "doc.md", Some("/docs/doc.md")); + let ss = test_syntax_set(); + let themes = test_theme_set(); + + app.toggle_soft_breaks(&ss, themes); + assert!(!app.keep_soft_breaks()); + let buffer = draw_app(&mut app, 80, 10); + assert!(status_text(&buffer).contains("Soft breaks joined")); + assert_eq!( + rendered_non_empty_lines(all_lines(&app)), + ["alpha beta gamma delta"] + ); + + app.toggle_soft_breaks(&ss, themes); + assert!(app.keep_soft_breaks()); + let buffer = draw_app(&mut app, 80, 10); + assert!(status_text(&buffer).contains("Soft breaks kept")); + assert_eq!( + rendered_non_empty_lines(all_lines(&app)), + ["alpha beta", "gamma delta"] + ); +} + +#[test] +fn toggle_soft_breaks_keeps_the_reading_position() { + let _guard = lock_theme_test_state(); + let mut src = String::new(); + for i in 0..40 { + src.push_str(&format!( + "paragraph {i} line one\nparagraph {i} line two\n\n" + )); + } + let mut app = markdown_app(&src, 80, "doc.md", Some("/docs/doc.md")); + draw_app(&mut app, 80, 10); + let ss = test_syntax_set(); + let themes = test_theme_set(); + + let target = all_lines(&app) + .iter() + .position(|line| crate::line_plain_text(line).starts_with("paragraph 20 ")) + .unwrap(); + app.scroll_to(target); + + app.toggle_soft_breaks(&ss, themes); + let top = crate::line_plain_text(&all_lines(&app)[app.scroll()]); + assert!(top.starts_with("paragraph 20 line one"), "{top:?}"); + + app.toggle_soft_breaks(&ss, themes); + let top = crate::line_plain_text(&all_lines(&app)[app.scroll()]); + assert_eq!(top, "paragraph 20 line one"); +} + +#[test] +fn toggle_to_joined_from_mid_paragraph_keeps_the_current_paragraph() { + let _guard = lock_theme_test_state(); + let mut src = String::new(); + for i in 0..40 { + src.push_str(&format!( + "paragraph {i} line one\nparagraph {i} line two\nparagraph {i} line three\n\n" + )); + } + let mut app = markdown_app(&src, 80, "doc.md", Some("/docs/doc.md")); + draw_app(&mut app, 80, 10); + let ss = test_syntax_set(); + let themes = test_theme_set(); + + assert!(app.keep_soft_breaks()); + let target = all_lines(&app) + .iter() + .position(|line| crate::line_plain_text(line) == "paragraph 20 line two") + .unwrap(); + app.scroll_to(target); + + app.toggle_soft_breaks(&ss, themes); + assert!(!app.keep_soft_breaks()); + let top = crate::line_plain_text(&all_lines(&app)[app.scroll()]); + assert!(top.starts_with("paragraph 20 line one"), "{top:?}"); +} diff --git a/src/tests/wrapping.rs b/src/tests/wrapping.rs index c036c9c..68e82e3 100644 --- a/src/tests/wrapping.rs +++ b/src/tests/wrapping.rs @@ -36,7 +36,8 @@ fn paragraph_with_vs16_emoji_fits_render_width() { "; let width = 40; let (lines, _, _, _) = - parse_markdown_with_width(md, &ss, &theme, width, &test_md_theme(), false, true).into(); + parse_markdown_with_width(md, &ss, &theme, width, &test_md_theme(), false, true, true) + .into(); let rendered = rendered_non_empty_lines(&lines); assert!(!rendered.is_empty()); for line in &rendered { @@ -55,7 +56,8 @@ fn code_block_with_vs16_emoji_wraps_within_width() { let md = "```\naaaaaaaa 🍀️ bbbbbbbb cccccccc dddddddd eeeeeeee ffffffff\n```\n"; let width = 30; let (lines, _, _, _) = - parse_markdown_with_width(md, &ss, &theme, width, &test_md_theme(), false, true).into(); + parse_markdown_with_width(md, &ss, &theme, width, &test_md_theme(), false, true, true) + .into(); let rendered = rendered_non_empty_lines(&lines); assert!(!rendered.is_empty()); for line in &rendered {