1mod 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::click::Click;
17use super::empty_state::EmptyState;
18use super::row_menu::{self, RowAnchor, RowMenuItems};
19use super::row_pointer::{self, PickedRows, Picking, RowDrop, Spot};
20use super::scrollbar::{self, ScrollbarStyle};
21use super::select_box;
22use super::{ContextItem, IndexMessage};
23use layout::{Layout, Sizing, Step};
24
25type CardBuilder<Msg> = Box<dyn Fn(&mut View<'_, Msg>, usize)>;
27
28pub struct CardGrid<Msg> {
118 count: usize,
119 min_width: u16,
120 max_width: u16,
121 height: u16,
122 column_gap: u16,
123 row_gap: u16,
124 selected: Option<usize>,
125 checked: Option<Vec<bool>>,
126 disabled: bool,
127 scrollbar: Option<ScrollbarStyle>,
128 on_select: Option<IndexMessage<Msg>>,
129 on_activate: Option<IndexMessage<Msg>>,
130 on_toggle: Option<IndexMessage<Msg>>,
131 card: Option<CardBuilder<Msg>>,
132 menu: Option<RowMenuItems<Msg>>,
133 empty: Vec<Node<Msg>>,
134 picking: Picking<Msg>,
135}
136
137#[derive(Debug, Default)]
139struct GridMemory {
140 offset: usize,
142 followed: Option<(Option<usize>, usize)>,
145 dragging: bool,
147 flashed: Option<usize>,
149 pointer: Option<(i32, i32)>,
151 pointed: bool,
153 layout: Layout,
155}
156
157impl<Msg: 'static> CardGrid<Msg> {
158 #[must_use]
162 pub fn new(count: usize) -> Self {
163 Self {
164 count,
165 min_width: 24,
166 max_width: 32,
167 height: 3,
168 column_gap: 2,
169 row_gap: 1,
170 selected: None,
171 checked: None,
172 disabled: false,
173 scrollbar: None,
174 on_select: None,
175 on_activate: None,
176 on_toggle: None,
177 card: None,
178 menu: None,
179 empty: Vec::new(),
180 picking: Picking::default(),
181 }
182 }
183
184 #[must_use]
187 pub fn card_width(mut self, min: u16, max: u16) -> Self {
188 self.min_width = min.max(1);
189 self.max_width = max.max(self.min_width);
190 self
191 }
192
193 #[must_use]
195 pub fn card_height(mut self, rows: u16) -> Self {
196 self.height = rows.max(1);
197 self
198 }
199
200 #[must_use]
202 pub fn gap(mut self, columns: u16, rows: u16) -> Self {
203 self.column_gap = columns;
204 self.row_gap = rows;
205 self
206 }
207
208 #[must_use]
210 pub fn selected(mut self, index: Option<usize>) -> Self {
211 self.selected = index;
212 self
213 }
214
215 #[must_use]
219 pub fn checked(mut self, checked: Vec<bool>) -> Self {
220 self.checked = Some(checked);
221 self
222 }
223
224 #[must_use]
227 pub fn disabled(mut self, disabled: bool) -> Self {
228 self.disabled = disabled;
229 self
230 }
231
232 #[must_use]
234 pub fn scrollbar(mut self, style: ScrollbarStyle) -> Self {
235 self.scrollbar = Some(style);
236 self
237 }
238
239 #[must_use]
241 pub fn on_select(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
242 self.on_select = Some(Box::new(message));
243 self
244 }
245
246 #[must_use]
248 pub fn on_activate(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
249 self.on_activate = Some(Box::new(message));
250 self
251 }
252
253 #[must_use]
256 pub fn on_toggle(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
257 self.on_toggle = Some(Box::new(message));
258 self
259 }
260
261 #[must_use]
264 pub fn card(mut self, build: impl Fn(&mut View<'_, Msg>, usize) + 'static) -> Self {
265 self.card = Some(Box::new(build));
266 self
267 }
268
269 #[must_use]
278 pub fn context_menu(mut self, items: impl Fn(usize) -> Vec<ContextItem<Msg>> + 'static) -> Self {
279 self.menu = Some(Box::new(items));
280 self
281 }
282
283 #[must_use]
287 pub fn activate_on(mut self, click: Click) -> Self {
288 self.picking.activate_on = click;
289 self
290 }
291
292 #[must_use]
302 pub fn multi_select(mut self, selected: &[usize], message: impl Fn(Vec<usize>) -> Msg + 'static) -> Self {
303 self.picking.chosen = selected.to_vec();
304 self.picking.on_choose = Some(Box::new(message));
305 self
306 }
307
308 #[must_use]
314 pub fn box_select(mut self, on: bool) -> Self {
315 self.picking.box_select = on;
316 self
317 }
318
319 #[must_use]
329 pub fn droppable(
330 mut self,
331 message: impl Fn(RowDrop) -> Msg + 'static,
332 accepts: impl Fn(usize) -> bool + 'static,
333 ) -> Self {
334 self.picking.dropping = Some((Box::new(message), Box::new(accepts)));
335 self
336 }
337
338 #[must_use]
342 pub fn on_copy_drop(mut self, message: impl Fn(RowDrop) -> Msg + 'static) -> Self {
343 self.picking.copy_drop = Some(Box::new(message));
344 self
345 }
346
347 fn menu_event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
349 row_menu::event(
350 cx,
351 event,
352 self.menu.as_ref(),
353 self.count,
354 |cx, x, y| {
355 let memory = cx.memory::<GridMemory>();
356 let (layout, offset) = (memory.layout, memory.offset);
357 let index = layout.index_at(x, y, offset)?;
358 if self.picking.is_multi() {
359 if !self.picking.is_chosen(index) {
360 self.picking.select_one(cx, self, index);
361 }
362 } else if !self.is_checked(index).unwrap_or(false) {
363 self.select(cx, index);
364 }
365 Some(RowAnchor { row: index, at: Rect::new(x, y, 1, 1), keyboard: false })
366 },
367 |cx| {
368 let index = self.current(cx)?;
369 let memory = cx.memory::<GridMemory>();
370 let layout = memory.layout;
371 memory.offset = layout.reveal(index, memory.offset);
372 let at = layout.card_rect(index, memory.offset);
373 Some(RowAnchor { row: index, at, keyboard: true })
374 },
375 )
376 }
377
378 fn active(&self) -> bool {
379 !self.disabled && self.count > 0
380 }
381
382 fn is_checked(&self, index: usize) -> Option<bool> {
383 self.checked.as_ref().map(|checked| checked.get(index).copied().unwrap_or(false))
384 }
385
386 fn toggles(&self) -> bool {
387 self.checked.is_some() && self.on_toggle.is_some()
388 }
389
390 fn sizing(&self, padding: Padding) -> Sizing {
391 Sizing {
392 min_width: self.min_width,
393 max_width: self.max_width,
394 height: self.height.saturating_add(padding.vertical()),
395 column_gap: self.column_gap,
396 row_gap: self.row_gap,
397 }
398 }
399
400 fn select(&self, cx: &mut EventCx<'_, Msg>, index: usize) {
401 if Some(index) != self.selected
402 && let Some(message) = &self.on_select
403 {
404 cx.emit(message(index));
405 }
406 }
407
408 fn activate(&self, cx: &mut EventCx<'_, Msg>, index: usize) -> bool {
409 let Some(message) = &self.on_activate else {
410 return false;
411 };
412 cx.memory::<GridMemory>().flashed = Some(index);
413 cx.flash();
414 cx.emit(message(index));
415 true
416 }
417
418 fn toggle(&self, cx: &mut EventCx<'_, Msg>, index: usize) -> bool {
419 match (&self.checked, &self.on_toggle) {
420 (Some(_), Some(message)) => {
421 cx.emit(message(index));
422 true
423 }
424 _ => false,
425 }
426 }
427
428 fn mark_at(&self, cx: &mut EventCx<'_, Msg>, x: i32, y: i32) -> Option<usize> {
430 let memory = cx.memory::<GridMemory>();
431 let (layout, offset) = (memory.layout, memory.offset);
432 let index = layout.index_at(x, y, offset)?;
433 let env = cx.env();
434 mark_zone(env, layout.card_rect(index, offset), card_padding(env)).contains(x, y).then_some(index)
435 }
436
437 fn current(&self, cx: &mut EventCx<'_, Msg>) -> Option<usize> {
440 let memory = cx.memory::<GridMemory>();
441 let pointed = memory.pointed.then_some(memory.pointer).flatten();
442 pointed.and_then(|(x, y)| memory.layout.index_at(x, y, memory.offset)).or(self.selected)
443 }
444
445 fn on_key(&self, cx: &mut EventCx<'_, Msg>, key: &crate::event::KeyEvent) -> bool {
446 let steps = [
447 (Key::Left, Step::Left),
448 (Key::Right, Step::Right),
449 (Key::Up, Step::Up),
450 (Key::Down, Step::Down),
451 (Key::PageUp, Step::PageUp),
452 (Key::PageDown, Step::PageDown),
453 (Key::Home, Step::Home),
454 (Key::End, Step::End),
455 ];
456 let step = steps.into_iter().find(|(k, _)| key.is_plain(*k)).map(|(_, step)| step);
457 let activates = key.is_plain(Key::Enter) || key.is_plain(Key::Space);
458 if step.is_none() && !activates {
459 return false;
460 }
461 let current = self.current(cx);
462 cx.memory::<GridMemory>().pointed = false;
464 if let Some(step) = step {
465 let target = cx.memory::<GridMemory>().layout.step(step, current);
466 if let Some(target) = target {
467 if self.picking.is_multi() {
468 self.picking.select_one(cx, self, target);
469 } else {
470 self.select(cx, target);
471 }
472 }
473 return target.is_some();
474 }
475 let Some(index) = current else {
476 return false;
477 };
478 self.select(cx, index);
479 if key.is_plain(Key::Space) && (self.picking.toggle(cx, self, index) || self.toggle(cx, index)) {
480 return true;
481 }
482 self.activate(cx, index)
483 }
484
485 fn on_mouse(&self, cx: &mut EventCx<'_, Msg>, mouse: &crate::event::MouseEvent) -> bool {
486 let marked = self.toggles().then(|| self.mark_at(cx, mouse.x, mouse.y)).flatten();
488 let memory = cx.memory::<GridMemory>();
489 let layout = memory.layout;
490 let bar = layout.bar();
491 let on_bar = bar.is_some_and(|bar| bar.contains(mouse.x, mouse.y));
492 let track = |y: i32| clamp_u16(y - layout.body.y);
493 match mouse.kind {
494 MouseKind::ScrollUp | MouseKind::ScrollDown => {
495 memory.offset = if mouse.kind == MouseKind::ScrollUp {
496 memory.offset.saturating_sub(1)
497 } else {
498 (memory.offset + 1).min(layout.max_offset())
499 };
500 memory.pointed = true;
502 true
503 }
504 MouseKind::Down(MouseButton::Left) if on_bar => {
505 memory.dragging = true;
506 memory.offset = layout.metrics(memory.offset).offset_at(track(mouse.y), layout.body.height);
507 cx.capture_pointer();
508 true
509 }
510 MouseKind::Drag(MouseButton::Left) if memory.dragging => {
511 memory.offset = layout.metrics(memory.offset).offset_at(track(mouse.y), layout.body.height);
512 true
513 }
514 MouseKind::Up(MouseButton::Left) if memory.dragging => {
515 memory.dragging = false;
516 true
517 }
518 MouseKind::Down(MouseButton::Left) if let Some(index) = marked => self.toggle(cx, index),
519 _ => self.picking.mouse(cx, mouse, self).unwrap_or(false),
520 }
521 }
522}
523
524fn card_padding(env: &Env) -> Padding {
526 let style = env.theme().style("card", None, &[]);
527 style.pair("padding").map_or(Padding::default(), |(vertical, horizontal)| Padding::symmetric(vertical, horizontal))
528}
529
530fn mark_width(env: &Env) -> u16 {
532 text::width(&env.icons().glyph("check")).max(1)
533}
534
535fn mark_cell(env: &Env, card: Rect, padding: Padding) -> Rect {
537 let width = mark_width(env);
538 Rect::new(card.right() - 1 - i32::from(width), card.y + i32::from(padding.top), width, 1)
539}
540
541fn mark_zone(env: &Env, card: Rect, padding: Padding) -> Rect {
543 let mark = mark_cell(env, card, padding);
544 Rect::new(mark.x - 1, mark.y, mark.width + 2, 1)
545}
546
547impl<Msg: Clone + 'static> CardGrid<Msg> {
548 #[must_use]
552 pub fn empty(mut self, state: EmptyState<Msg>) -> Self {
553 if self.count > 0 {
554 return self;
555 }
556 let mut node = Node::new(state, 0);
557 node.layout.width = Length::Fill(1);
558 node.layout.height = Length::Fill(1);
559 self.empty = vec![node];
560 self
561 }
562
563 fn paint_card(&self, cx: &mut PaintCx<'_>, rect: Rect, index: usize, states: &[State], padding: Padding) {
565 let style = cx.style("card", None, states);
566 let background = style.text().bg.unwrap_or_else(|| cx.color("raised"));
567 cx.clear(rect, background);
568 if let Some(pillar) = style.color("pillar") {
569 for row in 0..rect.height {
571 cx.pillar(rect.x, rect.y + i32::from(row), pillar);
572 }
573 }
574 let env = cx.env;
575 let mut inner = rect.inset(padding);
576 if self.checked.is_some() {
577 let reserve = mark_width(env) + 2;
579 inner.width = inner.width.saturating_sub(reserve.saturating_sub(padding.right));
580 }
581 if let Some(build) = &self.card {
582 let mut children = Vec::new();
583 let screen = Size::new(cx.buf.area.width, cx.buf.area.height);
584 let idle = IdleScope::new(cx.idle);
585 build(&mut View::new(&mut children, env, screen, &idle), index);
586 let mut node = Node::new(Flex::new(Axis::Column, children), index);
588 node.layout.width = Length::Fill(1);
589 node.assign_ids(cx.id());
590 cx.paint_child(&node, inner);
591 }
592 if let Some(checked) = self.is_checked(index) {
593 let lit = states.iter().any(|state| matches!(state, State::Hover | State::Selected));
594 if checked || (lit && self.on_toggle.is_some() && !self.disabled) {
595 let variant = (!checked).then_some("off");
596 let fg = cx.style("card-mark", variant, &[]).text().fg.unwrap_or_else(|| cx.color("accent"));
597 let mark = mark_cell(env, rect, padding);
598 cx.text(mark.x, mark.y, &env.icons().glyph("check"), CellStyle::fg(fg), mark.width);
599 }
600 }
601 if self.disabled {
602 cx.tint(rect, background, 0.5);
603 }
604 }
605}
606
607impl<Msg: Clone + 'static> Widget<Msg> for CardGrid<Msg> {
608 fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
609 if self.count == 0 {
610 return self.empty.first().map_or(Size::default(), |empty| cx.measure_child(empty, available));
611 }
612 let sizing = self.sizing(card_padding(cx.env()));
613 let (columns, _) = layout::columns(available.width, sizing);
614 let height = layout::height_of(self.count.div_ceil(columns), sizing);
615 Size::new(available.width, height).min(available)
616 }
617
618 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
619 if area.is_empty() {
620 return;
621 }
622 if self.count == 0 {
623 if let Some(empty) = self.empty.first() {
624 cx.paint_child(empty, area);
625 }
626 return;
627 }
628 cx.register_hit(area);
629 let padding = cx.style("card", None, &[]).padding();
630 let layout = Layout::new(area, self.count, self.sizing(padding));
631 let active = self.active();
632 let focus_visible = active && cx.is_focus_visible();
633 let pressed = cx.is_pressed();
634 let pointer = if active { cx.pointer_within() } else { None };
635 let (offset, pointed, flashed, dragging) = {
636 let memory = cx.memory::<GridMemory>();
637 if pointer != memory.pointer {
638 memory.pointer = pointer;
641 memory.pointed = pointer.is_some();
642 }
643 let follow = (self.selected, layout.columns);
644 if memory.followed != Some(follow) {
645 if let Some(selected) = self.selected.filter(|index| *index < self.count) {
646 memory.offset = layout.reveal(selected, memory.offset);
647 }
648 memory.followed = Some(follow);
649 }
650 memory.offset = memory.offset.min(layout.max_offset());
651 memory.layout = layout;
652 (memory.offset, memory.pointed && pointer.is_some(), memory.flashed, memory.dragging)
653 };
654 let menu_card = row_menu::open_row(cx, self.menu.as_ref());
657 if menu_card.is_some() {
658 cx.request_overlay(area);
659 }
660 let hovered = match menu_card {
661 Some(card) => Some(card),
662 None => pointer.filter(|_| pointed).and_then(|(x, y)| layout.index_at(x, y, offset)),
663 };
664
665 let target = row_pointer::dragged(cx).and_then(|((x, y), carried)| {
667 layout.index_at(x, y, offset).filter(|index| self.picking.takes_drop(&carried, *index))
668 });
669 let drawn = row_pointer::drawn_box(cx);
670 cx.with_clip(layout.body, |cx| {
671 for index in layout.shown(offset) {
672 let rect = layout.card_rect(index, offset);
673 let is_hovered = hovered == Some(index);
674 let cursor = self.selected == Some(index) && (!pointed || is_hovered);
677 let (lit, raised) = if self.picking.is_multi() {
681 let chosen = self.picking.is_chosen(index);
682 (chosen, is_hovered || (cursor && !chosen))
683 } else {
684 (cursor, is_hovered)
685 };
686 let mut states = Vec::new();
687 if raised {
688 states.push(State::Hover);
689 }
690 if lit {
691 states.push(State::Selected);
692 if focus_visible && (cursor || !self.picking.is_multi()) {
693 states.push(State::Focus);
694 }
695 }
696 if pressed && flashed == Some(index) {
697 states.push(State::Pressed);
698 }
699 self.paint_card(cx, rect, index, &states, padding);
700 if target == Some(index) {
701 let drop = cx.style("tree-drop", None, &[]).text();
703 let bg = drop.bg.unwrap_or_else(|| cx.color("accent"));
704 let readable = drop.fg.unwrap_or_else(|| cx.color("text"));
705 cx.fill_keeping_text_readable(rect, bg, readable);
706 }
707 }
708 if let Some(drawn) = drawn {
709 select_box::paint(cx, drawn, layout.body);
710 }
711 });
712
713 if let Some(bar) = layout.bar() {
714 let lit = dragging || pointer.is_some_and(|(x, y)| bar.contains(x, y));
715 scrollbar::paint(cx, bar, layout.metrics(offset), lit, self.scrollbar);
716 }
717 }
718
719 fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
720 row_menu::paint(cx, self.menu.as_ref(), anchor);
721 }
722
723 fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
724 if !self.active() {
725 return false;
726 }
727 if self.menu_event(cx, event) {
728 return true;
729 }
730 match event {
731 Event::Key(key) => self.on_key(cx, key),
732 Event::Mouse(mouse) => self.on_mouse(cx, mouse),
733 _ => false,
734 }
735 }
736
737 fn focusable(&self) -> bool {
738 self.active()
739 }
740
741 fn children(&self) -> &[Node<Msg>] {
742 &self.empty
743 }
744
745 fn children_mut(&mut self) -> &mut [Node<Msg>] {
746 &mut self.empty
747 }
748}
749
750impl<Msg: 'static> PickedRows<Msg> for CardGrid<Msg> {
751 fn spot(&self, cx: &mut EventCx<'_, Msg>, x: i32, y: i32) -> Spot {
752 let memory = cx.memory::<GridMemory>();
753 if !memory.layout.body.contains(x, y) {
754 return Spot::Outside;
755 }
756 memory.layout.index_at(x, y, memory.offset).map_or(Spot::Free, Spot::Row)
757 }
758
759 fn covered(&self, cx: &mut EventCx<'_, Msg>, rect: Rect) -> Vec<usize> {
760 let memory = cx.memory::<GridMemory>();
761 let (layout, offset) = (memory.layout, memory.offset);
762 layout.shown(offset).filter(|index| !layout.card_rect(*index, offset).intersect(rect).is_empty()).collect()
763 }
764
765 fn cursor(&self) -> Option<usize> {
766 self.selected
767 }
768
769 fn select(&self, cx: &mut EventCx<'_, Msg>, index: usize) {
770 CardGrid::select(self, cx, index);
771 }
772
773 fn open(&self, cx: &mut EventCx<'_, Msg>, index: usize, _at: (i32, i32)) {
774 self.activate(cx, index);
775 }
776}