use std::{ fs, io::Read, path::PathBuf, sync::{Arc, mpsc}, thread::JoinHandle, time::{Duration, Instant}, }; use crate::{ file_editor::{FileEditor, SaveStatus}, folder::Folder, preferences::Preferences, text_styles::{H1, H1_MONO, H2, H2_MONO, H3, H3_MONO, H4, H4_MONO, H5, H5_MONO, H6, H6_MONO}, util::{GuiSender, file_mtime, log_error}, }; use egui::{ Align, Button, Context, FontData, FontDefinitions, FontFamily, FontId, Image, Key, Modifiers, PointerButton, RichText, ScrollArea, Theme, Widget, include_image, }; use eyre::eyre; #[derive(serde::Deserialize, serde::Serialize)] #[serde(default)] pub struct App { preferences: Preferences, #[serde(skip)] actions_tx: mpsc::Sender, #[serde(skip)] actions_rx: mpsc::Receiver, #[serde(skip)] jobs: Jobs, tabs: Vec<(TabId, Tab)>, show_folders: bool, folders: Vec, open_tab_index: Option, next_tab_id: TabId, } pub struct Jobs { handles: Vec>, actions_tx: mpsc::Sender, } impl Jobs { pub fn start(&mut self, ctx: &Context, job: impl FnOnce() -> Option + Send + 'static) { let ctx = ctx.clone(); let actions_tx = self.actions_tx.clone(); self.handles.push(std::thread::spawn(move || { // start rendering the spinner thingy ctx.request_repaint(); let start = Instant::now(); if let Some(action) = job() { let _ = actions_tx.send(action); ctx.request_repaint(); }; // Make sure that task takes at least 250ms to run, so that the spinner won't blink let sleep_for = Duration::from_millis(250).saturating_sub(start.elapsed()); std::thread::sleep(sleep_for); })); } } #[derive(serde::Deserialize, serde::Serialize)] enum Tab { File(FileEditor), } impl Tab { pub fn title(&self) -> &str { match self { Tab::File(file_editor) => file_editor.title(), } } pub fn notice_symbol(&self) -> Option<&'static str> { match self { Tab::File(file_editor) => match file_editor.save_status() { SaveStatus::Synced => None, SaveStatus::NoFile => Some("?"), SaveStatus::FileOutdated => Some("*"), SaveStatus::BufferOutdated => Some("!"), SaveStatus::Desynced => Some("!!"), }, } } pub fn save(&mut self, ctx: &Context, jobs: &mut Jobs) { match self { Tab::File(file_editor) => file_editor.save(ctx, jobs), } } } pub type TabId = usize; pub enum Action { OpenFile(FileEditor), OpenFolder(Folder), MoveFile(TabId, PathBuf), CloseTab(TabId), // TODO //ShowError { // error: RichText //}, } impl Default for App { fn default() -> Self { let (actions_tx, actions_rx) = mpsc::channel(); Self { preferences: Preferences::default(), actions_tx: actions_tx.clone(/* this is silly, i know */), actions_rx, jobs: Jobs { handles: Default::default(), actions_tx, }, tabs: vec![(1, Tab::File(FileEditor::new("note.md")))], open_tab_index: None, next_tab_id: 2, show_folders: false, folders: vec![], } } } impl App { /// Called once before the first frame. pub fn new(cc: &eframe::CreationContext<'_>) -> Self { let mut fonts = FontDefinitions::empty(); fonts.font_data = [ //( // "IosevkaAile-Thin", // include_bytes!("../fonts/IosevkaAile-Thin.ttc").as_slice(), //), //( // "IosevkaAile-ExtraLight", // include_bytes!("../fonts/IosevkaAile-ExtraLight.ttc").as_slice(), //), //( // "IosevkaAile-Light", // include_bytes!("../fonts/IosevkaAile-Light.ttc").as_slice(), //), ( "IosevkaAile-Regular", include_bytes!("../fonts/IosevkaAile-Regular.ttc").as_slice(), ), //( // "IosevkaAile-Medium", // include_bytes!("../fonts/IosevkaAile-Medium.ttc").as_slice(), //), //( // "IosevkaAile-Bold", // include_bytes!("../fonts/IosevkaAile-Bold.ttc").as_slice(), //), ( "Iosevka-Thin", include_bytes!("../fonts/Iosevka-Thin.ttc").as_slice(), ), //( // "Iosevka-ExtraLight", // include_bytes!("../fonts/Iosevka-ExtraLight.ttc").as_slice(), //), //( // "Iosevka-Light", // include_bytes!("../fonts/Iosevka-Light.ttc").as_slice(), //), //( // "Iosevka-Medium", // include_bytes!("../fonts/Iosevka-Medium.ttc").as_slice(), //), //( // "Iosevka-Regular", // include_bytes!("../fonts/Iosevka-Regular.ttc").as_slice(), //), //( // "Iosevka-Heavy", // include_bytes!("../fonts/Iosevka-Heavy.ttc").as_slice(), //), ] .into_iter() .map(|(name, data)| (name.to_string(), Arc::new(FontData::from_static(data)))) .collect(); fonts .families .insert(FontFamily::Proportional, vec!["IosevkaAile-Regular".into()]); fonts .families .insert(FontFamily::Monospace, vec!["Iosevka-Thin".into()]); cc.egui_ctx.set_fonts(fonts); // markdown font styles for theme in [Theme::Dark, Theme::Light] { cc.egui_ctx.style_mut_of(theme, |style| { for (name, size, family) in [ (H1, 28.0, FontFamily::Proportional), (H2, 26.0, FontFamily::Proportional), (H3, 24.0, FontFamily::Proportional), (H4, 22.0, FontFamily::Proportional), (H5, 20.0, FontFamily::Proportional), (H6, 18.0, FontFamily::Proportional), (H1_MONO, 28.0, FontFamily::Monospace), (H2_MONO, 26.0, FontFamily::Monospace), (H3_MONO, 24.0, FontFamily::Monospace), (H4_MONO, 22.0, FontFamily::Monospace), (H5_MONO, 20.0, FontFamily::Monospace), (H6_MONO, 18.0, FontFamily::Monospace), ] { let name = egui::TextStyle::Name(name.into()); style.text_styles.insert(name, FontId { size, family }); } }); } // enable features on egui_extras to add more image types egui_extras::install_image_loaders(&cc.egui_ctx); if let Some(storage) = cc.storage { return eframe::get_value(storage, eframe::APP_KEY).unwrap_or_default(); } Default::default() } fn actions_tx(&self, ctx: &Context) -> GuiSender { GuiSender::new(self.actions_tx.clone(), ctx) } 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)); } Action::MoveFile(tab_id, new_path) => { let tab = self.tabs.iter_mut().find(|(id, _)| &tab_id == id); let Some((_, tab)) = tab else { return }; let Tab::File(editor) = tab; // else { return }; editor.set_path(new_path); } Action::CloseTab(id) => { // TODO: check if the file is dirty and ask to save it first? self.tabs.retain(|(tab_id, _)| &id != tab_id); } } } } impl eframe::App for App { fn save(&mut self, storage: &mut dyn eframe::Storage) { eframe::set_value(storage, eframe::APP_KEY, self); } fn update(&mut self, ctx: &Context, _frame: &mut eframe::Frame) { self.preferences.apply(ctx); self.jobs.handles.retain(|job| !job.is_finished()); while let Ok(action) = self.actions_rx.try_recv() { self.handle_action(action); } if self.open_tab_index >= Some(self.tabs.len()) { self.open_tab_index = Some(self.tabs.len().saturating_sub(1)); } ctx.input_mut(|input| { if input.consume_key(Modifiers::CTRL, Key::S) { self.save_active_tab(ctx); } }); egui::TopBottomPanel::top("top_panel").show(ctx, |ui| { egui::MenuBar::new().ui(ui, |ui| { // NOTE: no File->Quit on web pages! ui.menu_button("Menu ⚙", |ui| { ui.label(RichText::new("Action").weak()); if ui.button("New File").clicked() { let file = FileEditor::new("note.md"); self.open_tab(Tab::File(file)); } #[cfg(not(target_arch = "wasm32"))] if ui.button("Open File").clicked() { self.jobs.start(ui.ctx(), move || { let file_path = rfd::FileDialog::new().pick_file()?; let mut file = fs::File::open(&file_path) .inspect_err(|e| log::error!("Failed to open {file_path:?}: {e}")) .ok()?; let mtime = log_error(eyre!("file_path:?"), || file_mtime(&file))?; let mut text = String::new(); file.read_to_string(&mut text) .inspect_err(|e| log::error!("Failed to read {file_path:?}: {e}")) .ok()?; let editor = FileEditor::from_file(file_path, &text, mtime); Some(Action::OpenFile(editor)) }); } if ui.button("Open Folder").clicked() { 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 .add_enabled(self.open_tab_index.is_some(), Button::new("Close File")) .clicked() { if let Some(i) = self.open_tab_index.take() { self.tabs.remove(i); } } let can_save_file = self .open_tab_index .and_then(|i| self.tabs.get(i)) .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(); if ui.add_enabled(can_save_file, Button::new("Save")).clicked() { self.save_active_tab(ui.ctx()); } 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 .add_enabled(open_file.is_some(), Button::new("Save As")) .clicked() { let (tab_id, editor) = open_file.expect("We checked that open_file is_some"); let text = editor.to_string(); self.jobs.start(ui.ctx(), move || { let file_path = rfd::FileDialog::new().save_file()?; fs::write(&file_path, text.as_bytes()) .inspect_err(|e| log::error!("{e}")) .ok()?; Some(Action::MoveFile(tab_id, file_path)) }); } ui.add_space(8.0); self.preferences.show(ui); ui.add_space(8.0); if cfg!(not(target_arch = "wasm32")) && ui.button("Quit").clicked() { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } }); ui.add_space(8.0); let image = Image::new(include_image!("../assets/collapse-icon.svg")); let image = image.tint(ui.style().visuals.text_color()); if Button::image(image).ui(ui).clicked() { self.show_folders = !self.show_folders; } if !self.jobs.handles.is_empty() { ui.add_space(8.0); ui.spinner(); } ui.add_space(16.0); ScrollArea::horizontal().show(ui, |ui| { for (i, (tab_id, tab)) in self.tabs.iter().enumerate() { let selected = self.open_tab_index == Some(i); let mut button = Button::new(tab.title()).selected(selected); if let Some(symbol) = tab.notice_symbol() { button = button.right_text(RichText::new(symbol).strong()) } let response = ui.add(button); if response.clicked() { self.open_tab_index = Some(i); } else if response.clicked_by(PointerButton::Secondary) { let _ = self.actions_tx(ui.ctx()).send(Action::CloseTab(*tab_id)); } } }); }); }); egui::SidePanel::left("file browser") .resizable(true) .show_animated(ctx, self.show_folders, |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 mut file = fs::File::open(&file_path) .inspect_err(|e| { log::error!("Failed to open {file_path:?}: {e}") }) .ok()?; let mtime = log_error(eyre!("file_path:?"), || file_mtime(&file))?; let mut text = String::new(); file.read_to_string(&mut text) .inspect_err(|e| { log::error!("Failed to read {file_path:?}: {e}") }) .ok()?; let editor = FileEditor::from_file(file_path, &text, mtime); 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 .and_then(|i| self.tabs.get_mut(i)) .map(|(_tab_id, tab)| tab) { file_editor.show(ui, &self.preferences); } ui.with_layout(egui::Layout::bottom_up(Align::LEFT), |ui| { egui::warn_if_debug_build(ui); }); }); } } impl App { /// Figure out where we should insert the next tab. fn insert_tab_at(&self) -> usize { match self.open_tab_index { None => 0, Some(i) => (i + 1).min(self.tabs.len()), } } /// Open a [Tab]. fn open_tab(&mut self, tab: Tab) { let i = self.insert_tab_at(); let id = self.next_tab_id; self.next_tab_id += 1; self.tabs.insert(i, (id, tab)); self.open_tab_index = Some(i); } fn save_active_tab(&mut self, ctx: &Context) { let open_tab = self .open_tab_index .and_then(|i| self.tabs.get_mut(i)) .map(|(_id, tab)| tab); if let Some(open_tab) = open_tab { open_tab.save(ctx, &mut self.jobs); } } }