tui/components/
gallery.rs1use 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}