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::IndexMessage;
17use super::empty_state::EmptyState;
18use super::scrollbar::{self, ScrollbarStyle};
19use layout::{Layout, Sizing, Step};
20
21type CardBuilder<Msg> = Box<dyn Fn(&mut View<'_, Msg>, usize)>;
23
24pub 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#[derive(Debug, Default)]
125struct GridMemory {
126 offset: usize,
128 followed: Option<(Option<usize>, usize)>,
131 dragging: bool,
133 flashed: Option<usize>,
135 pointer: Option<(i32, i32)>,
137 pointed: bool,
139 layout: Layout,
141}
142
143impl<Msg: 'static> CardGrid<Msg> {
144 #[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 #[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 #[must_use]
179 pub fn card_height(mut self, rows: u16) -> Self {
180 self.height = rows.max(1);
181 self
182 }
183
184 #[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 #[must_use]
194 pub fn selected(mut self, index: Option<usize>) -> Self {
195 self.selected = index;
196 self
197 }
198
199 #[must_use]
203 pub fn checked(mut self, checked: Vec<bool>) -> Self {
204 self.checked = Some(checked);
205 self
206 }
207
208 #[must_use]
211 pub fn disabled(mut self, disabled: bool) -> Self {
212 self.disabled = disabled;
213 self
214 }
215
216 #[must_use]
218 pub fn scrollbar(mut self, style: ScrollbarStyle) -> Self {
219 self.scrollbar = Some(style);
220 self
221 }
222
223 #[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 #[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 #[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 #[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 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 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 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
399fn 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
405fn mark_width(env: &Env) -> u16 {
407 text::width(&env.icons().glyph("check")).max(1)
408}
409
410fn 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
416fn 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 #[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 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 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 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 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 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 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}