use std::path::Path;
use ratatui::style::Style;
use ratatui::text::Span;
use crate::config::Theme;
use crate::markdown::table_layout::preferred_cut;
#[derive(Debug, Clone, Copy)]
pub(super) struct StyledChar {
pub(super) ch: char,
pub(super) style: Style,
}
pub(super) fn is_soft_break_space(ch: char) -> bool {
ch.is_whitespace() && ch != '\u{00A0}'
}
fn tokenize_styled(chars: &[StyledChar]) -> Vec<Vec<StyledChar>> {
let mut tokens: Vec<Vec<StyledChar>> = Vec::new();
let mut tok: Vec<StyledChar> = Vec::new();
let mut in_ws = true;
for c in chars {
if is_soft_break_space(c.ch) {
if !in_ws && !tok.is_empty() {
tokens.push(std::mem::take(&mut tok));
}
tok.push(*c);
in_ws = true;
} else {
tok.push(*c);
in_ws = false;
}
}
if !tok.is_empty() {
tokens.push(tok);
}
tokens
}
pub(super) fn wrap_styled_chars(chars: &[StyledChar], width: usize) -> Vec<Vec<StyledChar>> {
if width == 0 {
return vec![chars.to_vec()];
}
if chars.is_empty() {
return vec![Vec::new()];
}
let tokens = tokenize_styled(chars);
let mut rows: Vec<Vec<StyledChar>> = Vec::new();
let mut current: Vec<StyledChar> = Vec::new();
let mut current_w = 0usize;
for token in tokens {
let w = token.len();
if current.is_empty() {
if w <= width {
current.extend(&token);
current_w = w;
} else {
for chunk in hard_split_styled(&token, width) {
rows.push(chunk);
}
current.clear();
current_w = 0;
}
} else if current_w + w <= width {
current.extend(&token);
current_w += w;
} else {
rows.push(std::mem::take(&mut current));
let trimmed: Vec<StyledChar> = token
.iter()
.skip_while(|c| is_soft_break_space(c.ch))
.copied()
.collect();
let tw = trimmed.len();
if tw <= width {
current.extend(&trimmed);
current_w = tw;
} else {
for chunk in hard_split_styled(&trimmed, width) {
rows.push(chunk);
}
current_w = 0;
}
}
}
if !current.is_empty() {
rows.push(current);
}
if rows.is_empty() {
rows.push(Vec::new());
}
rows
}
fn hard_split_styled(token: &[StyledChar], width: usize) -> Vec<Vec<StyledChar>> {
if width == 0 || token.is_empty() {
return vec![token.to_vec()];
}
let mut rows = Vec::new();
let mut rest = token;
while rest.len() > width {
let cut = preferred_cut(width, |i| rest[i].ch);
rows.push(rest[..cut].to_vec());
rest = &rest[cut..];
}
rows.push(rest.to_vec());
rows
}
pub(super) fn extend_with_styled_chars(out: &mut Vec<Span<'static>>, chars: &[StyledChar]) {
if chars.is_empty() {
return;
}
let mut current_style = chars[0].style;
let mut buf = String::new();
for c in chars {
if c.style != current_style {
if !buf.is_empty() {
out.push(Span::styled(std::mem::take(&mut buf), current_style));
}
current_style = c.style;
}
buf.push(c.ch);
}
if !buf.is_empty() {
out.push(Span::styled(buf, current_style));
}
}
pub(super) fn truncate_to_width(text: &str, width: usize) -> String {
text.chars().take(width).collect()
}
pub(super) fn link_fallback(url: &str) -> String {
if has_url_scheme(url) || url.starts_with('#') {
return url.to_string();
}
Path::new(url)
.file_name()
.and_then(|s| s.to_str())
.map(str::to_string)
.unwrap_or_else(|| url.to_string())
}
pub(super) fn link_style_for(url: &str, theme: &Theme) -> Style {
if url.starts_with('#') {
theme.link_heading
} else if has_url_scheme(url) {
theme.link_text
} else {
theme.link_file
}
}
fn has_url_scheme(url: &str) -> bool {
let Some((scheme, _)) = url.split_once(':') else {
return false;
};
!scheme.is_empty()
&& scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
}