use crate::parser::output::Alignment;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
const HALFWIDTH_KATAKANA_VOICED_SOUND_MARK: char = '\u{FF9E}';
const HALFWIDTH_KATAKANA_SEMI_VOICED_SOUND_MARK: char = '\u{FF9F}';
pub fn terminal_width(text: &str) -> usize {
text.width()
+ text
.chars()
.filter(|c| {
matches!(
*c,
HALFWIDTH_KATAKANA_VOICED_SOUND_MARK
| HALFWIDTH_KATAKANA_SEMI_VOICED_SOUND_MARK
)
})
.count()
}
fn terminal_char_width(c: char) -> usize {
if matches!(
c,
HALFWIDTH_KATAKANA_VOICED_SOUND_MARK | HALFWIDTH_KATAKANA_SEMI_VOICED_SOUND_MARK
) {
1
} else {
c.width().unwrap_or(1)
}
}
pub fn popup_area(
area: Rect,
percent_x: u16,
percent_y: u16,
min_width: u16,
min_height: u16,
) -> Rect {
let pct_width = area.width * percent_x / 100;
let pct_height = area.height * percent_y / 100;
let width = pct_width.max(min_width).min(area.width);
let height = pct_height.max(min_height).min(area.height);
let x = area.x + (area.width.saturating_sub(width)) / 2;
let y = area.y + (area.height.saturating_sub(height)) / 2;
Rect::new(x, y, width, height)
}
pub fn detect_checkbox_in_text(text: &str) -> (bool, bool, &str) {
let trimmed = text.trim_start();
if let Some(stripped) = trimmed
.strip_prefix("[x]")
.or_else(|| trimmed.strip_prefix("[X]"))
{
return (true, true, stripped.trim_start());
}
if let Some(stripped) = trimmed.strip_prefix("[ ]") {
return (true, false, stripped.trim_start());
}
(false, false, text)
}
pub fn align_text(text: &str, width: usize, alignment: &Alignment) -> String {
let text_width = terminal_width(text);
if text_width >= width {
if width > 3 {
let available = width.saturating_sub(2); let mut truncated = String::new();
let mut current_width = 0;
for c in text.chars() {
let char_width = terminal_char_width(c);
if current_width + char_width > available {
break;
}
truncated.push(c);
current_width += char_width;
}
let remaining = width.saturating_sub(current_width + 2); return format!(" {}…{}", truncated, " ".repeat(remaining));
}
let mut truncated = String::new();
let mut current_width = 0;
for c in text.chars() {
let char_width = terminal_char_width(c);
if current_width + char_width > width {
break;
}
truncated.push(c);
current_width += char_width;
}
return truncated;
}
let content_width = width;
match alignment {
Alignment::Left | Alignment::None => {
let right_padding = content_width.saturating_sub(text_width + 1);
format!(" {}{}", text, " ".repeat(right_padding))
}
Alignment::Center => {
let total_padding = content_width.saturating_sub(text_width);
let left_pad = total_padding / 2;
let right_pad = total_padding - left_pad;
format!("{}{}{}", " ".repeat(left_pad), text, " ".repeat(right_pad))
}
Alignment::Right => {
let left_padding = content_width.saturating_sub(text_width + 1);
format!("{}{} ", " ".repeat(left_padding), text)
}
}
}
pub fn highlight_search_matches(
text: &str,
query: &str,
base_style: Style,
highlight_style: Style,
) -> Vec<Span<'static>> {
if query.is_empty() {
return vec![Span::styled(text.to_string(), base_style)];
}
let text_lower = text.to_lowercase();
let query_lower = query.to_lowercase();
let mut spans = Vec::new();
let mut last_end = 0;
let mut search_start = 0;
while let Some(rel_pos) = text_lower[search_start..].find(&query_lower) {
let match_start = search_start + rel_pos;
let match_end = match_start + query.len();
if !text.is_char_boundary(match_start) || !text.is_char_boundary(match_end) {
search_start = match_start + 1;
continue;
}
if match_start > last_end {
spans.push(Span::styled(
text[last_end..match_start].to_string(),
base_style,
));
}
spans.push(Span::styled(
text[match_start..match_end].to_string(),
highlight_style,
));
last_end = match_end;
search_start = match_end;
if search_start >= text.len() {
break;
}
}
if last_end < text.len() {
spans.push(Span::styled(text[last_end..].to_string(), base_style));
}
if spans.is_empty() {
spans.push(Span::styled(text.to_string(), base_style));
}
spans
}
pub fn build_highlighted_line(
prefix: Vec<Span<'static>>,
text: &str,
query: Option<&str>,
base_style: Style,
highlight_style: Style,
) -> Line<'static> {
let mut spans = prefix;
if let Some(q) = query {
spans.extend(highlight_search_matches(
text,
q,
base_style,
highlight_style,
));
} else {
spans.push(Span::styled(text.to_string(), base_style));
}
Line::from(spans)
}
pub fn strip_frontmatter(content: &str) -> String {
let trimmed = content.trim_start();
if !trimmed.starts_with("---") {
return content.to_string();
}
if let Some(rest) = trimmed.strip_prefix("---") {
if let Some(end_pos) = rest.find("\n---") {
let after_close = &rest[end_pos + 4..];
let result = after_close.strip_prefix('\n').unwrap_or(after_close);
return result.to_string();
}
}
content.to_string()
}
pub fn strip_latex(content: &str) -> String {
use regex::{Captures, Regex};
use std::sync::OnceLock;
static SYMBOL_PATTERNS: OnceLock<Vec<(Regex, String)>> = OnceLock::new();
let symbol_patterns = SYMBOL_PATTERNS.get_or_init(|| {
let symbols: &[(&str, &str)] = &[
(r"\\alpha", "α"),
(r"\\beta", "β"),
(r"\\gamma", "γ"),
(r"\\delta", "δ"),
(r"\\epsilon", "ε"),
(r"\\zeta", "ζ"),
(r"\\eta", "η"),
(r"\\theta", "θ"),
(r"\\iota", "ι"),
(r"\\kappa", "κ"),
(r"\\lambda", "λ"),
(r"\\mu", "μ"),
(r"\\nu", "ν"),
(r"\\xi", "ξ"),
(r"\\pi", "π"),
(r"\\rho", "ρ"),
(r"\\sigma", "σ"),
(r"\\tau", "τ"),
(r"\\upsilon", "υ"),
(r"\\phi", "φ"),
(r"\\chi", "χ"),
(r"\\psi", "ψ"),
(r"\\omega", "ω"),
(r"\\Gamma", "Γ"),
(r"\\Delta", "Δ"),
(r"\\Theta", "Θ"),
(r"\\Lambda", "Λ"),
(r"\\Xi", "Ξ"),
(r"\\Pi", "Π"),
(r"\\Sigma", "Σ"),
(r"\\Phi", "Φ"),
(r"\\Psi", "Ψ"),
(r"\\Omega", "Ω"),
(r"\\sum", "∑"),
(r"\\prod", "∏"),
(r"\\int", "∫"),
(r"\\infty", "∞"),
(r"\\approx", "≈"),
(r"\\neq", "≠"),
(r"\\le", "≤"),
(r"\\ge", "≥"),
(r"\\pm", "±"),
(r"\\times", "×"),
(r"\\div", "÷"),
(r"\\partial", "∂"),
(r"\\nabla", "∇"),
(r"\\forall", "∀"),
(r"\\exists", "∃"),
(r"\\in", "∈"),
(r"\\notin", "∉"),
(r"\\subset", "⊂"),
(r"\\supset", "⊃"),
(r"\\cup", "∪"),
(r"\\cap", "∩"),
(r"\\Rightarrow", "⇒"),
(r"\\rightarrow", "→"),
(r"\\Leftarrow", "⇐"),
(r"\\leftarrow", "←"),
(r"\\Leftrightarrow", "⇔"),
(r"\\leftrightarrow", "↔"),
(r"\\cdot", "·"),
(r"\\dots", "…"),
];
symbols
.iter()
.map(|(pattern, replacement)| {
let re = Regex::new(&format!(r"{}([^a-zA-Z]|$)", pattern)).unwrap();
let repl = format!("{}$1", replacement);
(re, repl)
})
.collect()
});
static SUPERSCRIPT: OnceLock<Regex> = OnceLock::new();
static SUBSCRIPT: OnceLock<Regex> = OnceLock::new();
static DISPLAY_MATH: OnceLock<Regex> = OnceLock::new();
static INLINE_MATH: OnceLock<Regex> = OnceLock::new();
static PAREN_MATH: OnceLock<Regex> = OnceLock::new();
static BRACKET_MATH: OnceLock<Regex> = OnceLock::new();
static LATEX_ENV: OnceLock<Regex> = OnceLock::new();
static LATEX_ENV_INLINE: OnceLock<Regex> = OnceLock::new();
static BEGIN_END_STANDALONE: OnceLock<Regex> = OnceLock::new();
static FONT_SIZE_CMD: OnceLock<Regex> = OnceLock::new();
static STANDALONE_CMD: OnceLock<Regex> = OnceLock::new();
static CMD_WITH_ARGS_LINE: OnceLock<Regex> = OnceLock::new();
static CMD_WITH_ARGS_INLINE_STRIP: OnceLock<Regex> = OnceLock::new();
static FONTSIZE_INLINE: OnceLock<Regex> = OnceLock::new();
static CMD_WITH_ARGS_INLINE: OnceLock<Regex> = OnceLock::new();
static TEXT_FORMATTING: OnceLock<Regex> = OnceLock::new();
static TEXTCOLOR: OnceLock<Regex> = OnceLock::new();
static COLORBOX: OnceLock<Regex> = OnceLock::new();
static FONT_SIZE_INLINE: OnceLock<Regex> = OnceLock::new();
static BARE_CMD: OnceLock<Regex> = OnceLock::new();
static BARE_CMD_EOL: OnceLock<Regex> = OnceLock::new();
static MULTI_SPACE: OnceLock<Regex> = OnceLock::new();
let superscript = SUPERSCRIPT.get_or_init(|| Regex::new(r"\^\{?([0-9+\-=()nix])\}?").unwrap());
let subscript = SUBSCRIPT.get_or_init(|| {
Regex::new(
r"_(?:\{([0-9+\-=()aehijklmnoprstuvx])\}|([0-9+\-=()aehijklmnoprstuvx])([^a-zA-Z_]|$))",
)
.unwrap()
});
let display_math = DISPLAY_MATH.get_or_init(|| Regex::new(r"\$\$[\s\S]*?\$\$").unwrap());
let inline_math = INLINE_MATH.get_or_init(|| Regex::new(r"\$([^\$\n]+)\$").unwrap());
let paren_math = PAREN_MATH.get_or_init(|| Regex::new(r"\\\(([\s\S]*?)\\\)").unwrap());
let bracket_math = BRACKET_MATH.get_or_init(|| Regex::new(r"\\\[([\s\S]*?)\\\]").unwrap());
let latex_env = LATEX_ENV.get_or_init(|| {
Regex::new(r"(?s)^\s*\\begin\{[^}]+\}\s*(.*?)\s*\\end\{[^}]+\}\s*$").unwrap()
});
let latex_env_inline = LATEX_ENV_INLINE
.get_or_init(|| Regex::new(r"(?s)\\begin\{[^}]+\}(.*?)\\end\{[^}]+\}").unwrap());
let begin_end_standalone =
BEGIN_END_STANDALONE.get_or_init(|| Regex::new(r"\\(begin|end)\{[^}]*\}").unwrap());
let font_size_cmd = FONT_SIZE_CMD.get_or_init(|| Regex::new(
r"(?m)^\s*\\(tiny|scriptsize|footnotesize|small|normalsize|large|Large|LARGE|huge|Huge|HUGE|ssmall|miniscule)\s*$"
).unwrap());
let standalone_cmd = STANDALONE_CMD.get_or_init(|| Regex::new(
r"(?m)^\s*\\(newpage|clearpage|pagebreak|tableofcontents|maketitle|listoffigures|listoftables|appendix|frontmatter|mainmatter|backmatter|centering|raggedright|raggedleft|noindent|indent|par|bigskip|medskip|smallskip|vfill|hfill|newline|linebreak)\s*$"
).unwrap());
let cmd_with_args_line = CMD_WITH_ARGS_LINE.get_or_init(|| Regex::new(
r"(?m)^\s*\\(usepackage|documentclass|title|author|date|include|input|bibliography|bibliographystyle|setlength|renewcommand|newcommand|setcounter|addtocounter|pagenumbering|pagestyle|thispagestyle|geometry|hypersetup|definecolor|graphicspath|addbibresource|fontsize|sethlcolor|titlespacing|titleformat|captionsetup|lstset)(\[[^\]]*\])?(\{[^}]*\})+\s*$"
).unwrap());
let cmd_with_args_inline_strip = CMD_WITH_ARGS_INLINE_STRIP.get_or_init(|| Regex::new(
r"\\(usepackage|documentclass|setlength|renewcommand|newcommand|setcounter|addtocounter|pagenumbering|pagestyle|thispagestyle|geometry|hypersetup|definecolor|graphicspath|addbibresource|sethlcolor|titlespacing|titleformat|captionsetup|lstset)(\[[^\]]*\])?(\{[^}]*\})+"
).unwrap());
let fontsize_inline =
FONTSIZE_INLINE.get_or_init(|| Regex::new(r"\\fontsize\{[^}]*\}\{[^}]*\}").unwrap());
let cmd_with_args_inline = CMD_WITH_ARGS_INLINE.get_or_init(|| {
Regex::new(
r"\\(label|ref|cite|eqref|pageref|vspace|hspace|phantom|hphantom|vphantom)\{[^}]*\}",
)
.unwrap()
});
let text_formatting = TEXT_FORMATTING.get_or_init(|| {
Regex::new(r"\\(textbf|textit|emph|underline|texttt|hl|textsf|textsc|textsl)\{([^}]*)\}")
.unwrap()
});
let textcolor =
TEXTCOLOR.get_or_init(|| Regex::new(r"\\textcolor\{[^}]*\}\{([^}]*)\}").unwrap());
let colorbox = COLORBOX.get_or_init(|| Regex::new(r"\\colorbox\{[^}]*\}\{([^}]*)\}").unwrap());
let font_size_inline = FONT_SIZE_INLINE.get_or_init(|| Regex::new(
r"\\(tiny|scriptsize|footnotesize|small|normalsize|large|Large|LARGE|huge|Huge|HUGE|ssmall|miniscule)([^a-zA-Z]|$)"
).unwrap());
let bare_cmd = BARE_CMD.get_or_init(|| Regex::new(r"\\[a-zA-Z]+\$?([\s,;.!?\)\]\}])").unwrap());
let bare_cmd_eol = BARE_CMD_EOL.get_or_init(|| Regex::new(r"\\[a-zA-Z]+\$?$").unwrap());
fn to_superscript(c: char) -> Option<char> {
match c {
'0' => Some('⁰'),
'1' => Some('¹'),
'2' => Some('²'),
'3' => Some('³'),
'4' => Some('⁴'),
'5' => Some('⁵'),
'6' => Some('⁶'),
'7' => Some('⁷'),
'8' => Some('⁸'),
'9' => Some('⁹'),
'+' => Some('⁺'),
'-' => Some('⁻'),
'=' => Some('⁼'),
'(' => Some('⁽'),
')' => Some('⁾'),
'n' => Some('ⁿ'),
'i' => Some('ⁱ'),
'x' => Some('ˣ'),
_ => None,
}
}
fn to_subscript(c: char) -> Option<char> {
match c {
'0' => Some('₀'),
'1' => Some('₁'),
'2' => Some('₂'),
'3' => Some('₃'),
'4' => Some('₄'),
'5' => Some('₅'),
'6' => Some('₆'),
'7' => Some('₇'),
'8' => Some('₈'),
'9' => Some('₉'),
'+' => Some('₊'),
'-' => Some('₋'),
'=' => Some('₌'),
'(' => Some('₍'),
')' => Some('₎'),
'a' => Some('ₐ'),
'e' => Some('ₑ'),
'h' => Some('ₕ'),
'i' => Some('ᵢ'),
'j' => Some('ⱼ'),
'k' => Some('ₖ'),
'l' => Some('ₗ'),
'm' => Some('ₘ'),
'n' => Some('ₙ'),
'o' => Some('ₒ'),
'p' => Some('ₚ'),
'r' => Some('ᵣ'),
's' => Some('ₛ'),
't' => Some('ₜ'),
'u' => Some('ᵤ'),
'v' => Some('ᵥ'),
'x' => Some('ₓ'),
_ => None,
}
}
let mut result = content.to_string();
static CODE_FENCE: OnceLock<Regex> = OnceLock::new();
static CODE_SPAN_DOUBLE: OnceLock<Regex> = OnceLock::new();
static CODE_SPAN_SINGLE: OnceLock<Regex> = OnceLock::new();
let code_fence = CODE_FENCE.get_or_init(|| Regex::new(r"(?s)```[^\n]*\n.*?```").unwrap());
let code_span_double = CODE_SPAN_DOUBLE.get_or_init(|| Regex::new(r"``(.+?)``").unwrap());
let code_span_single = CODE_SPAN_SINGLE.get_or_init(|| Regex::new(r"`([^`]+)`").unwrap());
let mut code_placeholders: Vec<String> = Vec::new();
result = code_fence
.replace_all(&result, |caps: &Captures| {
let idx = code_placeholders.len();
code_placeholders.push(caps[0].to_string());
format!("\x00CODE{idx}\x00")
})
.to_string();
result = code_span_double
.replace_all(&result, |caps: &Captures| {
let idx = code_placeholders.len();
code_placeholders.push(caps[0].to_string());
format!("\x00CODE{idx}\x00")
})
.to_string();
result = code_span_single
.replace_all(&result, |caps: &Captures| {
let idx = code_placeholders.len();
code_placeholders.push(caps[0].to_string());
format!("\x00CODE{idx}\x00")
})
.to_string();
for (re, replacement) in symbol_patterns {
result = re.replace_all(&result, replacement.as_str()).to_string();
}
result = superscript
.replace_all(&result, |caps: &Captures| {
let val = caps[1].chars().next().unwrap();
to_superscript(val)
.map(|v| v.to_string())
.unwrap_or_else(|| caps[0].to_string())
})
.to_string();
result = subscript
.replace_all(&result, |caps: &Captures| {
let (val, trailing) = if let Some(m) = caps.get(1) {
(m.as_str().chars().next().unwrap(), "")
} else {
(
caps[2].chars().next().unwrap(),
caps.get(3).map_or("", |m| m.as_str()),
)
};
to_subscript(val)
.map(|v| format!("{v}{trailing}"))
.unwrap_or_else(|| caps[0].to_string())
})
.to_string();
result = display_math.replace_all(&result, "").to_string();
result = inline_math.replace_all(&result, "$1").to_string();
result = paren_math.replace_all(&result, "$1").to_string();
result = bracket_math.replace_all(&result, "$1").to_string();
result = latex_env.replace_all(&result, "$1").to_string();
result = latex_env_inline.replace_all(&result, "$1").to_string();
result = begin_end_standalone.replace_all(&result, "").to_string();
result = font_size_cmd.replace_all(&result, "").to_string();
result = standalone_cmd.replace_all(&result, "").to_string();
result = cmd_with_args_line.replace_all(&result, "").to_string();
result = cmd_with_args_inline_strip
.replace_all(&result, "")
.to_string();
result = fontsize_inline.replace_all(&result, "").to_string();
result = cmd_with_args_inline.replace_all(&result, "").to_string();
result = text_formatting.replace_all(&result, "$2").to_string();
result = textcolor.replace_all(&result, "$1").to_string();
result = colorbox.replace_all(&result, "$1").to_string();
result = font_size_inline.replace_all(&result, "$2").to_string();
result = bare_cmd.replace_all(&result, "$1").to_string();
result = bare_cmd_eol.replace_all(&result, "").to_string();
let multi_space = MULTI_SPACE.get_or_init(|| Regex::new(r" +").unwrap());
result = multi_space.replace_all(&result, " ").to_string();
for (idx, original) in code_placeholders.iter().enumerate() {
result = result.replace(&format!("\x00CODE{idx}\x00"), original);
}
result
}
pub fn strip_latex_aggressive(content: &str) -> String {
use regex::Regex;
use std::sync::OnceLock;
static BACKSLASH_LINE: OnceLock<Regex> = OnceLock::new();
let backslash_line =
BACKSLASH_LINE.get_or_init(|| Regex::new(r"(?m)^\s*\\[a-zA-Z].*$").unwrap());
backslash_line.replace_all(content, "").to_string()
}
pub fn wrap_text(text: &str, width: usize) -> Vec<String> {
if width == 0 {
return vec![text.to_string()];
}
let mut lines = Vec::new();
let mut current_line = String::new();
let mut current_width = 0;
for word in text.split_whitespace() {
let word_width = terminal_width(word);
if word_width > width {
if !current_line.is_empty() {
lines.push(std::mem::take(&mut current_line));
current_width = 0;
}
let mut remaining = word;
while !remaining.is_empty() {
let mut chunk = String::new();
let mut chunk_width = 0;
for c in remaining.chars() {
let c_width = terminal_char_width(c);
if chunk_width + c_width > width {
break;
}
chunk.push(c);
chunk_width += c_width;
}
if chunk.is_empty() {
break;
}
let chunk_len = chunk.len();
lines.push(chunk);
remaining = &remaining[chunk_len..];
}
continue;
}
let space_needed = if current_line.is_empty() { 0 } else { 1 };
if current_width + space_needed + word_width <= width {
if space_needed > 0 {
current_line.push(' ');
current_width += 1;
}
current_line.push_str(word);
current_width += word_width;
} else {
lines.push(std::mem::take(&mut current_line));
current_line = word.to_string();
current_width = word_width;
}
}
if !current_line.is_empty() {
lines.push(current_line);
}
if lines.is_empty() {
lines.push(if text.trim().is_empty() && !text.is_empty() {
text.to_string()
} else {
String::new()
});
}
lines
}
pub fn filter_content(
content: &str,
hide_frontmatter: bool,
hide_latex: bool,
latex_aggressive: bool,
) -> String {
let mut result = content.to_string();
if hide_frontmatter {
result = strip_frontmatter(&result);
}
if hide_latex {
result = strip_latex(&result);
if latex_aggressive {
result = strip_latex_aggressive(&result);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
mod strip_frontmatter_tests {
use super::*;
#[test]
fn test_simple_frontmatter() {
let content = "---\ntitle: Test\n---\n\n# Heading\n\nContent";
let result = strip_frontmatter(content);
assert_eq!(result, "\n# Heading\n\nContent");
}
#[test]
fn test_no_frontmatter() {
let content = "# Heading\n\nContent";
let result = strip_frontmatter(content);
assert_eq!(result, content);
}
#[test]
fn test_frontmatter_with_yaml() {
let content = "---\ntitle: My Doc\ntags:\n - rust\n - markdown\n---\n# Start";
let result = strip_frontmatter(content);
assert_eq!(result, "# Start");
}
#[test]
fn test_frontmatter_not_at_start() {
let content = "Some text\n---\ntitle: Test\n---\nMore text";
let result = strip_frontmatter(content);
assert_eq!(result, content); }
}
mod strip_latex_tests {
use super::*;
#[test]
fn test_inline_math() {
let content = "The formula $x^2$ is quadratic";
let result = strip_latex(content);
assert_eq!(result, "The formula x² is quadratic");
}
#[test]
fn test_display_math() {
let content = "The equation:\n$$\nE = mc^2\n$$\nis famous.";
let result = strip_latex(content);
assert_eq!(result, "The equation:\n\nis famous.");
}
#[test]
fn test_latex_environment() {
let content = "An equation:\n\\begin{equation}\ny = mx + b\n\\end{equation}\ndone.";
let result = strip_latex(content);
assert_eq!(result, "An equation:\n\ny = mx + b\n\ndone.");
}
#[test]
fn test_greek_letters() {
let content = "Angle $\\alpha$ and $\\beta$";
let result = strip_latex(content);
assert_eq!(result, "Angle α and β");
}
#[test]
fn test_math_symbols() {
let content = "$\\sum_{i=1}^n x_i \\approx \\int f(x) dx$";
let result = strip_latex(content);
assert!(result.contains("∑"));
assert!(result.contains("≈"));
assert!(result.contains("∫"));
}
#[test]
fn test_no_latex() {
let content = "Regular text without math";
let result = strip_latex(content);
assert_eq!(result, content);
}
#[test]
fn test_money_not_stripped() {
let content = "It costs $5";
let result = strip_latex(content);
assert_eq!(result, content);
}
#[test]
fn test_standalone_commands() {
let content = "Some text\n\\newpage\nMore text";
let result = strip_latex(content);
assert_eq!(result, "Some text\n\nMore text");
}
#[test]
fn test_clearpage() {
let content = "Chapter 1\n\\clearpage\nChapter 2";
let result = strip_latex(content);
assert_eq!(result, "Chapter 1\n\nChapter 2");
}
#[test]
fn test_usepackage() {
let content = "\\usepackage{amsmath}\nSome content";
let result = strip_latex(content);
assert_eq!(result, "\nSome content");
}
#[test]
fn test_text_formatting_preserved() {
let content = "This is \\textbf{bold} text";
let result = strip_latex(content);
assert_eq!(result, "This is bold text");
}
#[test]
fn test_font_size_normalsize() {
let content = "Some text\n\\normalsize\nMore text";
let result = strip_latex(content);
assert_eq!(result, "Some text\n\nMore text");
}
#[test]
fn test_font_size_large() {
let content = "\\Large\nHeading";
let result = strip_latex(content);
assert_eq!(result, "\nHeading");
}
#[test]
fn test_font_size_tiny() {
let content = "Normal\n\\tiny\nSmall text\n\\normalsize\nBack to normal";
let result = strip_latex(content);
assert_eq!(result, "Normal\n\nSmall text\n\nBack to normal");
}
#[test]
fn test_centering() {
let content = "\\centering\nCentered content";
let result = strip_latex(content);
assert_eq!(result, "\nCentered content");
}
#[test]
fn test_label_ref_stripped() {
let content = "See Figure \\ref{fig:example} for details.";
let result = strip_latex(content);
assert_eq!(result, "See Figure for details.");
}
#[test]
fn test_cite_stripped() {
let content = "As shown by \\cite{smith2020} in their work.";
let result = strip_latex(content);
assert_eq!(result, "As shown by in their work.");
}
#[test]
fn test_vspace_hspace_stripped() {
let content = "Text\\vspace{1em}More text\\hspace{2cm}End";
let result = strip_latex(content);
assert_eq!(result, "TextMore textEnd");
}
#[test]
fn test_geometry_stripped() {
let content = "\\geometry{margin=1in}\nDocument content";
let result = strip_latex(content);
assert_eq!(result, "\nDocument content");
}
#[test]
fn test_standalone_begin_end_stripped() {
let content = "Text \\begin{center} centered \\end{center} more";
let result = strip_latex(content);
assert_eq!(result, "Text centered more");
}
#[test]
fn test_unpaired_begin_stripped() {
let content = "Before \\begin{itemize} items";
let result = strip_latex(content);
assert_eq!(result, "Before items");
}
#[test]
fn test_inline_setlength_stripped() {
let content = "Text \\setlength{\\parindent}{0pt} more text";
let result = strip_latex(content);
assert!(result.contains("more text"));
assert!(!result.contains("setlength"));
}
#[test]
fn test_inline_fontsize_stripped() {
let content = "Normal \\fontsize{12}{14} text here";
let result = strip_latex(content);
assert!(!result.contains("fontsize"));
assert!(result.contains("text here"));
}
#[test]
fn test_bare_box_stripped() {
let content = "Check \\Box$ next";
let result = strip_latex(content);
assert!(!result.contains("\\Box"));
}
#[test]
fn test_bare_commands_stripped() {
let content = "Item \\no text \\yes more";
let result = strip_latex(content);
assert!(!result.contains("\\no"));
assert!(!result.contains("\\yes"));
}
#[test]
fn test_inline_pagestyle_stripped() {
let content = "Text \\pagestyle{fancy} more";
let result = strip_latex(content);
assert!(!result.contains("pagestyle"));
assert!(result.contains("more"));
}
#[test]
fn test_inline_thispagestyle_stripped() {
let content = "\\thispagestyle{empty} Content here";
let result = strip_latex(content);
assert!(!result.contains("thispagestyle"));
assert!(result.contains("Content here"));
}
#[test]
fn test_inline_usepackage_stripped() {
let content = "Load \\usepackage[utf8]{inputenc} text";
let result = strip_latex(content);
assert!(!result.contains("usepackage"));
}
#[test]
fn test_no_double_spaces_after_stripping() {
let content = "Text with \\fontsize{12}{14} commands and \\setlength{\\parskip}{1em} more text here.";
let result = strip_latex(content);
assert!(
!result.contains(" "),
"Double spaces found in: {:?}",
result
);
assert!(result.contains("Text with"));
assert!(result.contains("more text here."));
}
#[test]
fn test_code_spans_preserved_in_tables() {
let content =
"| `post_tweet` | Post a tweet |\n| `post_reddit` | Submit a Reddit post |";
let result = strip_latex(content);
assert!(
result.contains("`post_tweet`"),
"code span mangled: {result}"
);
assert!(
result.contains("`post_reddit`"),
"code span mangled: {result}"
);
assert!(
!result.contains("ₜ"),
"subscript leaked into code span: {result}"
);
assert!(
!result.contains("ᵣ"),
"subscript leaked into code span: {result}"
);
}
#[test]
fn test_code_spans_with_underscores_preserved() {
let content = "Use `my_variable` and `some_function` in code";
let result = strip_latex(content);
assert!(
result.contains("`my_variable`"),
"code span mangled: {result}"
);
assert!(
result.contains("`some_function`"),
"code span mangled: {result}"
);
}
#[test]
fn test_fenced_code_block_preserved() {
let content = "Text\n```rust\nlet x_n = 1;\n```\nMore text";
let result = strip_latex(content);
assert!(result.contains("x_n"), "fenced code mangled: {result}");
}
#[test]
fn test_subscript_preserves_snake_case() {
assert_eq!(strip_latex("x_1 + z_{i}th"), "x₁ + zᵢth");
assert_eq!(strip_latex("i_am_a_snake x_1"), "i_am_a_snake x₁");
}
}
mod strip_latex_aggressive_tests {
use super::*;
#[test]
fn test_aggressive_strips_any_backslash_line() {
let content = "Normal text\n\\unknowncommand\nMore text";
let result = strip_latex_aggressive(content);
assert_eq!(result, "Normal text\n\nMore text");
}
#[test]
fn test_aggressive_strips_with_args() {
let content = "\\customcmd{arg}\nContent here";
let result = strip_latex_aggressive(content);
assert_eq!(result, "\nContent here");
}
#[test]
fn test_aggressive_preserves_prose() {
let content = "Regular text without backslash commands";
let result = strip_latex_aggressive(content);
assert_eq!(result, content);
}
#[test]
fn test_aggressive_preserves_inline_backslash() {
let content = "Some text with \\command inline";
let result = strip_latex_aggressive(content);
assert_eq!(result, content);
}
}
mod detect_checkbox_tests {
use super::*;
#[test]
fn test_checked_lowercase() {
let (is_task, is_checked, text) = detect_checkbox_in_text("[x] Task done");
assert!(is_task);
assert!(is_checked);
assert_eq!(text, "Task done");
}
#[test]
fn test_checked_uppercase() {
let (is_task, is_checked, text) = detect_checkbox_in_text("[X] Also done");
assert!(is_task);
assert!(is_checked);
assert_eq!(text, "Also done");
}
#[test]
fn test_unchecked() {
let (is_task, is_checked, text) = detect_checkbox_in_text("[ ] Not done yet");
assert!(is_task);
assert!(!is_checked);
assert_eq!(text, "Not done yet");
}
#[test]
fn test_not_a_task() {
let (is_task, is_checked, text) = detect_checkbox_in_text("Regular text");
assert!(!is_task);
assert!(!is_checked);
assert_eq!(text, "Regular text");
}
#[test]
fn test_with_leading_whitespace() {
let (is_task, is_checked, text) = detect_checkbox_in_text(" [x] Indented task");
assert!(is_task);
assert!(is_checked);
assert_eq!(text, "Indented task");
}
#[test]
fn test_empty_task() {
let (is_task, is_checked, text) = detect_checkbox_in_text("[x]");
assert!(is_task);
assert!(is_checked);
assert_eq!(text, "");
}
#[test]
fn test_bracket_but_not_checkbox() {
let (is_task, is_checked, text) = detect_checkbox_in_text("[a] Not a checkbox");
assert!(!is_task);
assert!(!is_checked);
assert_eq!(text, "[a] Not a checkbox");
}
}
mod align_text_tests {
use super::*;
#[test]
fn test_halfwidth_katakana_sound_marks_match_terminal_width() {
assert_eq!(terminal_width("ガ"), 2);
assert_eq!(terminal_width("パ"), 2);
assert_eq!(terminal_width("a゙"), 2);
}
#[test]
fn test_left_align() {
let result = align_text("Hi", 10, &Alignment::Left);
assert_eq!(result, " Hi ");
assert_eq!(result.len(), 10);
}
#[test]
fn test_right_align() {
let result = align_text("Hi", 10, &Alignment::Right);
assert_eq!(result, " Hi ");
assert_eq!(result.len(), 10);
}
#[test]
fn test_center_align() {
let result = align_text("Hi", 10, &Alignment::Center);
assert_eq!(result, " Hi ");
assert_eq!(result.len(), 10);
}
#[test]
fn test_none_defaults_to_left() {
let result = align_text("Hi", 10, &Alignment::None);
assert_eq!(result, " Hi ");
}
#[test]
fn test_truncation_when_too_long() {
let result = align_text("This is a very long text", 10, &Alignment::Left);
assert!(result.contains("…"));
}
#[test]
fn test_exact_width() {
let result = align_text("Test", 6, &Alignment::Left);
assert_eq!(result, " Test ");
}
#[test]
fn test_unicode_width() {
let result = align_text("日本", 10, &Alignment::Left);
assert_eq!(terminal_width(&result), 10);
}
#[test]
fn test_align_halfwidth_katakana_sound_marks() {
let result = align_text("ガ", 6, &Alignment::Left);
assert_eq!(terminal_width(&result), 6);
}
#[test]
fn test_center_odd_padding() {
let result = align_text("A", 10, &Alignment::Center);
assert_eq!(result, " A ");
}
}
mod highlight_search_tests {
use super::*;
use ratatui::style::Color;
#[test]
fn test_no_match() {
let base = Style::default().fg(Color::White);
let highlight = Style::default().fg(Color::Yellow);
let spans = highlight_search_matches("Hello World", "xyz", base, highlight);
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].content.as_ref(), "Hello World");
}
#[test]
fn test_single_match() {
let base = Style::default().fg(Color::White);
let highlight = Style::default().fg(Color::Yellow);
let spans = highlight_search_matches("Hello World", "World", base, highlight);
assert_eq!(spans.len(), 2);
assert_eq!(spans[0].content.as_ref(), "Hello ");
assert_eq!(spans[1].content.as_ref(), "World");
assert_eq!(spans[1].style, highlight);
}
#[test]
fn test_case_insensitive() {
let base = Style::default().fg(Color::White);
let highlight = Style::default().fg(Color::Yellow);
let spans = highlight_search_matches("Hello World", "world", base, highlight);
assert_eq!(spans.len(), 2);
assert_eq!(spans[1].content.as_ref(), "World"); }
#[test]
fn test_multiple_matches() {
let base = Style::default().fg(Color::White);
let highlight = Style::default().fg(Color::Yellow);
let spans = highlight_search_matches("foo bar foo", "foo", base, highlight);
assert_eq!(spans.len(), 3);
assert_eq!(spans[0].content.as_ref(), "foo");
assert_eq!(spans[1].content.as_ref(), " bar ");
assert_eq!(spans[2].content.as_ref(), "foo");
}
#[test]
fn test_empty_query() {
let base = Style::default().fg(Color::White);
let highlight = Style::default().fg(Color::Yellow);
let spans = highlight_search_matches("Hello", "", base, highlight);
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].content.as_ref(), "Hello");
}
#[test]
fn test_match_at_start() {
let base = Style::default().fg(Color::White);
let highlight = Style::default().fg(Color::Yellow);
let spans = highlight_search_matches("Hello World", "Hello", base, highlight);
assert_eq!(spans.len(), 2);
assert_eq!(spans[0].content.as_ref(), "Hello");
assert_eq!(spans[0].style, highlight);
assert_eq!(spans[1].content.as_ref(), " World");
}
}
}