Implement savefile persistence

This commit is contained in:
Patrick Auernig 2023-08-12 21:11:41 +02:00
parent c094c646f5
commit 0118dd0073
5 changed files with 284 additions and 88 deletions

57
Cargo.lock generated
View File

@ -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",

View File

@ -7,6 +7,8 @@ license-file.workspace = true
[dependencies]
anyhow = "1.0"
directories = "5.0"
lazy_static = "1.4"
[dependencies.clap]
version = "4.3"

View File

@ -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)?,

View File

@ -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<Savefile>,
}
impl PersistentState {
pub fn load() -> Result<Self> {
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<P>(&mut self, path: P) -> Result<()>
where
P: AsRef<Path>,
{
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<Option<Savefile>> {
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))
}

View File

@ -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<CrosstermBackend<Stdout>>;
type Frame<'a> = ratatui::Frame<'a, CrosstermBackend<Stdout>>;
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,56 +93,67 @@ 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::<Message>();
let (mut msg_tx, msg_rx) = mpsc::channel::<Message>();
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,
};
}
Ok(())
}
#[cfg_attr(not(feature = "watch"), allow(unused_variables))]
fn handle_message(
state: &mut State,
msg_tx: &mut mpsc::Sender<Message>,
message: Message,
) -> Result<()> {
match message {
Message::Exit => break,
Message::SetMode(mode) => {
state.mode = mode;
}
Message::LoadFile => {
let path = PathBuf::from(state.file_select.value());
state.current_file = Savefile::from_path(&path)?;
state
.persistent
.set_active_savefile_path(state.file_select.value())?;
#[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,
};
msg_tx.send(Message::SetMode(Mode::Normal))?;
}
#[cfg(feature = "watch")]
Message::ToggleFileWatch => {
Message::ToggleFileWatch if state.persistent.savefile.is_some() => {
let savefile = state.persistent.savefile.as_ref().unwrap();
if state.file_watcher.is_none() {
let evq_tx = evq_tx.clone();
let evq_tx = msg_tx.clone();
let callback = move || {
evq_tx.send(Message::ReloadFile).unwrap();
};
let file_watcher = FileWatcher::new(&state.current_file.path, callback);
let file_watcher = FileWatcher::new(&savefile.path, callback);
state.file_watcher = Some(file_watcher);
} else {
state.file_watcher = None;
@ -150,11 +161,11 @@ fn run(terminal: &mut Terminal, mut state: State) -> Result<()> {
}
#[cfg(feature = "watch")]
Message::ReloadFile => {
let savefile = Savefile::from_path(&state.current_file.path)?;
state.current_file = savefile;
}
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<Message>, state: &mut State) ->
fn handle_keyboard_input(
key: KeyEvent,
event_queue: &mut mpsc::Sender<Message>,
msg_tx: &mut mpsc::Sender<Message>,
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,42 +258,78 @@ 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())
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(status_block);
frame.render_widget(input, rows[1]);
.block(block);
frame.render_widget(input, area);
frame.set_cursor(
rows[1].x + (state.file_select.visual_cursor() as u16) + 2,
rows[1].y,
area.x + (state.file_select.visual_cursor() + PROMPT.len() + 1 + PADDING) as u16,
area.y,
);
}
}
}