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::empty_state::EmptyState;
17use super::row_menu::{self, RowAnchor, RowMenuItems};
18use super::scrollbar::{self, ScrollbarStyle};
19use super::{ContextItem, IndexMessage};
20use layout::{Layout, Sizing, Step};
21
22type CardBuilder<Msg> = Box<dyn Fn(&mut View<'_, Msg>, usize)>;
24
25pub struct CardGrid<Msg> {
107 count: usize,
108 min_width: u16,
109 max_width: u16,
110 height: u16,
111 column_gap: u16,
112 row_gap: u16,
113 selected: Option<usize>,
114 checked: Option<Vec<bool>>,
115 disabled: bool,
116 scrollbar: Option<ScrollbarStyle>,
117 on_select: Option<IndexMessage<Msg>>,
118 on_activate: Option<IndexMessage<Msg>>,
119 on_toggle: Option<IndexMessage<Msg>>,
120 card: Option<CardBuilder<Msg>>,
121 menu: Option<RowMenuItems<Msg>>,
122 empty: Vec<Node<Msg>>,
123}
124
125#[derive(Debug, Default)]
127struct GridMemory {
128 offset: usize,
130 followed: Option<(Option<usize>, usize)>,
133 dragging: bool,
135 flashed: Option<usize>,
137 pointer: Option<(i32, i32)>,
139 pointed: bool,
141 layout: Layout,
143}
144
145impl<Msg: 'static> CardGrid<Msg> {
146 #[must_use]
150 pub fn new(count: usize) -> Self {
151 Self {
152 count,
153 min_width: 24,
154 max_width: 32,
155 height: 3,
156 column_gap: 2,
157 row_gap: 1,
158 selected: None,
159 checked: None,
160 disabled: false,
161 scrollbar: None,
162 on_select: None,
163 on_activate: None,
164 on_toggle: None,
165 card: None,
166 menu: None,
167 empty: Vec::new(),
168 }
169 }
170
171 #[must_use]
174 pub fn card_width(mut self, min: u16, max: u16) -> Self {
175 self.min_width = min.max(1);
176 self.max_width = max.max(self.min_width);
177 self
178 }
179
180 #[must_use]
182 pub fn card_height(mut self, rows: u16) -> Self {
183 self.height = rows.max(1);
184 self
185 }
186
187 #[must_use]
189 pub fn gap(mut self, columns: u16, rows: u16) -> Self {
190 self.column_gap = columns;
191 self.row_gap = rows;
192 self
193 }
194
195 #[must_use]
197 pub fn selected(mut self, index: Option<usize>) -> Self {
198 self.selected = index;
199 self
200 }
201
202 #[must_use]
206 pub fn checked(mut self, checked: Vec<bool>) -> Self {
207 self.checked = Some(checked);
208 self
209 }
210
211 #[must_use]
214 pub fn disabled(mut self, disabled: bool) -> Self {
215 self.disabled = disabled;
216 self
217 }
218
219 #[must_use]
221 pub fn scrollbar(mut self, style: ScrollbarStyle) -> Self {
222 self.scrollbar = Some(style);
223 self
224 }
225
226 #[must_use]
228 pub fn on_select(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
229 self.on_select = Some(Box::new(message));
230 self
231 }
232
233 #[must_use]
235 pub fn on_activate(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
236 self.on_activate = Some(Box::new(message));
237 self
238 }
239
240 #[must_use]
243 pub fn on_toggle(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
244 self.on_toggle = Some(Box::new(message));
245 self
246 }
247
248 #[must_use]
251 pub fn card(mut self, build: impl Fn(&mut View<'_, Msg>, usize) + 'static) -> Self {
252 self.card = Some(Box::new(build));
253 self
254 }
255
256 #[must_use]
265 pub fn context_menu(mut self, items: impl Fn(usize) -> Vec<ContextItem<Msg>> + 'static) -> Self {
266 self.menu = Some(Box::new(items));
267 self
268 }
269
270 fn menu_event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
272 row_menu::event(
273 cx,
274 event,
275 self.menu.as_ref(),
276 self.count,
277 |cx, x, y| {
278 let memory = cx.memory::<GridMemory>();
279 let (layout, offset) = (memory.layout, memory.offset);
280 let index = layout.index_at(x, y, offset)?;
281 if !self.is_checked(index).unwrap_or(false) {
282 self.select(cx, index);
283 }
284 Some(RowAnchor { row: index, at: Rect::new(x, y, 1, 1), keyboard: false })
285 },
286 |cx| {
287 let index = self.current(cx)?;
288 let memory = cx.memory::<GridMemory>();
289 let layout = memory.layout;
290 memory.offset = layout.reveal(index, memory.offset);
291 let at = layout.card_rect(index, memory.offset);
292 Some(RowAnchor { row: index, at, keyboard: true })
293 },
294 )
295 }
296
297 fn active(&self) -> bool {
298 !self.disabled && self.count > 0
299 }
300
301 fn is_checked(&self, index: usize) -> Option<bool> {
302 self.checked.as_ref().map(|checked| checked.get(index).copied().unwrap_or(false))
303 }
304
305 fn toggles(&self) -> bool {
306 self.checked.is_some() && self.on_toggle.is_some()
307 }
308
309 fn sizing(&self, padding: Padding) -> Sizing {
310 Sizing {
311 min_width: self.min_width,
312 max_width: self.max_width,
313 height: self.height.saturating_add(padding.vertical()),
314 column_gap: self.column_gap,
315 row_gap: self.row_gap,
316 }
317 }
318
319 fn select(&self, cx: &mut EventCx<'_, Msg>, index: usize) {
320 if Some(index) != self.selected
321 && let Some(message) = &self.on_select
322 {
323 cx.emit(message(index));
324 }
325 }
326
327 fn activate(&self, cx: &mut EventCx<'_, Msg>, index: usize) -> bool {
328 let Some(message) = &self.on_activate else {
329 return false;
330 };
331 cx.memory::<GridMemory>().flashed = Some(index);
332 cx.flash();
333 cx.emit(message(index));
334 true
335 }
336
337 fn toggle(&self, cx: &mut EventCx<'_, Msg>, index: usize) -> bool {
338 match (&self.checked, &self.on_toggle) {
339 (Some(_), Some(message)) => {
340 cx.emit(message(index));
341 true
342 }
343 _ => false,
344 }
345 }
346
347 fn current(&self, cx: &mut EventCx<'_, Msg>) -> Option<usize> {
350 let memory = cx.memory::<GridMemory>();
351 let pointed = memory.pointed.then_some(memory.pointer).flatten();
352 pointed.and_then(|(x, y)| memory.layout.index_at(x, y, memory.offset)).or(self.selected)
353 }
354
355 fn on_key(&self, cx: &mut EventCx<'_, Msg>, key: &crate::event::KeyEvent) -> bool {
356 let steps = [
357 (Key::Left, Step::Left),
358 (Key::Right, Step::Right),
359 (Key::Up, Step::Up),
360 (Key::Down, Step::Down),
361 (Key::PageUp, Step::PageUp),
362 (Key::PageDown, Step::PageDown),
363 (Key::Home, Step::Home),
364 (Key::End, Step::End),
365 ];
366 let step = steps.into_iter().find(|(k, _)| key.is_plain(*k)).map(|(_, step)| step);
367 let activates = key.is_plain(Key::Enter) || key.is_plain(Key::Space);
368 if step.is_none() && !activates {
369 return false;
370 }
371 let current = self.current(cx);
372 cx.memory::<GridMemory>().pointed = false;
374 if let Some(step) = step {
375 let target = cx.memory::<GridMemory>().layout.step(step, current);
376 if let Some(target) = target {
377 self.select(cx, target);
378 }
379 return target.is_some();
380 }
381 let Some(index) = current else {
382 return false;
383 };
384 self.select(cx, index);
385 if key.is_plain(Key::Space) && self.toggle(cx, index) {
386 return true;
387 }
388 self.activate(cx, index)
389 }
390
391 fn on_mouse(&self, cx: &mut EventCx<'_, Msg>, mouse: &crate::event::MouseEvent) -> bool {
392 let memory = cx.memory::<GridMemory>();
393 let layout = memory.layout;
394 let bar = layout.bar();
395 let on_bar = bar.is_some_and(|bar| bar.contains(mouse.x, mouse.y));
396 let track = |y: i32| clamp_u16(y - layout.body.y);
397 match mouse.kind {
398 MouseKind::ScrollUp | MouseKind::ScrollDown => {
399 memory.offset = if mouse.kind == MouseKind::ScrollUp {
400 memory.offset.saturating_sub(1)
401 } else {
402 (memory.offset + 1).min(layout.max_offset())
403 };
404 memory.pointed = true;
406 true
407 }
408 MouseKind::Down(MouseButton::Left) if on_bar => {
409 memory.dragging = true;
410 memory.offset = layout.metrics(memory.offset).offset_at(track(mouse.y), layout.body.height);
411 cx.capture_pointer();
412 true
413 }
414 MouseKind::Drag(MouseButton::Left) if memory.dragging => {
415 memory.offset = layout.metrics(memory.offset).offset_at(track(mouse.y), layout.body.height);
416 true
417 }
418 MouseKind::Up(MouseButton::Left) if memory.dragging => {
419 memory.dragging = false;
420 true
421 }
422 MouseKind::Down(MouseButton::Left) => {
423 let offset = memory.offset;
424 let Some(index) = layout.index_at(mouse.x, mouse.y, offset) else {
425 return false;
426 };
427 if self.toggles() {
428 let card = layout.card_rect(index, offset);
429 let padding = card_padding(cx.env());
430 if mark_zone(cx.env(), card, padding).contains(mouse.x, mouse.y) {
431 return self.toggle(cx, index);
432 }
433 }
434 self.select(cx, index);
435 self.activate(cx, index);
436 true
437 }
438 _ => false,
439 }
440 }
441}
442
443fn card_padding(env: &Env) -> Padding {
445 let style = env.theme().style("card", None, &[]);
446 style.pair("padding").map_or(Padding::default(), |(vertical, horizontal)| Padding::symmetric(vertical, horizontal))
447}
448
449fn mark_width(env: &Env) -> u16 {
451 text::width(&env.icons().glyph("check")).max(1)
452}
453
454fn mark_cell(env: &Env, card: Rect, padding: Padding) -> Rect {
456 let width = mark_width(env);
457 Rect::new(card.right() - 1 - i32::from(width), card.y + i32::from(padding.top), width, 1)
458}
459
460fn mark_zone(env: &Env, card: Rect, padding: Padding) -> Rect {
462 let mark = mark_cell(env, card, padding);
463 Rect::new(mark.x - 1, mark.y, mark.width + 2, 1)
464}
465
466impl<Msg: Clone + 'static> CardGrid<Msg> {
467 #[must_use]
471 pub fn empty(mut self, state: EmptyState<Msg>) -> Self {
472 if self.count > 0 {
473 return self;
474 }
475 let mut node = Node::new(state, 0);
476 node.layout.width = Length::Fill(1);
477 node.layout.height = Length::Fill(1);
478 self.empty = vec![node];
479 self
480 }
481
482 fn paint_card(&self, cx: &mut PaintCx<'_>, rect: Rect, index: usize, states: &[State], padding: Padding) {
484 let style = cx.style("card", None, states);
485 let background = style.text().bg.unwrap_or_else(|| cx.color("raised"));
486 cx.clear(rect, background);
487 if let Some(pillar) = style.color("pillar") {
488 for row in 0..rect.height {
490 cx.pillar(rect.x, rect.y + i32::from(row), pillar);
491 }
492 }
493 let env = cx.env;
494 let mut inner = rect.inset(padding);
495 if self.checked.is_some() {
496 let reserve = mark_width(env) + 2;
498 inner.width = inner.width.saturating_sub(reserve.saturating_sub(padding.right));
499 }
500 if let Some(build) = &self.card {
501 let mut children = Vec::new();
502 let screen = Size::new(cx.buf.area.width, cx.buf.area.height);
503 let idle = IdleScope::new(cx.idle);
504 build(&mut View::new(&mut children, env, screen, &idle), index);
505 let mut node = Node::new(Flex::new(Axis::Column, children), index);
507 node.layout.width = Length::Fill(1);
508 node.assign_ids(cx.id());
509 cx.paint_child(&node, inner);
510 }
511 if let Some(checked) = self.is_checked(index) {
512 let lit = states.iter().any(|state| matches!(state, State::Hover | State::Selected));
513 if checked || (lit && self.on_toggle.is_some() && !self.disabled) {
514 let variant = (!checked).then_some("off");
515 let fg = cx.style("card-mark", variant, &[]).text().fg.unwrap_or_else(|| cx.color("accent"));
516 let mark = mark_cell(env, rect, padding);
517 cx.text(mark.x, mark.y, &env.icons().glyph("check"), CellStyle::fg(fg), mark.width);
518 }
519 }
520 if self.disabled {
521 cx.tint(rect, background, 0.5);
522 }
523 }
524}
525
526impl<Msg: Clone + 'static> Widget<Msg> for CardGrid<Msg> {
527 fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
528 if self.count == 0 {
529 return self.empty.first().map_or(Size::default(), |empty| cx.measure_child(empty, available));
530 }
531 let sizing = self.sizing(card_padding(cx.env()));
532 let (columns, _) = layout::columns(available.width, sizing);
533 let height = layout::height_of(self.count.div_ceil(columns), sizing);
534 Size::new(available.width, height).min(available)
535 }
536
537 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
538 if area.is_empty() {
539 return;
540 }
541 if self.count == 0 {
542 if let Some(empty) = self.empty.first() {
543 cx.paint_child(empty, area);
544 }
545 return;
546 }
547 cx.register_hit(area);
548 let padding = cx.style("card", None, &[]).padding();
549 let layout = Layout::new(area, self.count, self.sizing(padding));
550 let active = self.active();
551 let focus_visible = active && cx.is_focus_visible();
552 let pressed = cx.is_pressed();
553 let pointer = if active { cx.pointer_within() } else { None };
554 let (offset, pointed, flashed, dragging) = {
555 let memory = cx.memory::<GridMemory>();
556 if pointer != memory.pointer {
557 memory.pointer = pointer;
560 memory.pointed = pointer.is_some();
561 }
562 let follow = (self.selected, layout.columns);
563 if memory.followed != Some(follow) {
564 if let Some(selected) = self.selected.filter(|index| *index < self.count) {
565 memory.offset = layout.reveal(selected, memory.offset);
566 }
567 memory.followed = Some(follow);
568 }
569 memory.offset = memory.offset.min(layout.max_offset());
570 memory.layout = layout;
571 (memory.offset, memory.pointed && pointer.is_some(), memory.flashed, memory.dragging)
572 };
573 let menu_card = row_menu::open_row(cx, self.menu.as_ref());
576 if menu_card.is_some() {
577 cx.request_overlay(area);
578 }
579 let hovered = match menu_card {
580 Some(card) => Some(card),
581 None => pointer.filter(|_| pointed).and_then(|(x, y)| layout.index_at(x, y, offset)),
582 };
583
584 cx.with_clip(layout.body, |cx| {
585 for index in layout.shown(offset) {
586 let rect = layout.card_rect(index, offset);
587 let is_hovered = hovered == Some(index);
588 let lit = self.selected == Some(index) && (!pointed || is_hovered);
591 let mut states = Vec::new();
592 if is_hovered {
593 states.push(State::Hover);
594 }
595 if lit {
596 states.push(State::Selected);
597 if focus_visible {
598 states.push(State::Focus);
599 }
600 }
601 if pressed && flashed == Some(index) {
602 states.push(State::Pressed);
603 }
604 self.paint_card(cx, rect, index, &states, padding);
605 }
606 });
607
608 if let Some(bar) = layout.bar() {
609 let lit = dragging || pointer.is_some_and(|(x, y)| bar.contains(x, y));
610 scrollbar::paint(cx, bar, layout.metrics(offset), lit, self.scrollbar);
611 }
612 }
613
614 fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
615 row_menu::paint(cx, self.menu.as_ref(), anchor);
616 }
617
618 fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
619 if !self.active() {
620 return false;
621 }
622 if self.menu_event(cx, event) {
623 return true;
624 }
625 match event {
626 Event::Key(key) => self.on_key(cx, key),
627 Event::Mouse(mouse) => self.on_mouse(cx, mouse),
628 _ => false,
629 }
630 }
631
632 fn focusable(&self) -> bool {
633 self.active()
634 }
635
636 fn children(&self) -> &[Node<Msg>] {
637 &self.empty
638 }
639
640 fn children_mut(&mut self) -> &mut [Node<Msg>] {
641 &mut self.empty
642 }
643}