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#[derive(Debug, PartialEq)]
77pub struct EntryRenderData<'a> {
78 prefix: &'a str,
79 search_hit: &'a str,
80 suffix: &'a str,
81
82 pub illegal_char_for_hotkey: Option<char>,
89
90 pub kind: &'a EntryKind,
93 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 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 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 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 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}