use ratatui::Frame;
use ratatui::layout::Alignment;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Clear, Paragraph, Wrap};
use unicode_width::UnicodeWidthStr;
use super::{design, theme};
use crate::app::{App, Screen};
use crate::changelog::{self, Entry, EntryKind, Section};
use crate::messages::whats_new as msg;
const ENTRY_INDENT: usize = 12;
pub fn render(frame: &mut Frame, app: &App) {
let state = match &app.screen {
Screen::WhatsNew(s) => s,
_ => return,
};
let area = design::overlay_area(
frame,
design::OVERLAY_W,
design::OVERLAY_H,
frame.area().height.saturating_sub(1),
);
frame.render_widget(Clear, area);
let block = design::overlay_block(msg::TITLE);
let inner = block.inner(area);
let sections = changelog::current_for_render();
let current = semver::Version::parse(env!("CARGO_PKG_VERSION")).ok();
let last = crate::preferences::load_last_seen_version()
.ok()
.flatten()
.and_then(|s| semver::Version::parse(&s).ok());
const RECENT_CAP: usize = 5;
let shown: &[Section] = sections
.get(..sections.len().min(RECENT_CAP))
.unwrap_or(&[]);
let current_str = current.as_ref().map(|v| v.to_string()).unwrap_or_default();
let last_str = last.as_ref().map(|v| v.to_string());
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(""));
for logo_line in design::LOGO {
lines.push(
Line::from(Span::styled(logo_line, theme::accent_bold())).alignment(Alignment::Center),
);
}
lines.push(Line::from(""));
lines.push(
Line::from(Span::styled(
msg::subtitle(last_str.as_deref(), ¤t_str),
theme::muted(),
))
.alignment(Alignment::Center),
);
if let Some(new_version) = app.update.available.as_ref() {
lines.push(
Line::from(Span::styled(
msg::update_available(new_version),
theme::accent_bold(),
))
.alignment(Alignment::Center),
);
}
lines.push(Line::from(""));
lines.push(Line::from(""));
if shown.is_empty() {
lines.push(Line::from(Span::raw(msg::EMPTY)).alignment(Alignment::Center));
} else {
for (i, section) in shown.iter().enumerate() {
if i > 0 {
lines.push(Line::from(""));
lines.push(design::section_divider());
lines.push(Line::from(""));
}
lines.push(section_header_line(section));
lines.push(Line::from(""));
for entry in §ion.entries {
for line in entry_lines(entry, inner.width as usize) {
lines.push(line);
}
}
}
}
lines.push(Line::from(""));
lines.push(Line::from(""));
lines.push(Line::from(""));
let total_lines = u16::try_from(lines.len()).unwrap_or(u16::MAX);
let viewport = inner.height.max(1);
let max_scroll = total_lines.saturating_sub(viewport);
let effective = state.scroll.min(max_scroll);
frame.render_widget(block, area);
let para = Paragraph::new(lines)
.wrap(Wrap { trim: false })
.scroll((effective, 0));
frame.render_widget(para, inner);
let footer_area = design::render_overlay_footer(frame, area);
design::Footer::new()
.action(msg::FOOTER_CLOSE_KEYS, msg::FOOTER_CLOSE_LABEL)
.action(msg::FOOTER_SCROLL_KEYS, msg::FOOTER_SCROLL_LABEL)
.action(msg::FOOTER_TOP_BOTTOM_KEYS, msg::FOOTER_TOP_BOTTOM_LABEL)
.render_with_status(frame, footer_area, app);
}
fn section_header_line(section: &Section) -> Line<'static> {
let mut spans = vec![
Span::raw(design::LIST_HIGHLIGHT),
Span::styled(section.version.to_string(), theme::section_header()),
];
if let Some(date) = §ion.date {
spans.push(Span::raw(design::COL_GAP_STR));
spans.push(Span::styled(date.clone(), theme::muted()));
}
Line::from(spans)
}
fn entry_lines(entry: &Entry, available_width: usize) -> Vec<Line<'static>> {
let (label, style) = match entry.kind {
EntryKind::Feature => (msg::KIND_FEAT, theme::accent_bold()),
EntryKind::Change => (msg::KIND_CHANGE, Style::default()),
EntryKind::Fix => (msg::KIND_FIX, Style::default()),
};
let text = strip_inline_markdown(&entry.text);
let text_width = available_width.saturating_sub(ENTRY_INDENT).max(1);
let wrapped = wrap_text(&text, text_width);
let indent: String = " ".repeat(ENTRY_INDENT);
let mut out: Vec<Line<'static>> = Vec::with_capacity(wrapped.len().max(1));
let mut iter = wrapped.into_iter();
let first = iter.next().unwrap_or_default();
out.push(Line::from(vec![
Span::raw(design::LIST_HIGHLIGHT),
Span::styled(label, style),
Span::raw(design::COL_GAP_STR),
Span::raw(first),
]));
for cont in iter {
out.push(Line::from(vec![Span::raw(indent.clone()), Span::raw(cont)]));
}
out
}
fn wrap_text(text: &str, width: usize) -> Vec<String> {
if text.is_empty() || width == 0 {
return vec![text.to_string()];
}
let mut lines: Vec<String> = Vec::new();
let mut current = String::new();
let mut current_width = 0usize;
for word in text.split_whitespace() {
let word_width = UnicodeWidthStr::width(word);
if current.is_empty() {
current.push_str(word);
current_width = word_width;
continue;
}
if current_width + 1 + word_width <= width {
current.push(' ');
current.push_str(word);
current_width += 1 + word_width;
} else {
lines.push(std::mem::take(&mut current));
current.push_str(word);
current_width = word_width;
}
}
if !current.is_empty() {
lines.push(current);
}
lines
}
fn strip_inline_markdown(input: &str) -> String {
let mut out = input.replace("**", "").replace("__", "");
let mut pos = 0;
while let Some(rel) = out[pos..].find('[') {
let start = pos + rel;
let Some(mid) = out[start..].find("](") else {
pos = start + 1;
continue;
};
let Some(end) = out[start + mid..].find(')') else {
pos = start + 1;
continue;
};
let text = out[start + 1..start + mid].to_string();
out.replace_range(start..start + mid + end + 1, &text);
pos = start + text.len();
}
out
}
#[cfg(test)]
mod strip_inline_markdown_tests {
use super::strip_inline_markdown;
#[test]
fn strips_link_keeps_text() {
assert_eq!(
strip_inline_markdown("Closes [#32](https://github.com/x/y/issues/32)"),
"Closes #32"
);
}
#[test]
fn strips_multiple_links() {
assert_eq!(
strip_inline_markdown("See [a](http://a) and [b](http://b)"),
"See a and b"
);
}
#[test]
fn strips_bold_markers() {
assert_eq!(
strip_inline_markdown("This is **important** and __critical__"),
"This is important and critical"
);
}
#[test]
fn leaves_bare_brackets_alone() {
assert_eq!(strip_inline_markdown("array[0] = 1"), "array[0] = 1");
}
#[test]
fn handles_unclosed_link() {
assert_eq!(
strip_inline_markdown("Broken [link](no-close"),
"Broken [link](no-close"
);
}
#[test]
fn handles_plain_text() {
assert_eq!(
strip_inline_markdown("no markdown here"),
"no markdown here"
);
}
}
#[cfg(test)]
mod wrap_tests {
use super::wrap_text;
#[test]
fn returns_single_line_when_fits() {
assert_eq!(wrap_text("short", 20), vec!["short".to_string()]);
}
#[test]
fn wraps_on_word_boundary() {
let out = wrap_text("one two three four five", 10);
assert_eq!(out, vec!["one two", "three four", "five"]);
}
#[test]
fn long_word_gets_own_line() {
let out = wrap_text("tiny supercalifragilistic end", 10);
assert_eq!(out, vec!["tiny", "supercalifragilistic", "end"]);
}
#[test]
fn handles_empty_input() {
assert_eq!(wrap_text("", 10), vec!["".to_string()]);
}
#[test]
fn handles_zero_width() {
assert_eq!(wrap_text("anything", 0), vec!["anything".to_string()]);
}
}