use std::{ fs, path::PathBuf, sync::{Arc, mpsc}, }; use crate::{file_editor::FileEditor, preferences::Preferences, util::GuiSender}; use egui::{ Align, Button, Color32, FontData, FontDefinitions, PointerButton, RichText, ScrollArea, Stroke, }; #[derive(serde::Deserialize, serde::Serialize)] #[serde(default)] pub struct App { preferences: Preferences, #[serde(skip)] actions_tx: mpsc::Sender, #[serde(skip)] actions_rx: mpsc::Receiver, tabs: Vec<(TabId, Tab)>, open_tab_index: Option, next_tab_id: TabId, } #[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 type TabId = usize; pub enum Action { OpenFile(FileEditor), 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_rx, tabs: vec![(1, Tab::File(FileEditor::new("note.md")))], open_tab_index: None, next_tab_id: 2, } } } 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( egui::FontFamily::Proportional, vec!["IosevkaAile-Regular".into()], ); fonts .families .insert(egui::FontFamily::Monospace, vec!["Iosevka-Thin".into()]); cc.egui_ctx.set_fonts(fonts); cc.egui_ctx.style_mut(|style| { // TODO: change color of text in TextEdit style.visuals.widgets.noninteractive.fg_stroke = Stroke::new(1.0, Color32::from_rgb(200, 200, 200)); }); if let Some(storage) = cc.storage { return eframe::get_value(storage, eframe::APP_KEY).unwrap_or_default(); } Default::default() } fn actions_tx(&self, ctx: &egui::Context) -> GuiSender { GuiSender::new(self.actions_tx.clone(), ctx) } fn handle_action(&mut self, action: Action) { match action { 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: &egui::Context, _frame: &mut eframe::Frame) { self.preferences.apply(ctx); 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::H) { // self.buffer.push(BufferItem::Painting(Default::default())); // } //}); egui::TopBottomPanel::top("top_panel").show(ctx, |ui| { egui::containers::menu::Bar::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() { let actions_tx = self.actions_tx(ui.ctx()); std::thread::spawn(move || { let file = rfd::FileDialog::new().pick_file(); let Some(file_path) = file else { return }; let text = match fs::read_to_string(&file_path) { Ok(text) => text, Err(e) => { log::error!("Failed to read {file_path:?}: {e}"); return; } }; let editor = FileEditor::from_file(file_path, &text); let _ = actions_tx.send(Action::OpenFile(editor)); }); } if ui.button("Open Folder").clicked() { log::error!("Open Folder not implemented"); } 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 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_with_path = open_file .clone() .and_then(|(_, file_editor)| file_editor.path().zip(Some(file_editor))); if ui .add_enabled(open_file_with_path.is_some(), Button::new("Save")) .clicked() { if let Some((file_path, file_editor)) = open_file_with_path { let text = file_editor.to_string(); let file_path = file_path.to_owned(); std::thread::spawn(move || { if let Err(e) = fs::write(file_path, text.as_bytes()) { log::error!("{e}"); }; }); } } #[cfg(not(target_arch = "wasm32"))] if ui .add_enabled(open_file.is_some(), Button::new("Save As")) .clicked() { let actions_tx = self.actions_tx(ui.ctx()); let (tab_id, editor) = open_file.expect("We checked that open_file is_some"); let text = editor.to_string(); std::thread::spawn(move || { let Some(file_path) = rfd::FileDialog::new().save_file() else { return; }; if let Err(e) = fs::write(&file_path, text.as_bytes()) { log::error!("{e}"); return; }; let _ = actions_tx.send(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(16.0); 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); let dirty = i == 0; // TODO: mark as dirty when contents hasn't been saved if dirty { button = button.right_text(RichText::new("*").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::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)); } }