aboutsummaryrefslogtreecommitdiff
path: root/src/ui/release_notes.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/ui/release_notes.rs')
-rw-r--r--src/ui/release_notes.rs345
1 files changed, 345 insertions, 0 deletions
diff --git a/src/ui/release_notes.rs b/src/ui/release_notes.rs
new file mode 100644
index 00000000..346b06d5
--- /dev/null
+++ b/src/ui/release_notes.rs
@@ -0,0 +1,345 @@
+use ratatui::{
+ layout::{Constraint, Layout, Rect},
+ style::{Modifier, Style},
+ text::{Line, Span},
+ widgets::{Paragraph, Wrap},
+ Frame,
+};
+
+use super::scrollbar::{release_notes_scrollbar_rect, render_scrollbar};
+use super::widgets::{
+ action_button_width, modal_stack_areas, panel_contrast_fg, render_action_button,
+ render_modal_header, render_modal_shell,
+};
+use crate::app::{
+ state::{Palette, ReleaseNotesState},
+ AppState,
+};
+
+pub(crate) const RELEASE_NOTES_MODAL_SIZE: (u16, u16) = (80, 24);
+
+pub(super) fn render_release_notes_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
+ let Some(notes) = &app.release_notes else {
+ return;
+ };
+
+ super::dim_background(frame, area);
+
+ let Some(inner) = render_modal_shell(
+ frame,
+ area,
+ RELEASE_NOTES_MODAL_SIZE.0,
+ RELEASE_NOTES_MODAL_SIZE.1,
+ &app.palette,
+ ) else {
+ return;
+ };
+ if inner.height < 8 || inner.width < 20 {
+ return;
+ }
+
+ let stack = modal_stack_areas(inner, 2, 1, 0, 1);
+ let header_rows =
+ Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas::<2>(stack.header);
+
+ let header_title_area = Rect::new(
+ header_rows[0].x + 1,
+ header_rows[0].y,
+ header_rows[0].width.saturating_sub(2),
+ header_rows[0].height,
+ );
+ let header_subtitle_area = Rect::new(
+ header_rows[1].x + 1,
+ header_rows[1].y,
+ header_rows[1].width.saturating_sub(2),
+ header_rows[1].height,
+ );
+
+ render_modal_header(
+ frame,
+ header_title_area,
+ &format!("v{}", notes.version),
+ &app.palette,
+ );
+ let subtitle = if notes.preview {
+ "update ready"
+ } else {
+ "what's new in this release"
+ };
+ frame.render_widget(
+ Paragraph::new(subtitle).style(Style::default().fg(app.palette.overlay1)),
+ header_subtitle_area,
+ );
+ render_action_button(
+ frame,
+ release_notes_close_button_rect(header_rows[0]),
+ Some("esc"),
+ "close",
+ Style::default()
+ .fg(panel_contrast_fg(&app.palette))
+ .bg(app.palette.accent)
+ .add_modifier(Modifier::BOLD),
+ );
+
+ let sections = release_notes_sections(stack.content, notes.preview);
+ let metrics = crate::pane::ScrollMetrics {
+ offset_from_bottom: app.release_notes_max_scroll().saturating_sub(notes.scroll) as usize,
+ max_offset_from_bottom: app.release_notes_max_scroll() as usize,
+ viewport_rows: sections.notes_body.height.max(1) as usize,
+ };
+ let track = release_notes_scrollbar_rect(sections.notes_body, metrics);
+ let notes_text_area = track
+ .map(|_| {
+ Rect::new(
+ sections.notes_body.x,
+ sections.notes_body.y,
+ sections.notes_body.width.saturating_sub(1),
+ sections.notes_body.height,
+ )
+ })
+ .unwrap_or(sections.notes_body);
+
+ if let Some(instructions_area) = sections.instructions {
+ render_release_notes_preview_panel(frame, instructions_area, &notes.version, &app.palette);
+ }
+
+ let body = Paragraph::new(
+ release_notes_display_lines(notes, &app.palette)
+ .into_iter()
+ .map(|(_, line)| line)
+ .collect::<Vec<_>>(),
+ )
+ .wrap(Wrap { trim: false })
+ .scroll((notes.scroll, 0));
+ frame.render_widget(body, notes_text_area);
+ if let Some(track) = track {
+ render_scrollbar(
+ frame,
+ metrics,
+ track,
+ app.palette.overlay0,
+ app.palette.overlay1,
+ "▐",
+ );
+ }
+
+ frame.render_widget(
+ Paragraph::new(Line::from(vec![
+ Span::styled(" scroll ", Style::default().fg(app.palette.overlay0)),
+ Span::styled("wheel ↑↓", Style::default().fg(app.palette.text)),
+ Span::styled(" · ", Style::default().fg(app.palette.overlay0)),
+ Span::styled("close", Style::default().fg(app.palette.overlay0)),
+ Span::styled(" esc / enter ", Style::default().fg(app.palette.text)),
+ ])),
+ stack.footer.unwrap_or_default(),
+ );
+}
+
+fn release_notes_inline_spans<'a>(
+ text: &str,
+ base_style: Style,
+ code_style: Style,
+) -> (usize, Vec<Span<'a>>) {
+ let mut spans = Vec::new();
+ let mut width = 0;
+ let mut remaining = text;
+
+ while let Some(start) = remaining.find('`') {
+ let (before, after_start) = remaining.split_at(start);
+ if !before.is_empty() {
+ width += before.chars().count();
+ spans.push(Span::styled(before.to_string(), base_style));
+ }
+
+ let after_start = &after_start[1..];
+ let Some(end) = after_start.find('`') else {
+ let literal = format!("`{after_start}");
+ width += literal.chars().count();
+ spans.push(Span::styled(literal, base_style));
+ remaining = "";
+ break;
+ };
+
+ let (code, after_end) = after_start.split_at(end);
+ width += code.chars().count();
+ if !code.is_empty() {
+ spans.push(Span::styled(code.to_string(), code_style));
+ }
+ remaining = &after_end[1..];
+ }
+
+ if !remaining.is_empty() {
+ width += remaining.chars().count();
+ spans.push(Span::styled(remaining.to_string(), base_style));
+ }
+
+ if spans.is_empty() {
+ spans.push(Span::styled(String::new(), base_style));
+ }
+
+ (width, spans)
+}
+
+pub(crate) fn release_notes_lines<'a>(body: &'a str, p: &Palette) -> Vec<(usize, Line<'a>)> {
+ let mut lines = Vec::new();
+ let mut in_fenced_code_block = false;
+ let text_style = Style::default().fg(p.text);
+ let inline_code_style = Style::default()
+ .fg(p.accent)
+ .bg(p.surface0)
+ .add_modifier(Modifier::BOLD);
+
+ for raw in body.lines() {
+ let trimmed = raw.trim_end();
+ if trimmed.trim_start().starts_with("```") {
+ in_fenced_code_block = !in_fenced_code_block;
+ continue;
+ }
+
+ if in_fenced_code_block {
+ let code_bg = p.surface1;
+ let gutter_style = Style::default().fg(p.accent).bg(code_bg);
+ let code_style = Style::default().fg(p.text).bg(code_bg);
+ let width = 2 + trimmed.chars().count();
+ let mut spans = vec![
+ Span::styled("▏", gutter_style),
+ Span::styled(" ", code_style),
+ ];
+ if !trimmed.is_empty() {
+ spans.push(Span::styled(trimmed.to_string(), code_style));
+ }
+ lines.push((width, Line::from(spans)));
+ continue;
+ }
+
+ if trimmed.is_empty() {
+ lines.push((0, Line::raw("")));
+ continue;
+ }
+
+ if let Some(rest) = trimmed.strip_prefix("### ") {
+ let text = rest.trim().to_string();
+ if text.is_empty() {
+ lines.push((0, Line::raw("")));
+ continue;
+ }
+ let width = 1 + text.chars().count();
+ lines.push((
+ width,
+ Line::from(vec![
+ Span::raw(" "),
+ Span::styled(
+ text.to_uppercase(),
+ Style::default().fg(p.accent).add_modifier(Modifier::BOLD),
+ ),
+ ]),
+ ));
+ continue;
+ }
+
+ if let Some(rest) = trimmed.strip_prefix("- ") {
+ let (text_width, mut spans) =
+ release_notes_inline_spans(rest, text_style, inline_code_style);
+ let width = 3 + text_width;
+ let mut line_spans = vec![Span::styled(" • ", Style::default().fg(p.accent))];
+ line_spans.append(&mut spans);
+ lines.push((width, Line::from(line_spans)));
+ continue;
+ }
+
+ let (text_width, mut spans) =
+ release_notes_inline_spans(trimmed, text_style, inline_code_style);
+ let width = 1 + text_width;
+ let mut line_spans = vec![Span::raw(" ")];
+ line_spans.append(&mut spans);
+ lines.push((width, Line::from(line_spans)));
+ }
+
+ lines
+}
+
+pub(crate) struct ReleaseNotesSections {
+ pub instructions: Option<Rect>,
+ pub notes_body: Rect,
+}
+
+pub(crate) fn release_notes_sections(area: Rect, preview: bool) -> ReleaseNotesSections {
+ if preview && area.height >= 6 {
+ let rows = Layout::vertical([Constraint::Length(5), Constraint::Min(0)]).areas::<2>(area);
+ ReleaseNotesSections {
+ instructions: Some(rows[0]),
+ notes_body: rows[1],
+ }
+ } else {
+ ReleaseNotesSections {
+ instructions: None,
+ notes_body: area,
+ }
+ }
+}
+
+pub(super) fn release_notes_preview_lines<'a>(_version: &str, p: &Palette) -> Vec<Line<'a>> {
+ let title_style = Style::default().fg(p.text).add_modifier(Modifier::BOLD);
+ let text_style = Style::default().fg(p.text);
+ let code_style = Style::default()
+ .fg(p.accent)
+ .bg(p.surface0)
+ .add_modifier(Modifier::BOLD);
+
+ vec![
+ Line::from(vec![
+ Span::styled(
+ "●",
+ Style::default().fg(p.accent).add_modifier(Modifier::BOLD),
+ ),
+ Span::styled(" update ready", title_style),
+ ]),
+ Line::from(vec![
+ Span::styled("detach from this session, then run ", text_style),
+ Span::styled("herdr update", code_style),
+ Span::styled(" in your shell", text_style),
+ ]),
+ ]
+}
+
+fn render_release_notes_preview_panel(frame: &mut Frame, area: Rect, _version: &str, p: &Palette) {
+ let rows = Layout::vertical([
+ Constraint::Length(2),
+ Constraint::Length(1),
+ Constraint::Length(1),
+ Constraint::Length(1),
+ ])
+ .areas::<4>(area);
+
+ let text_area = Rect::new(
+ rows[0].x + 1,
+ rows[0].y,
+ rows[0].width.saturating_sub(2),
+ rows[0].height,
+ );
+ frame.render_widget(
+ Paragraph::new(release_notes_preview_lines(_version, p)).wrap(Wrap { trim: false }),
+ text_area,
+ );
+
+ let divider_area = Rect::new(rows[2].x + 1, rows[2].y, rows[2].width.saturating_sub(2), 1);
+ frame.render_widget(
+ Paragraph::new(Line::from(vec![Span::styled(
+ "─".repeat(divider_area.width as usize),
+ Style::default().fg(p.surface1),
+ )])),
+ divider_area,
+ );
+}
+
+pub(crate) fn release_notes_display_lines<'a>(
+ notes: &'a ReleaseNotesState,
+ p: &Palette,
+) -> Vec<(usize, Line<'a>)> {
+ release_notes_lines(notes.body.as_str(), p)
+}
+
+pub(crate) fn release_notes_close_button_rect(area: Rect) -> Rect {
+ let width = action_button_width(Some("esc"), "close");
+ Rect::new(area.x + area.width.saturating_sub(width), area.y, width, 1)
+}