tiny_dc/
entry.rs

1use std::{
2    fs::{DirEntry, ReadDir},
3    path::PathBuf,
4};
5
6use ratatui::{prelude::*, widgets::*};
7
8use crate::hotkeys::KeyCombo;
9
10#[derive(Debug, PartialEq)]
11pub enum EntryKind {
12    File { extension: Option<String> },
13    Directory,
14}
15
16#[derive(Debug)]
17pub struct Entry {
18    pub path: PathBuf,
19    pub kind: EntryKind,
20    pub name: String,
21}
22
23impl TryFrom<DirEntry> for Entry {
24    type Error = anyhow::Error;
25
26    fn try_from(value: DirEntry) -> Result<Self, Self::Error> {
27        Entry::try_from(value.path())
28    }
29}
30
31impl TryFrom<PathBuf> for Entry {
32    type Error = anyhow::Error;
33
34    fn try_from(value: PathBuf) -> Result<Self, Self::Error> {
35        let file_type = value.metadata()?.file_type();
36        let name = value
37            .file_name()
38            .unwrap_or_default()
39            .to_string_lossy()
40            .into_owned();
41
42        let item = if file_type.is_dir() {
43            Entry {
44                path: value,
45                kind: EntryKind::Directory,
46                name,
47            }
48        } else {
49            let extension = value.extension().map(|x| x.to_string_lossy().into_owned());
50
51            Entry {
52                path: value,
53                kind: EntryKind::File { extension },
54                name,
55            }
56        };
57
58        Ok(item)
59    }
60}
61
62/// This struct represents the data that will be used to render an entry in the list. It is used in
63/// conjunction with the search query to determine how to render the entry.
64///
65/// It holds the prefix, search hit and suffix of the entry name, the next character after the
66/// search hit, the kind of the entry and the shortcut assigned to the entry.
67///
68/// This allows us to render the entry in the UI with the search hit underlined and the shortcut
69/// displayed next to the entry.
70///
71/// For example, if the entry name is "Cargo.toml" and the search query is "ar", the prefix will be
72/// "C", the search hit will be "ar", the suffix will be "go.toml", the next character will be "g"
73/// (the character immediately after the search hit)
74///
75/// The shortcut is assigned at a later stage and is used to quickly jump to the entry.
76#[derive(Debug, PartialEq)]
77pub struct EntryRenderData<'a> {
78    prefix: &'a str,
79    search_hit: &'a str,
80    suffix: &'a str,
81
82    /// The character that shouldn't appear in a hotkey sequence for the entry. That's normally the
83    /// first character of the name or first character after the search hit. The idea is to allow
84    /// the user to be able finish writing out the entry name without jumping to the entry itself.
85    ///
86    /// NOTE: that the character is converted to lowercase before being stored, since our search is
87    /// case insensitive.
88    pub illegal_char_for_hotkey: Option<char>,
89
90    /// The kind of the entry, we need to keep track of this because we render directories
91    /// differently than files.
92    pub kind: &'a EntryKind,
93    /// The key combo sequence assigned to the entry, it's an optional sequence of key combos.
94    pub key_combo_sequence: Option<Vec<KeyCombo>>,
95}
96
97impl EntryRenderData<'_> {
98    pub fn from_entry<T: AsRef<str>>(entry: &Entry, search_query: T) -> EntryRenderData<'_> {
99        // Since our "search"/"filter" is case insensitive, and our for entries are always in lower
100        // case, we need to make sure that the character we use for `illegal_char_for_hotkey` is
101        // lowercase as well
102        fn get_next_char_lowercase(name: &str) -> Option<char> {
103            name.chars().next().and_then(|c| c.to_lowercase().next())
104        }
105
106        if search_query.as_ref().is_empty() {
107            return EntryRenderData {
108                prefix: &entry.name,
109                search_hit: "",
110                suffix: "",
111                illegal_char_for_hotkey: get_next_char_lowercase(&entry.name),
112                kind: &entry.kind,
113                key_combo_sequence: None,
114            };
115        }
116
117        let search_query = search_query.as_ref();
118        let name = entry.name.to_lowercase();
119        let search_query = search_query.to_lowercase();
120
121        if let Some(index) = name.find(&search_query) {
122            let prefix = &entry.name[..index];
123            let search_hit = &entry.name[index..(index + search_query.len())];
124            let suffix = &entry.name[(index + search_query.len())..];
125
126            EntryRenderData {
127                prefix,
128                search_hit,
129                suffix,
130                illegal_char_for_hotkey: get_next_char_lowercase(suffix),
131                kind: &entry.kind,
132                key_combo_sequence: None,
133            }
134        } else {
135            EntryRenderData {
136                prefix: &entry.name,
137                search_hit: "",
138                suffix: "",
139                illegal_char_for_hotkey: get_next_char_lowercase(&entry.name),
140                kind: &entry.kind,
141                key_combo_sequence: None,
142            }
143        }
144    }
145}
146
147impl<'a> From<EntryRenderData<'a>> for ListItem<'a> {
148    fn from(value: EntryRenderData<'a>) -> Self {
149        let mut spans: Vec<Span> = Vec::new();
150
151        // we want to display the search hit with underscore
152        spans.push(Span::raw(value.prefix));
153        spans.push(Span::styled(
154            value.search_hit,
155            Style::default().underlined(),
156        ));
157        spans.push(Span::raw(value.suffix));
158
159        if value.kind == &EntryKind::Directory {
160            spans.push(Span::raw("/"));
161
162            if let Some(key_combo_sequence) = value.key_combo_sequence {
163                spans.push(Span::raw("  ").style(Style::default().dark_gray()));
164                for key_combo in key_combo_sequence {
165                    spans.push(Span::styled(
166                        key_combo.key_code.to_string(),
167                        Style::default().black().on_green(),
168                    ));
169                }
170            }
171
172            let line = Line::from(spans);
173            let style = Style::new().bold().fg(Color::White);
174
175            ListItem::new(line).style(style)
176        } else {
177            let style = Style::new().dark_gray();
178            let k = Line::from(spans);
179            ListItem::new(k).style(style)
180        }
181    }
182}
183
184#[derive(Debug, Default)]
185pub struct EntryList {
186    pub items: Vec<Entry>,
187    pub filtered_indices: Option<Vec<usize>>,
188}
189
190impl EntryList {
191    #[cfg(test)]
192    pub(crate) fn len(&self) -> usize {
193        self.items.len()
194    }
195
196    pub fn get_filtered_entries(&self) -> Vec<&Entry> {
197        match &self.filtered_indices {
198            Some(indices) => indices.iter().map(|&i| &self.items[i]).collect(),
199            None => self.items.iter().collect(),
200        }
201    }
202
203    pub fn update_filtered_indices<T: AsRef<str>>(&mut self, value: T) {
204        let value = value.as_ref().to_lowercase();
205
206        if value.is_empty() {
207            self.filtered_indices = None;
208        } else {
209            let indices = self
210                .items
211                .iter()
212                .enumerate()
213                .filter_map(|(i, entry)| {
214                    if entry.name.to_lowercase().contains(&value) {
215                        Some(i)
216                    } else {
217                        None
218                    }
219                })
220                .collect();
221
222            self.filtered_indices = Some(indices);
223        }
224    }
225}
226
227impl TryFrom<ReadDir> for EntryList {
228    type Error = anyhow::Error;
229
230    fn try_from(value: ReadDir) -> Result<Self, Self::Error> {
231        let mut items = Vec::new();
232
233        for dir_entry_result in value.into_iter() {
234            let dir_entry = dir_entry_result?;
235            let result = Entry::try_from(dir_entry);
236
237            match result {
238                Ok(item) => items.push(item),
239                Err(_) => {
240                    // Skip the entry if it can't be converted to an Entry
241                    //
242                    // This is useful for example when the entry is a symlink to a file that
243                    // doesn't exist
244                    continue;
245                }
246            }
247        }
248
249        Ok(EntryList {
250            items,
251            ..Default::default()
252        })
253    }
254}
255
256impl TryFrom<Vec<PathBuf>> for EntryList {
257    type Error = anyhow::Error;
258
259    fn try_from(value: Vec<PathBuf>) -> Result<Self, Self::Error> {
260        let mut items = Vec::new();
261
262        for path in value {
263            let result = Entry::try_from(path);
264            match result {
265                Ok(item) => items.push(item),
266                Err(_) => {
267                    // Skip the entry if it can't be converted to an Entry
268                    //
269                    // This is useful for example when the entry is a symlink to a file that
270                    // doesn't exist
271                    continue;
272                }
273            }
274        }
275
276        Ok(EntryList {
277            items,
278            ..Default::default()
279        })
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    mod entry_render_data {
288        use super::*;
289
290        #[test]
291        fn entry_render_data_from_entry_works_correctly_with_search_query() {
292            let entry = Entry {
293                name: "Cargo.toml".into(),
294                kind: EntryKind::File {
295                    extension: Some("toml".into()),
296                },
297                path: PathBuf::from("/home/user/Cargo.toml"),
298            };
299
300            let entry_render_data: EntryRenderData = EntryRenderData::from_entry(&entry, "car");
301
302            assert_eq!(
303                entry_render_data,
304                EntryRenderData {
305                    prefix: "",
306                    search_hit: "Car",
307                    suffix: "go.toml",
308                    illegal_char_for_hotkey: Some('g'),
309                    kind: &EntryKind::File {
310                        extension: Some("toml".into())
311                    },
312                    key_combo_sequence: None,
313                }
314            );
315
316            let entry_render_data: EntryRenderData = EntryRenderData::from_entry(&entry, "toml");
317
318            assert_eq!(
319                entry_render_data,
320                EntryRenderData {
321                    prefix: "Cargo.",
322                    search_hit: "toml",
323                    suffix: "",
324                    illegal_char_for_hotkey: None,
325                    kind: &EntryKind::File {
326                        extension: Some("toml".into())
327                    },
328                    key_combo_sequence: None,
329                }
330            );
331
332            let entry_render_data: EntryRenderData = EntryRenderData::from_entry(&entry, "argo");
333
334            assert_eq!(
335                entry_render_data,
336                EntryRenderData {
337                    prefix: "C",
338                    search_hit: "argo",
339                    suffix: ".toml",
340                    illegal_char_for_hotkey: Some('.'),
341                    kind: &EntryKind::File {
342                        extension: Some("toml".into())
343                    },
344                    key_combo_sequence: None,
345                }
346            );
347
348            let entry_render_data: EntryRenderData = EntryRenderData::from_entry(&entry, "");
349
350            assert_eq!(
351                entry_render_data,
352                EntryRenderData {
353                    prefix: "Cargo.toml",
354                    search_hit: "",
355                    suffix: "",
356                    illegal_char_for_hotkey: Some('c'),
357                    kind: &EntryKind::File {
358                        extension: Some("toml".into())
359                    },
360                    key_combo_sequence: None,
361                }
362            );
363        }
364    }
365}