Skip to main content

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

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