aboutsummaryrefslogtreecommitdiff
path: root/src/ui
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-19 21:32:40 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-19 23:06:48 +0300
commitd2074b12f1b2a2a97e188f0241863d6cb858efaf (patch)
tree99de2372409ac78a2ccf5cd16fc10ddfbf55174b /src/ui
parentaa89f536a28ad11d5931649e7a178863346b6d69 (diff)
feat: add hosted product announcements
Diffstat (limited to 'src/ui')
-rw-r--r--src/ui/release_notes.rs121
-rw-r--r--src/ui/sidebar.rs1
2 files changed, 121 insertions, 1 deletions
diff --git a/src/ui/release_notes.rs b/src/ui/release_notes.rs
index 91990695..9d069828 100644
--- a/src/ui/release_notes.rs
+++ b/src/ui/release_notes.rs
@@ -12,11 +12,12 @@ use super::widgets::{
render_modal_header, render_modal_shell,
};
use crate::app::{
- state::{Palette, ReleaseNotesState},
+ state::{Palette, ProductAnnouncementState, ReleaseNotesState},
AppState,
};
pub(crate) const RELEASE_NOTES_MODAL_SIZE: (u16, u16) = (80, 24);
+pub(crate) const PRODUCT_ANNOUNCEMENT_MODAL_SIZE: (u16, u16) = (88, 24);
pub(super) fn render_release_notes_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
let Some(notes) = &app.release_notes else {
@@ -141,6 +142,117 @@ pub(super) fn render_release_notes_overlay(app: &AppState, frame: &mut Frame, ar
);
}
+pub(super) fn render_product_announcement_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
+ let Some(announcement) = &app.product_announcement else {
+ return;
+ };
+
+ super::dim_background(frame, area);
+
+ let Some(inner) = render_modal_shell(
+ frame,
+ area,
+ PRODUCT_ANNOUNCEMENT_MODAL_SIZE.0,
+ PRODUCT_ANNOUNCEMENT_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, &announcement.title, &app.palette);
+ let subtitle = if announcement.preview {
+ "product announcement preview"
+ } else {
+ "product announcement"
+ };
+ frame.render_widget(
+ Paragraph::new(format!("{subtitle} · v{}", announcement.version))
+ .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 body_rect = stack.content;
+ let metrics = crate::pane::ScrollMetrics {
+ offset_from_bottom: app
+ .product_announcement_max_scroll()
+ .saturating_sub(announcement.scroll) as usize,
+ max_offset_from_bottom: app.product_announcement_max_scroll() as usize,
+ viewport_rows: body_rect.height.max(1) as usize,
+ };
+ let track = release_notes_scrollbar_rect(body_rect, metrics);
+ let text_area = track
+ .map(|_| {
+ Rect::new(
+ body_rect.x,
+ body_rect.y,
+ body_rect.width.saturating_sub(1),
+ body_rect.height,
+ )
+ })
+ .unwrap_or(body_rect);
+
+ let body = Paragraph::new(
+ product_announcement_display_lines(announcement, &app.palette)
+ .into_iter()
+ .map(|(_, line)| line)
+ .collect::<Vec<_>>(),
+ )
+ .wrap(Wrap { trim: false })
+ .scroll((announcement.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(" esc / enter ", Style::default().fg(app.palette.text)),
+ ])),
+ stack.footer.unwrap_or_default(),
+ );
+}
+
fn release_notes_inline_spans<'a>(
text: &str,
base_style: Style,
@@ -356,6 +468,13 @@ pub(crate) fn release_notes_display_lines<'a>(
release_notes_lines(notes.body.as_str(), p)
}
+pub(crate) fn product_announcement_display_lines<'a>(
+ announcement: &'a ProductAnnouncementState,
+ p: &Palette,
+) -> Vec<(usize, Line<'a>)> {
+ release_notes_lines(announcement.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)
diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs
index 116010d1..b6b5bc9e 100644
--- a/src/ui/sidebar.rs
+++ b/src/ui/sidebar.rs
@@ -78,6 +78,7 @@ fn agent_panel_current_workspace_idx(app: &AppState) -> Option<usize> {
| Mode::Settings
| Mode::GlobalMenu
| Mode::KeybindHelp
+ | Mode::ProductAnnouncement
) {
Some(app.selected)
} else {