Skip to main content

egui_macview/controls/
collection_view.rs

1//! Authentic macOS AppKit Collection View (`NSCollectionView` equivalent, REQ-012-06).
2//!
3//! Grounded in Apple AppKit `NSCollectionView` and `NSCollectionViewFlowLayout`
4//! with high-performance 2D viewport culling (`ui.clip_rect()`), 2D rubber-band
5//! marquee selection, and spatial arrow key navigation.
6
7use crate::controls::MacInsets;
8use crate::layout::subpixel_snap_rect;
9use crate::theme::MacTheme;
10use egui::{
11    CornerRadius, Id, Key, Modifiers, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui,
12    UiBuilder, Vec2,
13};
14use std::collections::BTreeSet;
15
16/// Flow layout engine matching AppKit `NSCollectionViewFlowLayout`.
17#[derive(Debug, Clone, PartialEq)]
18pub struct MacCollectionViewFlowLayout {
19    /// Nominal item dimensions in points ($50.0 \times 50.0\,\text{pt}$).
20    pub item_size: Vec2,
21    /// Vertical line spacing between rows ($10.0\,\text{pt}$).
22    pub minimum_line_spacing: f32,
23    /// Horizontal interitem spacing between columns ($10.0\,\text{pt}$).
24    pub minimum_interitem_spacing: f32,
25    /// Outer section padding insets ($0.0\,\text{pt}$).
26    pub section_inset: MacInsets,
27}
28
29impl Default for MacCollectionViewFlowLayout {
30    fn default() -> Self {
31        let theme = MacTheme::default();
32        let m = theme.collection_view();
33        let sz = m.item_size.points();
34        Self {
35            item_size: Vec2::new(sz, sz),
36            minimum_line_spacing: m.minimum_line_spacing.points(),
37            minimum_interitem_spacing: m.minimum_interitem_spacing.points(),
38            section_inset: MacInsets {
39                left: m.section_inset.points(),
40                right: m.section_inset.points(),
41                top: m.section_inset.points(),
42                bottom: m.section_inset.points(),
43            },
44        }
45    }
46}
47
48impl MacCollectionViewFlowLayout {
49    /// Computes column count fitting within available viewport width.
50    ///
51    /// Formula: $C = \lfloor (W_{\text{viewport}} + S_{\text{item}}) / (W_{\text{item}} + S_{\text{item}}) \rfloor$.
52    pub fn compute_column_count(&self, available_width: f32) -> usize {
53        let span = self.item_size.x + self.minimum_interitem_spacing;
54        if span <= 0.0 {
55            1
56        } else {
57            let cols = ((available_width + self.minimum_interitem_spacing) / span).floor();
58            (cols as usize).max(1)
59        }
60    }
61
62    /// Computes spatial 2D bounding rect for an item index.
63    pub fn compute_item_rect(&self, origin: Pos2, index: usize, columns: usize) -> Rect {
64        let cols = columns.max(1);
65        let col = index % cols;
66        let row = index / cols;
67
68        let x = origin.x
69            + self.section_inset.left
70            + (col as f32) * (self.item_size.x + self.minimum_interitem_spacing);
71        let y = origin.y
72            + self.section_inset.top
73            + (row as f32) * (self.item_size.y + self.minimum_line_spacing);
74
75        Rect::from_min_size(Pos2::new(x, y), self.item_size)
76    }
77}
78
79/// Authentic macOS Collection View (`NSCollectionView` equivalent).
80pub struct MacCollectionView<'a, T> {
81    items: &'a [T],
82    selected_indices: &'a mut BTreeSet<usize>,
83    layout: MacCollectionViewFlowLayout,
84    theme: Option<&'a MacTheme>,
85}
86
87impl<'a, T> MacCollectionView<'a, T> {
88    /// Creates a new collection view bound to an item slice and a mutable selection set.
89    pub fn new(items: &'a [T], selected_indices: &'a mut BTreeSet<usize>) -> Self {
90        Self {
91            items,
92            selected_indices,
93            layout: MacCollectionViewFlowLayout::default(),
94            theme: None,
95        }
96    }
97
98    /// Overrides flow layout parameters.
99    pub fn layout(mut self, layout: MacCollectionViewFlowLayout) -> Self {
100        self.layout = layout;
101        self
102    }
103
104    /// Injects dynamic theme.
105    pub fn theme(mut self, theme: &'a MacTheme) -> Self {
106        self.theme = Some(theme);
107        self
108    }
109
110    /// Shows the collection view and returns count of rendered items within the clip rect along with interaction response.
111    pub fn show_with_response<F>(self, ui: &mut Ui, mut render_item: F) -> (usize, Response)
112    where
113        F: FnMut(&mut Ui, &T),
114    {
115        let theme_cow = MacTheme::resolve(self.theme, ui.ctx());
116        let theme: &MacTheme = &theme_cow;
117        let metrics = theme.collection_view();
118
119        let available_w = ui.available_width();
120        let columns = self.layout.compute_column_count(available_w);
121        let item_count = self.items.len();
122        let rows = if item_count == 0 {
123            0
124        } else {
125            item_count.div_ceil(columns)
126        };
127
128        let total_h = self.layout.section_inset.top
129            + (rows as f32) * self.layout.item_size.y
130            + ((rows.saturating_sub(1)) as f32) * self.layout.minimum_line_spacing
131            + self.layout.section_inset.bottom;
132
133        let desired_size = Vec2::new(available_w, total_h.max(self.layout.item_size.y));
134        let (rect, mut response) =
135            ui.allocate_exact_size(desired_size, Sense::click_and_drag() | Sense::focusable_noninteractive());
136        let rect = subpixel_snap_rect(rect, ui.ctx().pixels_per_point());
137
138        // Keyboard 2D spatial navigation when focused
139        if response.has_focus() && !self.items.is_empty() {
140            let last_selected = self.selected_indices.iter().copied().last().unwrap_or(0);
141            let mut next_selected = None;
142
143            if ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::ArrowRight)) {
144                if last_selected + 1 < item_count {
145                    next_selected = Some(last_selected + 1);
146                }
147            } else if ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::ArrowLeft)) {
148                if last_selected > 0 {
149                    next_selected = Some(last_selected - 1);
150                }
151            } else if ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::ArrowDown)) {
152                if last_selected + columns < item_count {
153                    next_selected = Some(last_selected + columns);
154                }
155            } else if ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::ArrowUp)) && last_selected >= columns {
156                next_selected = Some(last_selected - columns);
157            }
158
159            if let Some(new_idx) = next_selected {
160                self.selected_indices.clear();
161                self.selected_indices.insert(new_idx);
162                response.mark_changed();
163            }
164        }
165
166        let clip = ui.clip_rect();
167        let painter = ui.painter_at(rect);
168        let sel_corner_r = metrics.selection_corner_radius.points();
169        let sel_stroke_w = metrics.selection_stroke_width.points();
170
171        let mut rendered_count = 0usize;
172
173        let row_pitch = self.layout.item_size.y + self.layout.minimum_line_spacing;
174        let (first_idx, last_idx) = if row_pitch <= 0.0 || rows == 0 {
175            (0, item_count)
176        } else {
177            let origin_y = rect.min.y + self.layout.section_inset.top;
178            let rel_top = (clip.min.y - origin_y).max(0.0);
179            let first_row = ((rel_top / row_pitch).floor() as usize).saturating_sub(1);
180            let rel_bottom = (clip.max.y - origin_y).max(0.0);
181            let last_row = (((rel_bottom / row_pitch).ceil() as usize) + 1).min(rows);
182            (
183                (first_row * columns).min(item_count),
184                (last_row * columns).min(item_count),
185            )
186        };
187
188        for (idx, item) in self.items[first_idx..last_idx]
189            .iter()
190            .enumerate()
191            .map(|(i, it)| (first_idx + i, it))
192        {
193            let item_rect = self.layout.compute_item_rect(rect.min, idx, columns);
194
195            // 2D Viewport culling oracle: strictly bypass if item does not intersect clip rect
196            if !clip.intersects(item_rect) {
197                continue;
198            }
199
200            rendered_count += 1;
201
202            let is_selected = self.selected_indices.contains(&idx);
203            let item_hovered = ui
204                .input(|inp| inp.pointer.hover_pos())
205                .is_some_and(|p| item_rect.contains(p));
206
207            if item_hovered && ui.input(|inp| inp.pointer.primary_clicked()) {
208                response.request_focus();
209                if !ui.input(|inp| inp.modifiers.shift || inp.modifiers.command) {
210                    self.selected_indices.clear();
211                }
212                self.selected_indices.insert(idx);
213                response.mark_changed();
214            }
215
216            // Draw item background and selection ring
217            let bg_color = if is_selected {
218                theme.selected_control()
219            } else if item_hovered {
220                theme.control()
221            } else {
222                theme.control_background()
223            };
224
225            painter.rect(
226                item_rect,
227                CornerRadius::same(sel_corner_r as u8),
228                bg_color,
229                Stroke::new(1.0, theme.separator()),
230                StrokeKind::Inside,
231            );
232
233            if is_selected {
234                painter.rect_stroke(
235                    item_rect,
236                    CornerRadius::same(sel_corner_r as u8),
237                    Stroke::new(sel_stroke_w, theme.control_accent()),
238                    StrokeKind::Outside,
239                );
240            }
241
242            // Render inner item
243            let mut child_ui = ui.new_child(
244                UiBuilder::new()
245                    .id_salt(Id::NULL.with("coll_item").with(idx))
246                    .max_rect(item_rect),
247            );
248            render_item(&mut child_ui, item);
249        }
250
251        // Rubber-band marquee selection during drag
252        if response.dragged() {
253            if let (Some(press_pos), Some(current_pos)) = (
254                ui.input(|i| i.pointer.press_origin()),
255                ui.input(|i| i.pointer.interact_pos()),
256            ) {
257                let marquee_rect = Rect::from_two_pos(press_pos, current_pos);
258                let fill = theme
259                    .selected_control()
260                    .gamma_multiply(metrics.marquee_alpha_fill.points());
261                let stroke = Stroke::new(
262                    1.0,
263                    theme
264                        .control_accent()
265                        .gamma_multiply(metrics.marquee_alpha_stroke.points()),
266                );
267
268                painter.rect(
269                    marquee_rect,
270                    CornerRadius::same(0),
271                    fill,
272                    stroke,
273                    StrokeKind::Inside,
274                );
275
276                // Compute 2D AABB intersection with candidate row items
277                let (m_first_idx, m_last_idx) = if row_pitch <= 0.0 || rows == 0 {
278                    (0, item_count)
279                } else {
280                    let origin_y = rect.min.y + self.layout.section_inset.top;
281                    let rel_top = (marquee_rect.min.y - origin_y).max(0.0);
282                    let first_row = ((rel_top / row_pitch).floor() as usize).saturating_sub(1);
283                    let rel_bottom = (marquee_rect.max.y - origin_y).max(0.0);
284                    let last_row = (((rel_bottom / row_pitch).ceil() as usize) + 1).min(rows);
285                    (
286                        (first_row * columns).min(item_count),
287                        (last_row * columns).min(item_count),
288                    )
289                };
290
291                for idx in m_first_idx..m_last_idx {
292                    let item_rect = self.layout.compute_item_rect(rect.min, idx, columns);
293                    if marquee_rect.intersects(item_rect) {
294                        self.selected_indices.insert(idx);
295                        response.mark_changed();
296                    }
297                }
298            }
299        }
300
301        (rendered_count, response)
302    }
303
304    /// Shows the collection view and returns count of rendered items within the clip rect.
305    pub fn show_with_item_tracker<F>(self, ui: &mut Ui, render_item: F) -> usize
306    where
307        F: FnMut(&mut Ui, &T),
308    {
309        self.show_with_response(ui, render_item).0
310    }
311
312    /// Shows the collection view with default item presentation.
313    pub fn show(self, ui: &mut Ui) -> Response {
314        self.show_with_response(ui, |_ui, _item| {}).1
315    }
316}