1use std::fmt;
4use std::rc::Rc;
5
6use ravenrs_core::{Border, Color, CursorIcon, Insets, Role};
7use ravenrs_layout::{Align, Axis, Justify, LayoutStyle};
8use ravenrs_style::{Semantic, TextStyle, Weight};
9
10use crate::context::{BuildCx, NodeId, Paint, QuadPaint, Semantics};
11use crate::{Avatar, IconName, View, ViewSeq, util};
12
13pub struct List<C> {
15 rows: C,
16}
17
18impl<C> fmt::Debug for List<C> {
19 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
20 f.write_str("List")
21 }
22}
23
24#[cfg_attr(feature = "live", ravenrs_live::expose)]
25impl<C: ViewSeq> List<C> {
26 pub fn new(rows: C) -> Self {
28 Self { rows }
29 }
30}
31
32impl<C: ViewSeq> View for List<C> {
33 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
34 let theme = *cx.theme();
35 let rows = cx.children(&self.rows);
36 let mut children = Vec::with_capacity(rows.len() * 2);
37 for (i, row) in rows.into_iter().enumerate() {
38 if i > 0 {
39 let line = cx.child(&crate::Divider::horizontal());
40 cx.semantics(line, Semantics::hidden());
41 children.push(line);
42 }
43 cx.semantics_if_none(row, Semantics::new(Role::ListItem));
44 children.push(row);
45 }
46 let style = LayoutStyle {
47 padding: Insets::uniform(4.0),
48 align: Align::Stretch,
49 ..LayoutStyle::stack(Axis::Vertical)
50 };
51 let paint = QuadPaint {
52 corner_radius: theme.radii.lg,
53 border: Some(Border { width: 1.0, color: theme.color(Semantic::Border) }),
54 ..QuadPaint::new(theme.color(Semantic::Surface))
55 };
56 let node = cx.container(style, &children, Paint::Quad(paint));
57 cx.semantics(node, Semantics::new(Role::List));
58 node
59 }
60}
61
62enum Leading {
63 None,
64 Icon(IconName),
65 Avatar(String),
66}
67
68#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
70pub struct ListItem {
71 title: String,
72 subtitle: Option<String>,
73 leading: Leading,
74 trailing: Option<String>,
75 selected: bool,
76 on_click: Option<Rc<dyn Fn()>>,
77}
78
79impl fmt::Debug for ListItem {
80 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81 f.debug_struct("ListItem").field("title", &self.title).finish_non_exhaustive()
82 }
83}
84
85#[cfg_attr(feature = "live", ravenrs_live::expose)]
86impl ListItem {
87 pub fn new(title: impl Into<String>) -> Self {
89 Self {
90 title: title.into(),
91 subtitle: None,
92 leading: Leading::None,
93 trailing: None,
94 selected: false,
95 on_click: None,
96 }
97 }
98
99 pub fn subtitle(mut self, text: impl Into<String>) -> Self {
101 self.subtitle = Some(text.into());
102 self
103 }
104
105 pub fn icon(mut self, icon: IconName) -> Self {
107 self.leading = Leading::Icon(icon);
108 self
109 }
110
111 pub fn avatar(mut self, name: impl Into<String>) -> Self {
113 self.leading = Leading::Avatar(name.into());
114 self
115 }
116
117 pub fn trailing(mut self, text: impl Into<String>) -> Self {
119 self.trailing = Some(text.into());
120 self
121 }
122
123 pub fn selected(mut self, selected: bool) -> Self {
125 self.selected = selected;
126 self
127 }
128
129 pub fn on_click(mut self, handler: impl Fn() + 'static) -> Self {
131 self.on_click = Some(Rc::new(handler));
132 self
133 }
134}
135
136impl View for ListItem {
137 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
138 let theme = *cx.theme();
139 let hovered = cx.is_hovered() && self.on_click.is_some();
140 let mut children = Vec::new();
141 match &self.leading {
142 Leading::None => {}
143 Leading::Icon(icon) => {
144 let i = util::icon(cx, *icon, 18.0, theme.color(Semantic::InkMuted));
145 children.push(cx.container(
146 LayoutStyle {
147 width: Some(36.0),
148 height: Some(36.0),
149 align: Align::Center,
150 justify: Justify::Center,
151 ..LayoutStyle::stack(Axis::Horizontal)
152 },
153 &[i],
154 Paint::Quad(QuadPaint {
155 corner_radius: 10.0,
156 ..QuadPaint::new(theme.color(Semantic::Control))
157 }),
158 ));
159 }
160 Leading::Avatar(name) => children.push(cx.child(&Avatar::new(name.clone()))),
161 }
162 let mut text = vec![util::label(
163 cx,
164 &self.title,
165 TextStyle::Body,
166 Some(Weight::SEMIBOLD),
167 theme.color(Semantic::Ink),
168 )];
169 if let Some(s) = &self.subtitle {
170 text.push(util::label(cx, s, TextStyle::Callout, None, theme.color(Semantic::InkMuted)));
171 }
172 children.push(cx.container(
173 LayoutStyle {
174 grow: 1.0,
175 min_width: Some(0.0),
176 spacing: 2.0,
177 ..LayoutStyle::stack(Axis::Vertical)
178 },
179 &text,
180 Paint::None,
181 ));
182 if let Some(t) = &self.trailing {
183 let attrs = util::attrs(&theme, TextStyle::Callout, None);
185 let width = cx.measure_text(t, &attrs).width.ceil();
186 let style = LayoutStyle { min_width: Some(width), ..LayoutStyle::leaf() };
187 children.push(cx.text(t, &attrs, theme.color(Semantic::InkMuted), style));
188 }
189 if self.on_click.is_some() {
190 children.push(util::icon(cx, IconName::ChevronRight, 16.0, theme.color(Semantic::InkMuted)));
191 }
192 let fill = if self.selected {
193 theme.color(Semantic::Control)
194 } else if hovered {
195 theme.color(Semantic::Ink).with_alpha(0.04)
196 } else {
197 Color::TRANSPARENT
198 };
199 let style = LayoutStyle {
200 padding: Insets::symmetric(10.0, 12.0),
201 spacing: 12.0,
202 align: Align::Center,
203 ..LayoutStyle::stack(Axis::Horizontal)
204 };
205 let node = cx.container(
206 style,
207 &children,
208 Paint::Quad(QuadPaint { corner_radius: 10.0, ..QuadPaint::new(fill) }),
209 );
210 cx.semantics(
211 node,
212 Semantics::new(if self.on_click.is_some() { Role::Button } else { Role::ListItem }),
213 );
214 if let Some(h) = &self.on_click {
215 cx.on_click(node, h.clone());
216 cx.focusable(node, 10.0);
217 cx.cursor(node, CursorIcon::Pointer);
218 }
219 node
220 }
221}
222
223#[derive(Clone, Debug)]
225#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
226pub struct Column {
227 title: String,
228 weight: f32,
229 align_end: bool,
230}
231
232#[cfg_attr(feature = "live", ravenrs_live::expose)]
233impl Column {
234 pub fn new(title: impl Into<String>) -> Self {
236 Self { title: title.into(), weight: 1.0, align_end: false }
237 }
238
239 pub fn weight(mut self, weight: f32) -> Self {
241 self.weight = weight.max(0.1);
242 self
243 }
244
245 pub fn numeric(mut self) -> Self {
247 self.align_end = true;
248 self
249 }
250}
251
252#[derive(Clone, Debug)]
254#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
255pub struct Table {
256 columns: Vec<Column>,
257 rows: Vec<Vec<String>>,
258}
259
260#[cfg_attr(feature = "live", ravenrs_live::expose)]
261impl Table {
262 pub fn new(columns: impl IntoIterator<Item = Column>) -> Self {
264 Self { columns: columns.into_iter().collect(), rows: Vec::new() }
265 }
266
267 pub fn row(mut self, cells: impl IntoIterator<Item = impl Into<String>>) -> Self {
269 self.rows.push(cells.into_iter().map(Into::into).collect());
270 self
271 }
272}
273
274struct TableRow<'a> {
275 columns: &'a [Column],
276 cells: &'a [String],
277 header: bool,
278}
279
280impl View for TableRow<'_> {
281 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
282 let theme = *cx.theme();
283 let cells: Vec<NodeId> = self
284 .columns
285 .iter()
286 .enumerate()
287 .map(|(i, col)| {
288 let text = if self.header {
289 col.title.to_uppercase()
290 } else {
291 self.cells.get(i).cloned().unwrap_or_default()
292 };
293 let (style, color, weight) = if self.header {
294 (TextStyle::Caption, theme.color(Semantic::InkMuted), None)
295 } else if i == 0 {
296 (TextStyle::Body, theme.color(Semantic::Ink), Some(Weight::SEMIBOLD))
297 } else {
298 (TextStyle::Body, theme.color(Semantic::InkMuted), None)
299 };
300 let label = util::label(cx, &text, style, weight, color);
301 let header = self.header;
302 let cell = cx.container(
303 LayoutStyle {
304 grow: col.weight,
305 width: Some(0.0),
306 min_width: Some(0.0),
307 justify: if col.align_end { Justify::End } else { Justify::Start },
308 ..LayoutStyle::stack(Axis::Horizontal)
309 },
310 &[label],
311 Paint::None,
312 );
313 let semantics = if header {
314 Semantics::new(Role::ColumnHeader).name(col.title.clone())
315 } else {
316 Semantics::new(Role::Cell)
317 };
318 cx.semantics(cell, semantics);
319 cell
320 })
321 .collect();
322 let hovered = !self.header && cx.is_hovered();
323 let fill = if hovered { theme.color(Semantic::Ink).with_alpha(0.04) } else { Color::TRANSPARENT };
324 let style = LayoutStyle {
325 padding: Insets::symmetric(if self.header { 10.0 } else { 12.0 }, 16.0),
326 spacing: 16.0,
327 align: Align::Center,
328 ..LayoutStyle::stack(Axis::Horizontal)
329 };
330 let node = cx.container(style, &cells, Paint::Quad(QuadPaint::new(fill)));
331 cx.semantics(node, Semantics::new(Role::Row));
332 if !self.header {
333 cx.cursor(node, CursorIcon::Default);
334 }
335 node
336 }
337}
338
339impl View for Table {
340 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
341 let theme = *cx.theme();
342 let mut children = vec![cx.child(&TableRow { columns: &self.columns, cells: &[], header: true })];
343 for (i, row) in self.rows.iter().enumerate() {
344 let line = cx.child(&crate::Divider::horizontal());
345 cx.semantics(line, Semantics::hidden());
346 children.push(line);
347 children.push(
348 cx.keyed_child(i as u64, &TableRow { columns: &self.columns, cells: row, header: false }),
349 );
350 }
351 let style = LayoutStyle {
352 align: Align::Stretch,
353 align_self: Some(Align::Stretch),
354 ..LayoutStyle::stack(Axis::Vertical)
355 };
356 let node = cx.container(
357 style,
358 &children,
359 Paint::Quad(QuadPaint {
360 corner_radius: theme.radii.lg,
361 border: Some(Border { width: 1.0, color: theme.color(Semantic::Border) }),
362 ..QuadPaint::new(theme.color(Semantic::Surface))
363 }),
364 );
365 cx.semantics(node, Semantics::new(Role::Table));
366 cx.clip(node);
367 node
368 }
369}