Skip to main content

ravenrs_widgets/
release_notes.rs

1//! What changed in each version of an app, and the command block it may show.
2
3use std::rc::Rc;
4
5use ravenrs_layout::Align;
6use ravenrs_reactive::Binding;
7use ravenrs_style::{Semantic, TextStyle, Weight};
8
9use crate::context::{BuildCx, NodeId};
10use crate::{
11    Badge, Button, ButtonSize, ButtonStyle, HStack, IconName, List, ListItem, ScrollView, Spacer, Text, Tone,
12    VStack, View, ViewExt,
13};
14
15/// A [`ReleaseNotes`] drops its list of versions below this window width.
16const LIST_BREAKPOINT: f32 = 640.0;
17const LIST_WIDTH: f32 = 220.0;
18
19/// A line of text to copy, such as a command: shown apart from the prose, with a
20/// button that puts it on the clipboard.
21#[derive(Clone, Debug)]
22#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
23pub struct CodeBlock {
24    code: String,
25}
26
27#[cfg_attr(feature = "live", ravenrs_live::expose)]
28impl CodeBlock {
29    /// A block showing `code`.
30    pub fn new(code: impl Into<String>) -> Self {
31        Self { code: code.into() }
32    }
33}
34
35impl View for CodeBlock {
36    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
37        let labels = cx.labels();
38        let copied = cx.use_state(|| false);
39        let clipboard = cx.clipboard();
40        let code = self.code.clone();
41        let done = copied.clone();
42        let (icon, label) = if copied.get() {
43            (IconName::Check, labels.copied.clone())
44        } else {
45            (IconName::Copy, labels.copy.clone())
46        };
47        let radius = cx.theme().radii.md;
48        let padding = cx.theme().spacing.s2;
49        cx.child(
50            &HStack::new((
51                Text::new(self.code.clone()).style(TextStyle::Caption).grow(1.0),
52                Button::new(label).icon(icon).style(ButtonStyle::Ghost).size(ButtonSize::Mini).on_click(
53                    move || {
54                        clipboard.write(&code);
55                        done.set(true);
56                    },
57                ),
58            ))
59            .spacing(padding)
60            .align(Align::Center)
61            .padding(padding)
62            .background(Semantic::Control)
63            .corner_radius(radius),
64        )
65    }
66}
67
68/// A label to click and what clicking it does, such as opening a page.
69type Link = (String, Rc<dyn Fn()>);
70
71#[derive(Clone)]
72enum Block {
73    Section(String, String),
74    Code(String),
75    Links(String, Vec<Link>),
76}
77
78impl std::fmt::Debug for Block {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        match self {
81            Self::Section(heading, text) => f.debug_tuple("Section").field(heading).field(text).finish(),
82            Self::Code(code) => f.debug_tuple("Code").field(code).finish(),
83            Self::Links(heading, links) => {
84                let labels: Vec<&str> = links.iter().map(|(label, _)| label.as_str()).collect();
85                f.debug_tuple("Links").field(heading).field(&labels).finish()
86            }
87        }
88    }
89}
90
91/// What one version brought, for a [`ReleaseNotes`].
92#[derive(Clone, Debug)]
93#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
94pub struct ReleaseNote {
95    version: String,
96    date: Option<String>,
97    title: String,
98    changes: Vec<String>,
99    blocks: Vec<Block>,
100    badge: Option<(String, Tone)>,
101}
102
103#[cfg_attr(feature = "live", ravenrs_live::expose)]
104impl ReleaseNote {
105    /// A note on `version`, as the app words it, with the title of the release.
106    pub fn new(version: impl Into<String>, title: impl Into<String>) -> Self {
107        Self {
108            version: version.into(),
109            date: None,
110            title: title.into(),
111            changes: Vec::new(),
112            blocks: Vec::new(),
113            badge: None,
114        }
115    }
116
117    /// The release date, already formatted.
118    pub fn date(mut self, date: impl Into<String>) -> Self {
119        self.date = Some(date.into());
120        self
121    }
122
123    /// The list of what changed.
124    pub fn changes(mut self, changes: impl IntoIterator<Item = impl Into<String>>) -> Self {
125        self.changes = changes.into_iter().map(Into::into).collect();
126        self
127    }
128
129    /// Adds a block of text with a heading, after what is already there.
130    pub fn section(mut self, heading: impl Into<String>, text: impl Into<String>) -> Self {
131        self.blocks.push(Block::Section(heading.into(), text.into()));
132        self
133    }
134
135    /// Adds a [`CodeBlock`], after what is already there.
136    pub fn code(mut self, code: impl Into<String>) -> Self {
137        self.blocks.push(Block::Code(code.into()));
138        self
139    }
140
141    /// Adds a heading over links, after what is already there: each a label and what
142    /// clicking it does, such as opening the issue a change came from.
143    pub fn links<F: Fn() + 'static>(
144        mut self,
145        heading: impl Into<String>,
146        links: impl IntoIterator<Item = (impl Into<String>, F)>,
147    ) -> Self {
148        let links: Vec<Link> = links
149            .into_iter()
150            .map(|(label, action)| (label.into(), Rc::new(action) as Rc<dyn Fn()>))
151            .collect();
152        if !links.is_empty() {
153            self.blocks.push(Block::Links(heading.into(), links));
154        }
155        self
156    }
157
158    /// Marks the version, such as "Installed" or "New".
159    pub fn badge(mut self, label: impl Into<String>, tone: Tone) -> Self {
160        self.badge = Some((label.into(), tone));
161        self
162    }
163
164    /// The details on the right of the list.
165    fn details(&self) -> impl View + use<> {
166        let date = self.date.clone().map(|date| format!("{} · {date}", self.version));
167        let changes: Vec<Text> = self
168            .changes
169            .iter()
170            .map(|change| Text::new(format!("• {change}")).style(TextStyle::Callout))
171            .collect();
172        let blocks: Vec<crate::AnyView> = self
173            .blocks
174            .iter()
175            .map(|block| match block {
176                Block::Section(heading, text) => Box::new(
177                    VStack::new((
178                        Text::new(heading.clone()).style(TextStyle::Callout).font_weight(Weight::SEMIBOLD),
179                        Text::new(text.clone()).style(TextStyle::Callout).foreground(Semantic::InkMuted),
180                    ))
181                    .spacing(4.0),
182                ) as crate::AnyView,
183                Block::Code(code) => Box::new(CodeBlock::new(code.clone())) as crate::AnyView,
184                Block::Links(heading, links) => {
185                    let buttons: Vec<Button> = links
186                        .iter()
187                        .map(|(label, action)| {
188                            let action = action.clone();
189                            Button::new(label.clone())
190                                .icon(IconName::ExternalLink)
191                                .style(ButtonStyle::Ghost)
192                                .size(ButtonSize::Small)
193                                .on_click(move || action())
194                        })
195                        .collect();
196                    Box::new(
197                        VStack::new((
198                            Text::new(heading.clone())
199                                .style(TextStyle::Callout)
200                                .font_weight(Weight::SEMIBOLD),
201                            HStack::new(buttons).spacing(4.0).wrap(),
202                        ))
203                        .spacing(4.0),
204                    ) as crate::AnyView
205                }
206            })
207            .collect();
208        VStack::new((
209            HStack::new((
210                Text::new(date.unwrap_or_else(|| self.version.clone()))
211                    .style(TextStyle::Caption)
212                    .foreground(Semantic::InkMuted),
213                self.badge.clone().map(|(label, tone)| Badge::new(label).tone(tone)),
214            ))
215            .spacing(8.0)
216            .align(Align::Center),
217            Text::new(self.title.clone()).style(TextStyle::Headline),
218            VStack::new(changes).spacing(6.0),
219            VStack::new(blocks).spacing(14.0).align(Align::Stretch),
220        ))
221        .spacing(14.0)
222        .align(Align::Stretch)
223    }
224}
225
226/// An app's versions, newest first: the list on the left, what the chosen one
227/// brought on the right, and buttons that walk from one to the next.
228///
229/// It shows whichever version `selected` points at, so the app opens it on the
230/// one it wants to talk about. It goes inside a [`Dialog`](crate::Dialog), or
231/// anywhere else with room for its height. In a narrow window the list goes away
232/// and the buttons remain.
233///
234/// ```
235/// use ravenrs_reactive::State;
236/// use ravenrs_widgets::{ReleaseNote, ReleaseNotes, Tone};
237///
238/// let selected = State::new(0);
239/// let notes = ReleaseNotes::new(
240///     vec![
241///         ReleaseNote::new("Version 1.3", "Faster start")
242///             .date("Nov 1, 2026")
243///             .badge("New", Tone::Success)
244///             .changes(["Opens in half the time"])
245///             .section("Why update", "Large files no longer stall the window."),
246///         ReleaseNote::new("Version 1.2", "Dark theme").badge("Installed", Tone::Neutral),
247///     ],
248///     selected.binding(),
249/// );
250/// ```
251#[derive(Debug)]
252#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
253pub struct ReleaseNotes {
254    notes: Vec<ReleaseNote>,
255    selected: Binding<usize>,
256    height: f32,
257}
258
259#[cfg_attr(feature = "live", ravenrs_live::expose)]
260impl ReleaseNotes {
261    /// The versions in `notes`, newest first, on the one at `selected`.
262    pub fn new(notes: Vec<ReleaseNote>, selected: Binding<usize>) -> Self {
263        Self { notes, selected, height: 460.0 }
264    }
265
266    /// The height, in logical pixels (460 by default). Longer notes scroll.
267    pub fn height(mut self, height: f32) -> Self {
268        self.height = height;
269        self
270    }
271}
272
273impl View for ReleaseNotes {
274    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
275        let last = self.notes.len().saturating_sub(1);
276        let at = self.selected.get().min(last);
277        let Some(note) = self.notes.get(at) else {
278            return cx.empty();
279        };
280        let labels = cx.labels();
281        let list = (cx.viewport().width >= LIST_BREAKPOINT).then(|| {
282            let rows: Vec<ListItem> = self
283                .notes
284                .iter()
285                .enumerate()
286                .map(|(i, note)| {
287                    let line = [note.badge.as_ref().map(|(label, _)| label.clone()), note.date.clone()];
288                    let line = line.into_iter().flatten().collect::<Vec<_>>().join(" · ");
289                    let selected = self.selected.clone();
290                    let row = ListItem::new(note.version.clone())
291                        .selected(i == at)
292                        .on_click(move || selected.set(i));
293                    if line.is_empty() { row } else { row.subtitle(line) }
294                })
295                .collect();
296            ScrollView::new((List::new(rows),)).frame(Some(LIST_WIDTH), None)
297        });
298        let walk = |to: Option<usize>| {
299            let selected = self.selected.clone();
300            move || {
301                if let Some(to) = to {
302                    selected.set(to);
303                }
304            }
305        };
306        let (newer, older) = (at.checked_sub(1), (at < last).then_some(at + 1));
307        let body = VStack::new((
308            ScrollView::new((note.details(),)),
309            HStack::new((
310                Button::new(labels.release_newer.clone())
311                    .icon(IconName::ChevronLeft)
312                    .style(ButtonStyle::Ghost)
313                    .size(ButtonSize::Small)
314                    .disabled(newer.is_none())
315                    .on_click(walk(newer)),
316                Button::new(labels.release_older.clone())
317                    .icon(IconName::ChevronRight)
318                    .style(ButtonStyle::Ghost)
319                    .size(ButtonSize::Small)
320                    .disabled(older.is_none())
321                    .on_click(walk(older)),
322                Spacer::new(),
323                Text::new(format!("{} {} {}", at + 1, labels.release_of, self.notes.len()))
324                    .style(TextStyle::Caption)
325                    .foreground(Semantic::InkMuted),
326            ))
327            .spacing(4.0)
328            .align(Align::Center),
329        ))
330        .spacing(10.0)
331        .align(Align::Stretch)
332        .grow(1.0);
333        cx.child(&HStack::new((list, body)).spacing(16.0).align(Align::Stretch).height(self.height))
334    }
335}