Skip to main content

ravenrs_widgets/
tiles.rs

1//! Things side by side: tiles to rearrange, and cards in as many columns as fit.
2
3use 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;
16/// Tall enough for a small control in the header to keep a margin above and below.
17const TILE_HEADER: f32 = 48.0;
18/// How far a tile keeps what it holds from its edges, on every side.
19const TILE_INSET: f32 = 16.0;
20/// The corner that resizes: what takes the pointer, and how far its grip stays from the edges.
21const CORNER: f32 = 20.0;
22const GRIP_INSET: f32 = 5.0;
23const CARD_OVERSCAN: usize = 2;
24
25/// Where a tile stands: which tile, and how many cells it takes. The order of a list
26/// of these is the order on the screen.
27#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
28#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
29pub struct TileSpot {
30    /// The tile, by [`Tile::new`]'s key.
31    pub key: String,
32    /// Columns it takes.
33    pub columns: u16,
34    /// Rows it takes.
35    pub rows: u16,
36}
37
38#[cfg_attr(feature = "live", ravenrs_live::expose)]
39impl TileSpot {
40    /// A tile taking this many cells.
41    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/// One tile of a [`TileLayout`].
47#[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    /// A tile known by `key`, under this title.
64    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    /// Something at the end of the header, after the title, such as a help button or a
69    /// choice that changes what the tile shows.
70    pub fn accessory(mut self, accessory: impl View + 'static) -> Self {
71        self.accessory = Some(Box::new(accessory));
72        self
73    }
74}
75
76/// Puts each tile in the first place it fits, going row by row: `(column, row)`.
77/// A tile wider than the grid takes the whole width.
78fn 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
108/// Spots in the order of the tiles, for tiles a saved layout has not heard of yet,
109/// dropping what is no longer there.
110fn 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/// A dashboard: tiles in a grid of cells, that people can drag to another place and
122/// resize. As the width falls, it uses fewer columns. From the keyboard, on a tile's
123/// handle: arrows move it, Shift with arrows resizes it.
124///
125/// ```
126/// use ravenrs_reactive::State;
127/// use ravenrs_widgets::{Text, Tile, TileLayout, TileSpot, Ui};
128///
129/// let spots = State::new(vec![TileSpot::new("sales", 2, 1)]);
130/// let tiles = TileLayout::new(spots.binding())
131///     .tile(Tile::new("sales", "Sales", Text::new("R$ 1.000")))
132///     .tile(Tile::new("stock", "Stock", Text::new("42")));
133/// let mut ui = Ui::new(tiles, ravenrs_style::Theme::raven_dark());
134/// ui.update();
135/// ```
136#[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    /// A layout bound to where the tiles stand, to keep between sessions. Tiles not in
155    /// the list take one cell, after the others.
156    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    /// Appends a tile.
168    pub fn tile(mut self, tile: Tile) -> Self {
169        self.tiles.push(tile);
170        self
171    }
172
173    /// The most columns, when there is room. 4 by default.
174    pub fn columns(mut self, columns: usize) -> Self {
175        self.columns = columns.max(1);
176        self
177    }
178
179    /// A column narrower than this makes the layout use one column less. 200 by default.
180    pub fn min_column_width(mut self, width: f32) -> Self {
181        self.min_column_width = width.max(1.0);
182        self
183    }
184
185    /// Height of a row of cells in logical pixels. 160 by default.
186    pub fn row_height(mut self, height: f32) -> Self {
187        self.row_height = height.max(TILE_HEADER);
188        self
189    }
190
191    /// Keeps tiles where they are, with no handles.
192    pub fn locked(mut self, locked: bool) -> Self {
193        self.locked = locked;
194        self
195    }
196}
197
198/// The geometry of the grid in this build, for what handles a drag.
199#[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    /// The cell under a point inside the layout.
209    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
216/// The grip of a tile. Focus and drags go by view, so it is one.
217struct 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                // The handle sits at the tile's corner, so this is where the pointer is
253                // inside the layout, near enough to pick a cell.
254                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
289/// The corner that resizes a tile with the pointer; the keyboard does it on the handle.
290struct TileCorner {
291    key: String,
292    /// Where the tile stands, in cells.
293    place: (usize, usize),
294    at: (f32, f32),
295    /// It is there to find, not to look at, so it shows only over its tile.
296    shown: bool,
297    spots: Binding<Vec<TileSpot>>,
298    cells: Cells,
299}
300
301/// Two short strokes across the corner, along the curve of the tile's edge.
302fn 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        // A 1.5 px stroke from (x, y - length) to (x - length, y), as a thin quad.
306        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        // Faint over the tile, plain under the pointer or while it drags.
324        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                // The corner's parent is the tile, which stands at its own cell.
354                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                // The handle's dots, like the accessory's edge, stand an inset from the side.
410                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
469/// Moves a tile to the place in the order that puts it at this cell.
470fn 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        // The first build has only the window to go by.
512        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        // The list the handles change, with every tile in it.
521        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/// Records as cards, in as many columns as fit, of which only those in sight are built.
562/// Arrows move between cards, Enter opens one and Space selects it.
563///
564/// ```
565/// use ravenrs_widgets::{AnyView, CardView, Text, Ui};
566///
567/// let cards = CardView::new(10_000, |i| Box::new(Text::new(format!("Card {i}"))) as AnyView).height(400.0);
568/// let mut ui = Ui::new(cards, ravenrs_style::Theme::raven_dark());
569/// ui.update();
570/// ```
571#[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    /// `count` cards, each the view `card` gives for its index.
594    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    /// The narrowest a card gets; they share what is left over. 240 by default.
610    pub fn card_width(mut self, width: f32) -> Self {
611        self.card_width = width.max(1.0);
612        self
613    }
614
615    /// Height of every card in logical pixels. 160 by default.
616    pub fn card_height(mut self, height: f32) -> Self {
617        self.card_height = height.max(1.0);
618        self
619    }
620
621    /// Scrolls inside this height. Without it, the view fills what its parent gives.
622    pub fn height(mut self, height: f32) -> Self {
623        self.height = Some(height);
624        self
625    }
626
627    /// How cards are selected. One at a time by default.
628    pub fn selection_mode(mut self, mode: SelectionMode) -> Self {
629        self.mode = mode;
630        self
631    }
632
633    /// The indexes selected.
634    pub fn selection(mut self, selection: Binding<BTreeSet<usize>>) -> Self {
635        self.selection = Some(selection);
636        self
637    }
638
639    /// Runs with the index on Enter or on a double click.
640    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    /// What a screen reader says for each card, since the card's content is free.
646    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    /// The name screen readers announce.
652    pub fn label(mut self, label: impl Into<String>) -> Self {
653        self.label = Some(label.into());
654        self
655    }
656}
657
658/// Picks `index`, as a click or Space does in this mode.
659fn 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}