363 lines
12 KiB
Rust
363 lines
12 KiB
Rust
use std::{
|
|
fs,
|
|
path::PathBuf,
|
|
sync::{Arc, mpsc},
|
|
thread,
|
|
};
|
|
|
|
use crate::{file_editor::FileEditor, preferences::Preferences, util::GuiSender};
|
|
use egui::{
|
|
Align, Button, Color32, FontData, FontDefinitions, PointerButton, RichText, ScrollArea, Stroke,
|
|
};
|
|
|
|
/// We derive Deserialize/Serialize so we can persist app state on shutdown.
|
|
#[derive(serde::Deserialize, serde::Serialize)]
|
|
#[serde(default)] // if we add new fields, give them default values when deserializing old state
|
|
pub struct App {
|
|
preferences: Preferences,
|
|
|
|
#[serde(skip)]
|
|
actions_tx: mpsc::Sender<Action>,
|
|
#[serde(skip)]
|
|
actions_rx: mpsc::Receiver<Action>,
|
|
|
|
tabs: Vec<(TabId, Tab)>,
|
|
open_tab_index: Option<usize>,
|
|
|
|
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));
|
|
});
|
|
|
|
// Load previous app state (if any).
|
|
// Note that you must enable the `persistence` feature for this to work.
|
|
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<Action> {
|
|
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| {
|
|
// The top panel is often a good place for a menu bar:
|
|
|
|
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));
|
|
}
|
|
|
|
if ui.button("Open File").clicked() {
|
|
let actions_tx = self.actions_tx(ui.ctx());
|
|
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}");
|
|
};
|
|
});
|
|
}
|
|
}
|
|
|
|
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));
|
|
}
|
|
}
|