Skip to main content

tui/components/
gallery.rs

1use crossterm::event::KeyCode;
2
3use super::component::{Component, Event};
4use super::panel::Panel;
5use super::split_panel::{Either, SplitLayout, SplitPanel};
6use super::wrap_selection;
7use crate::line::Line;
8use crate::rendering::frame::{Cursor, Frame};
9use crate::rendering::render_context::ViewContext;
10use crate::style::Style;
11
12pub enum GalleryMessage {
13    Quit,
14}
15
16pub struct Gallery<T: Component> {
17    split: SplitPanel<GallerySidebar, GalleryPreview<T>>,
18}
19
20impl<T: Component> Gallery<T> {
21    pub fn new(entries: Vec<(String, T)>) -> Self {
22        let names: Vec<String> = entries.iter().map(|(n, _)| n.clone()).collect();
23        let longest = names.iter().map(String::len).max().unwrap_or(0);
24        let layout = SplitLayout::fixed((longest + 6).clamp(20, 30));
25        let sidebar = GallerySidebar { names, selected: 0, focused: true };
26        let preview = GalleryPreview { entries, active: 0, focused: false };
27
28        Self { split: SplitPanel::new(sidebar, preview, layout).with_separator("│", Style::default()) }
29    }
30}
31
32impl<T: Component> Component for Gallery<T> {
33    type Message = GalleryMessage;
34
35    async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
36        if let Event::Tick = event {
37            let _ = self.split.right_mut().on_event(event).await;
38            return Some(vec![]);
39        }
40
41        if let Event::Key(key) = event
42            && key.code == KeyCode::Esc
43        {
44            return Some(vec![GalleryMessage::Quit]);
45        }
46
47        match self.split.on_event(event).await {
48            Some(msgs) => {
49                for msg in &msgs {
50                    if let Either::Left(GallerySidebarMessage::Selected(idx)) = msg {
51                        self.split.right_mut().active = *idx;
52                    }
53                }
54                Some(vec![])
55            }
56            None => None,
57        }
58    }
59
60    fn render(&mut self, ctx: &ViewContext) -> Frame {
61        if self.split.left().names.is_empty() {
62            return Frame::new(vec![Line::new("No stories")]);
63        }
64
65        let left_focused = self.split.is_left_focused();
66        self.split.left_mut().focused = left_focused;
67        self.split.right_mut().focused = !left_focused;
68        self.split.set_separator_style(Style::fg(ctx.theme.muted()));
69
70        self.split.render(ctx)
71    }
72}
73
74enum GallerySidebarMessage {
75    Selected(usize),
76}
77
78struct GallerySidebar {
79    names: Vec<String>,
80    selected: usize,
81    focused: bool,
82}
83
84impl Component for GallerySidebar {
85    type Message = GallerySidebarMessage;
86
87    async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
88        if let Event::Key(key) = event {
89            match key.code {
90                KeyCode::Up => {
91                    wrap_selection(&mut self.selected, self.names.len(), -1);
92                    Some(vec![GallerySidebarMessage::Selected(self.selected)])
93                }
94                KeyCode::Down => {
95                    wrap_selection(&mut self.selected, self.names.len(), 1);
96                    Some(vec![GallerySidebarMessage::Selected(self.selected)])
97                }
98                _ => Some(vec![]),
99            }
100        } else {
101            None
102        }
103    }
104
105    fn render(&mut self, ctx: &ViewContext) -> Frame {
106        let width = ctx.size.width as usize;
107        let height = ctx.size.height as usize;
108        let mut lines = Vec::with_capacity(height);
109
110        lines.push(Line::with_style(" Gallery", Style::fg(ctx.theme.accent()).bold()));
111        lines.push(Line::default());
112
113        for (i, name) in self.names.iter().enumerate() {
114            let is_selected = i == self.selected;
115            let indicator = if is_selected { ">" } else { " " };
116            let style = if is_selected && self.focused {
117                ctx.theme.selected_row_style()
118            } else if is_selected {
119                Style::fg(ctx.theme.text_primary()).bold()
120            } else {
121                Style::fg(ctx.theme.text_secondary())
122            };
123            let mut line = Line::with_style(format!(" {indicator} {name}"), style);
124            line.extend_bg_to_width(width);
125            lines.push(line);
126        }
127
128        while lines.len() < height {
129            lines.push(Line::default());
130        }
131        lines.truncate(height);
132
133        Frame::new(lines)
134    }
135}
136
137struct GalleryPreview<T: Component> {
138    entries: Vec<(String, T)>,
139    active: usize,
140    focused: bool,
141}
142
143impl<T: Component> Component for GalleryPreview<T> {
144    type Message = ();
145
146    async fn on_event(&mut self, event: &Event) -> Option<Vec<()>> {
147        if let Some((_, component)) = self.entries.get_mut(self.active) {
148            let _ = component.on_event(event).await;
149        }
150        match event {
151            Event::Key(_) | Event::Tick => Some(vec![]),
152            _ => None,
153        }
154    }
155
156    fn render(&mut self, ctx: &ViewContext) -> Frame {
157        let (name, component) = &mut self.entries[self.active];
158        let border_color = if self.focused { ctx.theme.accent() } else { ctx.theme.muted() };
159
160        let inner_width = Panel::inner_width(ctx.size.width);
161        let inner_ctx = ctx.with_size((inner_width, ctx.size.height.saturating_sub(4)));
162        let frame = component.render(&inner_ctx);
163        let (content_lines, cursor) = frame.into_parts();
164
165        let footer = if self.focused { "[Shift+Tab] sidebar  [Esc] quit" } else { "[Tab] preview  [Esc] quit" };
166        let mut panel = Panel::new(border_color).title(format!(" {name} ")).footer(footer);
167        panel.push(content_lines);
168
169        let panel_cursor = if self.focused && cursor.is_visible {
170            Cursor::visible(cursor.row + 2, cursor.col + 2)
171        } else {
172            Cursor::hidden()
173        };
174
175        Frame::new(panel.render(ctx)).with_cursor(panel_cursor)
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use crate::rendering::line::Line;
183
184    struct DummyComponent {
185        label: String,
186    }
187
188    impl Component for DummyComponent {
189        type Message = ();
190
191        async fn on_event(&mut self, _event: &Event) -> Option<Vec<()>> {
192            None
193        }
194
195        fn render(&mut self, _ctx: &ViewContext) -> Frame {
196            Frame::new(vec![Line::new(&self.label)])
197        }
198    }
199
200    fn dummy(name: &str, label: &str) -> (String, DummyComponent) {
201        (name.into(), DummyComponent { label: label.into() })
202    }
203
204    #[test]
205    fn empty_gallery_renders_placeholder() {
206        let mut gallery: Gallery<DummyComponent> = Gallery::new(vec![]);
207        let ctx = ViewContext::new((80, 24));
208        let frame = gallery.render(&ctx);
209        assert_eq!(frame.lines()[0].plain_text(), "No stories");
210    }
211
212    #[test]
213    fn sidebar_shows_all_entry_names() {
214        let mut gallery = Gallery::new(vec![dummy("Alpha", "a"), dummy("Beta", "b")]);
215        let ctx = ViewContext::new((80, 24));
216        let frame = gallery.render(&ctx);
217        let text: String = frame.lines().iter().map(Line::plain_text).collect::<Vec<_>>().join("\n");
218        assert!(text.contains("Alpha"), "should contain Alpha: {text}");
219        assert!(text.contains("Beta"), "should contain Beta: {text}");
220    }
221
222    #[test]
223    fn selected_entry_has_indicator() {
224        let mut gallery = Gallery::new(vec![dummy("Alpha", "a"), dummy("Beta", "b")]);
225        let ctx = ViewContext::new((80, 24));
226        let frame = gallery.render(&ctx);
227        let all_text: Vec<String> = frame.lines().iter().map(Line::plain_text).collect();
228        assert!(all_text.iter().any(|l| l.contains("> Alpha")), "should have > Alpha indicator: {all_text:?}");
229    }
230
231    #[tokio::test]
232    async fn down_arrow_changes_selection() {
233        let mut gallery = Gallery::new(vec![dummy("Alpha", "a"), dummy("Beta", "b")]);
234        assert_eq!(gallery.split.left().selected, 0);
235
236        let down = Event::Key(crossterm::event::KeyEvent::new(KeyCode::Down, crossterm::event::KeyModifiers::NONE));
237        gallery.on_event(&down).await;
238        assert_eq!(gallery.split.left().selected, 1);
239    }
240
241    #[tokio::test]
242    async fn esc_emits_quit() {
243        let mut gallery = Gallery::new(vec![dummy("A", "a")]);
244        let esc = Event::Key(crossterm::event::KeyEvent::new(KeyCode::Esc, crossterm::event::KeyModifiers::NONE));
245        let msgs = gallery.on_event(&esc).await.unwrap();
246        assert!(matches!(msgs[0], GalleryMessage::Quit));
247    }
248
249    #[tokio::test]
250    async fn tab_switches_focus() {
251        let mut gallery = Gallery::new(vec![dummy("A", "a")]);
252        assert!(gallery.split.is_left_focused());
253
254        let tab = Event::Key(crossterm::event::KeyEvent::new(KeyCode::Tab, crossterm::event::KeyModifiers::NONE));
255        gallery.on_event(&tab).await;
256        assert!(!gallery.split.is_left_focused());
257    }
258}