use crate::event::{Point, Rect};
use crate::theme::Theme;
use crate::types::{Color, Modifiers, WidgetId};
use serde::{Deserialize, Serialize};
pub const BOARD_COL_MIN_PX: f32 = 200.0;
pub const BOARD_COL_GAP_PX: f32 = 8.0;
pub const BOARD_HEADER_H_PX: f32 = 24.0;
pub const BOARD_CARD_H_PX: f32 = 64.0;
pub const BOARD_CARD_GAP_PX: f32 = 6.0;
pub const BOARD_CARD_CORNER_RADIUS_PX: f64 = 4.0;
pub const BOARD_CARD_H_PAD_PX: f64 = 8.0;
pub type CardId = WidgetId;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum BadgeStatus {
Pending,
Running,
Passed,
Warning,
Blocked,
}
pub fn badge_icon(status: BadgeStatus) -> char {
match status {
BadgeStatus::Passed => '✓',
BadgeStatus::Running => '●',
BadgeStatus::Warning => '↩',
BadgeStatus::Blocked => '✗',
BadgeStatus::Pending => '·',
}
}
pub fn badge_fg_color(status: BadgeStatus, theme: &Theme) -> Color {
match status {
BadgeStatus::Passed => theme.badge_passed,
BadgeStatus::Running => theme.badge_running,
BadgeStatus::Warning => theme.badge_warning,
BadgeStatus::Blocked => theme.badge_blocked,
BadgeStatus::Pending => theme.muted_fg,
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CardBadge {
pub label: String,
pub status: BadgeStatus,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BoardCard {
pub id: CardId,
pub title: String,
#[serde(default)]
pub labels: Vec<String>,
#[serde(default)]
pub badges: Vec<CardBadge>,
#[serde(default)]
pub hint: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BoardColumn {
pub id: WidgetId,
pub title: String,
pub cards: Vec<BoardCard>,
#[serde(default)]
pub scroll_offset: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BoardModel {
pub id: WidgetId,
pub columns: Vec<BoardColumn>,
#[serde(default)]
pub selected_card_id: Option<CardId>,
#[serde(default)]
pub col_scroll_offset: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum MoveDir {
Up,
Down,
Left,
Right,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum BoardAction {
SelectCard(CardId),
OpenIssue(CardId),
ContextMenu(CardId, Point),
MoveSelection(MoveDir),
JumpToTop,
JumpToBottom,
OpenReview(CardId),
}
impl BoardModel {
pub fn move_selection(&mut self, dir: MoveDir) {
let (cur_col, cur_card) = match self.selected_position() {
Some((ci, ri)) => (Some(ci), Some(ri)),
None => (None, None),
};
match dir {
MoveDir::Down => {
if let Some(ci) = cur_col {
let next = cur_card.map(|c| c + 1).unwrap_or(0);
if next < self.columns[ci].cards.len() {
self.selected_card_id = Some(self.columns[ci].cards[next].id);
}
}
}
MoveDir::Up => {
if let Some(ci) = cur_col {
if let Some(cc) = cur_card {
if cc > 0 {
self.selected_card_id = Some(self.columns[ci].cards[cc - 1].id);
}
}
}
}
MoveDir::Right => {
if let Some(ci) = cur_col {
if ci + 1 < self.columns.len() {
let next_ci = ci + 1;
let next_card = cur_card
.map(|c| c.min(self.columns[next_ci].cards.len().saturating_sub(1)));
self.selected_card_id = next_card
.and_then(|c| self.columns[next_ci].cards.get(c))
.map(|card| card.id);
}
} else if !self.columns.is_empty() && !self.columns[0].cards.is_empty() {
self.selected_card_id = Some(self.columns[0].cards[0].id);
}
}
MoveDir::Left => {
if let Some(ci) = cur_col {
if ci > 0 {
let prev_ci = ci - 1;
let next_card = cur_card
.map(|c| c.min(self.columns[prev_ci].cards.len().saturating_sub(1)));
self.selected_card_id = next_card
.and_then(|c| self.columns[prev_ci].cards.get(c))
.map(|card| card.id);
}
}
}
}
}
pub fn jump_to_top(&mut self) {
if let Some(ci) = self.selected_col_index() {
if let Some(card) = self.columns[ci].cards.first() {
self.selected_card_id = Some(card.id);
}
}
}
pub fn jump_to_bottom(&mut self) {
if let Some(ci) = self.selected_col_index() {
if let Some(card) = self.columns[ci].cards.last() {
self.selected_card_id = Some(card.id);
}
}
}
pub fn selected_col_index(&self) -> Option<usize> {
let id = self.selected_card_id.as_ref()?;
self.columns
.iter()
.position(|col| col.cards.iter().any(|c| &c.id == id))
}
pub fn selected_position(&self) -> Option<(usize, usize)> {
let id = self.selected_card_id.as_ref()?;
for (ci, col) in self.columns.iter().enumerate() {
if let Some(ri) = col.cards.iter().position(|c| &c.id == id) {
return Some((ci, ri));
}
}
None
}
pub fn handle_key(&self, key: &str, _modifiers: Modifiers) -> Option<BoardAction> {
let selected = self.selected_card_id.as_ref();
match key {
"j" | "Down" | "ArrowDown" => Some(BoardAction::MoveSelection(MoveDir::Down)),
"k" | "Up" | "ArrowUp" => Some(BoardAction::MoveSelection(MoveDir::Up)),
"h" | "Left" | "ArrowLeft" => Some(BoardAction::MoveSelection(MoveDir::Left)),
"l" | "Right" | "ArrowRight" => Some(BoardAction::MoveSelection(MoveDir::Right)),
"Enter" => selected.map(|id| BoardAction::OpenIssue(*id)),
"g" => Some(BoardAction::JumpToTop),
"G" => Some(BoardAction::JumpToBottom),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BoardMeasure {
pub col_min_width: f32,
pub col_gap: f32,
pub header_height: f32,
pub card_height: f32,
pub card_gap: f32,
}
impl BoardMeasure {
pub fn new(
col_min_width: f32,
col_gap: f32,
header_height: f32,
card_height: f32,
card_gap: f32,
) -> Self {
Self {
col_min_width,
col_gap,
header_height,
card_height,
card_gap,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct CardLayout {
pub id: CardId,
pub col_index: usize,
pub card_index: usize,
pub bounds: Rect,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ColumnLayout {
pub col_index: usize,
pub col_id: WidgetId,
pub bounds: Rect,
pub header_bounds: Rect,
pub body_bounds: Rect,
pub visible_cards: usize,
pub cards: Vec<CardLayout>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct BoardLayout {
pub bounds: Rect,
pub columns: Vec<ColumnLayout>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BoardHit {
Card(CardId),
ColumnHeader(WidgetId),
Empty,
}
impl BoardLayout {
pub fn hit_test(&self, x: f32, y: f32) -> BoardHit {
let point = Point::new(x, y);
for col in &self.columns {
if col.header_bounds.contains(point) {
return BoardHit::ColumnHeader(col.col_id);
}
for card in &col.cards {
if card.bounds.contains(point) {
return BoardHit::Card(card.id);
}
}
}
BoardHit::Empty
}
}
pub fn board_layout(
model: &BoardModel,
origin_x: f32,
origin_y: f32,
total_width: f32,
total_height: f32,
measure: BoardMeasure,
) -> BoardLayout {
let bounds = Rect::new(origin_x, origin_y, total_width, total_height);
let n_total = model.columns.len();
if n_total == 0 || total_width <= 0.0 || total_height <= 0.0 {
return BoardLayout {
bounds,
columns: vec![],
};
}
let gap_total = measure.col_gap * (n_total as f32 - 1.0).max(0.0);
let natural = (total_width - gap_total) / n_total as f32;
let col_w = natural.max(measure.col_min_width).max(1.0);
let start = crate::primitives::scrollbar::clamp_scroll_offset(model.col_scroll_offset, n_total);
let n_fit = count_fitting_columns(n_total - start, col_w, measure.col_gap, total_width);
let end = (start + n_fit).min(n_total);
let body_h = (total_height - measure.header_height).max(0.0);
let visible_cards_per_col = if measure.card_height > 0.0 {
let step = measure.card_height + measure.card_gap;
((body_h / step).floor() as usize).max(1)
} else {
0
};
let mut columns = Vec::with_capacity(end - start);
let mut cx = origin_x;
for col_i in start..end {
let col = &model.columns[col_i];
let col_bounds = Rect::new(cx, origin_y, col_w, total_height);
let header_bounds = Rect::new(cx, origin_y, col_w, measure.header_height);
let body_bounds = Rect::new(cx, origin_y + measure.header_height, col_w, body_h);
let (scroll_off, card_end) = crate::primitives::scrollbar::visible_window(
col.scroll_offset,
col.cards.len(),
visible_cards_per_col,
);
let mut card_layouts = Vec::with_capacity(card_end.saturating_sub(scroll_off));
let mut cy = origin_y + measure.header_height;
for (ci, card) in col.cards[scroll_off..card_end].iter().enumerate() {
let card_bounds = Rect::new(cx, cy, col_w, measure.card_height);
card_layouts.push(CardLayout {
id: card.id,
col_index: col_i,
card_index: scroll_off + ci,
bounds: card_bounds,
});
cy += measure.card_height + measure.card_gap;
}
columns.push(ColumnLayout {
col_index: col_i,
col_id: col.id,
bounds: col_bounds,
header_bounds,
body_bounds,
visible_cards: visible_cards_per_col,
cards: card_layouts,
});
cx += col_w + measure.col_gap;
}
BoardLayout { bounds, columns }
}
fn count_fitting_columns(available: usize, col_w: f32, gap: f32, total_width: f32) -> usize {
if available == 0 || col_w <= 0.0 {
return 0;
}
let mut count = 0usize;
let mut used = 0.0_f32;
for i in 0..available {
let needed = if i == 0 { col_w } else { gap + col_w };
if used + needed > total_width + 0.5 {
break;
}
used += needed;
count += 1;
}
count.max(1)
}
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
#[allow(dead_code)]
pub(crate) mod native_surface_paint {
use super::{
badge_fg_color, badge_icon, BoardCard, BoardLayout, BoardModel, BOARD_CARD_H_PAD_PX,
};
use crate::event::Rect;
use crate::paint_surface::PaintSurface;
use crate::primitives::layout_metrics::pixel_board_layout;
use crate::theme::Theme;
const TITLE_Y_OFF: f32 = 6.0;
const BADGE_Y_OFF: f32 = 26.0;
const BADGE_ROW_H: f32 = 18.0;
const HINT_Y_OFF: f32 = 18.0;
const HINT_H: f32 = 14.0;
const HEADER_Y_OFF: f32 = 4.0;
const CARD_BORDER_WIDTH: f32 = 1.0;
pub(crate) fn paint(
model: &BoardModel,
surface: &mut dyn PaintSurface,
theme: &Theme,
rect: Rect,
) -> BoardLayout {
let layout = pixel_board_layout(model, rect.x, rect.y, rect.width, rect.height);
if rect.width <= 0.0 || rect.height <= 0.0 {
return layout;
}
let h_pad = BOARD_CARD_H_PAD_PX as f32;
for col_layout in &layout.columns {
let col = &model.columns[col_layout.col_index];
let hb = col_layout.header_bounds;
surface.surface_fill_rect(hb, theme.board_col_header_bg);
surface.surface_push_clip(hb);
surface.surface_draw_text_run(
Rect::new(
hb.x + h_pad,
hb.y + HEADER_Y_OFF,
(hb.width - h_pad).max(0.0),
(hb.height - HEADER_Y_OFF).max(0.0),
),
&col.title,
theme.header_fg,
);
surface.surface_pop_clip();
for card_layout in &col_layout.cards {
let card: &BoardCard = &col.cards[card_layout.card_index];
let is_selected = model
.selected_card_id
.as_ref()
.map(|id| id == &card.id)
.unwrap_or(false);
let cb = card_layout.bounds;
if cb.width <= 0.0 || cb.height <= 0.0 {
continue;
}
let card_bg = if is_selected {
theme.board_selected_card_bg
} else {
theme.surface_bg
};
surface.surface_fill_rect(cb, card_bg);
let border_col = if is_selected {
theme.accent_bg
} else {
theme.border_fg
};
surface.surface_stroke_rect(cb, border_col, CARD_BORDER_WIDTH);
surface.surface_push_clip(cb);
let prefix = if card.labels.is_empty() {
String::new()
} else {
format!("{} ", card.labels.join(" "))
};
let full_title = format!("{}{}", prefix, card.title);
surface.surface_draw_text_run(
Rect::new(
cb.x + h_pad,
cb.y + TITLE_Y_OFF,
(cb.width - h_pad * 2.0).max(0.0),
(cb.height - TITLE_Y_OFF).max(0.0),
),
&full_title,
theme.surface_fg,
);
let badge_y = cb.y + BADGE_Y_OFF;
if !card.badges.is_empty() {
let badge_band = Rect::new(
cb.x,
badge_y - 2.0,
cb.width,
BADGE_ROW_H.min((cb.height - (badge_y - cb.y) + 2.0).max(0.0)),
);
surface.surface_push_clip(badge_band);
let mut badge_x = cb.x + h_pad;
for badge in &card.badges {
let badge_str = format!("{}{} ", badge_icon(badge.status), badge.label);
let (bw, bh) = surface.surface_measure_text(&badge_str);
let color = badge_fg_color(badge.status, theme);
surface.surface_draw_text_run(
Rect::new(badge_x, badge_y, bw.max(1.0), bh.max(1.0)),
&badge_str,
color,
);
badge_x += bw;
if badge_x > cb.x + cb.width - h_pad {
break;
}
}
surface.surface_pop_clip();
}
if let Some(hint) = &card.hint {
let hint_y = cb.y + cb.height - HINT_Y_OFF;
if hint_y > badge_y + 10.0 {
let strip =
Rect::new(cb.x + 2.0, hint_y - 2.0, (cb.width - 4.0).max(0.0), HINT_H);
surface.surface_fill_rect(strip, theme.card_hint_bg);
surface.surface_push_clip(strip);
surface.surface_draw_text_run(
Rect::new(
cb.x + h_pad,
hint_y,
(cb.width - h_pad * 2.0).max(0.0),
HINT_H,
),
hint,
theme.card_hint_fg,
);
surface.surface_pop_clip();
}
}
surface.surface_pop_clip();
}
}
layout
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::ImagePaintResult;
use crate::event::Viewport;
use crate::primitives::board::{BadgeStatus, BoardColumn, CardBadge};
use crate::types::{Color, WidgetId};
use crate::Image;
#[derive(Default)]
struct RecordingSurface {
fills: Vec<(Rect, Color)>,
strokes: Vec<(Rect, Color)>,
text_runs: Vec<(Rect, String, Color)>,
clip_pushes: Vec<Rect>,
clip_pops: usize,
}
impl PaintSurface for RecordingSurface {
fn surface_begin_frame(&mut self, _viewport: Viewport) {}
fn surface_end_frame(&mut self) {}
fn surface_viewport(&self) -> Viewport {
Viewport::new(460.0, 300.0, 1.0)
}
fn surface_line_height(&self) -> f32 {
16.0
}
fn surface_char_width(&self) -> f32 {
8.0
}
fn surface_measure_text(&self, text: &str) -> (f32, f32) {
(text.chars().count() as f32 * 8.0, 14.0)
}
fn surface_fill_rect(&mut self, rect: Rect, color: Color) {
self.fills.push((rect, color));
}
fn surface_fill_rounded_rect(&mut self, rect: Rect, _radius: f32, color: Color) {
self.fills.push((rect, color));
}
fn surface_stroke_rect(&mut self, rect: Rect, color: Color, _stroke_width: f32) {
self.strokes.push((rect, color));
}
fn surface_stroke_rounded_rect(
&mut self,
rect: Rect,
_radius: f32,
color: Color,
_stroke_width: f32,
) {
self.strokes.push((rect, color));
}
fn surface_draw_text_run(&mut self, rect: Rect, text: &str, color: Color) {
self.text_runs.push((rect, text.to_string(), color));
}
fn surface_draw_line(
&mut self,
_from: crate::Point,
_to: crate::Point,
_color: Color,
_stroke_width: f32,
) {
}
fn surface_push_clip(&mut self, rect: Rect) {
self.clip_pushes.push(rect);
}
fn surface_pop_clip(&mut self) {
self.clip_pops += 1;
}
fn surface_draw_image(&mut self, _rect: Rect, _image: &Image) -> ImagePaintResult {
ImagePaintResult::Unsupported
}
}
fn card(id: &str, title: &str) -> BoardCard {
BoardCard {
id: WidgetId::new(id),
title: title.into(),
labels: vec!["#1".into()],
badges: vec![CardBadge {
label: "P".into(),
status: BadgeStatus::Passed,
}],
hint: None,
}
}
fn sample_model() -> BoardModel {
BoardModel {
id: WidgetId::new("board"),
columns: vec![BoardColumn {
id: WidgetId::new("col:backlog"),
title: "Backlog".into(),
cards: vec![card("card:a", "First")],
scroll_offset: 0,
}],
selected_card_id: None,
col_scroll_offset: 0,
}
}
#[test]
fn zero_size_rect_paints_nothing() {
let model = sample_model();
let mut surface = RecordingSurface::default();
let layout = paint(
&model,
&mut surface,
&Theme::default(),
Rect::new(0.0, 0.0, 0.0, 0.0),
);
assert!(layout.columns.is_empty());
assert!(surface.fills.is_empty());
assert!(surface.text_runs.is_empty());
}
#[test]
fn column_header_text_is_clipped() {
let model = sample_model();
let mut surface = RecordingSurface::default();
paint(
&model,
&mut surface,
&Theme::default(),
Rect::new(0.0, 0.0, 220.0, 300.0),
);
assert_eq!(surface.clip_pushes.len(), surface.clip_pops);
assert!(surface.clip_pushes.len() >= 2);
let header = &model.columns[0];
assert!(surface.text_runs.iter().any(|(_, t, _)| t == &header.title));
}
#[test]
fn card_paints_a_straight_border_inside_one_clip_bracket() {
let model = sample_model();
let mut surface = RecordingSurface::default();
let layout = paint(
&model,
&mut surface,
&Theme::default(),
Rect::new(0.0, 0.0, 220.0, 300.0),
);
let cb = layout.columns[0].cards[0].bounds;
assert!(
surface.strokes.iter().any(|(r, _)| *r == cb),
"card border must be a straight surface_stroke_rect over the card bounds",
);
assert_eq!(surface.clip_pushes.len(), 3);
assert_eq!(surface.clip_pops, 3);
}
#[test]
fn badge_row_and_hint_strip_are_each_clipped_to_their_own_band() {
struct TallTextSurface(RecordingSurface);
impl PaintSurface for TallTextSurface {
fn surface_begin_frame(&mut self, v: Viewport) {
self.0.surface_begin_frame(v)
}
fn surface_end_frame(&mut self) {
self.0.surface_end_frame()
}
fn surface_viewport(&self) -> Viewport {
self.0.surface_viewport()
}
fn surface_line_height(&self) -> f32 {
self.0.surface_line_height()
}
fn surface_char_width(&self) -> f32 {
self.0.surface_char_width()
}
fn surface_measure_text(&self, text: &str) -> (f32, f32) {
let (w, _h) = self.0.surface_measure_text(text);
(w, 40.0)
}
fn surface_fill_rect(&mut self, rect: Rect, color: Color) {
self.0.surface_fill_rect(rect, color)
}
fn surface_fill_rounded_rect(&mut self, rect: Rect, radius: f32, color: Color) {
self.0.surface_fill_rounded_rect(rect, radius, color)
}
fn surface_stroke_rect(&mut self, rect: Rect, color: Color, stroke_width: f32) {
self.0.surface_stroke_rect(rect, color, stroke_width)
}
fn surface_stroke_rounded_rect(
&mut self,
rect: Rect,
radius: f32,
color: Color,
stroke_width: f32,
) {
self.0
.surface_stroke_rounded_rect(rect, radius, color, stroke_width)
}
fn surface_draw_text_run(&mut self, rect: Rect, text: &str, color: Color) {
self.0.surface_draw_text_run(rect, text, color)
}
fn surface_draw_line(
&mut self,
from: crate::Point,
to: crate::Point,
color: Color,
stroke_width: f32,
) {
self.0.surface_draw_line(from, to, color, stroke_width)
}
fn surface_push_clip(&mut self, rect: Rect) {
self.0.surface_push_clip(rect)
}
fn surface_pop_clip(&mut self) {
self.0.surface_pop_clip()
}
fn surface_draw_image(&mut self, rect: Rect, image: &Image) -> ImagePaintResult {
self.0.surface_draw_image(rect, image)
}
}
let mut model = sample_model();
model.columns[0].cards[0].hint = Some("blocked".into());
let mut surface = TallTextSurface(RecordingSurface::default());
let layout = paint(
&model,
&mut surface,
&Theme::default(),
Rect::new(0.0, 0.0, 220.0, 300.0),
);
let cb = layout.columns[0].cards[0].bounds;
let badge_y = cb.y + BADGE_Y_OFF;
let hint_y = cb.y + cb.height - HINT_Y_OFF;
assert_eq!(surface.0.clip_pushes.len(), 4);
assert_eq!(surface.0.clip_pops, 4);
let badge_clip = surface
.0
.clip_pushes
.iter()
.find(|r| (r.y - (badge_y - 2.0)).abs() < 0.01)
.expect("badge row must push its own clip band");
assert!(
badge_clip.height <= BADGE_ROW_H,
"badge-row clip band must stay within BADGE_ROW_H regardless of measured text height"
);
let hint_clip = surface
.0
.clip_pushes
.iter()
.find(|r| (r.y - (hint_y - 2.0)).abs() < 0.01)
.expect("hint strip must push its own clip over its background fill");
assert!(
(hint_clip.height - HINT_H).abs() < 0.01,
"hint clip must match the fixed HINT_H strip, not the taller measured text"
);
}
#[test]
fn selected_card_uses_the_selection_background() {
let mut model = sample_model();
model.selected_card_id = Some(WidgetId::new("card:a"));
let theme = Theme::default();
let mut surface = RecordingSurface::default();
let layout = paint(
&model,
&mut surface,
&theme,
Rect::new(0.0, 0.0, 220.0, 300.0),
);
let cb = layout.columns[0].cards[0].bounds;
assert!(surface
.fills
.iter()
.any(|(r, c)| *r == cb && *c == theme.board_selected_card_bg));
}
#[test]
fn badge_text_is_recorded() {
let model = sample_model();
let mut surface = RecordingSurface::default();
paint(
&model,
&mut surface,
&Theme::default(),
Rect::new(0.0, 0.0, 220.0, 300.0),
);
assert!(surface
.text_runs
.iter()
.any(|(_, t, _)| t.starts_with('✓') && t.contains('P')));
}
#[test]
fn hint_paints_only_when_present() {
let mut model = sample_model();
model.columns[0].cards[0].hint = Some("blocked".into());
let mut surface = RecordingSurface::default();
paint(
&model,
&mut surface,
&Theme::default(),
Rect::new(0.0, 0.0, 220.0, 300.0),
);
assert!(surface.text_runs.iter().any(|(_, t, _)| t == "blocked"));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_card(id: &str, title: &str) -> BoardCard {
BoardCard {
id: WidgetId::new(id),
title: title.to_string(),
labels: vec![],
badges: vec![
CardBadge {
label: "P".into(),
status: BadgeStatus::Passed,
},
CardBadge {
label: "W".into(),
status: BadgeStatus::Running,
},
CardBadge {
label: "T".into(),
status: BadgeStatus::Pending,
},
CardBadge {
label: "R".into(),
status: BadgeStatus::Pending,
},
CardBadge {
label: "M".into(),
status: BadgeStatus::Pending,
},
],
hint: None,
}
}
fn make_model() -> BoardModel {
BoardModel {
id: WidgetId::new("board"),
columns: vec![
BoardColumn {
id: WidgetId::new("col:backlog"),
title: "Backlog".to_string(),
cards: vec![
make_card("card:362", "#362 Board"),
make_card("card:300", "#300 Fixes"),
],
scroll_offset: 0,
},
BoardColumn {
id: WidgetId::new("col:ready"),
title: "Ready".to_string(),
cards: vec![make_card("card:521", "#521 Issues")],
scroll_offset: 0,
},
BoardColumn {
id: WidgetId::new("col:done"),
title: "Done".to_string(),
cards: vec![],
scroll_offset: 0,
},
],
selected_card_id: Some(WidgetId::new("card:362")),
col_scroll_offset: 0,
}
}
fn measure() -> BoardMeasure {
BoardMeasure::new(20.0, 2.0, 2.0, 5.0, 1.0)
}
#[test]
fn construction_and_field_access() {
let m = make_model();
assert_eq!(m.columns.len(), 3);
assert_eq!(m.columns[0].title, "Backlog");
assert_eq!(m.columns[0].cards.len(), 2);
assert_eq!(m.columns[0].cards[0].id.as_str(), "card:362");
assert_eq!(m.badges_count(), 15);
}
#[test]
fn badge_vocabulary_is_host_defined() {
let card = BoardCard {
id: WidgetId::new("card:1"),
title: "Bake bread".to_string(),
labels: vec![],
badges: vec![
CardBadge {
label: "Mix".to_string(),
status: BadgeStatus::Passed,
},
CardBadge {
label: "Proof".to_string(),
status: BadgeStatus::Running,
},
CardBadge {
label: "Bake".to_string(),
status: BadgeStatus::Blocked,
},
],
hint: Some("oven is out of order".to_string()),
};
assert_eq!(card.badges.len(), 3);
assert_eq!(card.badges[0].label, "Mix");
assert_eq!(card.badges[2].status, BadgeStatus::Blocked);
assert_eq!(card.hint.as_deref(), Some("oven is out of order"));
let json = serde_json::to_string(&card).unwrap();
let back: BoardCard = serde_json::from_str(&json).unwrap();
assert_eq!(card, back);
}
#[test]
fn layout_three_columns_fit_in_wide_viewport() {
let m = make_model();
let layout = board_layout(&m, 0.0, 0.0, 100.0, 20.0, measure());
assert_eq!(
layout.columns.len(),
3,
"all 3 columns should fit in 100-unit viewport"
);
}
#[test]
fn layout_equal_column_widths() {
let m = make_model();
let layout = board_layout(&m, 0.0, 0.0, 100.0, 20.0, measure());
let w0 = layout.columns[0].bounds.width;
let w1 = layout.columns[1].bounds.width;
let w2 = layout.columns[2].bounds.width;
assert!(
(w0 - w1).abs() < 0.5 && (w1 - w2).abs() < 0.5,
"columns should have equal width: {w0}, {w1}, {w2}"
);
}
#[test]
fn layout_horizontal_scroll_hides_first_column() {
let mut m = make_model();
m.col_scroll_offset = 1; let layout = board_layout(&m, 0.0, 0.0, 100.0, 20.0, measure());
assert_eq!(layout.columns[0].col_id.as_str(), "col:ready");
}
#[test]
fn layout_min_width_clamp_limits_visible_columns() {
let m = make_model();
let tight = BoardMeasure::new(50.0, 2.0, 2.0, 5.0, 1.0);
let layout = board_layout(&m, 0.0, 0.0, 60.0, 20.0, tight);
assert_eq!(
layout.columns.len(),
1,
"only 1 column should fit with min_width 50 in viewport 60"
);
}
#[test]
fn layout_always_shows_at_least_one_column() {
let m = make_model();
let layout = board_layout(&m, 0.0, 0.0, 5.0, 20.0, measure());
assert!(
layout.columns.len() >= 1,
"at least 1 column must be visible regardless of viewport"
);
}
#[test]
fn layout_origin_offset() {
let m = make_model();
let layout = board_layout(&m, 10.0, 5.0, 100.0, 20.0, measure());
assert_eq!(layout.bounds.x, 10.0);
assert_eq!(layout.bounds.y, 5.0);
assert_eq!(layout.columns[0].bounds.x, 10.0);
assert_eq!(layout.columns[0].bounds.y, 5.0);
}
#[test]
fn layout_visible_cards_per_column() {
let m = make_model();
let layout = board_layout(&m, 0.0, 0.0, 100.0, 20.0, measure());
assert_eq!(layout.columns[0].visible_cards, 3);
assert_eq!(layout.columns[0].cards.len(), 2);
assert_eq!(layout.columns[2].visible_cards, 3);
assert_eq!(layout.columns[2].cards.len(), 0);
}
#[test]
fn layout_scroll_offset_slices_cards() {
let mut m = make_model();
m.columns[0].scroll_offset = 1; let layout = board_layout(&m, 0.0, 0.0, 100.0, 20.0, measure());
let col0 = &layout.columns[0];
assert_eq!(col0.cards.len(), 1);
assert_eq!(col0.cards[0].id.as_str(), "card:300");
assert_eq!(col0.cards[0].card_index, 1); }
#[test]
fn layout_empty_model_is_safe() {
let m = BoardModel {
id: WidgetId::new("board"),
columns: vec![],
selected_card_id: None,
col_scroll_offset: 0,
};
let layout = board_layout(&m, 0.0, 0.0, 100.0, 30.0, measure());
assert_eq!(layout.columns.len(), 0);
}
#[test]
fn hit_test_card_body() {
let m = make_model();
let layout = board_layout(&m, 0.0, 0.0, 100.0, 20.0, measure());
let card0 = &layout.columns[0].cards[0];
let cx = card0.bounds.x + card0.bounds.width / 2.0;
let cy = card0.bounds.y + card0.bounds.height / 2.0;
match layout.hit_test(cx, cy) {
BoardHit::Card(id) => assert_eq!(id.as_str(), "card:362"),
other => panic!("expected Card hit, got {other:?}"),
}
}
#[test]
fn hit_test_column_header() {
let m = make_model();
let layout = board_layout(&m, 0.0, 0.0, 100.0, 20.0, measure());
let hdr = layout.columns[1].header_bounds;
match layout.hit_test(hdr.x + 1.0, hdr.y + 0.5) {
BoardHit::ColumnHeader(id) => assert_eq!(id.as_str(), "col:ready"),
other => panic!("expected ColumnHeader hit, got {other:?}"),
}
}
#[test]
fn hit_test_empty_area_returns_empty() {
let m = make_model();
let layout = board_layout(&m, 0.0, 0.0, 100.0, 20.0, measure());
assert_eq!(layout.hit_test(500.0, 500.0), BoardHit::Empty);
}
#[test]
fn selected_position_returns_col_and_card_index() {
let m = make_model();
assert_eq!(m.selected_position(), Some((0, 0)));
}
#[test]
fn selected_position_second_card_in_column() {
let mut m = make_model();
m.selected_card_id = Some(WidgetId::new("card:300"));
assert_eq!(m.selected_position(), Some((0, 1)));
}
#[test]
fn selected_position_other_column() {
let mut m = make_model();
m.selected_card_id = Some(WidgetId::new("card:521"));
assert_eq!(m.selected_position(), Some((1, 0)));
}
#[test]
fn selected_position_none_when_nothing_selected() {
let mut m = make_model();
m.selected_card_id = None;
assert_eq!(m.selected_position(), None);
}
#[test]
fn selected_position_none_when_id_not_found() {
let mut m = make_model();
m.selected_card_id = Some(WidgetId::new("card:missing"));
assert_eq!(m.selected_position(), None);
}
#[test]
fn handle_key_j_returns_move_down() {
let m = make_model();
assert_eq!(
m.handle_key("j", Modifiers::default()),
Some(BoardAction::MoveSelection(MoveDir::Down))
);
assert_eq!(
m.handle_key("Down", Modifiers::default()),
Some(BoardAction::MoveSelection(MoveDir::Down))
);
}
#[test]
fn handle_key_h_l_move_horizontal() {
let m = make_model();
assert_eq!(
m.handle_key("h", Modifiers::default()),
Some(BoardAction::MoveSelection(MoveDir::Left))
);
assert_eq!(
m.handle_key("l", Modifiers::default()),
Some(BoardAction::MoveSelection(MoveDir::Right))
);
}
#[test]
fn handle_key_enter_opens_selected() {
let m = make_model();
match m.handle_key("Enter", Modifiers::default()) {
Some(BoardAction::OpenIssue(id)) => assert_eq!(id.as_str(), "card:362"),
other => panic!("expected OpenIssue, got {other:?}"),
}
}
#[test]
fn handle_key_enter_noop_when_nothing_selected() {
let mut m = make_model();
m.selected_card_id = None;
assert_eq!(m.handle_key("Enter", Modifiers::default()), None);
}
#[test]
fn handle_key_unknown_returns_none() {
let m = make_model();
assert_eq!(m.handle_key("Escape", Modifiers::default()), None);
}
#[test]
fn handle_key_no_longer_consumes_former_workflow_verb_keys() {
let m = make_model();
for key in ["r", "d", "P", "m", "b"] {
assert_eq!(
m.handle_key(key, Modifiers::default()),
None,
"key {key:?} should not be consumed by the generic board keymap"
);
}
}
#[test]
fn serde_roundtrip() {
let m = make_model();
let json = serde_json::to_string(&m).unwrap();
let back: BoardModel = serde_json::from_str(&json).unwrap();
assert_eq!(m, back);
}
#[test]
fn action_serde_roundtrip() {
let actions = vec![
BoardAction::SelectCard(WidgetId::new("card:1")),
BoardAction::MoveSelection(MoveDir::Down),
BoardAction::OpenReview(WidgetId::new("card:2")),
BoardAction::ContextMenu(WidgetId::new("card:3"), Point { x: 10.0, y: 20.0 }),
];
for a in &actions {
let json = serde_json::to_string(a).unwrap();
let back: BoardAction = serde_json::from_str(&json).unwrap();
assert_eq!(a, &back);
}
}
}
impl BoardModel {
#[cfg(test)]
fn badges_count(&self) -> usize {
self.columns
.iter()
.flat_map(|c| c.cards.iter())
.map(|card| card.badges.len())
.sum()
}
}