file_view.rs - icy_draw - [fork] icy_draw is the successor to mystic draw.
(HTM) git clone https://git.drkhsh.at/icy_draw.git
(DIR) Log
(DIR) Files
(DIR) Refs
(DIR) README
---
file_view.rs (24060B)
---
1 use directories::UserDirs;
2 use eframe::{
3 egui::{self, Image, Layout, RichText, Sense, TopBottomPanel, WidgetText},
4 epaint::{FontFamily, FontId, Rounding},
5 };
6 use egui::{ScrollArea, TextEdit, Ui};
7 use i18n_embed_fl::fl;
8 use icy_sauce::SauceInformation;
9
10 use std::{
11 env,
12 fs::{self, File},
13 io::{Error, Read},
14 path::{Path, PathBuf},
15 };
16
17 use super::options::{Options, ScrollSpeed};
18
19 pub enum Message {
20 Select(usize, bool),
21 Open(usize),
22 Cancel,
23 Refresh,
24 ParentFolder,
25 ToggleAutoScroll,
26 ShowSauce(usize),
27 ShowHelpDialog,
28 ChangeScrollSpeed,
29 }
30
31 #[derive(Clone)]
32 pub struct FileEntry {
33 pub file_info: FileInfo,
34 pub file_data: Option<Vec<u8>>,
35 pub read_sauce: bool,
36 pub sauce: Option<SauceInformation>,
37 }
38
39 impl FileEntry {
40 pub fn get_data<T>(&self, func: fn(&PathBuf, &[u8]) -> T) -> anyhow::Result<T> {
41 if let Some(data) = &self.file_data {
42 return Ok(func(&self.file_info.path, data));
43 }
44
45 let file = File::open(&self.file_info.path)?;
46 let mmap = unsafe { memmap::MmapOptions::new().map(&file)? };
47 Ok(func(&self.file_info.path, &mmap))
48 }
49
50 pub fn read_image<'a>(&self, func: fn(&PathBuf, Vec<u8>) -> Image<'a>) -> anyhow::Result<Image<'a>> {
51 let path = self.file_info.clone();
52 if let Some(data) = &self.file_data {
53 let data = data.clone();
54 Ok(func(&path.path, data))
55 } else {
56 let data = fs::read(&path.path)?;
57 Ok(func(&path.path, data))
58 }
59 }
60
61 pub fn is_file(&self) -> bool {
62 self.file_data.is_some() || !self.file_info.dir
63 }
64
65 fn load_sauce(&mut self) {
66 if self.read_sauce {
67 return;
68 }
69 self.read_sauce = true;
70
71 if let Ok(Ok(Some(data))) = self.get_data(|_, data| SauceInformation::read(data)) {
72 self.sauce = Some(data);
73 }
74 }
75
76 pub(crate) fn is_dir(&self) -> bool {
77 self.file_info.dir
78 }
79
80 fn is_dir_or_archive(&self) -> bool {
81 if let Some(ext) = self.file_info.path.extension() {
82 if ext.to_string_lossy().to_ascii_lowercase() == "zip" {
83 return true;
84 }
85 }
86
87 self.is_dir()
88 }
89
90 pub(crate) fn get_sauce(&self) -> Option<SauceInformation> {
91 if !self.read_sauce {
92 return None;
93 }
94 self.sauce.clone()
95 }
96 }
97
98 pub struct FileView {
99 /// Current opened path.
100 path: PathBuf,
101 /// Selected file path
102 pub selected_file: Option<usize>,
103 pub scroll_pos: Option<usize>,
104 /// Files in directory.
105 pub files: Vec<FileEntry>,
106 pub upgrade_version: Option<String>,
107
108 pub options: super::options::Options,
109 pub filter: String,
110 pre_select_file: Option<String>,
111 }
112
113 impl FileView {
114 pub fn new(initial_path: Option<PathBuf>, options: Options) -> Self {
115 let mut path = if let Some(path) = initial_path {
116 path
117 } else if let Some(user_dirs) = UserDirs::new() {
118 user_dirs.home_dir().to_path_buf()
119 } else {
120 env::current_dir().unwrap_or_default()
121 };
122
123 let mut pre_select_file = None;
124
125 if !path.exists() {
126 pre_select_file = Some(path.file_name().unwrap().to_string_lossy().to_string());
127 path.pop();
128 }
129
130 if path.is_file() && path.extension().unwrap_or_default().to_string_lossy().to_ascii_lowercase() != "zip" {
131 pre_select_file = Some(path.file_name().unwrap().to_string_lossy().to_string());
132 path.pop();
133 }
134
135 Self {
136 path,
137 selected_file: None,
138 pre_select_file,
139 scroll_pos: None,
140 files: Vec::new(),
141 filter: String::new(),
142 options,
143 upgrade_version: None,
144 }
145 }
146
147 pub(crate) fn show_ui(&mut self, ui: &mut Ui, file_chooser: bool) -> Option<Message> {
148 let mut command: Option<Message> = None;
149
150 if file_chooser {
151 TopBottomPanel::bottom("bottom_buttons").show_inside(ui, |ui| {
152 ui.set_width(350.0);
153 ui.add_space(4.0);
154 ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| {
155 if ui.button(fl!(crate::LANGUAGE_LOADER, "button-open")).clicked() {
156 if let Some(sel) = self.selected_file {
157 command = Some(Message::Open(sel));
158 }
159 }
160 if ui.button(fl!(crate::LANGUAGE_LOADER, "button-cancel")).clicked() {
161 command = Some(Message::Cancel);
162 }
163 });
164 });
165 }
166 ui.add_space(4.0);
167 ui.horizontal(|ui| {
168 ui.add(
169 TextEdit::singleline(&mut self.filter)
170 .hint_text(fl!(crate::LANGUAGE_LOADER, "filter-entries-hint-text"))
171 .desired_width(300.),
172 );
173 let response = ui.button("🗙").on_hover_text(fl!(crate::LANGUAGE_LOADER, "tooltip-reset-filter-button"));
174 if response.clicked() {
175 self.filter.clear();
176 }
177 if let Some(ver) = &self.upgrade_version {
178 ui.hyperlink_to(
179 fl!(crate::LANGUAGE_LOADER, "menu-upgrade_version", version = ver.clone()),
180 "https://github.com/mkrueger/icy_tools/releases",
181 );
182 }
183 });
184
185 ui.horizontal(|ui| {
186 match self.path.to_str() {
187 Some(path) => {
188 let mut path_edit = path.to_string();
189 ui.add(TextEdit::singleline(&mut path_edit).desired_width(f32::INFINITY));
190 }
191 None => {
192 ui.colored_label(ui.style().visuals.error_fg_color, fl!(crate::LANGUAGE_LOADER, "error-invalid-path"));
193 }
194 }
195
196 ui.add_enabled_ui(self.path.parent().is_some(), |ui| {
197 let response = ui.button("⬆").on_hover_text("Parent Folder");
198 if response.clicked() {
199 command = Some(Message::ParentFolder);
200 }
201 });
202
203 let response = ui.button("⟲").on_hover_text(fl!(crate::LANGUAGE_LOADER, "tooltip-refresh"));
204 if response.clicked() {
205 command = Some(Message::Refresh);
206 }
207
208 ui.menu_button("…", |ui| {
209 let r = ui.hyperlink_to(
210 fl!(crate::LANGUAGE_LOADER, "menu-item-discuss"),
211 "https://github.com/mkrueger/icy_tools/discussions",
212 );
213 if r.clicked() {
214 ui.close_menu();
215 }
216 let r = ui.hyperlink_to(
217 fl!(crate::LANGUAGE_LOADER, "menu-item-report-bug"),
218 "https://github.com/mkrueger/icy_tools/issues/new",
219 );
220 if r.clicked() {
221 ui.close_menu();
222 }
223 let r = ui.hyperlink_to(
224 fl!(crate::LANGUAGE_LOADER, "menu-item-check-releases"),
225 "https://github.com/mkrueger/icy_tools/releases",
226 );
227 if r.clicked() {
228 ui.close_menu();
229 }
230 ui.separator();
231 let mut b = self.options.auto_scroll_enabled;
232 if ui.checkbox(&mut b, fl!(crate::LANGUAGE_LOADER, "menu-item-auto-scroll")).clicked() {
233 command = Some(Message::ToggleAutoScroll);
234 ui.close_menu();
235 }
236 let title = match self.options.scroll_speed {
237 ScrollSpeed::Slow => fl!(crate::LANGUAGE_LOADER, "menu-item-scroll-speed-slow"),
238 ScrollSpeed::Medium => {
239 fl!(crate::LANGUAGE_LOADER, "menu-item-scroll-speed-medium")
240 }
241 ScrollSpeed::Fast => fl!(crate::LANGUAGE_LOADER, "menu-item-scroll-speed-fast"),
242 };
243
244 let r = ui.selectable_label(false, title);
245 if r.clicked() {
246 command = Some(Message::ChangeScrollSpeed);
247 ui.close_menu();
248 }
249 });
250 });
251 if self.selected_file.is_none() && !self.files.is_empty() {
252 // command = Some(Command::Select(0));
253 }
254
255 let area = ScrollArea::vertical();
256 let row_height = ui.text_style_height(&egui::TextStyle::Body);
257 let strong_color = ui.style().visuals.strong_text_color();
258 let text_color = ui.style().visuals.text_color();
259
260 let filter = self.filter.to_lowercase();
261 let filtered_entries = self.files.iter_mut().enumerate().filter(|(_, p)| {
262 if filter.is_empty() {
263 return true;
264 }
265 if let Some(sauce) = &p.sauce {
266 if sauce.title().to_string().to_lowercase().contains(&filter)
267 /* || sauce
268 .group
269 .to_string()
270 .to_lowercase()
271 .contains(&filter)
272 || sauce
273 .author
274 .to_string()
275 .to_lowercase()
276 .contains(&filter)*/
277 {
278 return true;
279 }
280 }
281 p.file_info.path.to_string_lossy().to_lowercase().contains(&filter)
282 });
283
284 let mut indices = Vec::new();
285 let area_res = area.show(ui, |ui| {
286 for (real_idx, entry) in filtered_entries {
287 let (id, rect) = ui.allocate_space([ui.available_width(), row_height].into());
288
289 indices.push(real_idx);
290 let is_selected = Some(real_idx) == self.selected_file;
291 let text_color = if is_selected { strong_color } else { text_color };
292 let mut response = ui.interact(rect, id, Sense::click());
293 if response.hovered() {
294 ui.painter()
295 .rect_filled(rect.expand(1.0), Rounding::same(4.0), ui.style().visuals.widgets.active.bg_fill);
296 } else if is_selected {
297 ui.painter()
298 .rect_filled(rect.expand(1.0), Rounding::same(4.0), ui.style().visuals.extreme_bg_color);
299 }
300
301 let label = if !ui.is_rect_visible(rect) {
302 get_file_name(&entry.file_info.path).to_string()
303 } else {
304 match entry.is_dir_or_archive() {
305 true => "🗀 ",
306 false => "🗋 ",
307 }
308 .to_string()
309 + get_file_name(&entry.file_info.path)
310 };
311
312 let font_id = FontId::new(14.0, FontFamily::Proportional);
313 let text: WidgetText = label.into();
314 let galley = text.into_galley(ui, Some(egui::TextWrapMode::Truncate), f32::INFINITY, font_id);
315 ui.painter()
316 .galley_with_override_text_color(egui::Align2::LEFT_TOP.align_size_within_rect(galley.size(), rect).min, galley, text_color);
317 if response.hovered() {
318 entry.load_sauce();
319 if let Some(sauce) = &entry.sauce {
320 response = response.on_hover_ui(|ui| {
321 egui::Grid::new("some_unique_id").num_columns(2).spacing([4.0, 2.0]).show(ui, |ui| {
322 ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| {
323 ui.label(fl!(crate::LANGUAGE_LOADER, "heading-title"));
324 });
325 ui.strong(sauce.title().to_string());
326 ui.end_row();
327 ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| {
328 ui.label(fl!(crate::LANGUAGE_LOADER, "heading-author"));
329 });
330 ui.strong(sauce.author().to_string());
331 ui.end_row();
332 ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| {
333 ui.label(fl!(crate::LANGUAGE_LOADER, "heading-group"));
334 });
335 ui.strong(sauce.group().to_string());
336 ui.end_row();
337 ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| {
338 ui.label(fl!(crate::LANGUAGE_LOADER, "heading-screen-mode"));
339 });
340 let mut flags: String = String::new();
341 if let Ok(caps) = sauce.get_character_capabilities() {
342 if caps.use_ice {
343 flags.push_str("ICE");
344 }
345
346 if caps.use_letter_spacing {
347 if !flags.is_empty() {
348 flags.push(',');
349 }
350 flags.push_str("9px");
351 }
352
353 if caps.use_aspect_ratio {
354 if !flags.is_empty() {
355 flags.push(',');
356 }
357 flags.push_str("AR");
358 }
359
360 if flags.is_empty() {
361 ui.strong(RichText::new(format!("{}x{}", caps.width, caps.height)));
362 } else {
363 ui.strong(RichText::new(format!("{}x{} ({})", caps.width, caps.height, flags)));
364 }
365 }
366 ui.end_row();
367 });
368 });
369 }
370 }
371
372 if response.clicked() {
373 command = Some(Message::Select(real_idx, false));
374 }
375
376 if response.double_clicked() {
377 command = Some(Message::Open(real_idx));
378 }
379 }
380 });
381
382 if ui.is_enabled() {
383 if ui.input(|i| i.key_pressed(egui::Key::PageUp) && i.modifiers.alt) {
384 command = Some(Message::ParentFolder);
385 }
386
387 if ui.input(|i| i.key_pressed(egui::Key::F1)) {
388 command = Some(Message::ShowHelpDialog);
389 }
390
391 if ui.input(|i| i.key_pressed(egui::Key::F2)) {
392 command = Some(Message::ToggleAutoScroll);
393 }
394
395 if ui.input(|i| i.key_pressed(egui::Key::F3)) {
396 command = Some(Message::ChangeScrollSpeed);
397 }
398
399 if let Some(s) = self.selected_file {
400 if ui.input(|i| i.key_pressed(egui::Key::F4)) {
401 command = Some(Message::ShowSauce(s));
402 }
403 let found = indices.iter().position(|i| *i == s);
404 if let Some(idx) = found {
405 if ui.input(|i| i.key_pressed(egui::Key::ArrowUp) && i.modifiers.is_none()) && idx > 0 {
406 command = Some(Message::Select(indices[idx - 1], false));
407 }
408
409 if ui.input(|i| i.key_pressed(egui::Key::ArrowDown) && i.modifiers.is_none()) && idx + 1 < indices.len() {
410 command = Some(Message::Select(indices[idx + 1], false));
411 }
412
413 if ui.input(|i| i.key_pressed(egui::Key::Enter)) {
414 command = Some(Message::Open(s));
415 }
416
417 if !self.files.is_empty() {
418 if ui.input(|i: &egui::InputState| i.key_pressed(egui::Key::Home) && i.modifiers.is_none() && !indices.is_empty()) {
419 command = Some(Message::Select(indices[0], false));
420 }
421
422 if ui.input(|i| i.key_pressed(egui::Key::End) && i.modifiers.is_none()) && !indices.is_empty() {
423 command = Some(Message::Select(indices[indices.len() - 1], false));
424 }
425
426 if ui.input(|i| i.key_pressed(egui::Key::PageUp) && i.modifiers.is_none()) && !indices.is_empty() {
427 let page_size = (area_res.inner_rect.height() / row_height) as usize;
428 command = Some(Message::Select(indices[idx.saturating_sub(page_size)], false));
429 }
430
431 if ui.input(|i| i.key_pressed(egui::Key::PageDown) && i.modifiers.is_none()) && !indices.is_empty() {
432 let page_size = (area_res.inner_rect.height() / row_height) as usize;
433 command = Some(Message::Select(indices[(idx.saturating_add(page_size)).min(indices.len() - 1)], false));
434 }
435 }
436 }
437 } else if !self.files.is_empty() {
438 if ui.input(|i| {
439 i.key_pressed(egui::Key::ArrowUp)
440 || i.key_pressed(egui::Key::ArrowDown)
441 || i.key_pressed(egui::Key::PageUp)
442 || i.key_pressed(egui::Key::PageDown)
443 }) {
444 command = Some(Message::Select(0, false));
445 }
446
447 if ui.input(|i| i.key_pressed(egui::Key::Home)) {
448 command = Some(Message::Select(0, false));
449 }
450
451 if ui.input(|i| i.key_pressed(egui::Key::End)) {
452 command = Some(Message::Select(self.files.len().saturating_sub(1), false));
453 }
454 }
455 }
456
457 command
458 }
459
460 pub fn get_path(&self) -> PathBuf {
461 self.path.clone()
462 }
463
464 pub fn set_path(&mut self, path: impl Into<PathBuf>) -> Option<Message> {
465 self.path = path.into();
466 self.refresh()
467 }
468
469 pub fn refresh(&mut self) -> Option<Message> {
470 self.files.clear();
471
472 if self.path.is_file() {
473 match fs::File::open(&self.path) {
474 Ok(file) => match zip::ZipArchive::new(file) {
475 Ok(mut archive) => {
476 for i in 0..archive.len() {
477 match archive.by_index(i) {
478 Ok(mut file) => {
479 let mut data = Vec::new();
480 file.read_to_end(&mut data).unwrap_or_default();
481
482 let entry = FileEntry {
483 file_info: FileInfo {
484 path: file.enclosed_name().unwrap_or(PathBuf::from("unknown")).to_path_buf(),
485 dir: file.is_dir(),
486 },
487 file_data: Some(data),
488 read_sauce: false,
489 sauce: None,
490 };
491 self.files.push(entry);
492 }
493 Err(err) => {
494 log::error!("Error reading zip file: {}", err);
495 }
496 }
497 }
498 }
499 Err(err) => {
500 log::error!("Error reading zip archive: {}", err);
501 }
502 },
503 Err(err) => {
504 log::error!("Failed to open zip file: {}", err);
505 }
506 }
507 } else {
508 let folders = read_folder(&self.path);
509 match folders {
510 Ok(folders) => {
511 self.files = folders
512 .iter()
513 .map(|f| FileEntry {
514 file_info: f.clone(),
515 read_sauce: false,
516 sauce: None,
517 file_data: None,
518 })
519 .collect();
520 }
521 Err(err) => {
522 log::error!("Failed to read folder: {}", err);
523 }
524 }
525 }
526 self.selected_file = None;
527
528 if let Some(file) = &self.pre_select_file {
529 for (i, entry) in self.files.iter().enumerate() {
530 if let Some(file_name) = entry.file_info.path.file_name() {
531 if file_name.to_string_lossy() == *file {
532 return Message::Select(i, false).into();
533 }
534 }
535 }
536 }
537 None
538 }
539 }
540
541 #[cfg(windows)]
542 fn is_drive_root(path: &Path) -> bool {
543 path.to_str()
544 .filter(|path| &path[1..] == ":\\")
545 .and_then(|path| path.chars().next())
546 .map_or(false, |ch| ch.is_ascii_uppercase())
547 }
548
549 fn get_file_name(path: &Path) -> &str {
550 #[cfg(windows)]
551 if path.is_dir() && is_drive_root(path) {
552 return path.to_str().unwrap_or_default();
553 }
554 path.file_name().and_then(|name| name.to_str()).unwrap_or_default()
555 }
556
557 #[cfg(windows)]
558 extern "C" {
559 pub fn GetLogicalDrives() -> u32;
560 }
561
562 #[cfg(windows)]
563 fn get_drives() -> Vec<PathBuf> {
564 let mut drive_names = Vec::new();
565 let mut drives = unsafe { GetLogicalDrives() };
566 let mut letter = b'A';
567 while drives > 0 {
568 if drives & 1 != 0 {
569 drive_names.push(format!("{}:\\", letter as char).into());
570 }
571 drives >>= 1;
572 letter += 1;
573 }
574 drive_names
575 }
576
577 fn read_folder(path: &Path) -> Result<Vec<FileInfo>, Error> {
578 fs::read_dir(path).map(|entries| {
579 let mut file_infos: Vec<FileInfo> = entries
580 .filter_map(|result| result.ok())
581 .filter_map(|entry| {
582 let info = FileInfo::new(entry.path());
583 if !info.dir {
584 // Do not show system files.
585 if !info.path.is_file() {
586 return None;
587 }
588 }
589
590 #[cfg(unix)]
591 if info.get_file_name().starts_with('.') {
592 return None;
593 }
594
595 Some(info)
596 })
597 .collect();
598
599 // Sort keeping folders before files.
600 file_infos.sort_by(|a, b| match a.dir == b.dir {
601 true => a.path.file_name().cmp(&b.path.file_name()),
602 false => b.dir.cmp(&a.dir),
603 });
604
605 #[cfg(windows)]
606 let file_infos = {
607 let drives = get_drives();
608 let mut infos = Vec::with_capacity(drives.len() + file_infos.len());
609 for drive in drives {
610 infos.push(FileInfo { path: drive, dir: true });
611 }
612 infos.append(&mut file_infos);
613 infos
614 };
615
616 file_infos
617 })
618 }
619
620 #[derive(Clone, Debug, Default)]
621 pub struct FileInfo {
622 pub path: PathBuf,
623 pub dir: bool,
624 }
625
626 impl FileInfo {
627 pub fn new(path: PathBuf) -> Self {
628 let dir = path.is_dir();
629 Self { path, dir }
630 }
631
632 pub fn get_file_name(&self) -> &str {
633 #[cfg(windows)]
634 if self.dir && is_drive_root(&self.path) {
635 return self.path.to_str().unwrap_or_default();
636 }
637 self.path.file_name().and_then(|name| name.to_str()).unwrap_or_default()
638 }
639 }