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 item = Entry::try_from(dir_entry)?;
236            items.push(item);
237        }
238
239        Ok(EntryList {
240            items,
241            ..Default::default()
242        })
243    }
244}
245
246impl TryFrom<Vec<PathBuf>> for EntryList {
247    type Error = anyhow::Error;
248
249    fn try_from(value: Vec<PathBuf>) -> Result<Self, Self::Error> {
250        let mut items = Vec::new();
251
252        for path in value {
253            let item = Entry::try_from(path)?;
254            items.push(item);
255        }
256
257        Ok(EntryList {
258            items,
259            ..Default::default()
260        })
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    mod entry_render_data {
269        use super::*;
270
271        #[test]
272        fn entry_render_data_from_entry_works_correctly_with_search_query() {
273            let entry = Entry {
274                name: "Cargo.toml".into(),
275                kind: EntryKind::File {
276                    extension: Some("toml".into()),
277                },
278                path: PathBuf::from("/home/user/Cargo.toml"),
279            };
280
281            let entry_render_data: EntryRenderData = EntryRenderData::from_entry(&entry, "car");
282
283            assert_eq!(
284                entry_render_data,
285                EntryRenderData {
286                    prefix: "",
287                    search_hit: "Car",
288                    suffix: "go.toml",
289                    illegal_char_for_hotkey: Some('g'),
290                    kind: &EntryKind::File {
291                        extension: Some("toml".into())
292                    },
293                    key_combo_sequence: None,
294                }
295            );
296
297            let entry_render_data: EntryRenderData = EntryRenderData::from_entry(&entry, "toml");
298
299            assert_eq!(
300                entry_render_data,
301                EntryRenderData {
302                    prefix: "Cargo.",
303                    search_hit: "toml",
304                    suffix: "",
305                    illegal_char_for_hotkey: None,
306                    kind: &EntryKind::File {
307                        extension: Some("toml".into())
308                    },
309                    key_combo_sequence: None,
310                }
311            );
312
313            let entry_render_data: EntryRenderData = EntryRenderData::from_entry(&entry, "argo");
314
315            assert_eq!(
316                entry_render_data,
317                EntryRenderData {
318                    prefix: "C",
319                    search_hit: "argo",
320                    suffix: ".toml",
321                    illegal_char_for_hotkey: Some('.'),
322                    kind: &EntryKind::File {
323                        extension: Some("toml".into())
324                    },
325                    key_combo_sequence: None,
326                }
327            );
328
329            let entry_render_data: EntryRenderData = EntryRenderData::from_entry(&entry, "");
330
331            assert_eq!(
332                entry_render_data,
333                EntryRenderData {
334                    prefix: "Cargo.toml",
335                    search_hit: "",
336                    suffix: "",
337                    illegal_char_for_hotkey: Some('c'),
338                    kind: &EntryKind::File {
339                        extension: Some("toml".into())
340                    },
341                    key_combo_sequence: None,
342                }
343            );
344        }
345    }
346}