524 lines
18 KiB
Rust
524 lines
18 KiB
Rust
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, Frame, 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<Action>,
|
|
#[serde(skip)]
|
|
actions_rx: mpsc::Receiver<Action>,
|
|
#[serde(skip)]
|
|
jobs: Jobs,
|
|
|
|
tabs: Vec<(TabId, Tab)>,
|
|
|
|
show_folders: bool,
|
|
folders: Vec<Folder>,
|
|
|
|
open_tab_index: Option<usize>,
|
|
|
|
next_tab_id: TabId,
|
|
}
|
|
|
|
pub struct Jobs {
|
|
handles: Vec<JoinHandle<()>>,
|
|
actions_tx: mpsc::Sender<Action>,
|
|
}
|
|
|
|
impl Jobs {
|
|
pub fn start(&mut self, ctx: &Context, job: impl FnOnce() -> Option<Action> + 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<Action> {
|
|
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()
|
|
.frame(Frame {
|
|
fill: ctx.style().visuals.window_fill(),
|
|
..Frame::central_panel(&ctx.style())
|
|
})
|
|
.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);
|
|
}
|
|
}
|
|
}
|