1use std::collections::BTreeSet;
4use std::fmt;
5use std::rc::Rc;
6
7use ravenrs_core::{Border, Color, CursorIcon, Insets, Key, PathElement, PathFill, Point, Quad, Rect, Role};
8use ravenrs_layout::{Align, Axis, LayoutStyle};
9use ravenrs_reactive::{Binding, State};
10use ravenrs_style::{Semantic, TextStyle, Weight};
11
12use crate::context::{BuildCx, DragPhase, NodeId, Paint, QuadPaint, Semantics};
13use crate::{AnyView, SelectionMode, View, util};
14
15const TILE_GAP: f32 = 12.0;
16const TILE_HEADER: f32 = 48.0;
18const TILE_INSET: f32 = 16.0;
20const CORNER: f32 = 20.0;
22const GRIP_INSET: f32 = 5.0;
23const CARD_OVERSCAN: usize = 2;
24
25#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
28#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
29pub struct TileSpot {
30 pub key: String,
32 pub columns: u16,
34 pub rows: u16,
36}
37
38#[cfg_attr(feature = "live", ravenrs_live::expose)]
39impl TileSpot {
40 pub fn new(key: impl Into<String>, columns: u16, rows: u16) -> Self {
42 Self { key: key.into(), columns: columns.max(1), rows: rows.max(1) }
43 }
44}
45
46#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
48pub struct Tile {
49 key: String,
50 title: String,
51 content: AnyView,
52 accessory: Option<AnyView>,
53}
54
55impl fmt::Debug for Tile {
56 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57 f.debug_struct("Tile").field("key", &self.key).finish_non_exhaustive()
58 }
59}
60
61#[cfg_attr(feature = "live", ravenrs_live::expose)]
62impl Tile {
63 pub fn new(key: impl Into<String>, title: impl Into<String>, content: impl View + 'static) -> Self {
65 Self { key: key.into(), title: title.into(), content: Box::new(content), accessory: None }
66 }
67
68 pub fn accessory(mut self, accessory: impl View + 'static) -> Self {
71 self.accessory = Some(Box::new(accessory));
72 self
73 }
74}
75
76fn place(spots: &[TileSpot], columns: usize) -> Vec<(usize, usize)> {
79 let columns = columns.max(1);
80 let mut taken: Vec<Vec<bool>> = Vec::new();
81 let mut places = Vec::with_capacity(spots.len());
82 for spot in spots {
83 let (wide, tall) = ((spot.columns as usize).min(columns), spot.rows as usize);
84 let found = (0usize..)
85 .flat_map(|row| (0..=columns - wide).map(move |column| (column, row)))
86 .find(|&(column, row)| {
87 (row..row + tall)
88 .all(|r| (column..column + wide).all(|c| !taken.get(r).is_some_and(|line| line[c])))
89 })
90 .unwrap_or((0, taken.len()));
91 for r in found.1..found.1 + tall {
92 if taken.len() <= r {
93 taken.resize(r + 1, vec![false; columns]);
94 }
95 for cell in &mut taken[r][found.0..found.0 + wide] {
96 *cell = true;
97 }
98 }
99 places.push(found);
100 }
101 places
102}
103
104fn rows_used(spots: &[TileSpot], places: &[(usize, usize)]) -> usize {
105 spots.iter().zip(places).map(|(s, p)| p.1 + s.rows as usize).max().unwrap_or(0)
106}
107
108fn reconcile(saved: &[TileSpot], defaults: &[TileSpot]) -> Vec<TileSpot> {
111 let mut spots: Vec<TileSpot> =
112 saved.iter().filter(|s| defaults.iter().any(|d| d.key == s.key)).cloned().collect();
113 for default in defaults {
114 if !spots.iter().any(|s| s.key == default.key) {
115 spots.push(default.clone());
116 }
117 }
118 spots
119}
120
121#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
137pub struct TileLayout {
138 spots: Binding<Vec<TileSpot>>,
139 tiles: Vec<Tile>,
140 columns: usize,
141 min_column_width: f32,
142 row_height: f32,
143 locked: bool,
144}
145
146impl fmt::Debug for TileLayout {
147 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
148 f.debug_struct("TileLayout").field("tiles", &self.tiles).finish_non_exhaustive()
149 }
150}
151
152#[cfg_attr(feature = "live", ravenrs_live::expose)]
153impl TileLayout {
154 pub fn new(spots: Binding<Vec<TileSpot>>) -> Self {
157 Self {
158 spots,
159 tiles: Vec::new(),
160 columns: 4,
161 min_column_width: 200.0,
162 row_height: 160.0,
163 locked: false,
164 }
165 }
166
167 pub fn tile(mut self, tile: Tile) -> Self {
169 self.tiles.push(tile);
170 self
171 }
172
173 pub fn columns(mut self, columns: usize) -> Self {
175 self.columns = columns.max(1);
176 self
177 }
178
179 pub fn min_column_width(mut self, width: f32) -> Self {
181 self.min_column_width = width.max(1.0);
182 self
183 }
184
185 pub fn row_height(mut self, height: f32) -> Self {
187 self.row_height = height.max(TILE_HEADER);
188 self
189 }
190
191 pub fn locked(mut self, locked: bool) -> Self {
193 self.locked = locked;
194 self
195 }
196}
197
198#[derive(Clone, Copy)]
200struct Cells {
201 columns: usize,
202 column_step: f32,
203 row_step: f32,
204}
205
206#[cfg_attr(feature = "live", ravenrs_live::expose)]
207impl Cells {
208 fn at(&self, x: f32, y: f32) -> (usize, usize) {
210 let column = ((x / self.column_step).floor().max(0.0) as usize).min(self.columns - 1);
211 let row = (y / self.row_step).floor().max(0.0) as usize;
212 (column, row)
213 }
214}
215
216struct TileHandle {
218 name: String,
219 key: String,
220 origin: Point,
221 spots: Binding<Vec<TileSpot>>,
222 cells: Cells,
223}
224
225impl View for TileHandle {
226 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
227 let theme = *cx.theme();
228 let muted = theme.color(Semantic::InkMuted);
229 let handle = cx.container(
230 LayoutStyle { width: Some(20.0), height: Some(TILE_HEADER), ..LayoutStyle::leaf() },
231 &[],
232 Paint::Custom(Rc::new(move |f: Rect, scene| {
233 let center = f.center();
234 for column in [-3.0, 2.0] {
235 for row in [-6.0, -1.0, 4.0] {
236 let dot = Rect::new(center.x + column, center.y + row, 2.0, 2.0);
237 scene.push_quad(Quad::new(dot, muted).radius(1.0));
238 }
239 }
240 })),
241 );
242 cx.semantics(handle, Semantics::new(Role::Button).name(self.name.clone()));
243 cx.focusable(handle, theme.radii.sm);
244 cx.cursor(handle, CursorIcon::Pointer);
245 let (spots, key, cells, origin) = (self.spots.clone(), self.key.clone(), self.cells, self.origin);
246 cx.on_drag(
247 handle,
248 Rc::new(move |d| {
249 if d.phase != DragPhase::Move {
250 return;
251 }
252 let at = Point::new(
255 origin.x + d.position.x - d.container.x(),
256 origin.y + d.position.y - d.container.y(),
257 );
258 move_to_cell(&spots, &key, cells, cells.at(at.x, at.y));
259 }),
260 );
261 let (spots, key, wide) = (self.spots.clone(), self.key.clone(), self.cells.columns);
262 cx.on_key(
263 handle,
264 Rc::new(move |pressed, modifiers| {
265 if modifiers.shift {
266 let (columns, rows) = match pressed {
267 Key::ArrowLeft => (-1, 0),
268 Key::ArrowRight => (1, 0),
269 Key::ArrowUp => (0, -1),
270 Key::ArrowDown => (0, 1),
271 _ => return false,
272 };
273 resize(&spots, &key, columns, rows, wide);
274 } else {
275 let by = match pressed {
276 Key::ArrowLeft | Key::ArrowUp => -1,
277 Key::ArrowRight | Key::ArrowDown => 1,
278 _ => return false,
279 };
280 shift(&spots, &key, by);
281 }
282 true
283 }),
284 );
285 handle
286 }
287}
288
289struct TileCorner {
291 key: String,
292 place: (usize, usize),
294 at: (f32, f32),
295 shown: bool,
297 spots: Binding<Vec<TileSpot>>,
298 cells: Cells,
299}
300
301fn grip(frame: Rect, ink: Color) -> [PathFill; 2] {
303 let (x, y) = (frame.max_x() - GRIP_INSET, frame.max_y() - GRIP_INSET);
304 [10.0, 5.0].map(|length| {
305 let half = 0.75 * std::f32::consts::FRAC_1_SQRT_2;
307 let corners = [
308 Point::new(x + half, y - length + half),
309 Point::new(x - length + half, y + half),
310 Point::new(x - length - half, y - half),
311 Point::new(x - half, y - length - half),
312 ];
313 let mut path = vec![PathElement::MoveTo(corners[0])];
314 path.extend(corners[1..].iter().map(|&p| PathElement::LineTo(p)));
315 path.push(PathElement::Close);
316 PathFill::solid(path, ink)
317 })
318}
319
320impl View for TileCorner {
321 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
322 let theme = *cx.theme();
323 let ink = if cx.is_hovered() || cx.is_pressed() {
325 theme.color(Semantic::Ink)
326 } else {
327 theme.color(Semantic::InkMuted).with_alpha(if self.shown { 0.7 } else { 0.0 })
328 };
329 let corner = cx.container(
330 LayoutStyle {
331 width: Some(CORNER),
332 height: Some(CORNER),
333 absolute: Some(self.at),
334 ..LayoutStyle::leaf()
335 },
336 &[],
337 Paint::Custom(Rc::new(move |f: Rect, scene| {
338 for stroke in grip(f, ink) {
339 scene.push_path(stroke);
340 }
341 })),
342 );
343 cx.semantics(corner, Semantics::hidden());
344 cx.cursor(corner, CursorIcon::ColResize);
345 let (spots, key, cells, (column, row)) =
346 (self.spots.clone(), self.key.clone(), self.cells, self.place);
347 cx.on_drag(
348 corner,
349 Rc::new(move |d| {
350 if d.phase != DragPhase::Move {
351 return;
352 }
353 let (end_column, end_row) = cells.at(
355 d.position.x - d.container.x() + column as f32 * cells.column_step,
356 d.position.y - d.container.y() + row as f32 * cells.row_step,
357 );
358 let wide = (end_column + 1).saturating_sub(column).clamp(1, cells.columns) as u16;
359 let tall = (end_row + 1).saturating_sub(row).max(1) as u16;
360 spots.update(|all| {
361 if let Some(spot) = all.iter_mut().find(|s| s.key == key) {
362 spot.columns = wide;
363 spot.rows = tall;
364 }
365 });
366 }),
367 );
368 corner
369 }
370}
371
372struct TileCard<'a> {
373 tile: &'a Tile,
374 key: &'a str,
375 place: (usize, usize),
376 frame: (f32, f32, f32, f32),
377 spots: Binding<Vec<TileSpot>>,
378 cells: Cells,
379 locked: bool,
380}
381
382impl View for TileCard<'_> {
383 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
384 let theme = *cx.theme();
385 let (x, y, width, height) = self.frame;
386 let title = cx.text_truncated(
387 &self.tile.title,
388 &util::attrs(&theme, TextStyle::Callout, Some(Weight::SEMIBOLD)),
389 theme.color(Semantic::Ink),
390 LayoutStyle { grow: 1.0, min_width: Some(0.0), ..LayoutStyle::leaf() },
391 );
392 let mut header = Vec::new();
393 if !self.locked {
394 header.push(cx.child(&TileHandle {
395 name: format!("{} {}", cx.labels().tile_move, self.tile.title),
396 key: self.key.to_owned(),
397 origin: Point::new(x, y),
398 spots: self.spots.clone(),
399 cells: self.cells,
400 }));
401 }
402 header.push(title);
403 if let Some(accessory) = &self.tile.accessory {
404 header.push(cx.child(accessory.as_ref()));
405 }
406 let bar = cx.container(
407 LayoutStyle {
408 height: Some(TILE_HEADER),
409 padding: Insets {
411 left: if self.locked { TILE_INSET } else { TILE_INSET - 8.0 },
412 right: TILE_INSET,
413 ..Insets::ZERO
414 },
415 spacing: 8.0,
416 align: Align::Center,
417 align_self: Some(Align::Stretch),
418 ..LayoutStyle::stack(Axis::Horizontal)
419 },
420 &header,
421 Paint::None,
422 );
423 let content = cx.child(self.tile.content.as_ref());
424 let body = cx.container(
425 LayoutStyle {
426 grow: 1.0,
427 min_height: Some(0.0),
428 overflow_hidden: true,
429 padding: Insets { left: TILE_INSET, right: TILE_INSET, top: 0.0, bottom: TILE_INSET },
430 align: Align::Stretch,
431 align_self: Some(Align::Stretch),
432 ..LayoutStyle::stack(Axis::Vertical)
433 },
434 &[content],
435 Paint::None,
436 );
437 cx.clip(body);
438 let mut parts = vec![bar, body];
439 if !self.locked {
440 parts.push(cx.child(&TileCorner {
441 key: self.key.to_owned(),
442 place: self.place,
443 at: (width - CORNER, height - CORNER),
444 shown: cx.is_hover_within(),
445 spots: self.spots.clone(),
446 cells: self.cells,
447 }));
448 }
449 let node = cx.container(
450 LayoutStyle {
451 absolute: Some((x, y)),
452 width: Some(width),
453 height: Some(height),
454 align: Align::Stretch,
455 ..LayoutStyle::stack(Axis::Vertical)
456 },
457 &parts,
458 Paint::Quad(QuadPaint {
459 corner_radius: theme.radii.lg,
460 border: Some(Border { width: 1.0, color: theme.color(Semantic::Border) }),
461 ..QuadPaint::new(theme.color(Semantic::Surface))
462 }),
463 );
464 cx.semantics(node, Semantics::new(Role::Group).name(self.tile.title.clone()));
465 node
466 }
467}
468
469fn move_to_cell(spots: &Binding<Vec<TileSpot>>, key: &str, cells: Cells, target: (usize, usize)) {
471 let all = spots.get();
472 let places = place(&all, cells.columns);
473 let Some(from) = all.iter().position(|s| s.key == key) else { return };
474 let over = all.iter().zip(&places).position(|(spot, &(column, row))| {
475 (column..column + (spot.columns as usize).min(cells.columns)).contains(&target.0)
476 && (row..row + spot.rows as usize).contains(&target.1)
477 });
478 if let Some(to) = over
479 && to != from
480 {
481 spots.update(|all| {
482 let spot = all.remove(from);
483 all.insert(to, spot);
484 });
485 }
486}
487
488fn shift(spots: &Binding<Vec<TileSpot>>, key: &str, by: i32) {
489 spots.update(|all| {
490 if let Some(from) = all.iter().position(|s| s.key == key) {
491 let to = (from as i32 + by).clamp(0, all.len() as i32 - 1) as usize;
492 let spot = all.remove(from);
493 all.insert(to, spot);
494 }
495 });
496}
497
498fn resize(spots: &Binding<Vec<TileSpot>>, key: &str, columns: i32, rows: i32, most_columns: usize) {
499 spots.update(|all| {
500 if let Some(spot) = all.iter_mut().find(|s| s.key == key) {
501 spot.columns = (i32::from(spot.columns) + columns).clamp(1, most_columns as i32) as u16;
502 spot.rows = (i32::from(spot.rows) + rows).max(1) as u16;
503 }
504 });
505}
506
507impl View for TileLayout {
508 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
509 let defaults: Vec<TileSpot> = self.tiles.iter().map(|t| TileSpot::new(t.key.clone(), 1, 1)).collect();
510 let spots = reconcile(&self.spots.get(), &defaults);
511 let width = cx.scroll_viewport().map_or(cx.viewport().width, |size| size.width).max(1.0);
513 let columns = (((width + TILE_GAP) / (self.min_column_width + TILE_GAP)).floor() as usize)
514 .clamp(1, self.columns);
515 let step = (width + TILE_GAP) / columns as f32;
516 let row_step = self.row_height + TILE_GAP;
517 let cells = Cells { columns, column_step: step, row_step };
518 let places = place(&spots, columns);
519 let total = rows_used(&spots, &places);
520 let (read, write) = (self.spots.clone(), self.spots.clone());
522 let known = defaults.clone();
523 let bound = Binding::new(move || reconcile(&read.get(), &known), move |all| write.set(all));
524 let mut children = Vec::with_capacity(spots.len());
525 for (index, spot) in spots.iter().enumerate() {
526 let Some(tile) = self.tiles.iter().find(|t| t.key == spot.key) else { continue };
527 let (column, row) = places[index];
528 let wide = (spot.columns as usize).min(columns) as f32;
529 let card = TileCard {
530 tile,
531 key: &spot.key,
532 place: places[index],
533 frame: (
534 column as f32 * step,
535 row as f32 * row_step,
536 wide * step - TILE_GAP,
537 spot.rows as f32 * row_step - TILE_GAP,
538 ),
539 spots: bound.clone(),
540 cells,
541 locked: self.locked,
542 };
543 children.push(cx.keyed_child(util::hash_text(&spot.key), &card));
544 }
545 let node = cx.container(
546 LayoutStyle {
547 height: Some((total as f32 * row_step - TILE_GAP).max(0.0)),
548 align_self: Some(Align::Stretch),
549 ..LayoutStyle::leaf()
550 },
551 &children,
552 Paint::None,
553 );
554 cx.report_frame(node);
555 node
556 }
557}
558
559type CardBuilder = Rc<dyn Fn(usize) -> AnyView>;
560
561#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
572pub struct CardView {
573 count: usize,
574 card: CardBuilder,
575 card_width: f32,
576 card_height: f32,
577 height: Option<f32>,
578 mode: SelectionMode,
579 selection: Option<Binding<BTreeSet<usize>>>,
580 on_open: Option<Rc<dyn Fn(usize)>>,
581 card_name: Option<Rc<dyn Fn(usize) -> String>>,
582 label: Option<String>,
583}
584
585impl fmt::Debug for CardView {
586 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
587 f.debug_struct("CardView").field("count", &self.count).finish_non_exhaustive()
588 }
589}
590
591#[cfg_attr(feature = "live", ravenrs_live::expose)]
592impl CardView {
593 pub fn new(count: usize, card: impl Fn(usize) -> AnyView + 'static) -> Self {
595 Self {
596 count,
597 card: Rc::new(card),
598 card_width: 240.0,
599 card_height: 160.0,
600 height: None,
601 mode: SelectionMode::Single,
602 selection: None,
603 on_open: None,
604 card_name: None,
605 label: None,
606 }
607 }
608
609 pub fn card_width(mut self, width: f32) -> Self {
611 self.card_width = width.max(1.0);
612 self
613 }
614
615 pub fn card_height(mut self, height: f32) -> Self {
617 self.card_height = height.max(1.0);
618 self
619 }
620
621 pub fn height(mut self, height: f32) -> Self {
623 self.height = Some(height);
624 self
625 }
626
627 pub fn selection_mode(mut self, mode: SelectionMode) -> Self {
629 self.mode = mode;
630 self
631 }
632
633 pub fn selection(mut self, selection: Binding<BTreeSet<usize>>) -> Self {
635 self.selection = Some(selection);
636 self
637 }
638
639 pub fn on_open(mut self, handler: impl Fn(usize) + 'static) -> Self {
641 self.on_open = Some(Rc::new(handler));
642 self
643 }
644
645 pub fn card_name(mut self, name: impl Fn(usize) -> String + 'static) -> Self {
647 self.card_name = Some(Rc::new(name));
648 self
649 }
650
651 pub fn label(mut self, label: impl Into<String>) -> Self {
653 self.label = Some(label.into());
654 self
655 }
656}
657
658fn select_card(selection: &Binding<BTreeSet<usize>>, mode: SelectionMode, index: usize) {
660 match mode {
661 SelectionMode::None => {}
662 SelectionMode::Single => selection.set(BTreeSet::from([index])),
663 _ => selection.update(|set| {
664 if !set.remove(&index) {
665 set.insert(index);
666 }
667 }),
668 }
669}
670
671struct CardItem<'a> {
672 index: usize,
673 view: &'a CardView,
674 width: f32,
675 selection: Binding<BTreeSet<usize>>,
676 selected: bool,
677 active: bool,
678 focused: bool,
679 cursor: State<usize>,
680}
681
682impl View for CardItem<'_> {
683 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
684 let theme = *cx.theme();
685 let content = (self.view.card)(self.index);
686 let inner = cx.child(content.as_ref());
687 let accent = theme.color(Semantic::Accent);
688 let edge = if self.selected || (self.active && self.focused) {
689 Border { width: 1.5, color: accent }
690 } else if cx.is_hovered() {
691 Border { width: 1.0, color: theme.color(Semantic::Border).lerp(theme.color(Semantic::Ink), 0.25) }
692 } else {
693 Border { width: 1.0, color: theme.color(Semantic::Border) }
694 };
695 let fill = if self.selected { accent.with_alpha(0.08) } else { theme.color(Semantic::Surface) };
696 let node = cx.container(
697 LayoutStyle {
698 width: Some(self.width),
699 height: Some(self.view.card_height),
700 padding: Insets::uniform(12.0),
701 overflow_hidden: true,
702 align: Align::Stretch,
703 ..LayoutStyle::stack(Axis::Vertical)
704 },
705 &[inner],
706 Paint::Quad(QuadPaint {
707 corner_radius: theme.radii.lg,
708 border: Some(edge),
709 ..QuadPaint::new(fill)
710 }),
711 );
712 let (selection, mode, index, cursor) =
713 (self.selection.clone(), self.view.mode, self.index, self.cursor.clone());
714 cx.on_click(
715 node,
716 Rc::new(move || {
717 cursor.set(index);
718 select_card(&selection, mode, index);
719 }),
720 );
721 if let Some(open) = self.view.on_open.clone() {
722 cx.on_double_click(node, Rc::new(move || open(index)));
723 }
724 cx.cursor(node, CursorIcon::Pointer);
725 let mut semantics = Semantics::new(Role::ListItem).selected(self.selected).active(self.active);
726 if let Some(name) = &self.view.card_name {
727 semantics = semantics.name(name(self.index));
728 }
729 cx.semantics(node, semantics);
730 node
731 }
732}
733
734impl View for CardView {
735 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
736 let local: State<BTreeSet<usize>> = cx.use_state(BTreeSet::new);
737 let selection = self.selection.clone().unwrap_or_else(|| local.binding());
738 let cursor: State<usize> = cx.use_state(|| 0);
739 let reveal: State<bool> = cx.use_state(|| false);
740 let viewport = cx.scroll_viewport();
741 let width = viewport.map_or(cx.viewport().width, |size| size.width).max(1.0);
742 let height = viewport.map_or(self.height.unwrap_or(cx.viewport().height), |size| size.height);
743 let per_row = (((width + TILE_GAP) / (self.card_width + TILE_GAP)).floor() as usize).max(1);
744 let card_width = (width - TILE_GAP * (per_row - 1) as f32) / per_row as f32;
745 let row_step = self.card_height + TILE_GAP;
746 let rows = self.count.div_ceil(per_row);
747 let active = cursor.get().min(self.count.saturating_sub(1));
748 let mut offset = cx.scroll_offset();
749 if *reveal.peek() {
750 reveal.set_if_changed(false);
751 let top = (active / per_row) as f32 * row_step;
752 if top < offset.y {
753 offset.y = top;
754 cx.set_scroll_offset(offset);
755 } else if top + self.card_height > offset.y + height {
756 offset.y = top + self.card_height - height;
757 cx.set_scroll_offset(offset);
758 }
759 }
760 let first = ((offset.y / row_step).floor() as usize).saturating_sub(CARD_OVERSCAN).min(rows);
761 let last = (((offset.y + height) / row_step).ceil() as usize + CARD_OVERSCAN).min(rows);
762 let selected = selection.get();
763 let focused = cx.is_focused();
764 let mut lines = Vec::with_capacity(last.saturating_sub(first) + 2);
765 lines.push(cx.container(
766 LayoutStyle { height: Some(first as f32 * row_step), ..LayoutStyle::leaf() },
767 &[],
768 Paint::None,
769 ));
770 for row in first..last {
771 let mut cards = Vec::with_capacity(per_row);
772 for index in (row * per_row)..((row + 1) * per_row).min(self.count) {
773 let item = CardItem {
774 index,
775 view: self,
776 width: card_width,
777 selection: selection.clone(),
778 selected: selected.contains(&index),
779 active: index == active,
780 focused,
781 cursor: cursor.clone(),
782 };
783 cards.push(cx.keyed_child(index as u64, &item));
784 }
785 lines.push(cx.container(
786 LayoutStyle {
787 height: Some(row_step),
788 spacing: TILE_GAP,
789 align: Align::Start,
790 ..LayoutStyle::stack(Axis::Horizontal)
791 },
792 &cards,
793 Paint::None,
794 ));
795 }
796 lines.push(cx.container(
797 LayoutStyle { height: Some(rows.saturating_sub(last) as f32 * row_step), ..LayoutStyle::leaf() },
798 &[],
799 Paint::None,
800 ));
801 let content = cx.container(
802 LayoutStyle { align: Align::Stretch, ..LayoutStyle::stack(Axis::Vertical) },
803 &lines,
804 Paint::None,
805 );
806 let body = cx.container(
807 LayoutStyle {
808 height: self.height,
809 grow: if self.height.is_some() { 0.0 } else { 1.0 },
810 min_height: Some(0.0),
811 overflow_hidden: true,
812 align: Align::Stretch,
813 align_self: Some(Align::Stretch),
814 ..LayoutStyle::stack(Axis::Vertical)
815 },
816 &[content],
817 Paint::None,
818 );
819 cx.scrollable_virtual(body, false);
820 let name = self.label.clone().unwrap_or_else(|| cx.labels().cards_name.clone());
821 cx.semantics(body, Semantics::new(Role::List).name(name).description(self.count.to_string()));
822 cx.focusable(body, 4.0);
823 let (count, mode, open) = (self.count, self.mode, self.on_open.clone());
824 cx.on_key(
825 body,
826 Rc::new(move |key, _| {
827 if count == 0 {
828 return false;
829 }
830 let go = |index: usize| {
831 cursor.set(index.min(count - 1));
832 reveal.set(true);
833 };
834 match key {
835 Key::ArrowRight => go(active + 1),
836 Key::ArrowLeft => go(active.saturating_sub(1)),
837 Key::ArrowDown => go((active + per_row).min(count - 1)),
838 Key::ArrowUp => go(active.saturating_sub(per_row)),
839 Key::Home => go(0),
840 Key::End => go(count - 1),
841 Key::Space => select_card(&selection, mode, active),
842 Key::Enter => {
843 if let Some(open) = &open {
844 open(active);
845 }
846 }
847 _ => return false,
848 }
849 true
850 }),
851 );
852 body
853 }
854}
855
856#[cfg(test)]
857mod tests {
858 use super::*;
859
860 fn spot(key: &str, columns: u16, rows: u16) -> TileSpot {
861 TileSpot::new(key, columns, rows)
862 }
863
864 #[test]
865 fn tiles_take_the_first_place_they_fit() {
866 let spots = [spot("a", 2, 1), spot("b", 1, 2), spot("c", 1, 1), spot("d", 3, 1), spot("e", 1, 1)];
867 let places = place(&spots, 3);
868 assert_eq!(places, [(0, 0), (2, 0), (0, 1), (0, 2), (1, 1)]);
869 assert_eq!(rows_used(&spots, &places), 3);
870 }
871
872 #[test]
873 fn a_tile_wider_than_the_grid_takes_its_whole_width() {
874 let spots = [spot("a", 4, 1), spot("b", 1, 1)];
875 assert_eq!(place(&spots, 2), [(0, 0), (0, 1)]);
876 assert_eq!(place(&spots, 1), [(0, 0), (0, 1)]);
877 }
878
879 #[test]
880 fn a_saved_layout_keeps_up_with_the_tiles() {
881 let saved = [spot("gone", 1, 1), spot("b", 2, 1)];
882 let defaults = [spot("a", 1, 1), spot("b", 1, 1)];
883 assert_eq!(reconcile(&saved, &defaults), [spot("b", 2, 1), spot("a", 1, 1)]);
884 }
885
886 #[test]
887 fn moving_lands_on_the_tile_under_the_cell() {
888 let state = ravenrs_reactive::State::new(vec![spot("a", 1, 1), spot("b", 1, 1), spot("c", 1, 1)]);
889 let cells = Cells { columns: 3, column_step: 100.0, row_step: 100.0 };
890 move_to_cell(&state.binding(), "a", cells, (2, 0));
891 let order: Vec<String> = state.get().into_iter().map(|s| s.key).collect();
892 assert_eq!(order, ["b", "c", "a"]);
893 }
894
895 #[test]
896 fn selecting_a_card_follows_the_mode() {
897 let state = ravenrs_reactive::State::new(BTreeSet::from([1]));
898 select_card(&state.binding(), SelectionMode::Single, 3);
899 assert_eq!(state.get(), BTreeSet::from([3]));
900 select_card(&state.binding(), SelectionMode::Multiple, 4);
901 assert_eq!(state.get(), BTreeSet::from([3, 4]));
902 select_card(&state.binding(), SelectionMode::Multiple, 3);
903 assert_eq!(state.get(), BTreeSet::from([4]));
904 select_card(&state.binding(), SelectionMode::None, 9);
905 assert_eq!(state.get(), BTreeSet::from([4]));
906 }
907}