Skip to main content

qframe/widgets/card_grid/
mod.rs

1//! Card grids: cards in as many columns as fit, moved through with the keys in two dimensions.
2
3mod layout;
4#[cfg(test)]
5mod tests;
6
7use crate::env::Env;
8use crate::event::{Event, MouseButton, MouseKind};
9use crate::geometry::{Padding, Rect, Size, clamp_u16};
10use crate::keymap::Key;
11use crate::style::CellStyle;
12use crate::text;
13use crate::theme::State;
14use crate::widget::{Axis, EventCx, Flex, IdleScope, Length, MeasureCx, Node, PaintCx, View, Widget};
15
16use super::IndexMessage;
17use super::empty_state::EmptyState;
18use super::scrollbar::{self, ScrollbarStyle};
19use layout::{Layout, Sizing, Step};
20
21/// Builds what one card shows.
22type CardBuilder<Msg> = Box<dyn Fn(&mut View<'_, Msg>, usize)>;
23
24/// Cards laid out in as many columns as fit, for a store's apps, a launcher's programs or a
25/// choice of profiles. Only the cards on screen are built and drawn, so a grid of ten thousand
26/// cards costs what one screen of them costs.
27///
28/// Every card is a surface one step above its background, with no frame. Under the pointer a
29/// card rises one tone with a soft pillar `▌` down its left edge; the selected card takes the
30/// selected surface, and its pillar breathes while the grid has focus reached with the keyboard.
31/// Nothing slides: a card is a surface, not a list row. Only one card is lit at a time: while
32/// the pointer moves over the grid it carries the highlight and the selected card rests; the
33/// next key goes on from the card the pointer is on.
34///
35/// The column count follows the width: cards are at least [`card_width`](Self::card_width)'s
36/// least width and share the room left, up to the widest. An area narrower than one card shows
37/// one column as wide as the area, and the card's content is cut there (build it with
38/// [`Text::no_wrap`](super::Text::no_wrap) so it ends in `…`).
39///
40/// The application owns the selection and the checked cards; the grid reports changes through
41/// messages. Keys while focused: arrows move between cards and stop at the edges (Right on the
42/// last card of a row stays there), Home and End go to the first and last card, PgUp and PgDn
43/// move a screen of rows, Enter activates, and Space toggles the check when checks are on,
44/// activating otherwise. A click selects and activates a card; with checks on, a click on the
45/// mark in a card's top right corner only toggles it. The wheel scrolls a row of cards at a
46/// time, and the scrollbar can be pressed and dragged.
47///
48/// ```
49/// use std::rc::Rc;
50///
51/// use qframe::prelude::*;
52/// use qframe::widgets::CardGrid;
53///
54/// struct Store {
55///     apps: Rc<[(String, String)]>,
56///     selected: Option<usize>,
57/// }
58///
59/// #[derive(Clone)]
60/// enum Msg {
61///     Select(usize),
62///     Open(usize),
63/// }
64///
65/// impl App for Store {
66///     type Msg = Msg;
67///     fn update(&mut self, msg: Msg) -> Command<Msg> {
68///         if let Msg::Select(index) | Msg::Open(index) = msg {
69///             self.selected = Some(index);
70///         }
71///         Command::none()
72///     }
73///     fn view(&self, ui: &mut View<'_, Msg>) {
74///         let apps = Rc::clone(&self.apps);
75///         let grid = CardGrid::new(self.apps.len())
76///             .card_width(24, 32)
77///             .card_height(2)
78///             .selected(self.selected)
79///             .on_select(Msg::Select)
80///             .on_activate(Msg::Open)
81///             .card(move |ui, index| {
82///                 let (name, summary) = &apps[index];
83///                 ui.add(Text::new(name.as_str()).role("title").no_wrap());
84///                 ui.add(Text::new(summary.as_str()).role("secondary").no_wrap());
85///             });
86///         ui.add(grid).fill();
87///     }
88/// }
89///
90/// let apps: Rc<[(String, String)]> = (0..40).map(|n| (format!("App {n}"), "Does a thing".to_owned())).collect();
91/// let mut store = Harness::new(Store { apps, selected: None }, 80, 10);
92/// store.press("tab").press("right").press("right").press("down");
93/// assert_eq!(store.app().selected, Some(4));
94/// ```
95///
96/// Cards are built while the grid paints, once for each card on screen, by the closure given to
97/// [`card`](Self::card). It lives as long as the widget, so it owns what it reads, e.g. an
98/// `Rc<[App]>` cloned from the state. Widgets inside a card are drawn but take no input of their
99/// own: the card is the pressable surface, and a press anywhere on it is the card's. Idle
100/// watches ([`View::on_idle`]) belong in the application's own view, not in a card.
101///
102/// Style keys: `card` (`bg`, `padding`, `pillar`) with `hover`, `selected`, `focus`, `pressed`;
103/// `card-mark` for a checked card's mark and `card-mark.off` for the faint mark a lit card
104/// offers while checks are on; `scrollbar`.
105pub struct CardGrid<Msg> {
106    count: usize,
107    min_width: u16,
108    max_width: u16,
109    height: u16,
110    column_gap: u16,
111    row_gap: u16,
112    selected: Option<usize>,
113    checked: Option<Vec<bool>>,
114    disabled: bool,
115    scrollbar: Option<ScrollbarStyle>,
116    on_select: Option<IndexMessage<Msg>>,
117    on_activate: Option<IndexMessage<Msg>>,
118    on_toggle: Option<IndexMessage<Msg>>,
119    card: Option<CardBuilder<Msg>>,
120    empty: Vec<Node<Msg>>,
121}
122
123/// What a grid remembers between frames.
124#[derive(Debug, Default)]
125struct GridMemory {
126    /// First row of cards shown.
127    offset: usize,
128    /// The selection and column count the offset last followed: a new selection, or a resize
129    /// that moves it to another row, scrolls it into view once.
130    followed: Option<(Option<usize>, usize)>,
131    /// Whether the scrollbar thumb is being dragged.
132    dragging: bool,
133    /// The card activated last, for the press flash.
134    flashed: Option<usize>,
135    /// Where the pointer was when the grid was painted last.
136    pointer: Option<(i32, i32)>,
137    /// Whether the pointer moved over the grid since the last key, so it carries the highlight.
138    pointed: bool,
139    /// The layout painted last, which input is read against.
140    layout: Layout,
141}
142
143impl<Msg: 'static> CardGrid<Msg> {
144    /// A grid of `count` cards, 24 to 32 cells wide and three rows of content high, two cells
145    /// apart in a row and one row apart between rows. Give it what cards show with
146    /// [`card`](Self::card).
147    #[must_use]
148    pub fn new(count: usize) -> Self {
149        Self {
150            count,
151            min_width: 24,
152            max_width: 32,
153            height: 3,
154            column_gap: 2,
155            row_gap: 1,
156            selected: None,
157            checked: None,
158            disabled: false,
159            scrollbar: None,
160            on_select: None,
161            on_activate: None,
162            on_toggle: None,
163            card: None,
164            empty: Vec::new(),
165        }
166    }
167
168    /// The narrowest and the widest a card gets, in cells. As many columns as fit at `min`
169    /// share the width, each at most `max`.
170    #[must_use]
171    pub fn card_width(mut self, min: u16, max: u16) -> Self {
172        self.min_width = min.max(1);
173        self.max_width = max.max(self.min_width);
174        self
175    }
176
177    /// Rows of content in every card, without the card's padding.
178    #[must_use]
179    pub fn card_height(mut self, rows: u16) -> Self {
180        self.height = rows.max(1);
181        self
182    }
183
184    /// Cells between two cards of a row, and rows between two rows of cards.
185    #[must_use]
186    pub fn gap(mut self, columns: u16, rows: u16) -> Self {
187        self.column_gap = columns;
188        self.row_gap = rows;
189        self
190    }
191
192    /// The selected card's index.
193    #[must_use]
194    pub fn selected(mut self, index: Option<usize>) -> Self {
195        self.selected = index;
196        self
197    }
198
199    /// Turns checks on: `checked[i]` tells whether card `i` is checked, and a checked card
200    /// carries a mark in its top right corner. Space and a click on the mark report toggles
201    /// through [`on_toggle`](Self::on_toggle).
202    #[must_use]
203    pub fn checked(mut self, checked: Vec<bool>) -> Self {
204        self.checked = Some(checked);
205        self
206    }
207
208    /// Keeps the grid from being hovered, focused or pressed; its messages are not sent. The
209    /// cards fade and the selected card still shows.
210    #[must_use]
211    pub fn disabled(mut self, disabled: bool) -> Self {
212        self.disabled = disabled;
213        self
214    }
215
216    /// Draws the scrollbar in `style` whatever the theme chooses.
217    #[must_use]
218    pub fn scrollbar(mut self, style: ScrollbarStyle) -> Self {
219        self.scrollbar = Some(style);
220        self
221    }
222
223    /// Message for moving the selection to a card.
224    #[must_use]
225    pub fn on_select(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
226        self.on_select = Some(Box::new(message));
227        self
228    }
229
230    /// Message for opening a card (Enter, a click).
231    #[must_use]
232    pub fn on_activate(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
233        self.on_activate = Some(Box::new(message));
234        self
235    }
236
237    /// Message for checking or unchecking a card while checks are on (Space, a click on the
238    /// mark).
239    #[must_use]
240    pub fn on_toggle(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
241        self.on_toggle = Some(Box::new(message));
242        self
243    }
244
245    /// Builds what card `index` shows, into the card's padded content area. Called only for the
246    /// cards on screen, every time the grid paints.
247    #[must_use]
248    pub fn card(mut self, build: impl Fn(&mut View<'_, Msg>, usize) + 'static) -> Self {
249        self.card = Some(Box::new(build));
250        self
251    }
252
253    fn active(&self) -> bool {
254        !self.disabled && self.count > 0
255    }
256
257    fn is_checked(&self, index: usize) -> Option<bool> {
258        self.checked.as_ref().map(|checked| checked.get(index).copied().unwrap_or(false))
259    }
260
261    fn toggles(&self) -> bool {
262        self.checked.is_some() && self.on_toggle.is_some()
263    }
264
265    fn sizing(&self, padding: Padding) -> Sizing {
266        Sizing {
267            min_width: self.min_width,
268            max_width: self.max_width,
269            height: self.height.saturating_add(padding.vertical()),
270            column_gap: self.column_gap,
271            row_gap: self.row_gap,
272        }
273    }
274
275    fn select(&self, cx: &mut EventCx<'_, Msg>, index: usize) {
276        if Some(index) != self.selected
277            && let Some(message) = &self.on_select
278        {
279            cx.emit(message(index));
280        }
281    }
282
283    fn activate(&self, cx: &mut EventCx<'_, Msg>, index: usize) -> bool {
284        let Some(message) = &self.on_activate else {
285            return false;
286        };
287        cx.memory::<GridMemory>().flashed = Some(index);
288        cx.flash();
289        cx.emit(message(index));
290        true
291    }
292
293    fn toggle(&self, cx: &mut EventCx<'_, Msg>, index: usize) -> bool {
294        match (&self.checked, &self.on_toggle) {
295            (Some(_), Some(message)) => {
296                cx.emit(message(index));
297                true
298            }
299            _ => false,
300        }
301    }
302
303    /// The card the keys act on: the one the pointer carries the highlight to, else the
304    /// selected one.
305    fn current(&self, cx: &mut EventCx<'_, Msg>) -> Option<usize> {
306        let memory = cx.memory::<GridMemory>();
307        let pointed = memory.pointed.then_some(memory.pointer).flatten();
308        pointed.and_then(|(x, y)| memory.layout.index_at(x, y, memory.offset)).or(self.selected)
309    }
310
311    fn on_key(&self, cx: &mut EventCx<'_, Msg>, key: &crate::event::KeyEvent) -> bool {
312        let steps = [
313            (Key::Left, Step::Left),
314            (Key::Right, Step::Right),
315            (Key::Up, Step::Up),
316            (Key::Down, Step::Down),
317            (Key::PageUp, Step::PageUp),
318            (Key::PageDown, Step::PageDown),
319            (Key::Home, Step::Home),
320            (Key::End, Step::End),
321        ];
322        let step = steps.into_iter().find(|(k, _)| key.is_plain(*k)).map(|(_, step)| step);
323        let activates = key.is_plain(Key::Enter) || key.is_plain(Key::Space);
324        if step.is_none() && !activates {
325            return false;
326        }
327        let current = self.current(cx);
328        // A key takes the highlight back from a pointer resting on the grid, until it moves.
329        cx.memory::<GridMemory>().pointed = false;
330        if let Some(step) = step {
331            let target = cx.memory::<GridMemory>().layout.step(step, current);
332            if let Some(target) = target {
333                self.select(cx, target);
334            }
335            return target.is_some();
336        }
337        let Some(index) = current else {
338            return false;
339        };
340        self.select(cx, index);
341        if key.is_plain(Key::Space) && self.toggle(cx, index) {
342            return true;
343        }
344        self.activate(cx, index)
345    }
346
347    fn on_mouse(&self, cx: &mut EventCx<'_, Msg>, mouse: &crate::event::MouseEvent) -> bool {
348        let memory = cx.memory::<GridMemory>();
349        let layout = memory.layout;
350        let bar = layout.bar();
351        let on_bar = bar.is_some_and(|bar| bar.contains(mouse.x, mouse.y));
352        let track = |y: i32| clamp_u16(y - layout.body.y);
353        match mouse.kind {
354            MouseKind::ScrollUp | MouseKind::ScrollDown => {
355                memory.offset = if mouse.kind == MouseKind::ScrollUp {
356                    memory.offset.saturating_sub(1)
357                } else {
358                    (memory.offset + 1).min(layout.max_offset())
359                };
360                // The wheel is the pointer at work: the card now under it takes the highlight.
361                memory.pointed = true;
362                true
363            }
364            MouseKind::Down(MouseButton::Left) if on_bar => {
365                memory.dragging = true;
366                memory.offset = layout.metrics(memory.offset).offset_at(track(mouse.y), layout.body.height);
367                cx.capture_pointer();
368                true
369            }
370            MouseKind::Drag(MouseButton::Left) if memory.dragging => {
371                memory.offset = layout.metrics(memory.offset).offset_at(track(mouse.y), layout.body.height);
372                true
373            }
374            MouseKind::Up(MouseButton::Left) if memory.dragging => {
375                memory.dragging = false;
376                true
377            }
378            MouseKind::Down(MouseButton::Left) => {
379                let offset = memory.offset;
380                let Some(index) = layout.index_at(mouse.x, mouse.y, offset) else {
381                    return false;
382                };
383                if self.toggles() {
384                    let card = layout.card_rect(index, offset);
385                    let padding = card_padding(cx.env());
386                    if mark_zone(cx.env(), card, padding).contains(mouse.x, mouse.y) {
387                        return self.toggle(cx, index);
388                    }
389                }
390                self.select(cx, index);
391                self.activate(cx, index);
392                true
393            }
394            _ => false,
395        }
396    }
397}
398
399/// The card surface's padding from the theme.
400fn card_padding(env: &Env) -> Padding {
401    let style = env.theme().style("card", None, &[]);
402    style.pair("padding").map_or(Padding::default(), |(vertical, horizontal)| Padding::symmetric(vertical, horizontal))
403}
404
405/// Width of the check mark glyph.
406fn mark_width(env: &Env) -> u16 {
407    text::width(&env.icons().glyph("check")).max(1)
408}
409
410/// Where a card's check mark sits: in its top right corner, with a cell of air to its right.
411fn mark_cell(env: &Env, card: Rect, padding: Padding) -> Rect {
412    let width = mark_width(env);
413    Rect::new(card.right() - 1 - i32::from(width), card.y + i32::from(padding.top), width, 1)
414}
415
416/// The cells a click toggles the check in: the mark and a cell on each side of it.
417fn mark_zone(env: &Env, card: Rect, padding: Padding) -> Rect {
418    let mark = mark_cell(env, card, padding);
419    Rect::new(mark.x - 1, mark.y, mark.width + 2, 1)
420}
421
422impl<Msg: Clone + 'static> CardGrid<Msg> {
423    /// What the grid shows when it has no cards, e.g. "No apps match" with a way out. Without it
424    /// an empty grid draws nothing. Set the count with [`new`](Self::new) first; a grid with
425    /// cards leaves the empty state out.
426    #[must_use]
427    pub fn empty(mut self, state: EmptyState<Msg>) -> Self {
428        if self.count > 0 {
429            return self;
430        }
431        let mut node = Node::new(state, 0);
432        node.layout.width = Length::Fill(1);
433        node.layout.height = Length::Fill(1);
434        self.empty = vec![node];
435        self
436    }
437
438    /// Paints card `index` in `rect` in `states`.
439    fn paint_card(&self, cx: &mut PaintCx<'_>, rect: Rect, index: usize, states: &[State], padding: Padding) {
440        let style = cx.style("card", None, states);
441        let background = style.text().bg.unwrap_or_else(|| cx.color("raised"));
442        cx.clear(rect, background);
443        if let Some(pillar) = style.color("pillar") {
444            // A card is a whole surface, so its mark runs down its full left edge.
445            for row in 0..rect.height {
446                cx.pillar(rect.x, rect.y + i32::from(row), pillar);
447            }
448        }
449        let env = cx.env;
450        let mut inner = rect.inset(padding);
451        if self.checked.is_some() {
452            // The mark's corner is kept free on every row, so content never runs under it.
453            let reserve = mark_width(env) + 2;
454            inner.width = inner.width.saturating_sub(reserve.saturating_sub(padding.right));
455        }
456        if let Some(build) = &self.card {
457            let mut children = Vec::new();
458            let screen = Size::new(cx.buf.area.width, cx.buf.area.height);
459            let idle = IdleScope::new(cx.idle);
460            build(&mut View::new(&mut children, env, screen, &idle), index);
461            // Keyed by the card's index, so what a card's widgets remember follows the card.
462            let mut node = Node::new(Flex::new(Axis::Column, children), index);
463            node.layout.width = Length::Fill(1);
464            node.assign_ids(cx.id());
465            cx.paint_child(&node, inner);
466        }
467        if let Some(checked) = self.is_checked(index) {
468            let lit = states.iter().any(|state| matches!(state, State::Hover | State::Selected));
469            if checked || (lit && self.on_toggle.is_some() && !self.disabled) {
470                let variant = (!checked).then_some("off");
471                let fg = cx.style("card-mark", variant, &[]).text().fg.unwrap_or_else(|| cx.color("accent"));
472                let mark = mark_cell(env, rect, padding);
473                cx.text(mark.x, mark.y, &env.icons().glyph("check"), CellStyle::fg(fg), mark.width);
474            }
475        }
476        if self.disabled {
477            cx.tint(rect, background, 0.5);
478        }
479    }
480}
481
482impl<Msg: Clone + 'static> Widget<Msg> for CardGrid<Msg> {
483    fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
484        if self.count == 0 {
485            return self.empty.first().map_or(Size::default(), |empty| cx.measure_child(empty, available));
486        }
487        let sizing = self.sizing(card_padding(cx.env()));
488        let (columns, _) = layout::columns(available.width, sizing);
489        let height = layout::height_of(self.count.div_ceil(columns), sizing);
490        Size::new(available.width, height).min(available)
491    }
492
493    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
494        if area.is_empty() {
495            return;
496        }
497        if self.count == 0 {
498            if let Some(empty) = self.empty.first() {
499                cx.paint_child(empty, area);
500            }
501            return;
502        }
503        cx.register_hit(area);
504        let padding = cx.style("card", None, &[]).padding();
505        let layout = Layout::new(area, self.count, self.sizing(padding));
506        let active = self.active();
507        let focus_visible = active && cx.is_focus_visible();
508        let pressed = cx.is_pressed();
509        let pointer = if active { cx.pointer_within() } else { None };
510        let (offset, pointed, flashed, dragging) = {
511            let memory = cx.memory::<GridMemory>();
512            if pointer != memory.pointer {
513                // Only a moving pointer takes the highlight; one resting where the keys left it
514                // does not pull it back. Leaving the grid hands it back to the selection.
515                memory.pointer = pointer;
516                memory.pointed = pointer.is_some();
517            }
518            let follow = (self.selected, layout.columns);
519            if memory.followed != Some(follow) {
520                if let Some(selected) = self.selected.filter(|index| *index < self.count) {
521                    memory.offset = layout.reveal(selected, memory.offset);
522                }
523                memory.followed = Some(follow);
524            }
525            memory.offset = memory.offset.min(layout.max_offset());
526            memory.layout = layout;
527            (memory.offset, memory.pointed && pointer.is_some(), memory.flashed, memory.dragging)
528        };
529        let hovered = pointer.filter(|_| pointed).and_then(|(x, y)| layout.index_at(x, y, offset));
530
531        cx.with_clip(layout.body, |cx| {
532            for index in layout.shown(offset) {
533                let rect = layout.card_rect(index, offset);
534                let is_hovered = hovered == Some(index);
535                // While the pointer carries the highlight, the selected card rests unless it is
536                // the one under the pointer.
537                let lit = self.selected == Some(index) && (!pointed || is_hovered);
538                let mut states = Vec::new();
539                if is_hovered {
540                    states.push(State::Hover);
541                }
542                if lit {
543                    states.push(State::Selected);
544                    if focus_visible {
545                        states.push(State::Focus);
546                    }
547                }
548                if pressed && flashed == Some(index) {
549                    states.push(State::Pressed);
550                }
551                self.paint_card(cx, rect, index, &states, padding);
552            }
553        });
554
555        if let Some(bar) = layout.bar() {
556            let lit = dragging || pointer.is_some_and(|(x, y)| bar.contains(x, y));
557            scrollbar::paint(cx, bar, layout.metrics(offset), lit, self.scrollbar);
558        }
559    }
560
561    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
562        if !self.active() {
563            return false;
564        }
565        match event {
566            Event::Key(key) => self.on_key(cx, key),
567            Event::Mouse(mouse) => self.on_mouse(cx, mouse),
568            _ => false,
569        }
570    }
571
572    fn focusable(&self) -> bool {
573        self.active()
574    }
575
576    fn children(&self) -> &[Node<Msg>] {
577        &self.empty
578    }
579
580    fn children_mut(&mut self) -> &mut [Node<Msg>] {
581        &mut self.empty
582    }
583}