1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
#[allow(clippy::wildcard_imports)]
use super::*;
impl BoardState {
/// Map a terminal pointer `(x, y)` — given against the same `area` passed
/// to [`render`] — to the card or column underneath. `status` mirrors
/// whether [`render`] is drawing a status message, so the status row (not
/// part of any column) never resolves to a target. Returns `None` for
/// positions outside the board area (`y` above the columns or beyond the
/// board) or when nothing is drawn. Uses [`compute_column_rects`] and
/// [`super::widgets::card_line_count`] — the geometry `render` draws with —
/// so the hit-tested layout matches the rendered one by construction (D3).
pub fn hit_test(
&self,
area: Rect,
display: BoardDisplay,
status: bool,
x: u16,
y: u16,
) -> Option<HitTarget> {
if area.height < 2 || area.width < 4 || self.total_cols() == 0 {
return None;
}
let board_height = area.height.saturating_sub(u16::from(status));
if y >= area.y + board_height {
return None;
}
let board_area = Rect {
height: board_height,
..area
};
let rects = compute_column_rects(board_area, self.total_cols());
let (col, col_rect) = rects
.iter()
.enumerate()
.find(|(_, r)| r.contains(Position::new(x, y)))?;
let cards: &[Card] = if col < self.columns.len() {
&self.columns[col].cards
} else {
&self.mismatch.cards
};
let group_mode = self
.filter
.as_deref()
.and_then(|q| crate::search::control_token(q, "group"));
// Items stack top-down exactly as `render` pushes them: group headers
// occupy one row each, cards occupy `card_line_count` rows.
let mut items: Vec<Option<usize>> = Vec::new();
if let Some(mode) = group_mode.as_deref() {
if let Some(sections) = Self::group_sections(cards, Some(mode)) {
for section in sections {
items.push(None);
for (j, card) in cards
.iter()
.enumerate()
.take(section.end)
.skip(section.start)
{
let count = widgets::card_line_count(
card,
col_rect.width as usize,
display.wrap_titles,
display.title_lines,
);
for _ in 0..count {
items.push(Some(j));
}
}
}
}
} else {
for (j, card) in cards.iter().enumerate() {
let count = widgets::card_line_count(
card,
col_rect.width as usize,
display.wrap_titles,
display.title_lines,
);
for _ in 0..count {
items.push(Some(j));
}
}
}
let has_footer = BoardState::group_footer(cards, group_mode.as_deref()).is_some();
let list_height = col_rect.height.saturating_sub(u16::from(has_footer));
// Content rows live between the block's top border and bottom border.
let content_top = col_rect.y + 1;
let content_end = col_rect.y + list_height.saturating_sub(1);
if y >= content_top && y < content_end {
let offset = usize::from(y - content_top);
if let Some(Some(row)) = items.get(offset) {
return Some(HitTarget::Card { col, row: *row });
}
}
Some(HitTarget::Column { col })
}
}