aboutsummaryrefslogtreecommitdiff
path: root/src/ui.rs
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-04-01 02:26:03 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-04-01 02:26:03 +0300
commit063ffc2b7057b66b03b2d2d60232b235b123f3e4 (patch)
tree2acba8850acbb58461964dfa4efac7021d9f6851 /src/ui.rs
parentda99c089181b8a4dd276b4dfdb8dd2492adba0f2 (diff)
feat: show release notes after updates
Diffstat (limited to 'src/ui.rs')
-rw-r--r--src/ui.rs197
1 files changed, 178 insertions, 19 deletions
diff --git a/src/ui.rs b/src/ui.rs
index b3f0152e..9e2db7c6 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -1,8 +1,8 @@
use ratatui::{
- layout::{Constraint, Layout, Rect},
+ layout::{Alignment, Constraint, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
- widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Tabs},
+ widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Tabs, Wrap},
Frame,
};
use tui_term::widget::PseudoTerminal;
@@ -76,6 +76,7 @@ pub fn render(app: &AppState, frame: &mut Frame) {
match app.mode {
Mode::Onboarding => render_onboarding_overlay(app, frame, frame.area()),
+ Mode::ReleaseNotes => render_release_notes_overlay(app, frame, frame.area()),
Mode::Navigate => render_navigate_overlay(app, frame, terminal_area),
Mode::Resize => render_resize_overlay(app, frame, terminal_area),
Mode::ConfirmClose => render_confirm_close_overlay(app, frame, terminal_area),
@@ -694,7 +695,19 @@ pub(crate) fn pane_scrollbar_rect(info: &PaneInfo) -> Option<Rect> {
info.scrollbar_rect
}
-fn should_show_scrollbar(metrics: crate::pane::ScrollMetrics) -> bool {
+pub(crate) fn release_notes_scrollbar_rect(
+ body: Rect,
+ metrics: crate::pane::ScrollMetrics,
+) -> Option<Rect> {
+ (should_show_scrollbar(metrics) && body.width > 1).then_some(Rect::new(
+ body.x + body.width - 1,
+ body.y,
+ 1,
+ body.height,
+ ))
+}
+
+pub(crate) fn should_show_scrollbar(metrics: crate::pane::ScrollMetrics) -> bool {
metrics.max_offset_from_bottom > 0
}
@@ -803,18 +816,14 @@ pub(crate) fn scrollbar_offset_from_drag_row(
scrollbar_offset_from_thumb_top(metrics, track, desired_top)
}
-fn render_pane_scrollbar(
- app: &AppState,
+fn render_scrollbar(
frame: &mut Frame,
- info: &PaneInfo,
- rt: &crate::pane::PaneRuntime,
+ metrics: crate::pane::ScrollMetrics,
+ track: Rect,
+ track_color: Color,
+ thumb_color: Color,
+ thumb_symbol: &str,
) {
- let Some(metrics) = rt.scroll_metrics() else {
- return;
- };
- let Some(track) = pane_scrollbar_rect(info) else {
- return;
- };
if metrics.max_offset_from_bottom == 0 {
return;
}
@@ -823,12 +832,6 @@ fn render_pane_scrollbar(
return;
};
- let (track_color, thumb_color, thumb_symbol) = if info.is_focused {
- (app.palette.overlay0, app.palette.overlay1, "▐")
- } else {
- (app.palette.surface_dim, app.palette.overlay0, "▕")
- };
-
let buf = frame.buffer_mut();
for y in track.y..track.y + track.height {
let cell = &mut buf[(track.x, y)];
@@ -842,6 +845,35 @@ fn render_pane_scrollbar(
}
}
+fn render_pane_scrollbar(
+ app: &AppState,
+ frame: &mut Frame,
+ info: &PaneInfo,
+ rt: &crate::pane::PaneRuntime,
+) {
+ let Some(metrics) = rt.scroll_metrics() else {
+ return;
+ };
+ let Some(track) = pane_scrollbar_rect(info) else {
+ return;
+ };
+
+ let (track_color, thumb_color, thumb_symbol) = if info.is_focused {
+ (app.palette.overlay0, app.palette.overlay1, "▐")
+ } else {
+ (app.palette.surface_dim, app.palette.overlay0, "▕")
+ };
+
+ render_scrollbar(
+ frame,
+ metrics,
+ track,
+ track_color,
+ thumb_color,
+ thumb_symbol,
+ );
+}
+
fn render_selection_highlight(
selection: &Option<crate::selection::Selection>,
frame: &mut Frame,
@@ -973,6 +1005,133 @@ fn render_onboarding_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
}
}
+fn render_release_notes_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
+ let Some(notes) = &app.release_notes else {
+ return;
+ };
+
+ dim_background(frame, area);
+
+ let Some(inner) = render_modal_shell(frame, area, 76, 20, &app.palette) else {
+ return;
+ };
+ if inner.height < 8 || inner.width < 20 {
+ return;
+ }
+
+ let rows = Layout::vertical([
+ Constraint::Length(1),
+ Constraint::Length(1),
+ Constraint::Length(1),
+ Constraint::Min(1),
+ Constraint::Length(1),
+ ])
+ .areas::<5>(inner);
+
+ render_modal_header(frame, rows[0], &format!("v{}", notes.version), &app.palette);
+ frame.render_widget(
+ Paragraph::new(" what's new in this release")
+ .style(Style::default().fg(app.palette.overlay1)),
+ rows[1],
+ );
+ frame.render_widget(
+ Paragraph::new(Span::styled(
+ " [ close ] ",
+ Style::default()
+ .fg(app.palette.panel_bg)
+ .bg(app.palette.accent)
+ .add_modifier(Modifier::BOLD),
+ ))
+ .alignment(Alignment::Right),
+ rows[0],
+ );
+
+ let body_area = rows[3];
+ 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: body_area.height.max(1) as usize,
+ };
+ let track = release_notes_scrollbar_rect(body_area, metrics);
+ let text_area = track
+ .map(|_| {
+ Rect::new(
+ body_area.x,
+ body_area.y,
+ body_area.width.saturating_sub(1),
+ body_area.height,
+ )
+ })
+ .unwrap_or(body_area);
+
+ let body = Paragraph::new(render_release_notes_lines(
+ notes.body.as_str(),
+ &app.palette,
+ ))
+ .wrap(Wrap { trim: false })
+ .scroll((notes.scroll, 0));
+ frame.render_widget(body, 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(" q / esc / enter ", Style::default().fg(app.palette.text)),
+ ])),
+ rows[4],
+ );
+}
+
+fn render_release_notes_lines<'a>(body: &'a str, p: &Palette) -> Vec<Line<'a>> {
+ let mut lines = Vec::new();
+
+ for raw in body.lines() {
+ let trimmed = raw.trim_end();
+ if trimmed.is_empty() {
+ lines.push(Line::raw(""));
+ continue;
+ }
+
+ if let Some(rest) = trimmed.strip_prefix("### ") {
+ lines.push(Line::from(vec![
+ Span::raw(" "),
+ Span::styled(
+ rest.to_lowercase(),
+ Style::default().fg(p.accent).add_modifier(Modifier::BOLD),
+ ),
+ ]));
+ continue;
+ }
+
+ if let Some(rest) = trimmed.strip_prefix("- ") {
+ lines.push(Line::from(vec![
+ Span::styled(" • ", Style::default().fg(p.accent)),
+ Span::styled(rest.to_string(), Style::default().fg(p.text)),
+ ]));
+ continue;
+ }
+
+ lines.push(Line::from(vec![
+ Span::raw(" "),
+ Span::styled(trimmed.to_string(), Style::default().fg(p.text)),
+ ]));
+ }
+
+ lines
+}
+
fn render_onboarding_welcome(app: &AppState, frame: &mut Frame, area: Rect) {
let Some(inner) = render_modal_shell(frame, area, 64, 15, &app.palette) else {
return;