mod layout;
#[cfg(test)]
mod tests;
use crate::env::Env;
use crate::event::{Event, MouseButton, MouseKind};
use crate::geometry::{Padding, Rect, Size, clamp_u16};
use crate::keymap::Key;
use crate::style::CellStyle;
use crate::text;
use crate::theme::State;
use crate::widget::{Axis, EventCx, Flex, IdleScope, Length, MeasureCx, Node, PaintCx, View, Widget};
use super::empty_state::EmptyState;
use super::row_menu::{self, RowAnchor, RowMenuItems};
use super::scrollbar::{self, ScrollbarStyle};
use super::{ContextItem, IndexMessage};
use layout::{Layout, Sizing, Step};
type CardBuilder<Msg> = Box<dyn Fn(&mut View<'_, Msg>, usize)>;
pub struct CardGrid<Msg> {
count: usize,
min_width: u16,
max_width: u16,
height: u16,
column_gap: u16,
row_gap: u16,
selected: Option<usize>,
checked: Option<Vec<bool>>,
disabled: bool,
scrollbar: Option<ScrollbarStyle>,
on_select: Option<IndexMessage<Msg>>,
on_activate: Option<IndexMessage<Msg>>,
on_toggle: Option<IndexMessage<Msg>>,
card: Option<CardBuilder<Msg>>,
menu: Option<RowMenuItems<Msg>>,
empty: Vec<Node<Msg>>,
}
#[derive(Debug, Default)]
struct GridMemory {
offset: usize,
followed: Option<(Option<usize>, usize)>,
dragging: bool,
flashed: Option<usize>,
pointer: Option<(i32, i32)>,
pointed: bool,
layout: Layout,
}
impl<Msg: 'static> CardGrid<Msg> {
#[must_use]
pub fn new(count: usize) -> Self {
Self {
count,
min_width: 24,
max_width: 32,
height: 3,
column_gap: 2,
row_gap: 1,
selected: None,
checked: None,
disabled: false,
scrollbar: None,
on_select: None,
on_activate: None,
on_toggle: None,
card: None,
menu: None,
empty: Vec::new(),
}
}
#[must_use]
pub fn card_width(mut self, min: u16, max: u16) -> Self {
self.min_width = min.max(1);
self.max_width = max.max(self.min_width);
self
}
#[must_use]
pub fn card_height(mut self, rows: u16) -> Self {
self.height = rows.max(1);
self
}
#[must_use]
pub fn gap(mut self, columns: u16, rows: u16) -> Self {
self.column_gap = columns;
self.row_gap = rows;
self
}
#[must_use]
pub fn selected(mut self, index: Option<usize>) -> Self {
self.selected = index;
self
}
#[must_use]
pub fn checked(mut self, checked: Vec<bool>) -> Self {
self.checked = Some(checked);
self
}
#[must_use]
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
#[must_use]
pub fn scrollbar(mut self, style: ScrollbarStyle) -> Self {
self.scrollbar = Some(style);
self
}
#[must_use]
pub fn on_select(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
self.on_select = Some(Box::new(message));
self
}
#[must_use]
pub fn on_activate(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
self.on_activate = Some(Box::new(message));
self
}
#[must_use]
pub fn on_toggle(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
self.on_toggle = Some(Box::new(message));
self
}
#[must_use]
pub fn card(mut self, build: impl Fn(&mut View<'_, Msg>, usize) + 'static) -> Self {
self.card = Some(Box::new(build));
self
}
#[must_use]
pub fn context_menu(mut self, items: impl Fn(usize) -> Vec<ContextItem<Msg>> + 'static) -> Self {
self.menu = Some(Box::new(items));
self
}
fn menu_event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
row_menu::event(
cx,
event,
self.menu.as_ref(),
self.count,
|cx, x, y| {
let memory = cx.memory::<GridMemory>();
let (layout, offset) = (memory.layout, memory.offset);
let index = layout.index_at(x, y, offset)?;
if !self.is_checked(index).unwrap_or(false) {
self.select(cx, index);
}
Some(RowAnchor { row: index, at: Rect::new(x, y, 1, 1), keyboard: false })
},
|cx| {
let index = self.current(cx)?;
let memory = cx.memory::<GridMemory>();
let layout = memory.layout;
memory.offset = layout.reveal(index, memory.offset);
let at = layout.card_rect(index, memory.offset);
Some(RowAnchor { row: index, at, keyboard: true })
},
)
}
fn active(&self) -> bool {
!self.disabled && self.count > 0
}
fn is_checked(&self, index: usize) -> Option<bool> {
self.checked.as_ref().map(|checked| checked.get(index).copied().unwrap_or(false))
}
fn toggles(&self) -> bool {
self.checked.is_some() && self.on_toggle.is_some()
}
fn sizing(&self, padding: Padding) -> Sizing {
Sizing {
min_width: self.min_width,
max_width: self.max_width,
height: self.height.saturating_add(padding.vertical()),
column_gap: self.column_gap,
row_gap: self.row_gap,
}
}
fn select(&self, cx: &mut EventCx<'_, Msg>, index: usize) {
if Some(index) != self.selected
&& let Some(message) = &self.on_select
{
cx.emit(message(index));
}
}
fn activate(&self, cx: &mut EventCx<'_, Msg>, index: usize) -> bool {
let Some(message) = &self.on_activate else {
return false;
};
cx.memory::<GridMemory>().flashed = Some(index);
cx.flash();
cx.emit(message(index));
true
}
fn toggle(&self, cx: &mut EventCx<'_, Msg>, index: usize) -> bool {
match (&self.checked, &self.on_toggle) {
(Some(_), Some(message)) => {
cx.emit(message(index));
true
}
_ => false,
}
}
fn current(&self, cx: &mut EventCx<'_, Msg>) -> Option<usize> {
let memory = cx.memory::<GridMemory>();
let pointed = memory.pointed.then_some(memory.pointer).flatten();
pointed.and_then(|(x, y)| memory.layout.index_at(x, y, memory.offset)).or(self.selected)
}
fn on_key(&self, cx: &mut EventCx<'_, Msg>, key: &crate::event::KeyEvent) -> bool {
let steps = [
(Key::Left, Step::Left),
(Key::Right, Step::Right),
(Key::Up, Step::Up),
(Key::Down, Step::Down),
(Key::PageUp, Step::PageUp),
(Key::PageDown, Step::PageDown),
(Key::Home, Step::Home),
(Key::End, Step::End),
];
let step = steps.into_iter().find(|(k, _)| key.is_plain(*k)).map(|(_, step)| step);
let activates = key.is_plain(Key::Enter) || key.is_plain(Key::Space);
if step.is_none() && !activates {
return false;
}
let current = self.current(cx);
cx.memory::<GridMemory>().pointed = false;
if let Some(step) = step {
let target = cx.memory::<GridMemory>().layout.step(step, current);
if let Some(target) = target {
self.select(cx, target);
}
return target.is_some();
}
let Some(index) = current else {
return false;
};
self.select(cx, index);
if key.is_plain(Key::Space) && self.toggle(cx, index) {
return true;
}
self.activate(cx, index)
}
fn on_mouse(&self, cx: &mut EventCx<'_, Msg>, mouse: &crate::event::MouseEvent) -> bool {
let memory = cx.memory::<GridMemory>();
let layout = memory.layout;
let bar = layout.bar();
let on_bar = bar.is_some_and(|bar| bar.contains(mouse.x, mouse.y));
let track = |y: i32| clamp_u16(y - layout.body.y);
match mouse.kind {
MouseKind::ScrollUp | MouseKind::ScrollDown => {
memory.offset = if mouse.kind == MouseKind::ScrollUp {
memory.offset.saturating_sub(1)
} else {
(memory.offset + 1).min(layout.max_offset())
};
memory.pointed = true;
true
}
MouseKind::Down(MouseButton::Left) if on_bar => {
memory.dragging = true;
memory.offset = layout.metrics(memory.offset).offset_at(track(mouse.y), layout.body.height);
cx.capture_pointer();
true
}
MouseKind::Drag(MouseButton::Left) if memory.dragging => {
memory.offset = layout.metrics(memory.offset).offset_at(track(mouse.y), layout.body.height);
true
}
MouseKind::Up(MouseButton::Left) if memory.dragging => {
memory.dragging = false;
true
}
MouseKind::Down(MouseButton::Left) => {
let offset = memory.offset;
let Some(index) = layout.index_at(mouse.x, mouse.y, offset) else {
return false;
};
if self.toggles() {
let card = layout.card_rect(index, offset);
let padding = card_padding(cx.env());
if mark_zone(cx.env(), card, padding).contains(mouse.x, mouse.y) {
return self.toggle(cx, index);
}
}
self.select(cx, index);
self.activate(cx, index);
true
}
_ => false,
}
}
}
fn card_padding(env: &Env) -> Padding {
let style = env.theme().style("card", None, &[]);
style.pair("padding").map_or(Padding::default(), |(vertical, horizontal)| Padding::symmetric(vertical, horizontal))
}
fn mark_width(env: &Env) -> u16 {
text::width(&env.icons().glyph("check")).max(1)
}
fn mark_cell(env: &Env, card: Rect, padding: Padding) -> Rect {
let width = mark_width(env);
Rect::new(card.right() - 1 - i32::from(width), card.y + i32::from(padding.top), width, 1)
}
fn mark_zone(env: &Env, card: Rect, padding: Padding) -> Rect {
let mark = mark_cell(env, card, padding);
Rect::new(mark.x - 1, mark.y, mark.width + 2, 1)
}
impl<Msg: Clone + 'static> CardGrid<Msg> {
#[must_use]
pub fn empty(mut self, state: EmptyState<Msg>) -> Self {
if self.count > 0 {
return self;
}
let mut node = Node::new(state, 0);
node.layout.width = Length::Fill(1);
node.layout.height = Length::Fill(1);
self.empty = vec![node];
self
}
fn paint_card(&self, cx: &mut PaintCx<'_>, rect: Rect, index: usize, states: &[State], padding: Padding) {
let style = cx.style("card", None, states);
let background = style.text().bg.unwrap_or_else(|| cx.color("raised"));
cx.clear(rect, background);
if let Some(pillar) = style.color("pillar") {
for row in 0..rect.height {
cx.pillar(rect.x, rect.y + i32::from(row), pillar);
}
}
let env = cx.env;
let mut inner = rect.inset(padding);
if self.checked.is_some() {
let reserve = mark_width(env) + 2;
inner.width = inner.width.saturating_sub(reserve.saturating_sub(padding.right));
}
if let Some(build) = &self.card {
let mut children = Vec::new();
let screen = Size::new(cx.buf.area.width, cx.buf.area.height);
let idle = IdleScope::new(cx.idle);
build(&mut View::new(&mut children, env, screen, &idle), index);
let mut node = Node::new(Flex::new(Axis::Column, children), index);
node.layout.width = Length::Fill(1);
node.assign_ids(cx.id());
cx.paint_child(&node, inner);
}
if let Some(checked) = self.is_checked(index) {
let lit = states.iter().any(|state| matches!(state, State::Hover | State::Selected));
if checked || (lit && self.on_toggle.is_some() && !self.disabled) {
let variant = (!checked).then_some("off");
let fg = cx.style("card-mark", variant, &[]).text().fg.unwrap_or_else(|| cx.color("accent"));
let mark = mark_cell(env, rect, padding);
cx.text(mark.x, mark.y, &env.icons().glyph("check"), CellStyle::fg(fg), mark.width);
}
}
if self.disabled {
cx.tint(rect, background, 0.5);
}
}
}
impl<Msg: Clone + 'static> Widget<Msg> for CardGrid<Msg> {
fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
if self.count == 0 {
return self.empty.first().map_or(Size::default(), |empty| cx.measure_child(empty, available));
}
let sizing = self.sizing(card_padding(cx.env()));
let (columns, _) = layout::columns(available.width, sizing);
let height = layout::height_of(self.count.div_ceil(columns), sizing);
Size::new(available.width, height).min(available)
}
fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
if area.is_empty() {
return;
}
if self.count == 0 {
if let Some(empty) = self.empty.first() {
cx.paint_child(empty, area);
}
return;
}
cx.register_hit(area);
let padding = cx.style("card", None, &[]).padding();
let layout = Layout::new(area, self.count, self.sizing(padding));
let active = self.active();
let focus_visible = active && cx.is_focus_visible();
let pressed = cx.is_pressed();
let pointer = if active { cx.pointer_within() } else { None };
let (offset, pointed, flashed, dragging) = {
let memory = cx.memory::<GridMemory>();
if pointer != memory.pointer {
memory.pointer = pointer;
memory.pointed = pointer.is_some();
}
let follow = (self.selected, layout.columns);
if memory.followed != Some(follow) {
if let Some(selected) = self.selected.filter(|index| *index < self.count) {
memory.offset = layout.reveal(selected, memory.offset);
}
memory.followed = Some(follow);
}
memory.offset = memory.offset.min(layout.max_offset());
memory.layout = layout;
(memory.offset, memory.pointed && pointer.is_some(), memory.flashed, memory.dragging)
};
let menu_card = row_menu::open_row(cx, self.menu.as_ref());
if menu_card.is_some() {
cx.request_overlay(area);
}
let hovered = match menu_card {
Some(card) => Some(card),
None => pointer.filter(|_| pointed).and_then(|(x, y)| layout.index_at(x, y, offset)),
};
cx.with_clip(layout.body, |cx| {
for index in layout.shown(offset) {
let rect = layout.card_rect(index, offset);
let is_hovered = hovered == Some(index);
let lit = self.selected == Some(index) && (!pointed || is_hovered);
let mut states = Vec::new();
if is_hovered {
states.push(State::Hover);
}
if lit {
states.push(State::Selected);
if focus_visible {
states.push(State::Focus);
}
}
if pressed && flashed == Some(index) {
states.push(State::Pressed);
}
self.paint_card(cx, rect, index, &states, padding);
}
});
if let Some(bar) = layout.bar() {
let lit = dragging || pointer.is_some_and(|(x, y)| bar.contains(x, y));
scrollbar::paint(cx, bar, layout.metrics(offset), lit, self.scrollbar);
}
}
fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
row_menu::paint(cx, self.menu.as_ref(), anchor);
}
fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
if !self.active() {
return false;
}
if self.menu_event(cx, event) {
return true;
}
match event {
Event::Key(key) => self.on_key(cx, key),
Event::Mouse(mouse) => self.on_mouse(cx, mouse),
_ => false,
}
}
fn focusable(&self) -> bool {
self.active()
}
fn children(&self) -> &[Node<Msg>] {
&self.empty
}
fn children_mut(&mut self) -> &mut [Node<Msg>] {
&mut self.empty
}
}