From 0118dd007355abddc44c8c93872933891154eaae Mon Sep 17 00:00:00 2001 From: Patrick Auernig Date: Sat, 12 Aug 2023 21:11:41 +0200 Subject: [PATCH] Implement savefile persistence --- Cargo.lock | 57 +++++++++ crates/wayfarer/Cargo.toml | 2 + crates/wayfarer/src/main.rs | 2 +- crates/wayfarer/src/state.rs | 83 +++++++++++++ crates/wayfarer/src/tui.rs | 228 ++++++++++++++++++++++------------- 5 files changed, 284 insertions(+), 88 deletions(-) create mode 100644 crates/wayfarer/src/state.rs diff --git a/Cargo.lock b/Cargo.lock index f385584..0ab5c1a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -278,6 +278,27 @@ dependencies = [ "winapi", ] +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "either" version = "1.9.0" @@ -326,6 +347,17 @@ dependencies = [ "libc", ] +[[package]] +name = "getrandom" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", +] + [[package]] name = "heck" version = "0.4.1" @@ -438,6 +470,12 @@ dependencies = [ "libc", ] +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + [[package]] name = "libc" version = "0.2.147" @@ -511,6 +549,12 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "owo-colors" version = "3.5.0" @@ -597,6 +641,17 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "redox_users" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b" +dependencies = [ + "getrandom", + "redox_syscall 0.2.16", + "thiserror", +] + [[package]] name = "rustix" version = "0.38.4" @@ -852,7 +907,9 @@ dependencies = [ "anyhow", "clap", "crossterm 0.27.0", + "directories", "jrny-save", + "lazy_static", "notify", "ratatui", "tui-input", diff --git a/crates/wayfarer/Cargo.toml b/crates/wayfarer/Cargo.toml index 0a1bd9d..da2ed33 100644 --- a/crates/wayfarer/Cargo.toml +++ b/crates/wayfarer/Cargo.toml @@ -7,6 +7,8 @@ license-file.workspace = true [dependencies] anyhow = "1.0" +directories = "5.0" +lazy_static = "1.4" [dependencies.clap] version = "4.3" diff --git a/crates/wayfarer/src/main.rs b/crates/wayfarer/src/main.rs index d91f3d8..714a77f 100644 --- a/crates/wayfarer/src/main.rs +++ b/crates/wayfarer/src/main.rs @@ -1,5 +1,6 @@ mod edit; mod show; +mod state; mod tui; mod watcher; @@ -32,7 +33,6 @@ pub(crate) enum CommandArgs { fn main() -> Result<()> { let args = Args::parse(); - match &args.command { CommandArgs::Show(sub_args) => show::execute(&args, sub_args)?, diff --git a/crates/wayfarer/src/state.rs b/crates/wayfarer/src/state.rs new file mode 100644 index 0000000..8ecd135 --- /dev/null +++ b/crates/wayfarer/src/state.rs @@ -0,0 +1,83 @@ +#![cfg(feature = "tui")] + +use std::fs::{self, create_dir_all, read_to_string}; +use std::io::Write; +use std::os::unix::prelude::OsStrExt; +use std::path::Path; + +use anyhow::Result; +use directories::ProjectDirs; +use jrny_save::Savefile; + + +lazy_static::lazy_static! { + static ref DIRS: ProjectDirs = { + ProjectDirs::from("", "valeth", "wayfarer").unwrap() + }; +} + + +#[derive(Debug, Default)] +pub struct PersistentState { + pub savefile: Option, +} + +impl PersistentState { + pub fn load() -> Result { + let data_dir = DIRS.data_local_dir(); + + if !data_dir.exists() { + create_dir_all(&data_dir)?; + } + + let savefile = load_last_active_savefile()?; + + Ok(Self { savefile }) + } + + #[cfg(feature = "watch")] + pub fn reload_active_savefile(&mut self) -> Result<()> { + if let Some(cur_savefile) = &self.savefile { + let new_savefile = Savefile::from_path(&cur_savefile.path)?; + self.savefile = Some(new_savefile); + } + + Ok(()) + } + + pub fn set_active_savefile_path

(&mut self, path: P) -> Result<()> + where + P: AsRef, + { + let savefile = Savefile::from_path(&path)?; + + let state_path = DIRS.data_local_dir().join("active_savefile"); + let mut state_file = fs::OpenOptions::new() + .write(true) + .truncate(true) + .create(true) + .open(state_path)?; + + let active_savefile = savefile.path.as_os_str().as_bytes(); + + state_file.write_all(active_savefile)?; + self.savefile = Some(savefile); + + Ok(()) + } +} + + +fn load_last_active_savefile() -> Result> { + let state_path = DIRS.data_local_dir().join("active_savefile"); + + if !state_path.exists() { + return Ok(None); + } + + let path = read_to_string(&state_path).unwrap(); + + let savefile = Savefile::from_path(path.trim_end())?; + + Ok(Some(savefile)) +} diff --git a/crates/wayfarer/src/tui.rs b/crates/wayfarer/src/tui.rs index 4b6c8bc..dd05792 100644 --- a/crates/wayfarer/src/tui.rs +++ b/crates/wayfarer/src/tui.rs @@ -1,13 +1,14 @@ #![cfg(feature = "tui")] use std::io::{self, Stdout}; -use std::path::PathBuf; use std::sync::mpsc::{self, TryRecvError}; use std::time::Duration; use anyhow::Result; use clap::Parser as ArgParser; -use crossterm::event::{DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent}; +use crossterm::event::{ + DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyModifiers, +}; use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, }; @@ -15,23 +16,23 @@ use crossterm::{event, execute}; use jrny_save::{Savefile, LEVEL_NAMES}; use ratatui::backend::CrosstermBackend; use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; +use ratatui::style::Style; use ratatui::widgets::{Block, Borders, Cell, Padding, Paragraph, Row, Table}; use tui_input::backend::crossterm::EventHandler; use tui_input::Input; +use crate::state::PersistentState; #[cfg(feature = "watch")] use crate::watcher::FileWatcher; use crate::Args as AppArgs; #[derive(Debug, Clone, ArgParser)] -pub struct Args { - path: PathBuf, -} +pub struct Args; struct State { - current_file: Savefile, + persistent: PersistentState, mode: Mode, file_select: Input, #[cfg(feature = "watch")] @@ -44,8 +45,6 @@ struct State { enum Message { Exit, - ToggleFileSelect, - SetMode(Mode), LoadFile, @@ -58,11 +57,13 @@ enum Message { } -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Default, Clone, PartialEq, Eq)] enum Mode { #[default] Normal, + ShowError(String), + SelectFile, } @@ -71,14 +72,13 @@ type Terminal = ratatui::Terminal>; type Frame<'a> = ratatui::Frame<'a, CrosstermBackend>; -pub(crate) fn execute(_app_args: &AppArgs, args: &Args) -> Result<()> { +pub(crate) fn execute(_app_args: &AppArgs, _args: &Args) -> Result<()> { + let persistent = PersistentState::load()?; + let mut terminal = setup()?; - let savefile = Savefile::from_path(&args.path)?; - - // TODO: prompt file path let state = State { - current_file: savefile, + persistent, mode: Mode::default(), file_select: Input::default(), #[cfg(feature = "watch")] @@ -93,68 +93,79 @@ pub(crate) fn execute(_app_args: &AppArgs, args: &Args) -> Result<()> { } -#[allow(unused_mut)] +#[cfg_attr(not(feature = "watch"), allow(unused_mut))] fn run(terminal: &mut Terminal, mut state: State) -> Result<()> { - let (mut evq_tx, evq_rx) = mpsc::channel::(); + let (mut msg_tx, msg_rx) = mpsc::channel::(); loop { terminal.draw(|frame| { - render(&state, frame); + render(&mut state, frame); })?; - handle_events(&mut evq_tx, &mut state)?; + handle_events(&mut msg_tx, &mut state)?; - let message = match evq_rx.try_recv() { - Ok(msg) => msg, - Err(TryRecvError::Empty) => continue, + match msg_rx.try_recv() { + Ok(Message::Exit) => break, + Ok(message) => { + if let Err(err) = handle_message(&mut state, &mut msg_tx, message) { + state.mode = Mode::ShowError(format!("{}", err)); + } + } + Err(TryRecvError::Empty) => (), Err(_) => break, }; + } - match message { - Message::Exit => break, + Ok(()) +} - Message::SetMode(mode) => { - state.mode = mode; + +#[cfg_attr(not(feature = "watch"), allow(unused_variables))] +fn handle_message( + state: &mut State, + msg_tx: &mut mpsc::Sender, + message: Message, +) -> Result<()> { + match message { + Message::SetMode(mode) => { + state.mode = mode; + } + + Message::LoadFile => { + state + .persistent + .set_active_savefile_path(state.file_select.value())?; + + #[cfg(feature = "watch")] + if state.file_watcher.is_some() { + state.file_watcher = None; } - Message::LoadFile => { - let path = PathBuf::from(state.file_select.value()); + msg_tx.send(Message::SetMode(Mode::Normal))?; + } - state.current_file = Savefile::from_path(&path)?; + #[cfg(feature = "watch")] + Message::ToggleFileWatch if state.persistent.savefile.is_some() => { + let savefile = state.persistent.savefile.as_ref().unwrap(); - #[cfg(feature = "watch")] - if state.file_watcher.is_some() { - state.file_watcher = None; - } - } - - Message::ToggleFileSelect => { - state.mode = match state.mode { - Mode::SelectFile => Mode::Normal, - _ => Mode::SelectFile, + if state.file_watcher.is_none() { + let evq_tx = msg_tx.clone(); + let callback = move || { + evq_tx.send(Message::ReloadFile).unwrap(); }; - } - - #[cfg(feature = "watch")] - Message::ToggleFileWatch => { - if state.file_watcher.is_none() { - let evq_tx = evq_tx.clone(); - let callback = move || { - evq_tx.send(Message::ReloadFile).unwrap(); - }; - let file_watcher = FileWatcher::new(&state.current_file.path, callback); - state.file_watcher = Some(file_watcher); - } else { - state.file_watcher = None; - } - } - - #[cfg(feature = "watch")] - Message::ReloadFile => { - let savefile = Savefile::from_path(&state.current_file.path)?; - state.current_file = savefile; + let file_watcher = FileWatcher::new(&savefile.path, callback); + state.file_watcher = Some(file_watcher); + } else { + state.file_watcher = None; } } + + #[cfg(feature = "watch")] + Message::ReloadFile if state.persistent.savefile.is_some() => { + state.persistent.reload_active_savefile()?; + } + + _ => (), } Ok(()) @@ -177,17 +188,24 @@ fn handle_events(event_queue: &mut mpsc::Sender, state: &mut State) -> fn handle_keyboard_input( key: KeyEvent, - event_queue: &mut mpsc::Sender, + msg_tx: &mut mpsc::Sender, state: &mut State, ) -> Result<()> { - match (state.mode, key.code) { + match (&state.mode, key.code) { + (_, KeyCode::Char('q')) if key.modifiers.contains(KeyModifiers::CONTROL) => { + msg_tx.send(Message::Exit)?; + } + (_, KeyCode::Esc) => { - event_queue.send(Message::SetMode(Mode::Normal))?; + msg_tx.send(Message::SetMode(Mode::Normal))?; + } + + (Mode::ShowError(_), _) => { + msg_tx.send(Message::SetMode(Mode::Normal))?; } (Mode::SelectFile, KeyCode::Enter) => { - event_queue.send(Message::LoadFile)?; - event_queue.send(Message::ToggleFileSelect)?; + msg_tx.send(Message::LoadFile)?; } (Mode::SelectFile, _) => { @@ -195,16 +213,16 @@ fn handle_keyboard_input( } (Mode::Normal, KeyCode::Char('q')) => { - event_queue.send(Message::Exit)?; + msg_tx.send(Message::Exit)?; } (Mode::Normal, KeyCode::Char('o')) => { - event_queue.send(Message::ToggleFileSelect)?; + msg_tx.send(Message::SetMode(Mode::SelectFile))?; } #[cfg(feature = "watch")] (Mode::Normal, KeyCode::Char('w')) => { - event_queue.send(Message::ToggleFileWatch)?; + msg_tx.send(Message::ToggleFileWatch)?; } _ => (), @@ -240,44 +258,80 @@ fn reset(mut terminal: Terminal) -> Result<()> { } -fn render(state: &State, mut frame: &mut Frame) { +fn render(state: &mut State, mut frame: &mut Frame) { let rows = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(40), Constraint::Length(2)]) .split(frame.size()); - render_info(&state.current_file, &mut frame, rows[0]); + match &state.persistent.savefile { + Some(savefile) => render_info(&savefile, &mut frame, rows[0]), + None => render_no_active_file(&mut frame, rows[0]), + } + render_status_bar(&state, &mut frame, rows[1]); +} + + +fn render_no_active_file(frame: &mut Frame, area: Rect) { + let info_block = Block::default() + .padding(Padding::horizontal(2)) + .borders(Borders::ALL); + + let info = Paragraph::new("No active file.\nPress 'o' to open a file, or 'q' to quit.") + .block(info_block); + + frame.render_widget(info, area); +} + + +fn render_status_bar(state: &State, mut frame: &mut Frame, area: Rect) { let status_block = Block::default().padding(Padding::horizontal(2)); - match state.mode { - #[cfg(feature = "watch")] - Mode::Normal if state.file_watcher.is_some() => { - let text = format!("Watching file: {}", state.current_file.path.display()); - let status = Paragraph::new(text).block(status_block); - frame.render_widget(status, rows[1]); + match &state.mode { + Mode::ShowError(error_msg) => { + let error_msg = Paragraph::new(error_msg.clone()) + .style(Style::default().fg(ratatui::style::Color::LightRed)) + .block(status_block); + frame.render_widget(error_msg, area); } + #[cfg(feature = "watch")] + Mode::Normal if state.file_watcher.is_some() && state.persistent.savefile.is_some() => { + let savefile = state.persistent.savefile.as_ref().unwrap(); + let text = format!("Watching file: {}", savefile.path.display()); + let status = Paragraph::new(text).block(status_block); + frame.render_widget(status, area); + } + + Mode::Normal if state.persistent.savefile.is_none() => (), + Mode::Normal => { - let text = format!("Showing file: {}", state.current_file.path.display()); + let savefile = state.persistent.savefile.as_ref().unwrap(); + let text = format!("Showing file: {}", savefile.path.display()); let status = Paragraph::new(text).block(status_block); - frame.render_widget(status, rows[1]); + frame.render_widget(status, area); } - Mode::SelectFile => { - let scroll = state.file_select.visual_scroll(rows[1].width as usize); - let input = Paragraph::new(state.file_select.value()) - .scroll((0, scroll as u16)) - .block(status_block); - frame.render_widget(input, rows[1]); - frame.set_cursor( - rows[1].x + (state.file_select.visual_cursor() as u16) + 2, - rows[1].y, - ); - } + Mode::SelectFile => render_file_select(&state, &mut frame, status_block, area), } } +fn render_file_select(state: &State, frame: &mut Frame, block: Block, area: Rect) { + const PROMPT: &str = "Open file:"; + const PADDING: usize = 2; + + let scroll = state.file_select.visual_scroll(area.width as usize); + let input = Paragraph::new(format!("{} {}", PROMPT, state.file_select.value())) + .scroll((0, scroll as u16)) + .block(block); + frame.render_widget(input, area); + frame.set_cursor( + area.x + (state.file_select.visual_cursor() + PROMPT.len() + 1 + PADDING) as u16, + area.y, + ); +} + fn render_info(savefile: &Savefile, mut frame: &mut Frame, area: Rect) { let columns = Layout::default()