1use 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
15const LIST_BREAKPOINT: f32 = 640.0;
17const LIST_WIDTH: f32 = 220.0;
18
19#[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 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
68type 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#[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 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 pub fn date(mut self, date: impl Into<String>) -> Self {
119 self.date = Some(date.into());
120 self
121 }
122
123 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 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 pub fn code(mut self, code: impl Into<String>) -> Self {
137 self.blocks.push(Block::Code(code.into()));
138 self
139 }
140
141 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 pub fn badge(mut self, label: impl Into<String>, tone: Tone) -> Self {
160 self.badge = Some((label.into(), tone));
161 self
162 }
163
164 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#[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 pub fn new(notes: Vec<ReleaseNote>, selected: Binding<usize>) -> Self {
263 Self { notes, selected, height: 460.0 }
264 }
265
266 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}