Skip to main content

md_tui/pages/
file_explorer.rs

1use std::borrow::ToOwned;
2use std::cmp;
3use std::path::Path;
4
5use itertools::Itertools;
6use ratatui::buffer::Buffer;
7use ratatui::layout::{Alignment, Rect};
8use ratatui::style::Stylize;
9use ratatui::text::Text;
10use ratatui::widgets::{HighlightSpacing, Widget};
11use ratatui::{
12    style::{Modifier, Style},
13    widgets::{Block, List, ListItem, ListState, StatefulWidget},
14};
15
16use crate::search::find_files;
17use crate::util::colors::color_config;
18
19#[derive(Debug, Clone)]
20enum MdFileComponent {
21    File(MdFile),
22    Spacer,
23}
24
25#[derive(Debug, Clone, Default)]
26pub struct MdFile {
27    pub path: String,
28    pub name: String,
29}
30
31impl MdFile {
32    #[must_use]
33    pub fn new(path: String, name: String) -> Self {
34        Self { path, name }
35    }
36
37    #[must_use]
38    pub fn path_str(&self) -> &str {
39        &self.path
40    }
41
42    #[must_use]
43    pub fn path(&self) -> &Path {
44        Path::new(&self.path)
45    }
46
47    #[must_use]
48    pub fn name(&self) -> &str {
49        &self.name
50    }
51}
52
53impl From<MdFile> for ListItem<'_> {
54    fn from(val: MdFile) -> Self {
55        let mut text = Text::default();
56        text.extend([
57            val.name.clone().fg(color_config().file_tree_name_color),
58            val.path
59                .clone()
60                .italic()
61                .fg(color_config().file_tree_path_color),
62        ]);
63        ListItem::new(text)
64    }
65}
66
67impl From<MdFileComponent> for ListItem<'_> {
68    fn from(value: MdFileComponent) -> Self {
69        match value {
70            MdFileComponent::File(f) => f.into(),
71            MdFileComponent::Spacer => ListItem::new(Text::raw("")),
72        }
73    }
74}
75
76#[derive(Debug, Clone, Default)]
77pub struct FileTree {
78    all_files: Vec<MdFile>,
79    files: Vec<MdFileComponent>,
80    page: u32,
81    list_state: ListState,
82    search: Option<String>,
83    loaded: bool,
84}
85
86impl FileTree {
87    #[must_use]
88    pub fn new() -> Self {
89        Self {
90            all_files: Vec::new(),
91            files: Vec::new(),
92            list_state: ListState::default(),
93            page: 0,
94            search: None,
95            loaded: false,
96        }
97    }
98
99    #[must_use]
100    pub fn loaded(&self) -> bool {
101        self.loaded
102    }
103
104    #[must_use]
105    pub fn finish(self) -> Self {
106        let mut this = self;
107        this.loaded = true;
108        this
109    }
110
111    pub fn sort(&mut self) {
112        let filtered: Vec<&MdFile> = self
113            .files
114            .iter()
115            .filter_map(|c| match c {
116                MdFileComponent::File(f) => Some(f),
117                MdFileComponent::Spacer => None,
118            })
119            .sorted_unstable_by(|a, b| a.name.cmp(&b.name))
120            .collect();
121
122        let spacers = vec![MdFileComponent::Spacer; filtered.len()];
123
124        self.files = filtered
125            .into_iter()
126            .zip(spacers)
127            .flat_map(|(f, s)| vec![MdFileComponent::File(f.to_owned()), s])
128            .collect::<Vec<_>>();
129    }
130
131    pub fn sort_name(&mut self) {
132        // Separate files and spacers into two vectors
133        let (mut files, mut spacers): (Vec<_>, Vec<_>) = self
134            .files
135            .drain(..)
136            .partition(|c| matches!(c, MdFileComponent::File(_)));
137
138        // Sort the files in-place by name
139        files.sort_unstable_by(|a, b| match (a, b) {
140            (MdFileComponent::File(fa), MdFileComponent::File(fb)) => {
141                let a = fa
142                    .path()
143                    .to_str()
144                    .unwrap()
145                    .trim_start_matches("./")
146                    .trim_start_matches(char::is_alphabetic);
147                let b = fb
148                    .path()
149                    .to_str()
150                    .unwrap()
151                    .trim_start_matches("./")
152                    .trim_start_matches(char::is_alphabetic);
153
154                b.to_lowercase().cmp(&a.to_lowercase())
155            }
156            _ => unreachable!(), // This case should not happen
157        });
158
159        // Interleave files and spacers
160        let mut result = Vec::with_capacity(files.len() + spacers.len());
161        while let (Some(file), Some(spacer)) = (files.pop(), spacers.pop()) {
162            result.push(file);
163            result.push(spacer);
164        }
165
166        // Update self.files with the sorted and interleaved result
167        self.files = result;
168    }
169
170    pub fn search(&mut self, query: Option<&str>) {
171        self.state_mut().select(None);
172        self.page = 0;
173        self.search = query.map(ToOwned::to_owned);
174        match query {
175            Some(query) => {
176                self.files = find_files(&self.all_files, query)
177                    .into_iter()
178                    .map(MdFileComponent::File)
179                    .collect();
180            }
181            None => {
182                self.files = self
183                    .all_files
184                    .iter()
185                    .cloned()
186                    .map(MdFileComponent::File)
187                    .collect();
188            }
189        }
190        self.fill_spacers();
191    }
192
193    fn fill_spacers(&mut self) {
194        let spacers = vec![MdFileComponent::Spacer; self.files.len()];
195        self.files = self
196            .files
197            .iter()
198            .cloned()
199            .zip(spacers)
200            .flat_map(|(f, s)| vec![f, s])
201            .collect::<Vec<_>>();
202    }
203
204    pub fn next(&mut self, height: u16) {
205        let i = match self.list_state.selected() {
206            Some(i) => {
207                if i >= self.files.len() - 2 {
208                    0
209                } else {
210                    i + 2
211                }
212            }
213            None => 0,
214        };
215        self.page = (i / self.partition(height)) as u32;
216        self.list_state.select(Some(i));
217    }
218
219    pub fn previous(&mut self, height: u16) {
220        let i = match self.list_state.selected() {
221            Some(i) => {
222                if i == 0 {
223                    self.files.len() - 2
224                } else {
225                    i.saturating_sub(2)
226                }
227            }
228            None => 0,
229        };
230        self.page = (i / self.partition(height)) as u32;
231        self.list_state.select(Some(i));
232    }
233
234    pub fn next_page(&mut self, height: u16) {
235        let partition = self.partition(height);
236        let i = match self.list_state.selected() {
237            Some(i) => {
238                if i + partition >= self.files.len() {
239                    0
240                } else {
241                    i + partition
242                }
243            }
244            None => 0,
245        };
246        self.page = (i / partition) as u32;
247        self.list_state.select(Some(i));
248    }
249
250    pub fn previous_page(&mut self, height: u16) {
251        let partition = self.partition(height);
252        let i = match self.list_state.selected() {
253            Some(i) => {
254                if i < partition {
255                    self.files.len().saturating_sub(partition)
256                } else {
257                    i.saturating_sub(partition)
258                }
259            }
260            None => 0,
261        };
262        self.page = (i / partition) as u32;
263        self.list_state.select(Some(i));
264    }
265
266    pub fn first(&mut self) {
267        self.list_state.select(Some(0));
268        self.page = 0;
269    }
270
271    pub fn last(&mut self, height: u16) {
272        let partition = self.partition(height);
273        let i = self.files.len() - 2;
274        self.list_state.select(Some(i));
275        self.page = (i / partition) as u32;
276    }
277
278    pub fn unselect(&mut self) {
279        self.list_state.select(None);
280    }
281
282    #[must_use]
283    pub fn selected(&self) -> Option<&MdFile> {
284        match self.list_state.selected() {
285            Some(i) => self.files.get(i).and_then(|f| match f {
286                MdFileComponent::File(f) => Some(f),
287                MdFileComponent::Spacer => None,
288            }),
289            None => None,
290        }
291    }
292
293    pub fn add_file(&mut self, file: MdFile) {
294        self.all_files.push(file.clone());
295        self.files.push(MdFileComponent::File(file));
296        self.files.push(MdFileComponent::Spacer);
297    }
298
299    #[must_use]
300    pub fn files(&self) -> Vec<&MdFile> {
301        self.files
302            .iter()
303            .filter_map(|f| match f {
304                MdFileComponent::File(f) => Some(f),
305                MdFileComponent::Spacer => None,
306            })
307            .collect::<Vec<&MdFile>>()
308    }
309
310    #[must_use]
311    pub fn all_files(&self) -> &Vec<MdFile> {
312        &self.all_files
313    }
314
315    fn partition(&self, height: u16) -> usize {
316        let partition_size = usize::midpoint(height as usize, 2);
317
318        if partition_size.is_multiple_of(2) {
319            partition_size
320        } else {
321            partition_size + 1
322        }
323    }
324
325    #[must_use]
326    pub fn state(&self) -> &ListState {
327        &self.list_state
328    }
329
330    #[must_use]
331    pub fn height(&self, height: u16) -> usize {
332        cmp::min(
333            self.partition(height) / 2 * 3,
334            self.files
335                .iter()
336                .filter(|f| matches!(f, MdFileComponent::File(_)))
337                .count()
338                * 3,
339        )
340    }
341
342    pub fn state_mut(&mut self) -> &mut ListState {
343        &mut self.list_state
344    }
345}
346
347impl Widget for FileTree {
348    fn render(self, area: Rect, buf: &mut Buffer) {
349        let mut state = self.state().to_owned();
350        let file_len = self.files.len();
351        let partition = self.partition(area.height);
352
353        let items = if let Some(iter) = self
354            .files
355            .chunks(self.partition(area.height))
356            .nth(self.page as usize)
357        {
358            iter.to_owned()
359        } else {
360            self.files
361        };
362
363        state.select(state.selected().map(|i| i % partition));
364
365        let y_height = items.len() / 2 * 3;
366
367        let items = List::new(items)
368            .block(
369                Block::default()
370                    .title("MD-TUI")
371                    .add_modifier(Modifier::BOLD)
372                    .title_alignment(Alignment::Center),
373            )
374            .highlight_style(
375                Style::default()
376                    .fg(color_config().file_tree_selected_fg_color)
377                    .add_modifier(Modifier::BOLD),
378            )
379            .highlight_symbol("\u{02503} ")
380            .repeat_highlight_symbol(true)
381            .highlight_spacing(HighlightSpacing::Always);
382
383        StatefulWidget::render(items, area, buf, &mut state);
384
385        let area = Rect {
386            y: area.y + y_height as u16 + 2,
387            ..area
388        };
389
390        let total_pages = usize::div_ceil(file_len, partition);
391
392        let page_count_str = format!("  {}/{}", self.page + 1, total_pages);
393
394        let page_count = Text::styled(
395            page_count_str,
396            Style::default().fg(color_config().file_tree_page_count_color),
397        );
398
399        page_count.render(area, buf);
400    }
401}