Files
inkr/src/app.rs
2025-06-12 20:38:51 +02:00

359 lines
12 KiB
Rust

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<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));
});
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| {
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));
}
}