Yet Another Markdown Editor! TUI for READMEs and friends.
editor markdown readme development-tool
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687use 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<usize> = 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<StyledSpan>>) -> 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<StyledSpan> = 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<usize, Vec<(usize, usize, bool)>> = 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<String> = 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<String> = 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<String> = 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<String> = 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<String> = 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<String> = 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<String> = 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<String> = 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<String> = 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<String> = 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<String> = 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<String> = 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" ); } }}