pixels_graphics_lib/ui/
dir_panel.rs

1use crate::prelude::*;
2use crate::ui::dir_panel::FileEntry::*;
3use crate::ui::prelude::*;
4use buffer_graphics_lib::prelude::Positioning::*;
5use buffer_graphics_lib::prelude::*;
6use std::cmp::Ordering;
7use std::fs::{read_dir, ReadDir};
8use std::path::PathBuf;
9
10const ENTRY_FORMAT: TextFormat = TextFormat::new(
11    WrappingStrategy::Ellipsis(35),
12    PixelFont::Standard4x5,
13    BLACK,
14    LeftTop,
15);
16const ERROR_FORMAT: TextFormat = TextFormat::new(
17    WrappingStrategy::SpaceBeforeCol(20),
18    PixelFont::Standard6x7,
19    RED,
20    Center,
21);
22
23#[derive(Debug, PartialEq, Clone, Eq)]
24enum FileEntry {
25    ParentDir(String),
26    File(FileInfo),
27    Dir(String, String),
28}
29
30impl FileEntry {
31    pub fn to_result(&self) -> DirResult {
32        match self {
33            ParentDir(path) => DirResult::new(path.clone(), false),
34            File(info) => DirResult::new(info.path.clone(), true),
35            Dir(path, _) => DirResult::new(path.clone(), false),
36        }
37    }
38}
39
40#[derive(Debug, Clone)]
41pub struct DirResult {
42    pub path: String,
43    pub is_file: bool,
44}
45
46impl DirResult {
47    pub fn new(path: String, is_file: bool) -> Self {
48        Self { path, is_file }
49    }
50}
51
52impl PartialOrd for FileEntry {
53    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
54        Some(self.cmp(other))
55    }
56}
57
58impl Ord for FileEntry {
59    fn cmp(&self, other: &Self) -> Ordering {
60        if let ParentDir(_) = self {
61            Ordering::Less
62        } else if let ParentDir(_) = other {
63            Ordering::Greater
64        } else {
65            match (self, other) {
66                (File(info), Dir(_, name)) => info.filename.cmp(name),
67                (Dir(_, name), File(info)) => name.cmp(&info.filename),
68                (Dir(_, lhs), Dir(_, rhs)) => lhs.cmp(rhs),
69                (File(lhs), File(rhs)) => lhs.filename.cmp(&rhs.filename),
70                (_, _) => Ordering::Equal,
71            }
72        }
73    }
74}
75
76#[derive(Debug, PartialEq, Eq, Clone)]
77struct FileInfo {
78    pub path: String,
79    pub filename: String,
80    pub size: String,
81}
82
83/// Call [get_click_result] if [on_mouse_click] returns true
84#[derive(Debug)]
85pub struct DirPanel {
86    current_dir: String,
87    files: Vec<FileEntry>,
88    first_visible_file_index: usize,
89    entry_visible_count: usize,
90    background: ShapeCollection,
91    bounds: Rect,
92    error: Option<String>,
93    highlight: Option<usize>,
94    allowed_ext: Option<String>,
95    state: ViewState,
96}
97
98impl DirPanel {
99    pub fn new(current_dir: &str, bounds: Rect, allowed_ext: Option<&str>) -> Self {
100        let (background, entry_visible_count) = Self::layout(&bounds);
101        let mut panel = Self {
102            error: None,
103            current_dir: current_dir.to_string(),
104            bounds,
105            files: vec![],
106            entry_visible_count,
107            first_visible_file_index: 0,
108            background,
109            highlight: None,
110            allowed_ext: allowed_ext.map(|s| s.to_string()),
111            state: ViewState::Normal,
112        };
113        panel.set_dir(current_dir);
114        panel
115    }
116
117    fn layout(bounds: &Rect) -> (ShapeCollection, usize) {
118        let mut background = ShapeCollection::default();
119        InsertShape::insert_above(&mut background, bounds.clone(), fill(WHITE));
120        InsertShape::insert_above(&mut background, bounds.clone(), stroke(DARK_GRAY));
121        let entry_visible_count =
122            bounds.height() / (PixelFont::Standard4x5.size().1 + PixelFont::Standard4x5.spacing());
123        (background, entry_visible_count)
124    }
125}
126
127fn fs_size(bytes: u64) -> String {
128    if bytes < 1024 {
129        format!("{bytes}B")
130    } else if bytes < 1024 * 1024 {
131        format!("{}KB", bytes / 1024)
132    } else if bytes < 1024 * 1024 * 1024 {
133        format!("{}MB", bytes / 1024 / 1024)
134    } else {
135        format!("{}GB", bytes / 1024 / 1024 / 1024)
136    }
137}
138
139fn get_files(path: &str, dir: ReadDir, allowed_ext: &Option<String>) -> Vec<FileEntry> {
140    let path = PathBuf::from(path);
141    let mut results = vec![];
142    if let Some(parent) = path.parent() {
143        results.push(ParentDir(parent.to_string_lossy().to_string()));
144    }
145    for file in dir.flatten() {
146        if let Ok(file_type) = file.file_type() {
147            if file_type.is_file() {
148                let include = if let Some(allowed) = allowed_ext {
149                    &file
150                        .path()
151                        .extension()
152                        .unwrap_or_default()
153                        .to_string_lossy()
154                        .to_string()
155                        == allowed
156                } else {
157                    true
158                };
159                if include {
160                    results.push(File(FileInfo {
161                        path: file.path().to_string_lossy().to_string(),
162                        filename: file.file_name().to_string_lossy().to_string(),
163                        size: fs_size(file.metadata().unwrap().len()),
164                    }))
165                }
166            } else if file_type.is_dir() {
167                results.push(Dir(
168                    file.path().to_string_lossy().to_string(),
169                    file.file_name().to_string_lossy().to_string(),
170                ))
171            }
172        }
173    }
174    results
175}
176
177impl DirPanel {
178    pub fn set_dir(&mut self, path: &str) {
179        self.error = None;
180        self.first_visible_file_index = 0;
181        match read_dir(path) {
182            Ok(dir) => {
183                let mut files = get_files(path, dir, &self.allowed_ext);
184                files.sort();
185                self.files = files;
186            }
187            Err(err) => self.error = Some(err.to_string()),
188        }
189    }
190
191    #[must_use]
192    pub fn highlighted(&self) -> Option<DirResult> {
193        if let Some(i) = self.highlight {
194            self.files.get(i).map(|e| e.to_result())
195        } else {
196            None
197        }
198    }
199
200    pub fn set_highlight(&mut self, path: &str) {
201        for (i, entry) in self.files.iter().enumerate() {
202            let entry_path = match entry {
203                ParentDir(path) => path,
204                File(info) => &info.path,
205                Dir(path, _) => path,
206            };
207            if path == entry_path {
208                self.highlight = Some(i);
209                break;
210            }
211        }
212    }
213
214    #[inline]
215    #[must_use]
216    pub fn current_dir(&self) -> &str {
217        &self.current_dir
218    }
219
220    pub fn on_scroll(&mut self, xy: Coord, diff: isize) {
221        if self.bounds.contains(xy) {
222            let factor = diff.abs() % 5;
223            let up = diff < 0;
224            if up && self.first_visible_file_index > 0 {
225                self.first_visible_file_index = self
226                    .first_visible_file_index
227                    .saturating_sub(factor.unsigned_abs());
228            }
229            if !up && (self.first_visible_file_index + self.entry_visible_count < self.files.len())
230            {
231                self.first_visible_file_index = (self.first_visible_file_index
232                    + factor.unsigned_abs())
233                .min(self.files.len() - self.entry_visible_count);
234            }
235        }
236    }
237
238    fn bounds_for_row(&self, row: usize) -> Rect {
239        let xy = self.bounds.top_left()
240            + (
241                2,
242                row * (PixelFont::Standard4x5.spacing() + PixelFont::Standard4x5.size().1)
243                    + PixelFont::Standard4x5.spacing() * 2,
244            );
245        Rect::new(
246            xy,
247            (
248                self.bounds.right() - 2,
249                xy.y + (PixelFont::Standard4x5.size().1) as isize,
250            ),
251        )
252    }
253
254    pub fn on_mouse_click(&mut self, down: Coord, up: Coord) -> Option<DirResult> {
255        if self.state == ViewState::Disabled {
256            return None;
257        }
258        if self.bounds.contains(down) && self.bounds.contains(up) {
259            for i in 0..self.entry_visible_count {
260                if self.bounds_for_row(i).contains(up) {
261                    return self
262                        .files
263                        .get(i + self.first_visible_file_index)
264                        .map(|e| e.to_result());
265                }
266            }
267        }
268        None
269    }
270}
271
272impl PixelView for DirPanel {
273    fn set_position(&mut self, top_left: Coord) {
274        self.bounds = self.bounds.move_to(top_left);
275        let (background, entry_visible_count) = Self::layout(&self.bounds);
276        self.background = background;
277        self.entry_visible_count = entry_visible_count;
278    }
279
280    #[inline]
281    fn bounds(&self) -> &Rect {
282        &self.bounds
283    }
284
285    fn render(&self, graphics: &mut Graphics, mouse: &MouseData) {
286        graphics.draw(&self.background);
287
288        if let Some(txt) = &self.error {
289            graphics.draw_text(txt, TextPos::px(self.bounds.center()), ERROR_FORMAT);
290        } else {
291            let mut row = 0;
292            for i in self.first_visible_file_index
293                ..self.first_visible_file_index + self.entry_visible_count
294            {
295                let highlighted = self.highlight.map(|r| r == i).unwrap_or_default();
296                if i < self.files.len() {
297                    let back = self.bounds_for_row(row);
298                    if back.contains(mouse.xy) || highlighted {
299                        graphics.draw_rect(
300                            back.clone(),
301                            fill(if highlighted { CYAN } else { LIGHT_GRAY }),
302                        );
303                    }
304                    match &self.files[i] {
305                        ParentDir(_) => {
306                            graphics.draw_text("..", TextPos::px(back.top_left()), ENTRY_FORMAT)
307                        }
308                        File(info) => graphics.draw_text(
309                            &info.filename,
310                            TextPos::px(back.top_left()),
311                            ENTRY_FORMAT,
312                        ),
313                        Dir(_, name) => {
314                            graphics.draw_text(name, TextPos::px(back.top_left()), ENTRY_FORMAT)
315                        }
316                    }
317                    row += 1;
318                }
319            }
320        }
321    }
322
323    fn update(&mut self, _: &Timing) {}
324
325    #[inline]
326    fn set_state(&mut self, new_state: ViewState) {
327        self.state = new_state;
328    }
329
330    #[inline]
331    fn get_state(&self) -> ViewState {
332        self.state
333    }
334}
335
336impl LayoutView for DirPanel {
337    fn set_bounds(&mut self, bounds: Rect) {
338        self.bounds = bounds.clone();
339        self.set_position(bounds.top_left());
340    }
341}