From b39419888bd0f572ce8aba89ef10d08dbf5ddf7a Mon Sep 17 00:00:00 2001 From: Joakim Hulthe Date: Thu, 19 Jun 2025 23:09:41 +0200 Subject: [PATCH] Add folder tree --- src/app.rs | 78 ++++++++++++--- src/folder.rs | 218 +++++++++++++++++++++++++++++++++++++++++ src/handwriting/mod.rs | 5 +- src/lib.rs | 1 + src/rasterizer.rs | 6 +- 5 files changed, 291 insertions(+), 17 deletions(-) create mode 100644 src/folder.rs diff --git a/src/app.rs b/src/app.rs index 1d05b3d..9273a8b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -6,7 +6,7 @@ use std::{ time::{Duration, Instant}, }; -use crate::{file_editor::FileEditor, preferences::Preferences, util::GuiSender}; +use crate::{file_editor::FileEditor, folder::Folder, preferences::Preferences, util::GuiSender}; use egui::{ Align, Button, Color32, Context, FontData, FontDefinitions, Key, Modifiers, PointerButton, RichText, ScrollArea, Stroke, @@ -25,6 +25,9 @@ pub struct App { jobs: Jobs, tabs: Vec<(TabId, Tab)>, + + folders: Vec, + open_tab_index: Option, next_tab_id: TabId, @@ -80,6 +83,7 @@ pub type TabId = usize; pub enum Action { OpenFile(FileEditor), + OpenFolder(Folder), MoveFile(TabId, PathBuf), CloseTab(TabId), // TODO @@ -102,6 +106,7 @@ impl Default for App { tabs: vec![(1, Tab::File(FileEditor::new("note.md")))], open_tab_index: None, next_tab_id: 2, + folders: vec![], } } } @@ -194,6 +199,18 @@ impl App { fn handle_action(&mut self, action: Action) { match action { + Action::OpenFolder(new_folder) => { + if let Some(folder) = self + .folders + .iter_mut() + .find(|folder| folder.path() == new_folder.path()) + { + *folder = new_folder; + } else { + self.folders.push(new_folder); + self.folders.sort_by(|a, b| a.name().cmp(b.name())); + } + } Action::OpenFile(file_editor) => { self.open_tab(Tab::File(file_editor)); } @@ -262,7 +279,12 @@ impl eframe::App for App { } if ui.button("Open Folder").clicked() { - log::error!("Open Folder not implemented"); + self.jobs.start(ui.ctx(), move || { + let path = rfd::FileDialog::new().pick_folder()?; + let name = path.file_name()?.to_string_lossy().to_string(); + let folder = Folder::NotLoaded { name, path }; + Some(Action::OpenFolder(folder)) + }); } if ui @@ -277,8 +299,8 @@ impl eframe::App for App { let can_save_file = self .open_tab_index .and_then(|i| self.tabs.get(i)) - .and_then(|(id, tab)| match tab { - Tab::File(file_editor) => Some((*id, file_editor)), + .map(|(id, tab)| match tab { + Tab::File(file_editor) => (*id, file_editor), }) .and_then(|(_, file_editor)| file_editor.path().zip(Some(file_editor))) .is_some(); @@ -287,12 +309,11 @@ impl eframe::App for App { self.save_active_tab(ui.ctx()); } - let open_file = - self.open_tab_index - .and_then(|i| self.tabs.get(i)) - .and_then(|(id, tab)| match tab { - Tab::File(file_editor) => Some((*id, file_editor)), - }); + let open_file = self.open_tab_index.and_then(|i| self.tabs.get(i)).map( + |(id, tab)| match tab { + Tab::File(file_editor) => (*id, file_editor), + }, + ); #[cfg(not(target_arch = "wasm32"))] if ui @@ -351,6 +372,39 @@ impl eframe::App for App { }); }); + egui::SidePanel::left("file browser") + .resizable(true) + .show(ctx, |ui| { + if ui.button("refresh").clicked() { + for folder in &mut self.folders { + folder.unload(); + } + } + + ScrollArea::both().auto_shrink(false).show(ui, |ui| { + self.folders.retain_mut(|folder| { + let response = folder.show(ui); + + if let Some(file_path) = response.open_file { + let file_path = file_path.to_owned(); + self.jobs.start(ui.ctx(), move || { + let text = fs::read_to_string(&file_path) + .inspect_err(|e| { + log::error!("Failed to read {file_path:?}: {e}") + }) + .ok()?; + + let editor = FileEditor::from_file(file_path, &text); + Some(Action::OpenFile(editor)) + }); + } + + // delete on right-click + !response.clicked_by(PointerButton::Secondary) + }); + }); + }); + egui::CentralPanel::default().show(ctx, |ui| { if let Some(Tab::File(file_editor)) = self .open_tab_index @@ -389,8 +443,8 @@ impl App { let open_file = self .open_tab_index .and_then(|i| self.tabs.get_mut(i)) - .and_then(|(id, tab)| match tab { - Tab::File(file_editor) => Some((*id, file_editor)), + .map(|(id, tab)| match tab { + Tab::File(file_editor) => (*id, file_editor), }) .and_then(|(_, file_editor)| { file_editor diff --git a/src/folder.rs b/src/folder.rs new file mode 100644 index 0000000..6cf9628 --- /dev/null +++ b/src/folder.rs @@ -0,0 +1,218 @@ +use std::{ + fs::read_dir, + mem, + ops::Deref, + path::{Path, PathBuf}, + sync::mpsc, + thread, +}; + +use egui::{Response, Ui}; +use eyre::{Context, OptionExt, eyre}; +use serde::{Deserialize, Serialize}; + +pub enum Folder { + NotLoaded { + name: String, + path: PathBuf, + }, + Loading { + name: String, + path: PathBuf, + recv: mpsc::Receiver, + }, + Loaded(LoadedFolder), +} + +pub struct LoadedFolder { + pub name: String, + pub path: PathBuf, + pub child_folders: Vec, + pub child_files: Vec, +} + +pub struct File { + pub name: String, + pub path: PathBuf, +} + +pub struct FolderResponse<'a> { + inner: Response, + pub open_file: Option<&'a Path>, +} + +impl Deref for FolderResponse<'_> { + type Target = Response; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl LoadedFolder { + pub fn show<'a>(&'a mut self, ui: &mut Ui) -> FolderResponse<'a> { + let mut open_file = None; + let inner = ui + .collapsing(&self.name, |ui| { + for folder in &mut self.child_folders { + open_file = open_file.or(folder.show(ui).open_file); + } + + for file in &mut self.child_files { + if ui.button(&file.name).clicked() { + open_file = Some(file.path.as_path()) + }; + } + }) + .header_response; + + FolderResponse { inner, open_file } + } + + fn load(path: PathBuf) -> eyre::Result { + let name = path + .file_name() + .ok_or_eyre("Path is missing a file-name")? + .to_string_lossy() + .to_string(); + + let mut child_folders = vec![]; + let mut child_files = vec![]; + + for entry in read_dir(&path).with_context(|| eyre!("Couldn't read dir {path:?}"))? { + let entry = entry.with_context(|| eyre!("Couldn't read dir {path:?}"))?; + let path = entry.path(); + let name = path + .file_name() + .ok_or_eyre("Path is missing a file-name")? + .to_string_lossy() + .to_string(); + + let file_type = entry.file_type()?; + + if file_type.is_symlink() { + log::error!("Symlinks not yet supported, skipping {path:?}"); + continue; + } else if file_type.is_file() { + child_files.push(File { name, path }); + } else if file_type.is_dir() { + child_folders.push(Folder::NotLoaded { name, path }); + } + } + + let folder = LoadedFolder { + name, + path, + child_folders, + child_files, + }; + + Ok(folder) + } +} + +impl Folder { + fn load(&mut self, ui: &mut Ui) -> Option<&mut LoadedFolder> { + if let Folder::NotLoaded { name, path } = self { + let (tx, rx) = mpsc::channel(); + + { + let path = path.clone(); + let ctx = ui.ctx().clone(); + thread::spawn(move || match LoadedFolder::load(path) { + Err(e) => log::error!("Failed to load folder: {e}"), + Ok(folder) => { + let _ = tx.send(folder); + ctx.request_repaint(); + } + }); + } + + *self = Folder::Loading { + name: mem::take(name), + path: mem::take(path), + recv: rx, + }; + } + + if let Folder::Loading { recv, .. } = self { + match recv.try_recv() { + Ok(folder) => *self = Folder::Loaded(folder), + Err(_) => return None, + } + } + + let Folder::Loaded(folder) = self else { + unreachable!() + }; + + Some(folder) + } + + pub fn show<'a>(&'a mut self, ui: &mut Ui) -> FolderResponse<'a> { + self.load(ui); + + if let Folder::Loaded(folder) = self { + return folder.show(ui); + } + + FolderResponse { + inner: ui.label(self.name()), + open_file: None, + } + } + + pub fn path(&self) -> &Path { + match self { + Folder::NotLoaded { path, .. } => path, + Folder::Loading { path, .. } => path, + Folder::Loaded(folder) => &folder.path, + } + } + + pub fn name(&self) -> &str { + match self { + Folder::NotLoaded { name, .. } => name, + Folder::Loading { name, .. } => name, + Folder::Loaded(folder) => &folder.name, + } + } + + pub fn unload(&mut self) { + let (name, path) = match self { + Folder::NotLoaded { .. } => return, + Folder::Loading { name, path, .. } => (name, path), + Folder::Loaded(folder) => (&mut folder.name, &mut folder.path), + }; + + *self = Folder::NotLoaded { + name: mem::take(name), + path: mem::take(path), + } + } +} + +impl Serialize for Folder { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.path().serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for Folder { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use serde::de::Error; + let path = PathBuf::deserialize(deserializer)?; + let name = path + .file_name() + .ok_or(D::Error::custom("Path is missing a file-name"))? + .to_string_lossy() + .to_string(); + Ok(Folder::NotLoaded { name, path }) + } +} diff --git a/src/handwriting/mod.rs b/src/handwriting/mod.rs index f7dbeae..6a6efc3 100644 --- a/src/handwriting/mod.rs +++ b/src/handwriting/mod.rs @@ -289,11 +289,11 @@ impl Handwriting { _ => Some(next), } }) - .cloned() .filter(|event| { // FIXME: pinenote: PointerMoved are duplicated after the MouseMoved events cfg!(not(feature = "pinenote")) || !matches!(event, Event::PointerMoved(..)) }) + .cloned() .collect::>() }); @@ -459,6 +459,8 @@ impl Handwriting { mesh_context: MeshContext, ui: &mut Ui, ) { + // TODO: don't tesselate and rasterize on the GUI thread + self.last_mesh_ctx = Some(mesh_context); self.refresh_texture = false; @@ -646,6 +648,7 @@ impl FromStr for Handwriting { .wrap_err("Failed to decode painting data from base64")?; // HACK: first iteration of disk format did not have version header + //let mut bytes = bytes; //bytes.insert(0, 0); //bytes.insert(0, 1); diff --git a/src/lib.rs b/src/lib.rs index 491b59d..5b324f9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ pub mod constants; pub mod custom_code_block; pub mod easy_mark; pub mod file_editor; +pub mod folder; pub mod handwriting; pub mod preferences; pub mod rasterizer; diff --git a/src/rasterizer.rs b/src/rasterizer.rs index cdc12bc..623224e 100644 --- a/src/rasterizer.rs +++ b/src/rasterizer.rs @@ -165,10 +165,8 @@ fn point_in_triangle(point: Pos2, triangle: [&Vertex; 3]) -> PointInTriangle { // Normalize the weights. let weights = areas.map(|area| area / triangle_area); - if cfg!(debug_assertions) { - if weights.into_iter().any(f32::is_nan) { - panic!("weights must not be NaN! {weights:?} {triangle_area:?} {areas:?} {sides:?}"); - } + if cfg!(debug_assertions) && weights.into_iter().any(f32::is_nan) { + panic!("weights must not be NaN! {weights:?} {triangle_area:?} {areas:?} {sides:?}"); } PointInTriangle { inside, weights }