use ratatui::{
Frame,
prelude::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Clear, Paragraph, Wrap},
};
use unicode_width::UnicodeWidthStr;
use crate::i18n;
use crate::state::AppState;
use crate::theme::theme;
const MODAL_MIN_WIDTH: u16 = 40;
const MODAL_MAX_WIDTH_RATIO: u16 = 3;
const MODAL_MAX_WIDTH_DIVISOR: u16 = 4;
const MODAL_MIN_HEIGHT: u16 = 6;
const MODAL_MAX_HEIGHT: u16 = 25;
const MODAL_HEIGHT_PADDING: u16 = 4;
const BORDER_WIDTH: u16 = 2;
const HEADER_LINES: u16 = 2;
const FOOTER_LINES: u16 = 1;
const TOTAL_HEADER_FOOTER_LINES: u16 = HEADER_LINES + FOOTER_LINES;
const CONTENT_PADDING: u16 = 2;
const CONTENT_FOOTER_BUFFER: u16 = 2;
fn calculate_wrapped_lines(content: &str, available_width: u16) -> u16 {
if content.trim().is_empty() {
return 1;
}
let width = available_width.max(1) as usize;
let mut total_lines: u16 = 0;
for line in content.lines() {
if line.is_empty() {
total_lines = total_lines.saturating_add(1);
} else {
let line_width = line.width();
#[allow(clippy::cast_possible_truncation)]
let wrapped = line_width.div_ceil(width).max(1).min(u16::MAX as usize) as u16;
total_lines = total_lines.saturating_add(wrapped);
}
}
total_lines.max(1)
}
fn calculate_content_width(content: &str, max_width: u16) -> u16 {
let mut max_line_len = 0;
for line in content.lines() {
let cleaned = line
.replace("**", "")
.replace("## ", "")
.replace("### ", "")
.replace("# ", "");
let line_width = cleaned.trim().width();
#[allow(clippy::cast_possible_truncation)]
let line_len = line_width.min(u16::MAX as usize) as u16;
max_line_len = max_line_len.max(line_len);
}
max_line_len.min(max_width).max(MODAL_MIN_WIDTH)
}
fn calculate_modal_rect(area: Rect, content: &str, app: &crate::state::AppState) -> Rect {
let max_available_width = (area.width * MODAL_MAX_WIDTH_RATIO) / MODAL_MAX_WIDTH_DIVISOR;
let content_width = calculate_content_width(content, max_available_width);
let footer_text = crate::i18n::t(app, "app.modals.announcement.footer_hint");
let footer_text_display_width = footer_text.width();
#[allow(clippy::cast_possible_truncation)]
let footer_text_width = footer_text_display_width.min(u16::MAX as usize) as u16;
let footer_width = footer_text_width + CONTENT_PADDING * 2;
let required_width = content_width.max(footer_width) + CONTENT_PADDING * 2 + BORDER_WIDTH;
let modal_width = required_width.min(max_available_width).max(MODAL_MIN_WIDTH);
let content_area_width = modal_width.saturating_sub(BORDER_WIDTH + CONTENT_PADDING * 2);
let content_lines = calculate_wrapped_lines(content, content_area_width);
let modal_height =
(content_lines + TOTAL_HEADER_FOOTER_LINES + CONTENT_FOOTER_BUFFER + BORDER_WIDTH)
.min(area.height.saturating_sub(MODAL_HEIGHT_PADDING))
.min(MODAL_MAX_HEIGHT)
.clamp(MODAL_MIN_HEIGHT, MODAL_MAX_HEIGHT);
let x = area.x + (area.width.saturating_sub(modal_width)) / 2;
let y = area.y + (area.height.saturating_sub(modal_height)) / 2;
Rect {
x,
y,
width: modal_width,
height: modal_height,
}
}
fn detect_urls(text: &str) -> Vec<(usize, usize, String)> {
let mut urls = Vec::new();
let text_bytes = text.as_bytes();
let mut i = 0;
while i < text_bytes.len() {
let is_http = i + 7 < text_bytes.len() && &text_bytes[i..i + 7] == b"http://";
let is_https = i + 8 < text_bytes.len() && &text_bytes[i..i + 8] == b"https://";
if is_http || is_https {
let offset = if is_https { 8 } else { 7 };
if let Some(end) = find_url_end(text, i + offset) {
let url = text[i..end].to_string();
urls.push((i, end, url));
i = end;
continue;
}
}
if i + 4 < text_bytes.len()
&& (i == 0 || text_bytes[i - 1].is_ascii_whitespace())
&& &text_bytes[i..i + 4] == b"www."
&& let Some(end) = find_url_end(text, i + 4)
{
let url = format!("https://{}", &text[i..end]);
urls.push((i, end, url));
i = end;
continue;
}
i += 1;
}
urls
}
fn find_url_end(text: &str, start: usize) -> Option<usize> {
let mut end = start;
let text_bytes = text.as_bytes();
while end < text_bytes.len() {
let byte = text_bytes[end];
if byte.is_ascii_whitespace() || byte == b')' || byte == b']' || byte == b'>' {
break;
}
end += 1;
}
while end > start {
let last_char = text_bytes[end - 1];
if matches!(last_char, b'.' | b',' | b';' | b':' | b'!' | b'?') {
end -= 1;
} else {
break;
}
}
if end > start { Some(end) } else { None }
}
fn parse_header_line(trimmed: &str) -> Option<Line<'static>> {
let th = theme();
if trimmed.starts_with("# ") {
let text = trimmed.strip_prefix("# ").unwrap_or(trimmed).to_string();
Some(Line::from(Span::styled(
text,
Style::default().fg(th.mauve).add_modifier(Modifier::BOLD),
)))
} else if trimmed.starts_with("## ") {
let text = trimmed.strip_prefix("## ").unwrap_or(trimmed).to_string();
Some(Line::from(Span::styled(
text,
Style::default().fg(th.mauve),
)))
} else if trimmed.starts_with("### ") {
let text = trimmed.strip_prefix("### ").unwrap_or(trimmed).to_string();
Some(Line::from(Span::styled(
text,
Style::default()
.fg(th.subtext1)
.add_modifier(Modifier::BOLD),
)))
} else {
None
}
}
fn parse_code_block_line(trimmed: &str) -> Option<Line<'static>> {
if trimmed.starts_with("```") {
let th = theme();
Some(Line::from(Span::styled(
trimmed.to_string(),
Style::default().fg(th.subtext0),
)))
} else {
None
}
}
fn parse_text_segments(trimmed: &str) -> Vec<(String, Style, bool, Option<String>)> {
let th = theme();
let urls = detect_urls(trimmed);
let mut segments: Vec<(String, Style, bool, Option<String>)> = Vec::new();
let mut i = 0usize;
let trimmed_bytes = trimmed.as_bytes();
while i < trimmed_bytes.len() {
if let Some((url_start, url_end, url)) = urls.iter().find(|(s, _e, _)| *s == i) {
if *url_start > i {
segments.push((
trimmed[i..*url_start].to_string(),
Style::default().fg(th.text),
false,
None,
));
}
segments.push((
trimmed[*url_start..*url_end].to_string(),
Style::default()
.fg(th.mauve)
.add_modifier(Modifier::UNDERLINED | Modifier::BOLD),
true,
Some(url.clone()),
));
i = *url_end;
continue;
}
if let Some(pos) = trimmed[i..].find("**") {
let pos = i + pos;
if pos > i {
segments.push((
trimmed[i..pos].to_string(),
Style::default().fg(th.text),
false,
None,
));
}
if let Some(end_pos) = trimmed[pos + 2..].find("**") {
let end_pos = pos + 2 + end_pos;
segments.push((
trimmed[pos + 2..end_pos].to_string(),
Style::default()
.fg(th.lavender)
.add_modifier(Modifier::BOLD),
false,
None,
));
i = end_pos + 2;
} else {
segments.push((
trimmed[pos + 2..].to_string(),
Style::default()
.fg(th.lavender)
.add_modifier(Modifier::BOLD),
false,
None,
));
break;
}
continue;
}
if i < trimmed.len() {
segments.push((
trimmed[i..].to_string(),
Style::default().fg(th.text),
false,
None,
));
}
break;
}
if segments.is_empty() {
segments.push((
trimmed.to_string(),
Style::default().fg(th.text),
false,
None,
));
}
segments
}
fn build_wrapped_lines_from_segments(
segments: Vec<(String, Style, bool, Option<String>)>,
content_width: usize,
content_rect: Rect,
start_y: u16,
url_positions: &mut Vec<(u16, u16, u16, String)>,
) -> (Vec<Line<'static>>, u16) {
let mut lines = Vec::new();
let mut current_line_spans: Vec<Span<'static>> = Vec::new();
let mut current_line_width = 0usize;
let mut line_y = start_y;
for (text, style, is_url, url_string) in segments {
let words: Vec<&str> = text.split_whitespace().collect();
for word in words {
let word_width = word.width();
let separator_width = usize::from(current_line_width > 0);
let test_width = current_line_width + separator_width + word_width;
if test_width > content_width && !current_line_spans.is_empty() {
lines.push(Line::from(current_line_spans.clone()));
current_line_spans.clear();
current_line_width = 0;
line_y += 1;
}
if is_url && let Some(ref url) = url_string {
let url_x = content_rect.x
+ u16::try_from(current_line_width + separator_width).unwrap_or(u16::MAX);
let url_width = u16::try_from(word_width).unwrap_or(u16::MAX);
url_positions.push((url_x, line_y, url_width, url.clone()));
}
if current_line_width > 0 {
current_line_spans.push(Span::raw(" "));
current_line_width += 1;
}
current_line_spans.push(Span::styled(word.to_string(), style));
current_line_width += word_width;
}
}
if !current_line_spans.is_empty() {
lines.push(Line::from(current_line_spans));
}
(lines, line_y + 1)
}
fn parse_markdown(
content: &str,
scroll: u16,
max_lines: usize,
url_positions: &mut Vec<(u16, u16, u16, String)>,
content_rect: Rect,
start_y: u16,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
let content_lines: Vec<&str> = content.lines().collect();
let scroll_usize = scroll as usize;
let start_idx = scroll_usize.min(content_lines.len());
let lines_to_take = max_lines.min(content_lines.len().saturating_sub(start_idx));
let mut current_y = start_y;
let content_width = content_rect.width as usize;
for line in content_lines.iter().skip(start_idx).take(lines_to_take) {
let trimmed = line.trim();
if trimmed.is_empty() {
lines.push(Line::from(""));
current_y += 1;
continue;
}
if let Some(header_line) = parse_header_line(trimmed) {
lines.push(header_line);
current_y += 1;
continue;
}
if let Some(code_line) = parse_code_block_line(trimmed) {
lines.push(code_line);
current_y += 1;
continue;
}
let segments = parse_text_segments(trimmed);
let (wrapped_lines, final_y) = build_wrapped_lines_from_segments(
segments,
content_width,
content_rect,
current_y,
url_positions,
);
lines.extend(wrapped_lines);
current_y = final_y;
}
lines
}
fn build_footer(app: &AppState) -> Line<'static> {
let th = theme();
let footer_text = i18n::t(app, "app.modals.announcement.footer_hint");
Line::from(Span::styled(footer_text, Style::default().fg(th.overlay1)))
}
pub fn render_announcement(
f: &mut Frame,
app: &mut AppState,
area: Rect,
title: &str,
content: &str,
scroll: u16,
) {
let rect = calculate_modal_rect(area, content, app);
app.announcement_rect = Some((rect.x, rect.y, rect.width, rect.height));
let th = theme();
let footer_height = FOOTER_LINES; let footer_total_height = footer_height + CONTENT_FOOTER_BUFFER; let inner_height = rect.height.saturating_sub(BORDER_WIDTH); let content_area_height = inner_height.saturating_sub(footer_total_height);
let min_content_height = HEADER_LINES + 1;
let content_rect = Rect {
x: rect.x + 1, y: rect.y + 1, width: rect.width.saturating_sub(2), height: content_area_height.max(min_content_height), };
let footer_y = rect.y + 1 + content_area_height + CONTENT_FOOTER_BUFFER;
let footer_available_height =
inner_height.saturating_sub(content_area_height + CONTENT_FOOTER_BUFFER);
let footer_rect = Rect {
x: rect.x + 1, y: footer_y.min(rect.y + rect.height.saturating_sub(footer_height + 1)), width: rect.width.saturating_sub(2), height: footer_height.min(footer_available_height),
};
app.announcement_urls.clear();
let mut content_lines = Vec::new();
content_lines.push(Line::from(Span::styled(
title.to_string(),
Style::default().fg(th.mauve).add_modifier(Modifier::BOLD),
)));
content_lines.push(Line::from(""));
let available_height = content_area_height.saturating_sub(HEADER_LINES);
let max_content_lines = available_height.max(1) as usize;
let start_y = content_rect.y + HEADER_LINES;
let parsed_content = parse_markdown(
content,
scroll,
max_content_lines,
&mut app.announcement_urls,
content_rect,
start_y,
);
content_lines.extend(parsed_content);
f.render_widget(Clear, rect);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(th.subtext0));
let empty_paragraph = Paragraph::new(vec![]).block(block);
f.render_widget(empty_paragraph, rect);
let content_paragraph = Paragraph::new(content_lines).wrap(Wrap { trim: true });
f.render_widget(content_paragraph, content_rect);
let footer_lines = vec![build_footer(app)];
let footer_paragraph = Paragraph::new(footer_lines);
f.render_widget(footer_paragraph, footer_rect);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_urls_https() {
let text = "Visit https://example.com for more info";
let urls = detect_urls(text);
assert_eq!(urls.len(), 1);
assert_eq!(urls[0].0, 6); assert_eq!(urls[0].1, 25); assert_eq!(urls[0].2, "https://example.com");
}
#[test]
fn test_detect_urls_http() {
let text = "Visit http://example.com for more info";
let urls = detect_urls(text);
assert_eq!(urls.len(), 1);
assert_eq!(urls[0].2, "http://example.com");
}
#[test]
fn test_detect_urls_www() {
let text = "Visit www.example.com for more info";
let urls = detect_urls(text);
assert_eq!(urls.len(), 1);
assert_eq!(urls[0].2, "https://www.example.com");
}
#[test]
fn test_detect_urls_multiple() {
let text = "Visit https://example.com and http://test.org for more";
let urls = detect_urls(text);
assert_eq!(urls.len(), 2);
assert_eq!(urls[0].2, "https://example.com");
assert_eq!(urls[1].2, "http://test.org");
}
#[test]
fn test_detect_urls_trailing_punctuation() {
let text = "Visit https://example.com. Also http://test.org!";
let urls = detect_urls(text);
assert_eq!(urls.len(), 2);
assert_eq!(urls[0].2, "https://example.com");
assert_eq!(urls[1].2, "http://test.org");
}
#[test]
fn test_detect_urls_end_of_text() {
let text = "Visit https://example.com";
let urls = detect_urls(text);
assert_eq!(urls.len(), 1);
assert_eq!(urls[0].2, "https://example.com");
}
#[test]
fn test_detect_urls_parentheses() {
let text = "Visit (https://example.com) for more";
let urls = detect_urls(text);
assert_eq!(urls.len(), 1);
assert_eq!(urls[0].2, "https://example.com");
}
#[test]
fn test_detect_urls_empty() {
let text = "";
let urls = detect_urls(text);
assert!(urls.is_empty());
}
#[test]
fn test_detect_urls_none() {
let text = "This is just regular text without any URLs";
let urls = detect_urls(text);
assert!(urls.is_empty());
}
#[test]
fn test_find_url_end() {
let text = "https://example.com more text";
assert_eq!(find_url_end(text, 8), Some(19));
let text2 = "https://example.com)";
assert_eq!(find_url_end(text2, 8), Some(19));
let text3 = "https://example.com.";
assert_eq!(find_url_end(text3, 8), Some(19));
let text4 = "https://example.com";
assert_eq!(find_url_end(text4, 8), Some(19)); }
#[test]
fn test_parse_header_line_h1() {
let line = "# Main Title";
let result = parse_header_line(line);
assert!(result.is_some());
let line_result = result.expect("should parse H1 header");
assert_eq!(line_result.spans.len(), 1);
assert_eq!(line_result.spans[0].content.as_ref(), "Main Title");
}
#[test]
fn test_parse_header_line_h2() {
let line = "## Section Title";
let result = parse_header_line(line);
assert!(result.is_some());
let line_result = result.expect("should parse H2 header");
assert_eq!(line_result.spans.len(), 1);
assert_eq!(line_result.spans[0].content.as_ref(), "Section Title");
}
#[test]
fn test_parse_header_line_h3() {
let line = "### Subsection Title";
let result = parse_header_line(line);
assert!(result.is_some());
let line_result = result.expect("should parse H3 header");
assert_eq!(line_result.spans.len(), 1);
assert_eq!(line_result.spans[0].content.as_ref(), "Subsection Title");
}
#[test]
fn test_parse_header_line_non_header() {
let line = "This is not a header";
let result = parse_header_line(line);
assert!(result.is_none());
let line2 = "#Not a header (no space)";
let result2 = parse_header_line(line2);
assert!(result2.is_none());
}
#[test]
fn test_parse_code_block_line() {
let line = "```rust";
let result = parse_code_block_line(line);
assert!(result.is_some());
let line_result = result.expect("should parse code block");
assert_eq!(line_result.spans.len(), 1);
assert_eq!(line_result.spans[0].content.as_ref(), "```rust");
let line2 = "```";
let result2 = parse_code_block_line(line2);
assert!(result2.is_some());
}
#[test]
fn test_parse_code_block_line_non_code() {
let line = "This is not a code block";
let result = parse_code_block_line(line);
assert!(result.is_none());
}
#[test]
fn test_calculate_wrapped_lines_empty() {
let result = calculate_wrapped_lines("", 40);
assert_eq!(result, 1);
}
#[test]
fn test_calculate_wrapped_lines_single_line() {
let result = calculate_wrapped_lines("Short line", 40);
assert_eq!(result, 1);
let long_line = "This is a very long line that should wrap when the width is limited";
let result2 = calculate_wrapped_lines(long_line, 20);
assert!(result2 > 1);
}
#[test]
fn test_calculate_wrapped_lines_multi_line() {
let content = "Line one\nLine two\nLine three";
let result = calculate_wrapped_lines(content, 40);
assert_eq!(result, 3);
let content2 = "Line one\n\nLine two";
let result2 = calculate_wrapped_lines(content2, 40);
assert_eq!(result2, 3); }
#[test]
fn test_calculate_wrapped_lines_unicode() {
let content = "测试中文";
let result = calculate_wrapped_lines(content, 4);
assert!(result >= 1);
}
#[test]
fn test_calculate_content_width_empty() {
let result = calculate_content_width("", 100);
assert_eq!(result, MODAL_MIN_WIDTH);
}
#[test]
fn test_calculate_content_width_single_line() {
let content = "Short line";
let result = calculate_content_width(content, 100);
assert_eq!(result, 10.max(MODAL_MIN_WIDTH));
}
#[test]
fn test_calculate_content_width_markdown() {
let content = "## Header with **bold** text";
let result = calculate_content_width(content, 100);
assert_eq!(result, MODAL_MIN_WIDTH); }
#[test]
fn test_calculate_content_width_max() {
let content = "This is a very long line that exceeds the maximum width limit";
let max_width = 30;
let result = calculate_content_width(content, max_width);
assert_eq!(result, max_width.max(MODAL_MIN_WIDTH));
}
}