Skip to main content

mecomp_tui/ui/components/content_view/views/
generic.rs

1use crossterm::event::{KeyCode, KeyEvent, MouseEvent};
2use ratatui::{
3    layout::{Alignment, Margin, Rect},
4    style::{Style, Stylize},
5    text::{Line, Span},
6    widgets::{Block, Borders},
7};
8use tokio::sync::mpsc::UnboundedSender;
9
10use crate::{
11    state::action::{Action, ViewAction},
12    ui::{
13        AppState,
14        colors::{TEXT_HIGHLIGHT, TEXT_NORMAL, border_color},
15        components::{
16            Component, ComponentRender, RenderProps,
17            content_view::views::traits::{SortMode, SortableViewProps},
18        },
19        widgets::tree::{CheckTree, state::CheckTreeState},
20    },
21};
22
23use super::{
24    ItemViewProps,
25    checktree_utils::{
26        construct_add_to_playlist_action, construct_add_to_queue_action,
27        construct_start_radio_action,
28    },
29};
30
31#[derive(Debug)]
32pub struct ItemView<Props> {
33    /// Action Sender
34    pub action_tx: UnboundedSender<Action>,
35    /// Mapped Props from state
36    pub props: Option<Props>,
37    /// tree state
38    pub tree_state: CheckTreeState<String>,
39}
40
41impl<Props> Component for ItemView<Props>
42where
43    Props: ItemViewProps,
44{
45    fn new(state: &AppState, action_tx: UnboundedSender<Action>) -> Self
46    where
47        Self: Sized,
48    {
49        let props = Props::retrieve(&state.additional_view_data);
50        let tree_state = CheckTreeState::default();
51        Self {
52            action_tx,
53            props,
54            tree_state,
55        }
56    }
57
58    fn move_with_state(self, state: &AppState) -> Self
59    where
60        Self: Sized,
61    {
62        if let Some(props) = Props::retrieve(&state.additional_view_data) {
63            Self {
64                props: Some(props),
65                tree_state: CheckTreeState::default(),
66                ..self
67            }
68        } else {
69            self
70        }
71    }
72
73    fn name(&self) -> &str {
74        Props::title()
75    }
76
77    fn handle_key_event(&mut self, key: KeyEvent) {
78        match key.code {
79            // arrow keys
80            KeyCode::Up => {
81                self.tree_state.key_up();
82            }
83            KeyCode::Down => {
84                self.tree_state.key_down();
85            }
86            KeyCode::Left => {
87                self.tree_state.key_left();
88            }
89            KeyCode::Right => {
90                self.tree_state.key_right();
91            }
92            KeyCode::Char(' ') => {
93                self.tree_state.key_space();
94            }
95            // Enter key opens selected view
96            KeyCode::Enter => {
97                if self.tree_state.toggle_selected() {
98                    let things = self.tree_state.get_selected_thing();
99
100                    if let Some(thing) = things {
101                        self.action_tx
102                            .send(Action::ActiveView(ViewAction::Set(thing.into())))
103                            .unwrap();
104                    }
105                }
106            }
107            // if there are checked items, add them to the queue, otherwise send the song to the queue
108            KeyCode::Char('q') => {
109                let checked_things = self.tree_state.get_checked_things();
110                if let Some(action) = construct_add_to_queue_action(
111                    checked_things,
112                    self.props.as_ref().map(super::ItemViewProps::id),
113                ) {
114                    self.action_tx.send(action).unwrap();
115                }
116            }
117            // if there are checked items, start radio from checked items, otherwise start radio from song
118            KeyCode::Char('r') => {
119                let checked_things = self.tree_state.get_checked_things();
120                if let Some(action) = construct_start_radio_action(
121                    checked_things,
122                    self.props.as_ref().map(super::ItemViewProps::id),
123                ) {
124                    self.action_tx.send(action).unwrap();
125                }
126            }
127            // if there are checked items, add them to playlist, otherwise add the song to playlist
128            KeyCode::Char('p') => {
129                let checked_things = self.tree_state.get_checked_things();
130                if let Some(action) = construct_add_to_playlist_action(
131                    checked_things,
132                    self.props.as_ref().map(super::ItemViewProps::id),
133                ) {
134                    self.action_tx.send(action).unwrap();
135                }
136            }
137            _ => {
138                if let Some(props) = &mut self.props {
139                    props.handle_extra_key_events(
140                        key,
141                        self.action_tx.clone(),
142                        &mut self.tree_state,
143                    );
144                }
145            }
146        }
147    }
148
149    fn handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
150        // adjust the area to account for the border
151        let area = area.inner(Margin::new(1, 1));
152        let [_, content_area] = Props::split_area(area);
153        let footer = u16::from(Props::extra_footer().is_some());
154        let content_area = Rect {
155            y: content_area.y + 2,
156            height: content_area.height - 2 - footer,
157            ..content_area
158        };
159
160        let result = self
161            .tree_state
162            .handle_mouse_event(mouse, content_area, false);
163        if let Some(action) = result {
164            self.action_tx.send(action).unwrap();
165        }
166    }
167}
168
169impl<Props> ComponentRender<RenderProps> for ItemView<Props>
170where
171    Props: ItemViewProps,
172{
173    fn render_border(&mut self, frame: &mut ratatui::Frame<'_>, props: RenderProps) -> RenderProps {
174        let border_style = Style::default().fg(border_color(props.is_focused).into());
175
176        // draw borders and get area for content
177        let area = if let Some(state) = &self.props {
178            let border = Block::bordered()
179                .title_top(Props::title())
180                .title_bottom(" \u{23CE} : Open | ←/↑/↓/→: Navigate | \u{2423} Check")
181                .border_style(border_style);
182            let content_area = border.inner(props.area);
183            frame.render_widget(border, props.area);
184
185            // split area to make room for item info
186            let [info_area, content_area] = Props::split_area(content_area);
187
188            // render item info
189            frame.render_widget(state.info_widget(), info_area);
190
191            // draw an additional border around the content area to display additional instructions
192            let border = Block::default()
193                .borders(Borders::TOP)
194                .title_top("q: add to queue | r: start radio | p: add to playlist")
195                .border_style(border_style);
196            frame.render_widget(&border, content_area);
197            let content_area = border.inner(content_area);
198
199            // draw an additional border around the content area to indicate whether operations will be performed on the entire item, or just the checked items
200            let border = Block::default()
201                .borders(Borders::TOP)
202                .title_top(Line::from(vec![
203                    Span::raw("Performing operations on "),
204                    Span::raw(if self.tree_state.get_checked_things().is_empty() {
205                        Props::none_checked_string()
206                    } else {
207                        "checked items"
208                    })
209                    .fg(*TEXT_HIGHLIGHT),
210                ]))
211                .italic()
212                .border_style(border_style);
213            frame.render_widget(&border, content_area);
214            border.inner(content_area)
215        } else {
216            let border = Block::bordered()
217                .title_top(Props::title())
218                .border_style(border_style);
219            frame.render_widget(&border, props.area);
220            border.inner(props.area)
221        };
222
223        RenderProps { area, ..props }
224    }
225
226    fn render_content(&mut self, frame: &mut ratatui::Frame<'_>, props: RenderProps) {
227        let Some(state) = &self.props else {
228            let text = format!("No active {}", Props::name());
229
230            frame.render_widget(
231                Line::from(text)
232                    .style(Style::default().fg((*TEXT_NORMAL).into()))
233                    .alignment(Alignment::Center),
234                props.area,
235            );
236            return;
237        };
238
239        // create a tree to hold the items children
240        let items = state.tree_items().unwrap();
241
242        // render the tree
243        frame.render_stateful_widget(
244            CheckTree::new(&items)
245                .unwrap()
246                .highlight_style(Style::default().fg((*TEXT_HIGHLIGHT).into()).bold())
247                .experimental_scrollbar(Props::scrollbar()),
248            props.area,
249            &mut self.tree_state,
250        );
251    }
252}
253
254/// Wraps an [`ItemView`] with sorting functionality
255///
256/// Defines additional key handling for changing sort modes,
257///
258/// Overrides rendering since we need to display the current sort mode in the border
259#[derive(Debug)]
260pub struct SortableItemView<Props, Mode, Item> {
261    pub item_view: ItemView<Props>,
262    pub sort_mode: Mode,
263    _item: std::marker::PhantomData<Item>,
264}
265
266impl<Props, Mode, Item> Component for SortableItemView<Props, Mode, Item>
267where
268    Props: ItemViewProps + SortableViewProps<Item>,
269    Mode: SortMode<Item>,
270{
271    fn new(state: &AppState, action_tx: UnboundedSender<Action>) -> Self
272    where
273        Self: Sized,
274    {
275        let item_view = ItemView::new(state, action_tx);
276        let sort_mode = Mode::default();
277        Self {
278            item_view,
279            sort_mode,
280            _item: std::marker::PhantomData,
281        }
282    }
283
284    fn move_with_state(self, state: &AppState) -> Self
285    where
286        Self: Sized,
287    {
288        let mut item_view = self.item_view.move_with_state(state);
289
290        if let Some(props) = &mut item_view.props {
291            props.sort_items(&self.sort_mode);
292        }
293
294        Self { item_view, ..self }
295    }
296
297    fn name(&self) -> &str {
298        self.item_view.name()
299    }
300
301    fn handle_key_event(&mut self, key: KeyEvent) {
302        match key.code {
303            // Change sort mode
304            crossterm::event::KeyCode::Char('s') => {
305                self.sort_mode = self.sort_mode.next();
306                if let Some(props) = &mut self.item_view.props {
307                    props.sort_items(&self.sort_mode);
308                    self.item_view.tree_state.scroll_selected_into_view();
309                }
310            }
311            crossterm::event::KeyCode::Char('S') => {
312                self.sort_mode = self.sort_mode.prev();
313                if let Some(props) = &mut self.item_view.props {
314                    props.sort_items(&self.sort_mode);
315                    self.item_view.tree_state.scroll_selected_into_view();
316                }
317            }
318            _ => self.item_view.handle_key_event(key),
319        }
320    }
321
322    fn handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
323        self.item_view.handle_mouse_event(mouse, area);
324    }
325}
326
327impl<Props, Mode, Item> ComponentRender<RenderProps> for SortableItemView<Props, Mode, Item>
328where
329    Props: ItemViewProps + SortableViewProps<Item>,
330    Mode: SortMode<Item>,
331{
332    fn render_border(&mut self, frame: &mut ratatui::Frame<'_>, props: RenderProps) -> RenderProps {
333        let border_style = Style::default().fg(border_color(props.is_focused).into());
334
335        // draw borders and get area for content
336        let area = if let Some(state) = &self.item_view.props {
337            let border = Block::bordered()
338                .title_top(Line::from(vec![
339                    Span::styled(Props::title(), Style::default().bold()),
340                    Span::raw(" sorted by: "),
341                    Span::styled(self.sort_mode.to_string(), Style::default().italic()),
342                ]))
343                .title_bottom(" \u{23CE} : Open | ←/↑/↓/→: Navigate | \u{2423} Check")
344                .border_style(border_style);
345            let content_area = border.inner(props.area);
346            frame.render_widget(border, props.area);
347
348            // split area to make room for item info
349            let [info_area, content_area] = Props::split_area(content_area);
350
351            // render item info
352            frame.render_widget(state.info_widget(), info_area);
353
354            // draw an additional border around the content area to display additional instructions
355            let border = Block::default()
356                .title_top("q: add to queue | r: start radio | p: add to playlist")
357                .border_style(border_style);
358            let border = if let Some(extra_footer) = Props::extra_footer() {
359                border
360                    .borders(Borders::TOP | Borders::BOTTOM)
361                    .title_bottom(extra_footer)
362            } else {
363                border.border_style(border_style)
364            };
365            frame.render_widget(&border, content_area);
366            let content_area = border.inner(content_area);
367
368            // draw an additional border around the content area to indicate whether operations will be performed on the entire item, or just the checked items
369            let border = Block::default()
370                .borders(Borders::TOP)
371                .title_top(Line::from(vec![
372                    Span::raw("Performing operations on "),
373                    Span::raw(
374                        if self.item_view.tree_state.get_checked_things().is_empty() {
375                            Props::none_checked_string()
376                        } else {
377                            "checked items"
378                        },
379                    )
380                    .fg(*TEXT_HIGHLIGHT),
381                ]))
382                .italic()
383                .border_style(border_style);
384            frame.render_widget(&border, content_area);
385            border.inner(content_area)
386        } else {
387            let border = Block::bordered()
388                .title_top(Props::title())
389                .border_style(border_style);
390            frame.render_widget(&border, props.area);
391            border.inner(props.area)
392        };
393
394        RenderProps { area, ..props }
395    }
396
397    fn render_content(&mut self, frame: &mut ratatui::Frame<'_>, props: RenderProps) {
398        self.item_view.render_content(frame, props);
399    }
400}