use ratatui::{ buffer::Buffer, layout::Rect, style::{Modifier, Style}, widgets::Widget, }; use crate::decoration::{DecorationMap, StyledSpan}; use crate::search::Match as SearchMatch; pub(crate) mod settings_modal; mod status; mod utils; pub use self::search_bar::{ render_search_bar, render_search_help_modal, render_shortcuts_modal, search_bar_height, }; pub use self::settings_modal::render_settings_modal; pub use status::{render_info_line, render_status_bar}; pub use utils::{format_thousands, shorten_path, split_into_spans}; mod search_bar; // --------------------------------------------------------------------------- // Soft-wrap helpers // --------------------------------------------------------------------------- /// Soft-wrap a string into visual rows of at most `width` **terminal columns**. /// /// Wide characters (CJK, emoji, …) are counted by their display width (1 or 2 /// columns) rather than by Unicode scalar-value count. Breaks at the last /// space before `width`; falls back to a hard break when no space exists. /// Returns byte-slices of `s` so no allocation is needed. pub fn wrap_line(s: &str, width: usize) -> Vec<&str> { use unicode_width::UnicodeWidthChar; if s.is_empty() || width == 0 { return vec![s]; } let char_indices: Vec<(usize, char)> = s.char_indices().collect(); // Fast path: total display width fits on one row. let total_display: usize = char_indices .iter() .map(|&(_, c)| UnicodeWidthChar::width(c).unwrap_or(1)) .sum(); if total_display <= width { return vec![s]; } let mut result: Vec<&str> = Vec::new(); let mut char_start = 0usize; // index into char_indices loop { if char_start >= char_indices.len() { break; } // Check whether the rest fits without another break. let remaining_display: usize = char_indices[char_start..] .iter() .map(|&(_, c)| UnicodeWidthChar::width(c).unwrap_or(1)) .sum(); if remaining_display <= width { let byte_start = char_indices[char_start].0; result.push(&s[byte_start..]); break; } // Walk chars until adding the next one would exceed `width` display cols. let mut display_col = 0usize; let mut last_space: Option = None; // char_indices index of last fitting space let mut chunk_end = char_start; // exclusive end: first char that doesn't fit for (ci, &(_, c)) in char_indices[char_start..].iter().enumerate() { let cw = UnicodeWidthChar::width(c).unwrap_or(1); if display_col + cw > width { chunk_end = char_start + ci; break; } display_col += cw; chunk_end = char_start + ci + 1; if c == ' ' { last_space = Some(char_start + ci); } } if chunk_end == char_start { // Pathological: first char alone is wider than `width` (e.g. width=1 // with a CJK char). Hard-break after exactly one char to avoid an // infinite loop. let byte_start = char_indices[char_start].0; let byte_end = char_indices .get(char_start + 1) .map_or(s.len(), |&(b, _)| b); result.push(&s[byte_start..byte_end]); char_start += 1; continue; } // Prefer breaking at the last in-range space; fall back to hard break. let (break_at, next_start) = match last_space { Some(sp) => (sp, sp + 1), None => (chunk_end, chunk_end), }; let byte_start = char_indices[char_start].0; let byte_end = char_indices.get(break_at).map_or(s.len(), |&(b, _)| b); if byte_start < byte_end { result.push(&s[byte_start..byte_end]); } char_start = next_start; } if result.is_empty() { result.push(s); } result } /// Soft-wrap with separate widths for the first row and continuation rows. /// /// The first visual row is wrapped at `first_width` terminal columns. All /// subsequent rows (which are indented by `first_width − cont_width` columns /// during rendering) are wrapped at `cont_width` so they don't overflow the /// right edge. /// /// When `cont_width >= first_width` this degenerates to `wrap_line(s, /// first_width)`. All returned `&str` slices point into the original `s` /// so [`wrap_char_ranges`] works unchanged. pub fn wrap_line_indented(s: &str, first_width: usize, cont_width: usize) -> Vec<&str> { // Fast path: no effective indent, delegate entirely. if cont_width >= first_width { return wrap_line(s, first_width); } // Wrap the full string at first_width to peel off exactly the first row. let first_pass = wrap_line(s, first_width); if first_pass.len() <= 1 { // Everything fit on one row — no continuation rows needed. return first_pass; } let first_row = first_pass[0]; // Byte position just after first_row in `s`. let first_end_byte = first_row.as_ptr() as usize - s.as_ptr() as usize + first_row.len(); // `wrap_line` skips the break-space; skip it here too so we hand the // correct remainder to the next wrap pass. Using `.get()` avoids an // explicit bounds check (which would be an equivalent mutant when the // condition is always true after the `first_pass.len() <= 1` guard). let rest_start = match s.as_bytes().get(first_end_byte) { Some(&b' ') => first_end_byte + 1, _ => first_end_byte, }; let rest = &s[rest_start..]; // Wrap the remainder at the narrower continuation width. let mut result = vec![first_row]; result.extend(wrap_line(rest, cont_width.max(1))); result } /// Return the effective continuation indent for a logical line. /// /// Prefers the `continuation_indent` from decoration spans (set for list /// bullets, blockquote indicators, etc.). Falls back to the leading-space /// count of the raw line text so that fenced code blocks indented under a /// list item (e.g. ` ```toml`) keep soft-wrap continuations visually /// aligned instead of landing at column 0. fn effective_ci(line: &str, decs: Option<&Vec>) -> usize { let from_spans = decs .map(|d| d.iter().map(|s| s.continuation_indent).max().unwrap_or(0)) .unwrap_or(0) as usize; if from_spans > 0 { from_spans } else { line.bytes().take_while(|&b| b == b' ').count() } } /// Compute the `(char_start, char_len)` pair for each element of a /// [`wrap_line`] (or [`wrap_line_indented`]) output slice, measured in char /// units within `line`. /// /// `wrap_line` breaks at spaces and **skips** the break-space. A naïve /// approach of accumulating `char_len` produces incorrect char offsets for /// every segment after the first soft-wrap break. This function corrects for /// that by measuring the byte gap between segments (the skipped space). pub fn wrap_char_ranges(line: &str, wrapped: &[&str]) -> Vec<(usize, usize)> { let base = line.as_ptr() as usize; let mut result = Vec::with_capacity(wrapped.len()); let mut prev_byte_end = 0usize; let mut prev_char_end = 0usize; for row_str in wrapped { let seg_byte_start = row_str.as_ptr() as usize - base; let gap_chars = line[prev_byte_end..seg_byte_start].chars().count(); let char_start = prev_char_end + gap_chars; let char_len = row_str.chars().count(); result.push((char_start, char_len)); prev_byte_end = seg_byte_start + row_str.len(); prev_char_end = char_start + char_len; } result } /// Count the display columns occupied by the first `n_chars` characters of `s`. /// /// Wide characters (e.g. CJK, emoji) count as 2 columns; narrow characters /// count as 1. If `n_chars` exceeds the string's character count the full /// display width is returned. pub fn display_cols_for_chars(s: &str, n_chars: usize) -> usize { use unicode_width::UnicodeWidthChar; s.chars() .take(n_chars) .map(|c| UnicodeWidthChar::width(c).unwrap_or(1)) .sum() } /// Return the number of characters from the start of `s` that fit within /// `target_dcols` display columns. /// /// Stops before including a wide character that would push the total past /// `target_dcols` (i.e. rounds down). Returns `s.chars().count()` if the /// string is exhausted before the budget is reached. pub fn chars_for_display_cols(s: &str, target_dcols: usize) -> usize { use unicode_width::UnicodeWidthChar; let mut cols = 0usize; let mut n = 0usize; for c in s.chars() { let cw = UnicodeWidthChar::width(c).unwrap_or(1); if cols + cw > target_dcols { break; } cols += cw; n += 1; } n } /// Compute the display-column offset of `cursor_col` within its visual subrow, /// using the same wrap parameters as the renderer. /// /// Equivalent to `cursor_col - subrow_char_start` for ASCII text but correctly /// accounts for wide characters (CJK, emoji) whose display width is 2 columns. pub fn cursor_vcol(line: &str, cursor_col: usize, first_width: usize, cont_width: usize) -> usize { let wrapped = wrap_line_indented(line, first_width.max(1), cont_width.max(1)); let char_ranges = wrap_char_ranges(line, &wrapped); let last = char_ranges.len().saturating_sub(1); for (i, (row_str, &(char_start, char_len))) in wrapped.iter().zip(char_ranges.iter()).enumerate() { if cursor_col < char_start + char_len || i == last { let chars_into_row = cursor_col.saturating_sub(char_start); return display_cols_for_chars(row_str, chars_into_row); } } 0 } /// Find which visual subrow the cursor sits on within a soft-wrapped logical /// line. /// /// Wraps `line` with [`wrap_line_indented`] using `(first_width, cont_width)` /// so the result matches what the renderer draws. Pass `cont_width == /// first_width` for lines without a continuation indent. /// /// Returns `(subrow_index, char_start_of_subrow, total_subrows)`. pub fn cursor_subrow_info( line: &str, cursor_col: usize, first_width: usize, cont_width: usize, ) -> (usize, usize, usize) { let wrapped = wrap_line_indented(line, first_width.max(1), cont_width.max(1)); let char_ranges = wrap_char_ranges(line, &wrapped); let total = char_ranges.len().max(1); for (i, &(char_start, char_len)) in char_ranges.iter().enumerate() { if cursor_col < char_start + char_len || i + 1 == char_ranges.len() { return (i, char_start, total); } } (0, 0, total) } /// Find the logical char column for a given `(target_subrow, target_vcol)` /// within a soft-wrapped logical line. /// /// `target_vcol` is the **display-column** offset within the subrow (i.e. the /// sticky column from a vertical-navigation gesture, stored in display columns /// so that wide characters are accounted for correctly). Clamps to the last /// character of the subrow so the cursor never overshoots a short row. pub fn char_col_at_visual( line: &str, target_subrow: usize, target_vcol: usize, first_width: usize, cont_width: usize, ) -> usize { let wrapped = wrap_line_indented(line, first_width.max(1), cont_width.max(1)); let char_ranges = wrap_char_ranges(line, &wrapped); let last_idx = char_ranges.len().saturating_sub(1); let idx = target_subrow.min(last_idx); let row_str = wrapped.get(idx).copied().unwrap_or(""); let (char_start, char_len) = char_ranges.get(idx).copied().unwrap_or((0, 0)); let chars_at_vcol = chars_for_display_cols(row_str, target_vcol); (char_start + chars_at_vcol).min(char_start + char_len) } /// Blank columns on each side of the text content within the editor column. pub const GUTTER: u16 = 1; /// Width of the left gutter in terminal columns, including any line-number /// field and its trailing separator space. /// /// When `show_line_numbers` is false: returns [`GUTTER`] (= 1), preserving the /// existing single-cell blank gutter behaviour. /// /// When true: returns `digit_count(total_lines.max(1)) + 2`. /// The `+ 2` is the two-cell separator gap between the number and content. /// Examples: 0–9 lines → 3, 10–99 lines → 4, 100–999 lines → 5. pub fn left_gutter_width(total_lines: usize, show_line_numbers: bool) -> u16 { if !show_line_numbers { return GUTTER; } let digits = total_lines.max(1).ilog10() as usize + 1; (digits + 2) as u16 } // --------------------------------------------------------------------------- // MarkdownView widget // --------------------------------------------------------------------------- /// The main editor rendering widget. pub struct MarkdownView<'a> { pub lines: &'a [String], pub decoration_map: &'a DecorationMap, pub scroll_top: usize, pub cursor: (usize, usize), pub selection: Option<((usize, usize), (usize, usize))>, pub theme: &'a crate::config::Theme, pub column_width: u16, /// When true, line numbers are rendered in the left gutter. /// The gutter width adjusts automatically based on the total line count. pub show_line_numbers: bool, /// All current search matches `(line, char_start, char_end)`. Empty when /// no search is active or the query is blank. pub search_matches: &'a [SearchMatch], /// Index of the currently selected search match (highlighted brighter). pub search_current: usize, /// When `Some((start, end))`, lines outside the inclusive range are dimmed /// to `theme.muted`. Compute with [`focus_paragraph_bounds`] when focus /// mode is active; pass `None` to disable. pub focus_mode_paragraph: Option<(usize, usize)>, } impl Widget for MarkdownView<'_> { #[mutants::skip] // Writes into ratatui Buffer — void, not testable via return value. fn render(self, area: Rect, buf: &mut Buffer) { let left_gutter = left_gutter_width(self.lines.len(), self.show_line_numbers); let content_width = (self.column_width as usize).saturating_sub(left_gutter as usize + GUTTER as usize); let visible = area.height as usize; let bg = self.theme.bg; let default_style = Style::default().fg(self.theme.text).bg(bg); // Flood-fill the entire area with the background colour. for row in 0..area.height { for col in 0..area.width { buf[(area.x + col, area.y + row)].set_bg(bg); } } let (cursor_log_row, cursor_log_col) = self.cursor; let mut cursor_buf_pos: Option<(u16, u16)> = None; let mut visual_row: usize = 0; let total = self.lines.len(); let mut log_row = self.scroll_top; while visual_row < visible && log_row < total { let line = &self.lines[log_row]; let line_decs = self.decoration_map.get(&log_row); // Compute indents before wrapping so rows are wrapped at the correct // effective width. // `line_ci` — continuation indent (wrap_idx > 0 only, e.g. list bullets). // `line_ri` — first-row indent (wrap_idx == 0, e.g. frontmatter content). let line_ci = effective_ci(line, line_decs); let line_ri = line_decs .map(|decs| decs.iter().map(|s| s.row_indent).max().unwrap_or(0)) .unwrap_or(0) as usize; let wrapped = wrap_line_indented( line, content_width.saturating_sub(line_ri).max(1), content_width.saturating_sub(line_ci).max(1), ); let char_ranges = wrap_char_ranges(line, &wrapped); for (wrap_idx, (&row_str, &(char_start, char_len))) in wrapped.iter().zip(char_ranges.iter()).enumerate() { if visual_row >= visible { break; } let char_end = char_start + char_len; // Adjust decoration spans to this visual row's char range. // Built first so continuation_indent is available for cursor tracking. let row_spans: Vec = line_decs .map(|decs| { decs.iter() .filter(|s| s.char_end > char_start && s.char_start < char_end) .map(|s| StyledSpan { char_start: s.char_start.saturating_sub(char_start), char_end: s.char_end.saturating_sub(char_start).min(char_len), style: s.style, is_blockquote: s.is_blockquote, continuation_indent: s.continuation_indent, row_indent: s.row_indent, full_line_bg: s.full_line_bg, border_bottom: s.border_bottom, is_rule: s.is_rule, line_default_style: s.line_default_style, }) .collect() }) .unwrap_or_default(); // Visual left-margin indent for this row. // // • wrap_idx > 0: use `continuation_indent` (blockquote/list alignment). // • wrap_idx == 0: use `row_indent` (e.g. frontmatter content offset). // // IMPORTANT: read from `line_decs` (all logical-line spans), NOT from // `row_spans` (filtered to visual-row char range). The bullet/indicator // span covers chars 0..1, which is outside the char range of any // continuation row — so it would be stripped from `row_spans` and the // indent would compute to zero. let continuation_indent: u16 = if wrap_idx > 0 { line_ci as u16 } else { line_ri as u16 }; // Cursor tracking if log_row == cursor_log_row { let is_last_wrap = wrap_idx + 1 == wrapped.len(); let in_range = cursor_log_col >= char_start && (cursor_log_col < char_end || is_last_wrap); if in_range { let chars_into_row = cursor_log_col.saturating_sub(char_start); let col_in_row = display_cols_for_chars(row_str, chars_into_row) .min(content_width.saturating_sub(1)); cursor_buf_pos = Some(( area.x + left_gutter + continuation_indent + col_in_row as u16, area.y + visual_row as u16, )); } } // full_line_bg is a whole-line property: look it up from the // full logical-line decorations, not from row_spans (which are // filtered to this visual subrow's char range and drop spans on // blank lines where char_len == 0). let line_bg = line_decs .and_then(|decs| decs.iter().find_map(|s| s.full_line_bg)) .unwrap_or(bg); let border_color = row_spans.iter().find_map(|s| s.border_bottom); let is_rule = row_spans.iter().any(|s| s.is_rule); let is_last_wrap = wrap_idx + 1 == wrapped.len(); let y = area.y + visual_row as u16; // When line numbers are on, clip special block backgrounds (fenced, // heading) to the content columns only — the gutter always uses the // plain editor bg so numbers stay legible on any block background. if self.show_line_numbers { for col in 0..left_gutter { buf[(area.x + col, y)].set_bg(bg); } for col in left_gutter..self.column_width { buf[(area.x + col, y)].set_bg(line_bg); } } else { for col in 0..self.column_width { buf[(area.x + col, y)].set_bg(line_bg); } } // Line numbers: render right-aligned number on the first sub-row, // leave continuation rows blank (gutter inherits plain bg from above). if self.show_line_numbers && wrap_idx == 0 { let num_color = if log_row == cursor_log_row { self.theme.line_num_active } else { self.theme.line_num_inactive }; // Number occupies exactly `digits` columns (left_gutter - 2), // right-aligned; the two trailing cells are the separator gap. let num_width = (left_gutter as usize).saturating_sub(2); let num_str = format!("{:>width$}", log_row + 1, width = num_width); for (i, ch) in num_str.chars().enumerate() { buf[(area.x + i as u16, y)].set_char(ch).set_fg(num_color); } } if is_rule { let rule_style = row_spans .iter() .find(|s| s.is_rule) .map(|s| s.style) .unwrap_or(default_style); for x in area.x + left_gutter..area.x + left_gutter + content_width as u16 { buf[(x, y)].set_char('─').set_style(rule_style); } } else { use unicode_width::UnicodeWidthChar; // Blockquote lines: use blockquote_color as the default fg for any // undecorated text. The wide content span was removed from the // decoration pipeline (fix for #120) so that inline markup (bold, // italic, etc.) can emit their own correctly-colored spans. The // renderer restores the blockquote color here for plain text gaps. // Read from `line_decs` (all logical spans), not `row_spans` (filtered // to the visual row's char range), so continuation rows also apply it. let is_blockquote_line = line_decs .map(|decs| decs.iter().any(|s| s.is_blockquote)) .unwrap_or(false); // Heading lines carry `line_default_style` on their delimiter span // instead of emitting a wide content span that would block inline // decorations. Read from `line_decs` so continuation rows inherit it. let heading_default = line_decs.and_then(|decs| decs.iter().find_map(|s| s.line_default_style)); let row_default = if is_blockquote_line { Style::default().fg(self.theme.blockquote_color).bg(line_bg) } else if let Some(hd) = heading_default { hd.bg(line_bg) } else { default_style.bg(line_bg) }; let segments = split_into_spans(row_str, &row_spans, row_default); let mut x = area.x + left_gutter + continuation_indent; for span in &segments { for ch in span.content.chars() { let cw = UnicodeWidthChar::width(ch).unwrap_or(1); // Stop before this char would overflow the content area. if (x.saturating_sub(area.x + left_gutter)) as usize + cw > content_width { break; } buf[(x, y)].set_char(ch).set_style(span.style); if cw == 2 { // Explicitly clear the second terminal column of the // wide char. Without this, ratatui's frame-diff never // "owns" that cell, so when the line scrolls away the // cell can retain stale content from the previous frame. let x2 = x + 1; if ((x2.saturating_sub(area.x + left_gutter)) as usize) < content_width { buf[(x2, y)].set_char(' ').set_style(span.style); } } x += cw as u16; } } } // Bottom border (H1–H3 heading underline, last visual row only). // With line numbers: start after the gutter so the underline // never bleeds into the number column. Without line numbers: // start at column 0 so the underline matches the heading bg. if let Some(bc) = border_color && is_last_wrap { use ratatui::layout::Rect as R; let ul_offset = if self.show_line_numbers { left_gutter } else { 0 }; buf.set_style( R { x: area.x + ul_offset, y, width: self.column_width.saturating_sub(ul_offset), height: 1, }, Style::default() .underline_color(bc) .add_modifier(Modifier::UNDERLINED), ); } visual_row += 1; } log_row += 1; } // Focus overlay — dims lines outside the current paragraph so the // cursor paragraph reads as the primary content. Applied before the // search and selection overlays so those always win on top. if let Some(focus) = self.focus_mode_paragraph { apply_focus_overlay(area, buf, &self, focus); } // Search-match overlay — below selection so selection takes priority. if !self.search_matches.is_empty() { apply_search_overlay(area, buf, &self); } // Selection overlay — applied after content, before cursor. if let Some(selection) = self.selection { apply_selection_overlay(area, buf, &self, selection); } // Cursor cell — always on top. if let Some((cx, cy)) = cursor_buf_pos { buf[(cx, cy)] .set_fg(self.theme.bg) .set_bg(self.theme.accent); } } } // Writes terminal cell colours into a ratatui Buffer — no return value to assert. // The `< → >` mutant at line 613 is also not a live defect: the existing comment // at that line explains that `<→<=` is untestable for an analogous reason, and // `<→>` would simply paint no overlay (observable only through rendered pixels, // not through a pure-Rust unit assertion). Suite timeouts caused by parallel // slow integration tests prevent cargo-mutants from confirming the skip empirically. #[mutants::skip] fn apply_selection_overlay( area: Rect, buf: &mut Buffer, view: &MarkdownView<'_>, selection: ((usize, usize), (usize, usize)), ) { let ((sel_row_start, sel_col_start), (sel_row_end, sel_col_end)) = selection; let left_gutter = left_gutter_width(view.lines.len(), view.show_line_numbers); let content_width = (view.column_width as usize).saturating_sub(left_gutter as usize + GUTTER as usize); let visible = area.height as usize; let total = view.lines.len(); let sel_fg = view.theme.selection_fg; let sel_bg = view.theme.selection_bg; let mut visual_row: usize = 0; let mut log_row = view.scroll_top; // Equivalent mutation note: `visual_row < visible` has an untestable `<→<=` // because the inner `if visual_row >= visible { break; }` fires immediately // on the extra iteration, making it a behavioural no-op. while visual_row < visible && log_row < total { if log_row > sel_row_end { break; } let line = &view.lines[log_row]; let line_decs_sel = view.decoration_map.get(&log_row); let line_ci = effective_ci(line, line_decs_sel); let line_ri = line_decs_sel .map(|decs| decs.iter().map(|s| s.row_indent).max().unwrap_or(0)) .unwrap_or(0) as usize; let wrapped = wrap_line_indented( line, content_width.saturating_sub(line_ri).max(1), content_width.saturating_sub(line_ci).max(1), ); let char_ranges = wrap_char_ranges(line, &wrapped); for (wrap_idx, (row_str, &(char_start, char_len))) in wrapped.iter().zip(char_ranges.iter()).enumerate() { if visual_row >= visible { break; } let char_end = char_start + char_len; // Mirror the indent logic from render() so selection highlighting // respects the same left-margin offset. let continuation_indent: u16 = if wrap_idx > 0 { line_ci as u16 } else { line_ri as u16 }; if log_row >= sel_row_start && log_row <= sel_row_end { let line_sel_start = if log_row == sel_row_start { sel_col_start } else { 0 }; let line_sel_end = if log_row == sel_row_end { sel_col_end } else { usize::MAX }; let row_sel_start = line_sel_start.max(char_start); let row_sel_end = line_sel_end.min(char_end); // Equivalent mutation note: `<→<=` is untestable because when // row_sel_start == row_sel_end the ci term makes x_start >= x_end, // so the for loop produces an empty range regardless. if row_sel_start < row_sel_end { let y = area.y + visual_row as u16; let start_dcols = display_cols_for_chars(row_str, row_sel_start - char_start); let end_dcols = display_cols_for_chars(row_str, row_sel_end - char_start); let x_start = area.x + left_gutter + continuation_indent + start_dcols as u16; let x_end = (area.x + left_gutter + continuation_indent + end_dcols as u16) .min(area.x + left_gutter + content_width as u16); for x in x_start..x_end { buf[(x, y)].set_fg(sel_fg).set_bg(sel_bg); } } } visual_row += 1; } log_row += 1; } } /// Paint search-match highlights over already-rendered content. /// /// Dim background for all non-current matches; brighter background for the /// current one. Applied before the selection and cursor overlays so those /// always win when they overlap a match cell. #[mutants::skip] // Writes into ratatui Buffer — no return value to assert. fn apply_search_overlay(area: Rect, buf: &mut Buffer, view: &MarkdownView<'_>) { let left_gutter = left_gutter_width(view.lines.len(), view.show_line_numbers); let content_width = (view.column_width as usize).saturating_sub(left_gutter as usize + GUTTER as usize); let visible = area.height as usize; let total = view.lines.len(); let dim_bg = view.theme.search_match_bg; let cur_bg = view.theme.search_current_bg; // Group matches by line for efficient lookup during the visual scan. // Build a small sorted vec of (char_start, char_end, is_current). let mut by_line: std::collections::HashMap> = std::collections::HashMap::new(); for (i, &(ml, ms, me)) in view.search_matches.iter().enumerate() { by_line .entry(ml) .or_default() .push((ms, me, i == view.search_current)); } let mut visual_row: usize = 0; let mut log_row = view.scroll_top; while visual_row < visible && log_row < total { if let Some(row_matches) = by_line.get(&log_row) { let line = &view.lines[log_row]; let line_ci = effective_ci(line, view.decoration_map.get(&log_row)); let wrapped = wrap_line_indented( line, content_width.max(1), content_width.saturating_sub(line_ci).max(1), ); let char_ranges = wrap_char_ranges(line, &wrapped); for (wrap_idx, (&row_str, &(char_start, char_len))) in wrapped.iter().zip(char_ranges.iter()).enumerate() { if visual_row >= visible { break; } let char_end = char_start + char_len; let continuation_indent: u16 = if wrap_idx > 0 { line_ci as u16 } else { 0 }; for &(ms, me, is_current) in row_matches { let row_ms = ms.max(char_start); let row_me = me.min(char_end); if row_ms >= row_me { visual_row += 1; continue; } let y = area.y + visual_row as u16; let start_dcols = display_cols_for_chars(row_str, row_ms - char_start); let end_dcols = display_cols_for_chars(row_str, row_me - char_start); let x_start = area.x + left_gutter + continuation_indent + start_dcols as u16; let x_end = (area.x + left_gutter + continuation_indent + end_dcols as u16) .min(area.x + left_gutter + content_width as u16); let highlight_bg = if is_current { cur_bg } else { dim_bg }; for x in x_start..x_end { buf[(x, y)].set_bg(highlight_bg); } } visual_row += 1; } log_row += 1; } else { // No matches on this line — count its visual rows and move on. let line = &view.lines[log_row]; let line_ci = effective_ci(line, view.decoration_map.get(&log_row)); let wrapped = wrap_line_indented( line, content_width.max(1), content_width.saturating_sub(line_ci).max(1), ); visual_row += wrapped.len().max(1); log_row += 1; } } } // --------------------------------------------------------------------------- // Focus mode helpers // --------------------------------------------------------------------------- /// Return the inclusive `(start, end)` line range of the logical paragraph that /// contains `cursor_row`. /// /// A paragraph is a contiguous run of non-blank lines bounded above and below /// by blank lines (or the document edges). Blank lines are their own /// single-line "paragraph": when the cursor sits on a blank line the function /// returns `(cursor_row, cursor_row)`. /// /// This is a pure function with no ratatui dependency — it is unit-tested /// directly from the test suite. /// Scan downward from `cursor_row + 1` to find the last non-blank line before /// the next blank gap (or the last line if no blank is found). /// /// # Mutation notes /// `cursor_row + 1 → cursor_row * 1` is `#[mutants::skip]`-protected: the /// caller guarantees `lines[cursor_row]` is non-blank (blank rows return early /// in `focus_paragraph_bounds`), so starting the scan one position earlier /// would still find the same blank line — the cursor row is never blank here. #[mutants::skip] fn paragraph_end(lines: &[String], cursor_row: usize) -> usize { let n = lines.len(); (cursor_row + 1..n) .find(|&i| lines[i].trim().is_empty()) .map_or(n - 1, |i| i - 1) } pub fn focus_paragraph_bounds(lines: &[String], cursor_row: usize) -> (usize, usize) { let n = lines.len(); if n == 0 { return (0, 0); } let cursor_row = cursor_row.min(n.saturating_sub(1)); // Cursor on a blank line → the focus is just that single line. if lines[cursor_row].trim().is_empty() { return (cursor_row, cursor_row); } // Scan upward to find the first blank line above; the paragraph starts // on the line immediately after it (or at 0 if none found). let start = (0..cursor_row) .rev() .find(|&i| lines[i].trim().is_empty()) .map_or(0, |i| i + 1); // Scan downward to find the first blank line below; the paragraph ends // on the line immediately before it (or at n-1 if none found). let end = paragraph_end(lines, cursor_row); (start, end) } /// Dim all text cells outside the focus paragraph by overwriting their fg with /// `theme.muted`, making out-of-focus content recede visually. /// /// Applied before search and selection overlays so those always render on top. #[mutants::skip] // Writes into ratatui Buffer — void, not testable via return value. fn apply_focus_overlay( area: Rect, buf: &mut Buffer, view: &MarkdownView<'_>, focus: (usize, usize), ) { let (focus_start, focus_end) = focus; let left_gutter = left_gutter_width(view.lines.len(), view.show_line_numbers); let content_width = (view.column_width as usize).saturating_sub(left_gutter as usize + GUTTER as usize); let dim_fg = view.theme.muted; let visible = area.height as usize; let total = view.lines.len(); let mut visual_row = 0usize; let mut log_row = view.scroll_top; while visual_row < visible && log_row < total { let line = &view.lines[log_row]; let line_ci = view .decoration_map .get(&log_row) .map(|decs| { decs.iter() .map(|s| s.continuation_indent) .max() .unwrap_or(0) }) .unwrap_or(0) as usize; let wrapped = wrap_line_indented( line, content_width.max(1), content_width.saturating_sub(line_ci).max(1), ); for _ in 0..wrapped.len() { if visual_row >= visible { break; } if log_row < focus_start || log_row > focus_end { let y = area.y + visual_row as u16; let x_end = (area.x + left_gutter + content_width as u16).min(area.x + area.width); for x in (area.x + left_gutter)..x_end { buf[(x, y)].set_fg(dim_fg); } } visual_row += 1; } log_row += 1; } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use std::path::{Path, PathBuf}; use tui_textarea::TextArea; use ratatui::style::{Modifier, Style}; use crate::app::App; use crate::config::Theme; use crate::decoration::{DecorationMap, StyledSpan}; use crate::status::StatusLine; use super::search_bar::search_bar_height; use super::status::{build_goto_line_bar, build_normal_status_bar, build_timed_message_bar}; use super::*; fn make_app() -> App { App { textarea: TextArea::default(), file_path: Some(PathBuf::from("notes/foo.md")), is_dirty: false, saved_content: None, theme: Theme::default_theme(), italic_support: false, powerline_glyphs: false, last_keystroke: None, force_redecorate: false, decoration_map: DecorationMap::default(), word_count: 0, status: StatusLine::default(), config_warnings: vec![], scroll_top: 0, free_scroll: false, sticky_col: None, content_width: 0, clipboard: crate::app::ClipboardState::Uninitialized, shortened_path: "notes/foo.md".to_string(), tab_width: 4, highlight_cache: None, file_mode: crate::app::FileMode::Markdown, show_line_numbers: false, search: None, search_last_typed: None, typewriter_mode: false, focus_mode: false, show_shortcuts: false, read_only: false, auto_close_pairs: false, autosave_secs: 0, settings: None, } } // --- display_cols_for_chars --- // ── left_gutter_width ──────────────────────────────────────────────────── #[test] fn left_gutter_width_off_returns_gutter_constant() { // When line numbers are disabled the gutter is always GUTTER (= 1), // regardless of document size. assert_eq!(left_gutter_width(0, false), GUTTER); assert_eq!(left_gutter_width(9, false), GUTTER); assert_eq!(left_gutter_width(1000, false), GUTTER); } #[test] fn left_gutter_width_on_zero_or_single_digit_lines() { // 0–9 lines → 1 digit + 2 separator cells = 3. // 0.max(1) = 1; 1.ilog10() = 0; digits = 1; result = 3. assert_eq!(left_gutter_width(0, true), 3); assert_eq!(left_gutter_width(1, true), 3); assert_eq!(left_gutter_width(9, true), 3); } #[test] fn left_gutter_width_on_two_digit_lines() { // 10–99 lines → 2 digits + 2 separator cells = 4. assert_eq!(left_gutter_width(10, true), 4); assert_eq!(left_gutter_width(99, true), 4); } #[test] fn left_gutter_width_on_three_digit_lines() { // 100–999 lines → 3 digits + 2 separator cells = 5. assert_eq!(left_gutter_width(100, true), 5); assert_eq!(left_gutter_width(999, true), 5); } #[test] fn left_gutter_width_on_four_digit_lines() { // 1000–9999 lines → 4 digits + 2 separator cells = 6. assert_eq!(left_gutter_width(1000, true), 6); assert_eq!(left_gutter_width(9999, true), 6); } // ── display_cols_for_chars ──────────────────────────────────────────────── #[test] fn display_cols_ascii_equals_char_count() { assert_eq!(display_cols_for_chars("hello", 3), 3); } #[test] fn display_cols_cjk_each_two_columns() { // "日本語" = 3 chars × 2 cols each = 6 display cols total. assert_eq!(display_cols_for_chars("日本語", 3), 6); } #[test] fn display_cols_cjk_partial() { // First 2 chars of "日本語" = 4 display cols. assert_eq!(display_cols_for_chars("日本語", 2), 4); } #[test] fn display_cols_mixed_ascii_and_cjk() { // "a日b" = 1 + 2 + 1 = 4 display cols. assert_eq!(display_cols_for_chars("a日b", 3), 4); } #[test] fn display_cols_zero_chars_is_zero() { assert_eq!(display_cols_for_chars("hello", 0), 0); } #[test] fn display_cols_n_exceeds_string_returns_full_width() { // Asking for 99 chars of a 3-char string returns the full display width. assert_eq!(display_cols_for_chars("abc", 99), 3); } // --- chars_for_display_cols --- #[test] fn chars_for_display_ascii_equals_display_cols() { assert_eq!(chars_for_display_cols("hello", 3), 3); } #[test] fn chars_for_display_cjk_two_cols_per_char() { // 4 display cols into "日本語" = 2 chars (日本). assert_eq!(chars_for_display_cols("日本語", 4), 2); } #[test] fn chars_for_display_cjk_odd_budget_rounds_down() { // 3 display cols into "日本語": 日 takes 2, next char (本) would push to 4 > 3 → 1 char. assert_eq!(chars_for_display_cols("日本語", 3), 1); } #[test] fn chars_for_display_zero_budget_returns_zero() { assert_eq!(chars_for_display_cols("hello", 0), 0); } #[test] fn chars_for_display_budget_exceeds_string() { // Budget larger than string width → returns full char count. assert_eq!(chars_for_display_cols("ab", 99), 2); } // --- cursor_vcol --- #[test] fn cursor_vcol_ascii_equals_char_offset() { // ASCII: display cols == char count. // "abcde fghij" at width 8 → ["abcde", "fghij"]. Cursor at char 7 (index 1 into "fghij"). assert_eq!(cursor_vcol("abcde fghij", 7, 8, 8), 1); } #[test] fn cursor_vcol_cjk_counts_display_cols() { // "日本語" fits on one row at width=10. Cursor at char 1 (本) → 2 display cols (日 = 2). assert_eq!(cursor_vcol("日本語", 1, 10, 10), 2); } #[test] fn cursor_vcol_cjk_start_of_line_is_zero() { assert_eq!(cursor_vcol("日本語", 0, 10, 10), 0); } // Cursor at the very end of a single-segment line (col == char_count). // The last-segment fallback (`i == last`) must fire via `||`, not `&&`. // // Kills `|| → &&` (col 47): with `&&`, entering the if requires BOTH // `cursor_col < char_start + char_len` AND `i == last`. At the end of a // single-segment line cursor_col = char_len and `cursor_col < char_len` is // false, so the `&&` mutant never enters the branch → returns 0 instead of 3. #[test] fn cursor_vcol_cursor_at_end_of_single_segment() { // "abc" at width=10 → one segment [(0,3)]. Cursor at col 3 (past last char). assert_eq!(cursor_vcol("abc", 3, 10, 10), 3); } // Cursor in the first segment of a two-segment wrapped line. // // Kills `< → ==` (col 23): with `==`, cursor_col=2 never equals 0+5=5 on // the first segment, so we skip it and land on the last-segment fallback // with char_start=6 → chars_into_row = 2-6 = 0 → returns 0 instead of 2. // // Kills `+ → *` (col 36): char_start(0) * char_len(5) = 0 → cursor_col=2 // passes the `<0` check on the last segment (char_start=6) → same wrong path. #[test] fn cursor_vcol_cursor_in_first_of_two_rows() { // "abcde fghij" width 8 → ["abcde" (0-4), "fghij" (6-10)]. // Cursor at col 2 (inside "abcde") → vcol 2. assert_eq!(cursor_vcol("abcde fghij", 2, 8, 8), 2); } // Cursor at exactly the wrap boundary (the consumed space char, col 5). // // Kills `< → <=` (col 23): with `<=`, 5 <= 0+5 is true on segment 0 // → chars_into_row = 5-0=5 → display_cols("abcde",5)=5 → returns 5. // Original: 5 < 5 is false → falls through to segment 1 (char_start=6) // → chars_into_row = 5-6 = 0 → returns 0 (space is at the wrap point). #[test] fn cursor_vcol_cursor_at_wrap_boundary() { // "abcde fghij" width 8. Col 5 is the consumed space → visual col 0 on row 1. assert_eq!(cursor_vcol("abcde fghij", 5, 8, 8), 0); } // --- char_col_at_visual (CJK) --- #[test] fn char_col_at_visual_cjk_display_col_to_char() { // "日本語" on one subrow. vcol=2 display cols → char 1 (after 日 which takes 2 cols). assert_eq!(char_col_at_visual("日本語", 0, 2, 10, 10), 1); } #[test] fn char_col_at_visual_cjk_odd_vcol_rounds_down() { // vcol=1 into "日本語": 日 would take 2 cols, can't fit → 0 chars → char 0. assert_eq!(char_col_at_visual("日本語", 0, 1, 10, 10), 0); } // --- cursor_subrow_info --- #[test] fn subrow_info_unwrapped_line_is_subrow_zero() { // "hello" fits in width 10; cursor at col 3 is on subrow 0. let (sub, char_start, total) = cursor_subrow_info("hello", 3, 10, 10); assert_eq!(sub, 0); assert_eq!(char_start, 0); assert_eq!(total, 1); } #[test] fn subrow_info_cursor_on_first_visual_row() { // "abcde fghij" with width 8 wraps: ["abcde", "fghij"] // char_ranges = [(0,5),(6,5)]. Cursor at col 2 → subrow 0. let (sub, char_start, total) = cursor_subrow_info("abcde fghij", 2, 8, 8); assert_eq!(sub, 0); assert_eq!(char_start, 0); assert_eq!(total, 2); } #[test] fn subrow_info_cursor_on_second_visual_row() { // Same wrap; cursor at col 6 (first char of "fghij") → subrow 1. let (sub, char_start, total) = cursor_subrow_info("abcde fghij", 6, 8, 8); assert_eq!(sub, 1); assert_eq!(char_start, 6); assert_eq!(total, 2); } #[test] fn subrow_info_empty_line_is_subrow_zero() { let (sub, char_start, total) = cursor_subrow_info("", 0, 10, 10); assert_eq!(sub, 0); assert_eq!(char_start, 0); assert_eq!(total, 1); } // Cursor at the wrap boundary (the consumed space, col 5) must land on // subrow 1, not subrow 0. // // Kills `< → <=` (col 23): with `<=`, cursor_col=5 satisfies 5 <= 0+5=5 on // segment 0, returning (0, 0, 2) instead of (1, 6, 2). #[test] fn subrow_info_cursor_at_wrap_boundary_lands_on_next_subrow() { // "abcde fghij" width 8: char_ranges = [(0,5),(6,5)]. // col 5 is the space (consumed by wrap) → first char of second subrow. let (sub, char_start, total) = cursor_subrow_info("abcde fghij", 5, 8, 8); assert_eq!(sub, 1, "space col must map to second subrow"); assert_eq!(char_start, 6); assert_eq!(total, 2); } // Cursor at the very end of the last segment (col == line char count) // must still return the last subrow via the `i+1 == len` fallback. // // Kills `+ → *` (col 52): `i * 1 == len` is never true inside the loop // bounds, disabling the fallback. When cursor_col=11 >= char_start(6)+char_len(5)=11 // on the last segment, the loop exits without returning, yielding (0, 0, 2). #[test] fn subrow_info_cursor_at_end_of_line_returns_last_subrow() { // "abcde fghij" (11 chars). Col 11 (past end) → last subrow (1, char_start=6). let (sub, char_start, total) = cursor_subrow_info("abcde fghij", 11, 8, 8); assert_eq!(sub, 1, "cursor past end must land on last subrow"); assert_eq!(char_start, 6); assert_eq!(total, 2); } // --- char_col_at_visual --- #[test] fn char_col_at_visual_no_wrap() { // Unwrapped line; subrow 0, vcol 3 → char col 3. assert_eq!(char_col_at_visual("hello world", 0, 3, 20, 20), 3); } #[test] fn char_col_at_visual_second_subrow() { // "abcde fghij" width 8 → [(0,5),(6,5)]. Subrow 1, vcol 2 → char 8. assert_eq!(char_col_at_visual("abcde fghij", 1, 2, 8, 8), 8); } #[test] fn char_col_at_visual_clamps_to_end_of_subrow() { // vcol=99 on a 5-char subrow starting at 0 → clamped to 5. assert_eq!(char_col_at_visual("hello", 0, 99, 20, 20), 5); } #[test] fn char_col_at_visual_clamps_to_last_subrow_when_index_oob() { // target_subrow=5 on a 1-subrow line → falls back to subrow 0. assert_eq!(char_col_at_visual("hi", 5, 0, 20, 20), 0); } // --- shorten_path --- #[test] fn shorten_path_long() { let p = Path::new("/home/user/docs/notes/foo.md"); assert_eq!(shorten_path(p, 2), "notes/foo.md"); } #[test] fn shorten_path_short_stays_whole() { let p = Path::new("foo.md"); assert_eq!(shorten_path(p, 3), "foo.md"); } #[test] fn shorten_path_exact_components() { let p = Path::new("/a/b/c"); assert_eq!(shorten_path(p, 3), "a/b/c"); } #[test] fn shorten_path_more_components_than_max() { let p = Path::new("/home/user/projects/yame/src/main.rs"); let result = shorten_path(p, 3); assert_eq!(result, "yame/src/main.rs"); } // --- format_thousands --- #[test] fn format_thousands_small() { assert_eq!(format_thousands(0), "0"); assert_eq!(format_thousands(999), "999"); } #[test] fn format_thousands_1204() { assert_eq!(format_thousands(1204), "1,204"); } #[test] fn format_thousands_million() { assert_eq!(format_thousands(1_000_000), "1,000,000"); } #[test] fn format_thousands_exactly_1000() { assert_eq!(format_thousands(1000), "1,000"); } // --- split_into_spans --- fn bold_style() -> Style { Style::default().add_modifier(Modifier::BOLD) } #[test] fn span_split_no_spans_returns_whole_line() { let result = split_into_spans("hello world", &[], Style::default()); assert_eq!(result.len(), 1); assert_eq!(result[0].content, "hello world"); } #[test] fn span_split_mid_span_correct_boundaries() { let span = StyledSpan { char_start: 2, char_end: 5, style: bold_style(), ..Default::default() }; let result = split_into_spans("0123456789", &[span], Style::default()); assert_eq!(result[0].content, "01"); assert_eq!(result[1].content, "234"); assert_eq!(result[2].content, "56789"); } #[test] fn span_split_multibyte_safe() { let span = StyledSpan { char_start: 1, char_end: 2, ..Default::default() }; let result = split_into_spans("café", &[span], Style::default()); assert_eq!(result[0].content, "c"); assert_eq!(result[1].content, "a"); } #[test] fn span_split_full_line_span() { let span = StyledSpan { char_start: 0, char_end: 5, style: bold_style(), ..Default::default() }; let result = split_into_spans("hello", &[span], Style::default()); assert_eq!(result.len(), 1); assert_eq!(result[0].content, "hello"); } #[test] fn span_split_empty_line_returns_one_span() { let result = split_into_spans("", &[], Style::default()); assert_eq!(result.len(), 1); assert_eq!(result[0].content, ""); } #[test] fn span_split_overlapping_clips_later_span() { let s1 = StyledSpan { char_start: 0, char_end: 5, style: bold_style(), ..Default::default() }; let s2 = StyledSpan { char_start: 3, char_end: 8, ..Default::default() }; let result = split_into_spans("0123456789", &[s1, s2], Style::default()); assert_eq!(result[0].content, "01234"); assert_eq!(result[1].content, "567"); assert_eq!(result[2].content, "89"); } // --- wrap_line --- #[test] fn wrap_short_line_unchanged() { assert_eq!(wrap_line("hello", 40), vec!["hello"]); } #[test] fn wrap_breaks_at_word_boundary() { let result = wrap_line("one two three four five", 12); assert!(result[0].len() <= 12); assert!(result.iter().all(|s| s.len() <= 12)); } #[test] fn wrap_hard_breaks_unbreakable_word() { let long = "abcdefghijklmnop"; let result = wrap_line(long, 8); assert_eq!(result[0], "abcdefgh"); assert_eq!(result[1], "ijklmnop"); } #[test] fn wrap_wide_chars_count_display_columns() { // Each CJK char is 2 display columns wide. // "日本語テ" = 8 display cols; at width=4 it wraps into two rows of 2 chars each. let result = wrap_line("日本語テ", 4); assert_eq!( result.len(), 2, "4 CJK chars (8 display cols) must wrap at width=4" ); assert_eq!(result[0], "日本"); assert_eq!(result[1], "語テ"); } #[test] fn wrap_wide_chars_single_row_when_fits() { // "日本" = 4 display cols; at width=4 it fits without wrapping. let result = wrap_line("日本", 4); assert_eq!( result.len(), 1, "2 CJK chars (4 display cols) must not wrap at width=4" ); } #[test] fn wrap_empty_string() { let result = wrap_line("", 40); assert_eq!(result, vec![""]); } #[test] fn wrap_exact_width_no_break() { let result = wrap_line("abcde", 5); assert_eq!(result, vec!["abcde"]); } #[test] fn wrap_single_word_over_width_hard_breaks() { let result = wrap_line("abcdef", 4); assert_eq!(result[0], "abcd"); assert_eq!(result[1], "ef"); } #[test] fn wrap_zero_width_returns_whole_string() { // Early-exit uses `||`: both `s.is_empty()` and `width == 0` independently // trigger it. With ||→&& mutation, width=0 no longer short-circuits and // the function hard-breaks each character individually instead. assert_eq!(wrap_line("hello", 0), vec!["hello"]); } #[test] fn wrap_soft_break_exact_content() { // Exercises the soft-break path (break at last space before width). // Kills line-96 `sp+1→sp-1` (next_start goes backward → includes "z abc") // and `sp+1→sp*1` (next_start stays at space → includes " abc"). assert_eq!(wrap_line("xyz abc", 5), vec!["xyz", "abc"]); } #[test] fn wrap_cjk_pathological_single_col() { // CJK chars are 2 display columns; width=1 forces the pathological path where // the first char alone exceeds the limit (chunk_end == char_start). // Kills line-87 `+→*` (byte_end = byte_start → empty segment pushed) and // `+→-` (0usize - 1 underflows in debug → panic). // Also kills line-90 `-=` (char_start underflows after the first hard-break). assert_eq!(wrap_line("日", 1), vec!["日"]); } #[test] fn wrap_skips_empty_span_at_space_boundary() { // After wrapping "a" the next segment starts at the space; that space becomes // the break point so byte_start == byte_end. The `if byte_start < byte_end` // guard must skip it. With `<→<=` an empty string slice is pushed instead, // giving ["a", "", "b"]. assert_eq!(wrap_line("a b", 1), vec!["a", "b"]); } // --- wrap_line_indented --- #[test] fn wrap_indented_no_indent_same_as_wrap_line() { // cont_width >= first_width → fast path, identical to wrap_line. let s = "one two three four five"; assert_eq!(wrap_line_indented(s, 12, 12), wrap_line(s, 12)); } #[test] fn wrap_indented_single_row_unchanged() { // Line fits on one row — no continuation rows, no clipping. assert_eq!(wrap_line_indented("hello", 40, 38), vec!["hello"]); } #[test] fn wrap_indented_continuation_row_narrower() { // "- word1 word2 word3" with first_width=20, cont_width=18 (indent=2). // The first row gets up to 20 cols; continuation row(s) get up to 18. // This test verifies continuation rows don't exceed cont_width characters. let s = "- word1 word2 word3 word4 word5 word6 word7"; let rows = wrap_line_indented(s, 20, 18); assert!( rows.len() >= 2, "expected wrapping to produce multiple rows" ); // First row may use up to first_width. assert!(rows[0].len() <= 20, "first row too wide: {:?}", rows[0]); // All continuation rows must fit within cont_width. for row in &rows[1..] { assert!( row.len() <= 18, "continuation row exceeds cont_width: {:?}", row ); } } #[test] fn wrap_indented_roundtrips_via_char_ranges() { // All &str slices returned by wrap_line_indented point into the original // string, so wrap_char_ranges must be able to locate them. let s = "- some indented list item that is long enough to wrap at a narrow width"; let rows = wrap_line_indented(s, 30, 28); // Must not panic and char ranges must be non-overlapping ascending. let ranges = wrap_char_ranges(s, &rows); let mut prev_end = 0usize; for (start, len) in &ranges { assert!(*start >= prev_end, "ranges overlap or go backward"); prev_end = start + len; } } #[test] fn wrap_indented_hard_break_continuation() { // An unbreakable word on a continuation row should still hard-break // within cont_width, not overflow to first_width. let s = "- abcdefghijklmnopqrstuvwx"; // first_width=20, cont_width=18. "- " is 2 chars on row 0; the 24-char // tail must hard-break at 18 on the continuation row. let rows = wrap_line_indented(s, 20, 18); assert!(rows.len() >= 2); for row in &rows[1..] { assert!( row.len() <= 18, "continuation hard-break too wide: {:?}", row ); } } // Kills: renderer/mod.rs:144 (match arm Some(&b' ') mutations). // If the break-space is NOT skipped, rest starts with ' ' and wrap_line // produces a continuation row with a leading space. // Specifically kills: // old `< → ==` / `< → >` (condition becomes false → else branch → space not skipped), // old `+ → *` (first_end_byte * 1 == first_end_byte → same as else branch), // new `Some(&b' ') → Some(&b'\0')` (space not matched → else branch), // new `+ 1 → * 1` in match arm (rest_start = first_end_byte → space included). #[test] fn wrap_indented_continuation_rows_have_no_leading_space() { // "- abc def ghi" with first_width=8, cont_width=6. // first_pass wraps at a space; rest must start AFTER the space. // If the space is not skipped, continuation rows begin with ' '. let s = "- abc def ghi jkl"; let rows = wrap_line_indented(s, 8, 6); assert!( rows.len() >= 2, "expected wrapping to produce multiple rows" ); for row in &rows[1..] { assert!( !row.starts_with(' '), "continuation row must not start with space (break-space must be skipped): {:?}", row ); } } // --- wrap_char_ranges --- #[test] fn char_ranges_two_segments() { // "hello world" at width 5 → ["hello", "world"]. // Kills all four wrap_char_ranges arithmetic mutants: // line 132 +→- and +→* (wrong char_start for segment 2), // line 135 +→* (wrong prev_byte_end → inflated gap → wrong char_start), // line 136 +→* (wrong prev_char_end → wrong char_start for segment 2). let line = "hello world"; let wrapped = wrap_line(line, 5); assert_eq!(wrapped, vec!["hello", "world"]); assert_eq!(wrap_char_ranges(line, &wrapped), vec![(0, 5), (6, 5)]); } #[test] fn char_ranges_three_segments() { // Three single-char segments with gap chars between each ensures // prev_byte_end and prev_char_end are threaded correctly across all iterations. let line = "a b c"; let wrapped = wrap_line(line, 2); assert_eq!(wrapped, vec!["a", "b", "c"]); assert_eq!( wrap_char_ranges(line, &wrapped), vec![(0, 1), (2, 1), (4, 1)] ); } // --- build_normal_status_bar --- #[test] fn status_bar_clean_has_no_dirty_flag() { let app = make_app(); let line = build_normal_status_bar(&app); let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); assert!(!text.contains("[*]"), "clean file must not show [*]"); } #[test] fn status_bar_dirty_shows_flag() { let mut app = make_app(); app.is_dirty = true; let line = build_normal_status_bar(&app); let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); assert!(text.contains("[*]"), "dirty file must show [*]"); } #[test] fn status_bar_includes_path() { let app = make_app(); let line = build_normal_status_bar(&app); let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); assert!(text.contains("foo.md"), "status bar must include filename"); } #[test] fn status_bar_no_panic() { let app = make_app(); let _ = build_normal_status_bar(&app); } // --- build_timed_message_bar --- #[test] fn timed_bar_includes_filename() { let app = make_app(); let line = build_timed_message_bar(&app, "Saved."); let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); assert!( text.contains("foo.md"), "timed message bar must include the filename" ); } #[test] fn timed_bar_includes_message_text() { let app = make_app(); let line = build_timed_message_bar(&app, "Saved."); let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); assert!( text.contains("Saved."), "timed message bar must include the message text" ); } #[test] fn timed_bar_message_span_is_bold_accent() { let app = make_app(); let theme = Theme::default_theme(); let line = build_timed_message_bar(&app, "Saved."); assert!( line.spans.iter().any(|s| s.content.contains("Saved.") && s.style.fg == Some(theme.accent) && s.style.add_modifier.contains(Modifier::BOLD)), "message span must be bold accent" ); } #[test] fn timed_bar_has_no_hints_bg() { // The hints zone (ui_bg) must not appear — it should dissolve into canvas_bg. let app = make_app(); let theme = Theme::default_theme(); let line = build_timed_message_bar(&app, "Saved."); assert!( !line.spans.iter().any(|s| s.style.bg == Some(theme.ui_bg)), "timed message bar must not use hints_bg on any span" ); } #[test] fn timed_bar_dirty_shows_dirty_marker() { let mut app = make_app(); app.is_dirty = true; let line = build_timed_message_bar(&app, "Saved."); let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); assert!( text.contains("[*]"), "timed message bar must show [*] when dirty" ); } #[test] fn timed_bar_clean_has_no_dirty_marker() { let app = make_app(); let line = build_timed_message_bar(&app, "Saved."); let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); assert!( !text.contains("[*]"), "timed message bar must not show [*] when clean" ); } #[test] fn heading_delimiter_bold_survives_split_into_spans() { // Verify that Modifier::BOLD on a StyledSpan passes through split_into_spans // to the resulting ratatui Span — confirming the renderer pipeline carries BOLD. let delim_span = StyledSpan { char_start: 0, char_end: 2, style: Style::default() .fg(ratatui::style::Color::Rgb(100, 150, 200)) .add_modifier(Modifier::BOLD), ..Default::default() }; let segments = split_into_spans("# Hello", &[delim_span], Style::default()); let hash_span = segments .iter() .find(|s| s.content.starts_with('#')) .expect("span starting with # must exist"); assert!( hash_span.style.add_modifier.contains(Modifier::BOLD), "BOLD modifier must survive split_into_spans for heading delimiter" ); } #[test] fn heading_delimiter_bold_reaches_buffer() { // End-to-end: render a decorated H1 line into a ratatui Buffer and confirm // that the `#` cell carries Modifier::BOLD. This catches any renderer path // that might silently drop the modifier before it hits the terminal. use ratatui::buffer::Buffer; use ratatui::widgets::Widget; let area = ratatui::layout::Rect { x: 0, y: 0, width: 40, height: 3, }; let mut buf = Buffer::empty(area); let theme = crate::config::Theme::default_theme(); let mut deco = DecorationMap::default(); deco.insert( 0, vec![ StyledSpan { char_start: 0, char_end: 2, // "# " style: Style::default() .fg(theme.headings.h1) .add_modifier(Modifier::BOLD), ..Default::default() }, StyledSpan { char_start: 2, char_end: 7, // "Hello" style: Style::default() .fg(theme.headings.h1) .add_modifier(Modifier::BOLD), ..Default::default() }, ], ); let view = MarkdownView { lines: &["# Hello".to_string()], decoration_map: &deco, scroll_top: 0, cursor: (0, 0), selection: None, theme: &theme, column_width: 40, show_line_numbers: false, search_matches: &[], search_current: 0, focus_mode_paragraph: None, }; view.render(area, &mut buf); // The `#` char is at x = GUTTER (= 1), y = 0. let cell = buf.cell((GUTTER, 0)).expect("cell must exist"); assert!( cell.modifier.contains(Modifier::BOLD), "H1 # character must carry Modifier::BOLD in the rendered ratatui buffer" ); } /// Regression test for #133: blank lines inside a fenced code block must /// render with `fenced_bg`, not the plain editor background. /// /// Root cause: the `row_spans` filter (`s.char_start < char_end`) dropped /// every span on blank lines because `char_end == 0` for an empty line, /// making `full_line_bg` invisible to the renderer. The fix sources /// `full_line_bg` from `line_decs` (all logical spans) instead. /// /// Note: the cursor is on row 0 ("code"), so its accent-bg cell is at /// visual row 0. We assert on visual row 1 (the blank line) to avoid the /// cursor cell. #[test] fn fenced_blank_line_renders_with_fenced_bg() { use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::widgets::Widget; // Two visible rows: row 0 = "code" (cursor here), row 1 = "" (blank). let area = Rect { x: 0, y: 0, width: 20, height: 2, }; let mut buf = Buffer::empty(area); let theme = crate::config::Theme::default_theme(); let fenced_bg = theme.fenced_bg; // Both lines carry fenced_bg; the blank one (log_row 1) is the // regression target. The decoration pass emits char_end=1 even for // blank lines so the span exists in the map — the bug was the renderer // dropping it because `char_start (0) < char_end (0)` is false. let mut deco = DecorationMap::default(); deco.insert( 0, vec![StyledSpan { char_start: 0, char_end: 4, style: Style::default().fg(theme.text).bg(fenced_bg), full_line_bg: Some(fenced_bg), ..Default::default() }], ); deco.insert( 1, vec![StyledSpan { char_start: 0, char_end: 1, // blank line — max(line_len, 1) = 1 style: Style::default().fg(theme.text).bg(fenced_bg), full_line_bg: Some(fenced_bg), ..Default::default() }], ); let view = MarkdownView { lines: &["code".to_string(), "".to_string()], decoration_map: &deco, scroll_top: 0, cursor: (0, 0), // cursor on row 0 → accent cell at (GUTTER, 0) selection: None, theme: &theme, column_width: 20, show_line_numbers: false, search_matches: &[], search_current: 0, focus_mode_paragraph: None, }; view.render(area, &mut buf); // Every cell on visual row 1 (the blank line) must have fenced_bg. // Row 0 contains the cursor cell which legitimately has accent bg. for x in 0..area.width { let cell = buf.cell((x, 1)).expect("cell must exist"); assert_eq!( cell.bg, fenced_bg, "blank fenced line cell x={x} y=1 must have fenced_bg (regression #133)" ); } } #[test] fn heading_delimiter_fg_survives_split_into_spans() { use ratatui::style::Color; let green = Color::Rgb(166, 227, 161); let heading = Color::Rgb(203, 166, 247); let delim_span = StyledSpan { char_start: 0, char_end: 2, style: Style::default().fg(green), ..Default::default() }; let content_span = StyledSpan { char_start: 2, char_end: 7, style: Style::default().fg(heading), ..Default::default() }; let segments = split_into_spans("# Hello", &[delim_span, content_span], Style::default()); let hash_span = segments .iter() .find(|s| s.content.starts_with('#')) .expect("must have a span starting with #"); assert_eq!( hash_span.style.fg, Some(green), "# character must carry green fg through split_into_spans" ); } // ── apply_selection_overlay ────────────────────────────────────────────── // // Tests operate directly on a ratatui Buffer (no terminal required). // GUTTER = 1, so for area.x=0 the content column starts at x=1. // char N on a line maps to buffer x = area.x + GUTTER + N = 1 + N. fn sel_view<'a>( lines: &'a [String], deco: &'a DecorationMap, theme: &'a Theme, ) -> MarkdownView<'a> { MarkdownView { lines, decoration_map: deco, scroll_top: 0, cursor: (0, 0), selection: None, theme, column_width: 10, show_line_numbers: false, search_matches: &[], search_current: 0, focus_mode_paragraph: None, } } #[test] fn selection_overlay_single_row_highlights_correct_cells() { // Selection: row 0, chars 1..3 (exclusive). // content_width = 10 - 2*GUTTER = 8. Lines fit without wrapping. // Expected highlighted buffer cells: x=2, x=3 (= GUTTER + char_offset). use ratatui::buffer::Buffer; let area = Rect { x: 0, y: 0, width: 10, height: 3, }; let mut buf = Buffer::empty(area); let theme = Theme::default_theme(); let deco = DecorationMap::default(); let lines: Vec = vec!["abcde".into(), "fghij".into(), "klmno".into()]; let view = sel_view(&lines, &deco, &theme); apply_selection_overlay(area, &mut buf, &view, ((0, 1), (0, 3))); let sel_bg = theme.selection_bg; assert_ne!( buf.cell((1, 0)).unwrap().bg, sel_bg, "char 0 before selection" ); assert_eq!(buf.cell((2, 0)).unwrap().bg, sel_bg, "char 1 selected"); assert_eq!(buf.cell((3, 0)).unwrap().bg, sel_bg, "char 2 selected"); assert_ne!( buf.cell((4, 0)).unwrap().bg, sel_bg, "char 3 after selection" ); assert_ne!(buf.cell((1, 1)).unwrap().bg, sel_bg, "row 1 not selected"); } #[test] fn selection_overlay_multi_row_highlights_correct_cells() { // Selection: row 0 col 2 to row 1 col 2. // Row 0: chars 2..4 → x=3, x=4 // Row 1: chars 0..2 → x=1, x=2 // Row 2: no highlight use ratatui::buffer::Buffer; let area = Rect { x: 0, y: 0, width: 10, height: 4, }; let mut buf = Buffer::empty(area); let theme = Theme::default_theme(); let deco = DecorationMap::default(); let lines: Vec = vec!["aaaa".into(), "bbbb".into(), "cccc".into()]; let view = sel_view(&lines, &deco, &theme); apply_selection_overlay(area, &mut buf, &view, ((0, 2), (1, 2))); let sel_bg = theme.selection_bg; // Row 0: only chars 2 and 3 highlighted assert_ne!(buf.cell((1, 0)).unwrap().bg, sel_bg, "row 0 char 0"); assert_ne!(buf.cell((2, 0)).unwrap().bg, sel_bg, "row 0 char 1"); assert_eq!(buf.cell((3, 0)).unwrap().bg, sel_bg, "row 0 char 2"); assert_eq!(buf.cell((4, 0)).unwrap().bg, sel_bg, "row 0 char 3"); assert_ne!(buf.cell((5, 0)).unwrap().bg, sel_bg, "row 0 beyond line"); // Row 1: chars 0 and 1 highlighted assert_eq!(buf.cell((1, 1)).unwrap().bg, sel_bg, "row 1 char 0"); assert_eq!(buf.cell((2, 1)).unwrap().bg, sel_bg, "row 1 char 1"); assert_ne!( buf.cell((3, 1)).unwrap().bg, sel_bg, "row 1 char 2 not selected" ); // Row 2: no highlight assert_ne!(buf.cell((1, 2)).unwrap().bg, sel_bg, "row 2 not selected"); } #[test] fn selection_overlay_middle_row_fully_highlighted() { // 3-row selection: the middle row has neither a start-col nor end-col // constraint — it should be highlighted from char 0 to line end. use ratatui::buffer::Buffer; let area = Rect { x: 0, y: 0, width: 10, height: 4, }; let mut buf = Buffer::empty(area); let theme = Theme::default_theme(); let deco = DecorationMap::default(); let lines: Vec = vec!["aaaa".into(), "bbbb".into(), "cccc".into()]; let view = sel_view(&lines, &deco, &theme); // Select row 0 col 1 → row 2 col 1. Row 1 is the unconstrained middle. apply_selection_overlay(area, &mut buf, &view, ((0, 1), (2, 1))); let sel_bg = theme.selection_bg; // Row 1 (middle): chars 0..4 all highlighted assert_eq!(buf.cell((1, 1)).unwrap().bg, sel_bg, "middle row char 0"); assert_eq!(buf.cell((2, 1)).unwrap().bg, sel_bg, "middle row char 1"); assert_eq!(buf.cell((3, 1)).unwrap().bg, sel_bg, "middle row char 2"); assert_eq!(buf.cell((4, 1)).unwrap().bg, sel_bg, "middle row char 3"); // Row 2: only char 0 highlighted (col_end = 1) assert_eq!(buf.cell((1, 2)).unwrap().bg, sel_bg, "last row char 0"); assert_ne!( buf.cell((2, 2)).unwrap().bg, sel_bg, "last row char 1 excluded" ); } #[test] fn selection_overlay_row_after_sel_end_not_highlighted() { // log_row > sel_row_end → the loop must break and leave that row clean. use ratatui::buffer::Buffer; let area = Rect { x: 0, y: 0, width: 10, height: 3, }; let mut buf = Buffer::empty(area); let theme = Theme::default_theme(); let deco = DecorationMap::default(); let lines: Vec = vec!["aaaa".into(), "bbbb".into(), "cccc".into()]; let view = sel_view(&lines, &deco, &theme); // Selection is only on row 0. apply_selection_overlay(area, &mut buf, &view, ((0, 0), (0, 4))); let sel_bg = theme.selection_bg; // Row 1 (past sel_row_end) must not be touched. assert_ne!( buf.cell((1, 1)).unwrap().bg, sel_bg, "row after sel_row_end" ); assert_ne!( buf.cell((1, 2)).unwrap().bg, sel_bg, "two rows past sel_row_end" ); } #[test] fn selection_overlay_row_before_sel_start_not_highlighted() { // scroll_top=0, sel_row_start=1: row 0 must not be highlighted. use ratatui::buffer::Buffer; let area = Rect { x: 0, y: 0, width: 10, height: 3, }; let mut buf = Buffer::empty(area); let theme = Theme::default_theme(); let deco = DecorationMap::default(); let lines: Vec = vec!["aaaa".into(), "bbbb".into(), "cccc".into()]; let view = sel_view(&lines, &deco, &theme); // Selection starts on row 1. apply_selection_overlay(area, &mut buf, &view, ((1, 0), (1, 3))); let sel_bg = theme.selection_bg; // Row 0 (before sel_row_start) must not be touched. assert_ne!( buf.cell((1, 0)).unwrap().bg, sel_bg, "row before sel_row_start" ); // Row 1 IS selected. assert_eq!(buf.cell((1, 1)).unwrap().bg, sel_bg, "selected row char 0"); } #[test] fn selection_overlay_empty_selection_no_highlight() { // row_sel_start == row_sel_end → nothing should be highlighted. use ratatui::buffer::Buffer; let area = Rect { x: 0, y: 0, width: 10, height: 2, }; let mut buf = Buffer::empty(area); let theme = Theme::default_theme(); let deco = DecorationMap::default(); let lines: Vec = vec!["aaaa".into(), "bbbb".into()]; let view = sel_view(&lines, &deco, &theme); // Zero-width selection (col_start == col_end). apply_selection_overlay(area, &mut buf, &view, ((0, 2), (0, 2))); let sel_bg = theme.selection_bg; for x in 0..10u16 { assert_ne!( buf.cell((x, 0)).unwrap().bg, sel_bg, "zero-width selection must not highlight x={x}" ); } } // Kills: renderer/mod.rs:346:71 replace * with + in apply_selection_overlay. // "abcdefgh" exactly fills content_width (cw = column_width - 2*GUTTER = 10-2 = 8). // x_end = min(GUTTER + 8, GUTTER + cw) = min(9, 9) = 9 → cell x=8 IS highlighted. // With *→+ (cw=7): min(9, 8) = 8 → cell x=8 NOT highlighted. #[test] fn selection_overlay_content_width_last_char_included() { use ratatui::buffer::Buffer; let area = Rect { x: 0, y: 0, width: 10, height: 2, }; let mut buf = Buffer::empty(area); let theme = Theme::default_theme(); let deco = DecorationMap::default(); let lines: Vec = vec!["abcdefgh".into(), "x".into()]; // 8 chars = full cw let view = sel_view(&lines, &deco, &theme); apply_selection_overlay(area, &mut buf, &view, ((0, 0), (0, 8))); let sel_bg = theme.selection_bg; assert_eq!( buf.cell((8, 0)).unwrap().bg, sel_bg, "char 7 at x=8 must be highlighted (last char within content_width=8)" ); assert_ne!( buf.cell((9, 0)).unwrap().bg, sel_bg, "x=9 is past content_width and must not be highlighted" ); } // Combined test for wrap+continuation_indent correctness in apply_selection_overlay. // // Setup: column_width=8, GUTTER=1, cw=6. // "abcdefghij" (10 chars, no spaces) wraps to "abcdef" (chars 0-5) + "ghij" (chars 6-9). // Decoration: continuation_indent=2 on line 0. // Selection: full line (0..10). // // Sub-row 0 (wrap_idx=0): ci must be 0 (original: wrap_idx > 0 is false). // x_start=1+0+0=1, x_end=min(1+0+6, 1+6)=min(7,7)=7. Highlights x=1..6. // Sub-row 1 (wrap_idx=1): ci=2 (original: wrap_idx > 0 is true). // x_start=1+2+0=3, x_end=min(1+2+(10-6), 1+6)=min(7,7)=7. Highlights x=3..6. // // Kills: // 346:71 *→+ or *→/: cw=5 → sub-row 0 x_end clips at 6, cell (6,0) excluded. // 375:56 >→== / >→>=: ci applied to sub-row 0 → x_start=3, cell (1,0) excluded. // 375:56 >→<: no ci ever → sub-row 1 x_start=1, cell (1,1) highlighted (wrong). // 407:80 -→+: x_start=1+2+(6+6)=15, out of area → cell (3,1) not highlighted. // 408:97 -→+: x_end=min(1+2+(10+6),7)=7 (same); killed instead by (5,1) and (6,1). // 409:37 +→*: min clips 1 cell early → cell (6,0) not highlighted. // 409:46 +→*: same. #[test] fn selection_overlay_continuation_indent_only_on_wrapped_subrows() { use ratatui::buffer::Buffer; let area = Rect { x: 0, y: 0, width: 8, height: 3, }; let mut buf = Buffer::empty(area); let theme = Theme::default_theme(); let mut deco = DecorationMap::default(); deco.insert( 0, vec![StyledSpan { char_start: 0, char_end: 1, continuation_indent: 2, ..Default::default() }], ); let lines: Vec = vec!["abcdefghij".into(), "xx".into()]; let view = MarkdownView { lines: &lines, decoration_map: &deco, scroll_top: 0, cursor: (0, 0), selection: None, theme: &theme, column_width: 8, // cw = 8 - 2*1 = 6 show_line_numbers: false, search_matches: &[], search_current: 0, focus_mode_paragraph: None, }; apply_selection_overlay(area, &mut buf, &view, ((0, 0), (0, 10))); let sel_bg = theme.selection_bg; // Sub-row 0, first cell: ci must be 0 for wrap_idx=0. // With >→== or >→>=: ci=2 → x_start=3, cell (1,0) not highlighted. assert_eq!( buf.cell((1, 0)).unwrap().bg, sel_bg, "sub-row 0 char 0 (x=1) must be highlighted — no continuation indent on first sub-row" ); // Sub-row 0, last cell (char 5, x=GUTTER+5=6): must reach the full cw. // With 409 *→* mutations or 346 *→+: x_end clips 1 cell early → x=6 excluded. assert_eq!( buf.cell((6, 0)).unwrap().bg, sel_bg, "sub-row 0 char 5 (x=6) must be highlighted — last char fills content_width" ); // Sub-row 1, before continuation indent: must not be highlighted. // With >→<: ci never applied → x_start=1, cell (1,1) highlighted (wrong). assert_ne!( buf.cell((1, 1)).unwrap().bg, sel_bg, "sub-row 1 x=1 must not be highlighted (before continuation_indent=2)" ); // Sub-row 1, all four chars "ghij" at x=3..6 must be highlighted. // x_start=GUTTER+ci+0=3, x_end=min(GUTTER+ci+(10-6), GUTTER+cw)=min(7,7)=7. // With >→<: ci=0 → x_start=1, cell (1,1) highlighted instead of (3,1). // With 407:80 -→+: x_start=1+2+(6+6)=15, out of area → cell (3,1) not highlighted. assert_eq!( buf.cell((3, 1)).unwrap().bg, sel_bg, "sub-row 1 char 0 'g' (x=3 with ci=2) must be highlighted" ); assert_eq!( buf.cell((4, 1)).unwrap().bg, sel_bg, "sub-row 1 char 1 'h' (x=4) must be highlighted" ); assert_eq!( buf.cell((5, 1)).unwrap().bg, sel_bg, "sub-row 1 char 2 'i' (x=5) must be highlighted" ); assert_eq!( buf.cell((6, 1)).unwrap().bg, sel_bg, "sub-row 1 char 3 'j' (x=6) must be highlighted" ); // x=7 is one past the last char on sub-row 1 — must not be highlighted. assert_ne!( buf.cell((7, 1)).unwrap().bg, sel_bg, "sub-row 1 x=7 (one past char_len=4 with ci=2) must not be highlighted" ); } // Kills: renderer/mod.rs:407:41 replace + with - in apply_selection_overlay (x_start formula). // Selection starts partway into the second sub-row (char 7, within sub-row chars 6-9). // x_start = GUTTER + ci + (row_sel_start - char_start) = 1 + 2 + (7-6) = 4. // With +→-: x_start = 1 + 2 - 1 = 2 → cell (2,1) highlighted (wrong). // // Note: the existing selection_overlay_multi_row_highlights_correct_cells test already // catches this via u16 underflow when row_sel_start=2, char_start=0, ci=0 → 1-2 panics // in debug mode. This test provides an explicit non-overflow scenario. #[test] fn selection_overlay_subrow_partial_start_x_is_exact() { use ratatui::buffer::Buffer; let area = Rect { x: 0, y: 0, width: 8, height: 3, }; let mut buf = Buffer::empty(area); let theme = Theme::default_theme(); let mut deco = DecorationMap::default(); deco.insert( 0, vec![StyledSpan { char_start: 0, char_end: 1, continuation_indent: 2, ..Default::default() }], ); let lines: Vec = vec!["abcdefghij".into(), "xx".into()]; let view = MarkdownView { lines: &lines, decoration_map: &deco, scroll_top: 0, cursor: (0, 0), selection: None, theme: &theme, column_width: 8, show_line_numbers: false, search_matches: &[], search_current: 0, focus_mode_paragraph: None, }; // Selection: char 7 to end of line (within sub-row 1, chars 6-9). // sub-row 1: row_sel_start=max(7,6)=7. x_start = 1+2+(7-6) = 4. Highlights x=4. apply_selection_overlay(area, &mut buf, &view, ((0, 7), (0, 10))); let sel_bg = theme.selection_bg; // Sub-row 0 must be completely clean (selection col_start=7 > sub-row 0 char_end=6). assert_ne!( buf.cell((1, 0)).unwrap().bg, sel_bg, "sub-row 0 must not be highlighted when selection starts at char 7" ); // x=2 must NOT be highlighted (selection offset is 1, not -1). // With +→-: x_start = 1+2-(7-6) = 2 → cell (2,1) highlighted (wrong). assert_ne!( buf.cell((2, 1)).unwrap().bg, sel_bg, "x=2 must not be highlighted — start offset must add (not subtract) the intra-chunk offset" ); // First highlighted cell is x=4 = GUTTER + ci + (row_sel_start - char_start) = 1+2+1. assert_eq!( buf.cell((4, 1)).unwrap().bg, sel_bg, "x=4 must be highlighted — correct partial-start offset in wrapped sub-row" ); } // Kills: renderer/mod.rs:413 GUTTER+GUTTER → GUTTER-GUTTER (content_width=10 instead of 8). // Line has 9 chars; selection covers all of them. With correct cw=8 the x_end clamp // stops highlighting at x=9 (GUTTER + 8 = 9 is exclusive), so x=9 is NOT coloured. // With `+→-` (cw=10): x_end = min(GUTTER+9, GUTTER+10) = 10 → x=9 IS coloured. ✗ // With `+→*` (cw=GUTTER*GUTTER=1): x_end = min(10, 2) = 2 → x=8 NOT coloured. ✗ #[test] fn selection_overlay_content_width_clamps_selection_at_gutter_boundary() { use ratatui::buffer::Buffer; // column_width=10, cw=8. Line has 9 chars (one past cw). let area = Rect { x: 0, y: 0, width: 11, height: 2, }; let mut buf = Buffer::empty(area); let theme = Theme::default_theme(); let deco = DecorationMap::default(); let lines: Vec = vec!["abcdefghi".into(), "x".into()]; // 9 chars let view = MarkdownView { lines: &lines, decoration_map: &deco, scroll_top: 0, cursor: (0, 0), selection: None, theme: &theme, column_width: 10, // cw = 10 - (left_gutter+GUTTER) = 10-1-1 = 8 show_line_numbers: false, search_matches: &[], search_current: 0, focus_mode_paragraph: None, }; apply_selection_overlay(area, &mut buf, &view, ((0, 0), (0, 9))); let sel_bg = theme.selection_bg; // char 7 (x=8) is within content_width=8, must be highlighted. assert_eq!( buf.cell((8, 0)).unwrap().bg, sel_bg, "char 7 (x=8) is within content_width=8 and must be highlighted" ); // x=9 = GUTTER + content_width is the first column PAST content; must not be highlighted. // `+→-` widens cw to 10: x_end becomes 10, x=9 gets highlighted → test fails. ✓ assert_ne!( buf.cell((9, 0)).unwrap().bg, sel_bg, "x=9 is past content_width=8 and must not be highlighted" ); } // Kills: renderer/mod.rs `replace - with + in apply_selection_overlay` (end_dcols formula). // // `end_dcols = display_cols_for_chars(row_str, row_sel_end - char_start)` // On sub-row 0 char_start=0 so `-` and `+` produce the same result — the // mutation is only observable on a continuation sub-row where char_start > 0. // // "abcde fghij" at cw=8 wraps into: // sub-row 0: "abcde" char_start=0, char_len=5 // sub-row 1: "fghij" char_start=6, char_len=5 // // Selection: chars 7..9 on logical line 0 (two chars "gh" inside "fghij"): // row_sel_start = max(7, 6) = 7, row_sel_end = min(9, 11) = 9 // start_dcols = display_cols_for_chars("fghij", 7-6=1) = 1 → x_start = 1+0+1 = 2 // end_dcols = display_cols_for_chars("fghij", 9-6=3) = 3 → x_end = min(1+0+3,9) = 4 // Highlighted cells: x=2 and x=3 (y=1). // // With `- → +` mutation: // end_dcols = display_cols_for_chars("fghij", 9+6=15) = 5 → x_end = min(1+0+5,9) = 6 // Highlighted cells: x=2..5 — cell (4,1) and (5,1) are wrongly coloured. #[test] fn selection_overlay_continuation_row_end_dcols_uses_subtraction() { use ratatui::buffer::Buffer; let area = Rect { x: 0, y: 0, width: 10, height: 2, }; let mut buf = Buffer::empty(area); let theme = Theme::default_theme(); let deco = DecorationMap::default(); let lines: Vec = vec!["abcde fghij".into()]; // column_width=10, cw = 10-2 = 8. "abcde fghij" wraps at 8: ["abcde","fghij"]. let view = MarkdownView { lines: &lines, decoration_map: &deco, scroll_top: 0, cursor: (0, 0), selection: None, theme: &theme, column_width: 10, show_line_numbers: false, search_matches: &[], search_current: 0, focus_mode_paragraph: None, }; // Select chars 7..9 on line 0 — straddles within the second visual row. apply_selection_overlay(area, &mut buf, &view, ((0, 7), (0, 9))); let sel_bg = theme.selection_bg; // x=2 and x=3 must be highlighted (chars 7 and 8 of "fghij"). assert_eq!( buf.cell((2, 1)).unwrap().bg, sel_bg, "char 7 ('g') at x=2 must be selected" ); assert_eq!( buf.cell((3, 1)).unwrap().bg, sel_bg, "char 8 ('h') at x=3 must be selected" ); // x=4 is NOT selected (end_dcols=3 makes x_end=4 exclusive). // With `- → +`: end_dcols = display_cols("fghij", 9+6=15) = 5 → x_end=6 // → cells (4,1) and (5,1) would be wrongly coloured. assert_ne!( buf.cell((4, 1)).unwrap().bg, sel_bg, "char 9 at x=4 must NOT be selected (end_dcols must use row_sel_end - char_start)" ); } // Kills: renderer/mod.rs:425 `&&→||` and `log_row < total → <=`. // // The `log_row > sel_row_end { break; }` guard fires *before* the // out-of-bounds access — so to reach it we must set sel_row_end beyond // the last valid line index (= lines.len()). That way the break never // fires and the mutated loop tries view.lines[total] → index panic. // // With correct `&&`: `while 2 < 5 && 2 < 2` = false → clean exit. ✓ // With `&&→||`: `2 < 5 || 2 < 2` = true → view.lines[2] → panic. ✓ // With `<→<=` (log): `2 < 5 && 2 <= 2` = true → view.lines[2] → panic. ✓ // ── search_bar_height ──────────────────────────────────────────────────── #[test] fn search_bar_height_none_returns_zero() { let mut app = make_app(); app.search = None; assert_eq!(search_bar_height(&app), 0); } #[test] fn search_bar_height_search_only_returns_one() { let mut app = make_app(); app.search = Some(crate::search::SearchState::new(false)); assert_eq!(search_bar_height(&app), 1); } #[test] fn search_bar_height_with_replace_returns_two() { let mut app = make_app(); app.search = Some(crate::search::SearchState::new(true)); assert_eq!(search_bar_height(&app), 2); } // ── status bar builders ────────────────────────────────────────────────── #[test] fn build_normal_status_bar_has_four_spans() { let app = make_app(); let line = build_normal_status_bar(&app); assert_eq!( line.spans.len(), 4, "normal bar: pill | sep | hints | trailing-sep" ); } #[test] fn build_normal_status_bar_hints_span_contains_save_shortcut() { let app = make_app(); let line = build_normal_status_bar(&app); let hints = &line.spans[2]; assert!( hints.content.contains("^S Save"), "hints span must contain the Save shortcut" ); } #[test] fn build_normal_status_bar_pill_shows_filename() { let app = make_app(); let line = build_normal_status_bar(&app); let pill = &line.spans[0]; assert!( pill.content.contains("foo.md"), "pill must show the shortened path" ); } #[test] fn build_normal_status_bar_clean_pill_bg_is_text_colour() { let app = make_app(); let theme = app.theme.clone(); let line = build_normal_status_bar(&app); let pill = &line.spans[0]; assert_eq!( pill.style.bg, Some(theme.text), "clean file pill background must be theme.text" ); } #[test] fn build_normal_status_bar_dirty_pill_bg_is_accent_colour() { let mut app = make_app(); app.is_dirty = true; let theme = app.theme.clone(); let line = build_normal_status_bar(&app); let pill = &line.spans[0]; assert_eq!( pill.style.bg, Some(theme.accent), "dirty file pill background must be theme.accent" ); } #[test] fn build_normal_status_bar_dirty_pill_shows_dirty_marker() { let mut app = make_app(); app.is_dirty = true; let line = build_normal_status_bar(&app); let pill = &line.spans[0]; assert!( pill.content.contains("[*]"), "dirty pill must contain the [*] marker" ); } #[test] fn build_timed_message_bar_has_three_spans() { let app = make_app(); let line = build_timed_message_bar(&app, "Saved."); assert_eq!(line.spans.len(), 3, "timed bar: pill | sep | message"); } #[test] fn build_timed_message_bar_last_span_contains_message() { let app = make_app(); let line = build_timed_message_bar(&app, "File saved."); let msg = &line.spans[2]; assert!( msg.content.contains("File saved."), "last span must contain the message text" ); } #[test] fn build_timed_message_bar_pill_still_shows_path() { let app = make_app(); let line = build_timed_message_bar(&app, "Saved."); let pill = &line.spans[0]; assert!( pill.content.contains("foo.md"), "pill must still show the path during a timed message" ); } #[test] fn build_goto_line_bar_has_one_span() { let app = make_app(); let line = build_goto_line_bar(&app, "42"); assert_eq!(line.spans.len(), 1, "goto-line bar is a single span"); } #[test] fn build_goto_line_bar_span_contains_prompt_and_input() { let app = make_app(); let line = build_goto_line_bar(&app, "99"); let span = &line.spans[0]; assert!( span.content.contains("Go to line:"), "span must contain the 'Go to line:' prompt" ); assert!( span.content.contains("99"), "span must contain the user's input" ); } #[test] fn build_goto_line_bar_empty_input_shows_cursor_only() { let app = make_app(); let line = build_goto_line_bar(&app, ""); let span = &line.spans[0]; assert!( span.content.contains("Go to line:"), "prompt present even with empty input" ); assert!( span.content.contains('_'), "cursor indicator must be present" ); } #[test] fn selection_overlay_loop_stops_at_document_end() { use ratatui::buffer::Buffer; // 2-line document in a 5-row viewport — total(2) < visible(5). let area = Rect { x: 0, y: 0, width: 10, height: 5, }; let mut buf = Buffer::empty(area); let theme = Theme::default_theme(); let deco = DecorationMap::default(); let lines: Vec = vec!["abcd".into(), "efgh".into()]; let view = sel_view(&lines, &deco, &theme); // sel_row_end = lines.len() (= 2, one past the last valid index). // This prevents the `log_row > sel_row_end` early-break from saving // the mutated code when log_row reaches total=2 and tries to access // view.lines[2] out of bounds. apply_selection_overlay(area, &mut buf, &view, ((0, 0), (lines.len(), 0))); let sel_bg = theme.selection_bg; // Both lines selected — spot-check a cell on each. assert_eq!(buf.cell((1, 0)).unwrap().bg, sel_bg, "row 0 selected"); assert_eq!(buf.cell((1, 1)).unwrap().bg, sel_bg, "row 1 selected"); // Rows 2-4 are past the document and must not be touched. for y in 2..5u16 { assert_ne!( buf.cell((1, y)).unwrap().bg, sel_bg, "row {y} is past document end and must not be highlighted" ); } } }