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 }