diff --git a/crates/wayfarer/src/tui.rs b/crates/wayfarer/src/tui.rs index 0ab017d..36fd15e 100644 --- a/crates/wayfarer/src/tui.rs +++ b/crates/wayfarer/src/tui.rs @@ -54,6 +54,16 @@ pub enum Message { #[cfg(feature = "watch")] ToggleFileWatch, + StartEditEntry, + + CommitEditEntry, + + CancelEditEntry, + + NextEntryValue, + + PreviousEntryValue, + ReloadFile, MoveSection(Direction), @@ -122,16 +132,12 @@ fn handle_message( message: Message, ) -> Result<()> { match message { - Message::SetMode(Mode::Edit) => { - state.edit_current_file(); - } - + Message::SetMode(Mode::Edit) => state.edit_current_file(), Message::SetMode(mode) => { debug!("Setting mode to {:?}", mode); state.mode = mode; } - Message::LoadFile => { let file_path = state.file_select.value(); info!("Loading file {}", file_path); @@ -145,10 +151,9 @@ fn handle_message( msg_tx.send(Message::SetMode(Mode::Normal))?; } - #[cfg(feature = "watch")] Message::ToggleFileWatch => { - if let Some(savefile) = state.savefile() { + if let Some(savefile) = &state.savefile { if state.is_watching_file() { let evq_tx = msg_tx.clone(); let callback = move || { @@ -163,19 +168,26 @@ fn handle_message( } } } - - Message::ReloadFile => { - state.reload_active_savefile()?; + Message::ReloadFile => state.reload_active_savefile()?, + Message::MoveSection(direction) => state.move_section(direction), + Message::MoveCur(direction) => state.move_in_current_section(direction), + Message::StartEditEntry => state.start_editing_entry(), + Message::CommitEditEntry => { + if let Err(err) = state.commit_entry_edit() { + error!(%err); + } } - - Message::MoveSection(direction) => { - state.move_section(direction); + Message::CancelEditEntry => state.cancel_editing_entry(), + Message::NextEntryValue => { + if let Err(err) = state.next_entry_value() { + error!(%err); + } } - - Message::MoveCur(direction) => { - state.move_in_current_section(direction); + Message::PreviousEntryValue => { + if let Err(err) = state.previous_entry_value() { + error!(%err); + } } - _ => (), } diff --git a/crates/wayfarer/src/tui/events.rs b/crates/wayfarer/src/tui/events.rs index 488c3ae..5b1012b 100644 --- a/crates/wayfarer/src/tui/events.rs +++ b/crates/wayfarer/src/tui/events.rs @@ -49,6 +49,10 @@ fn handle_keyboard_input( msg_tx.send(Message::Exit)?; } + (Mode::Insert, KeyCode::Esc) => { + msg_tx.send(Message::CancelEditEntry)?; + } + (_, KeyCode::Esc) => { msg_tx.send(Message::SetMode(Mode::Normal))?; } @@ -109,6 +113,28 @@ fn handle_keyboard_input( msg_tx.send(Message::MoveCur(Direction::Right))?; } + (Mode::Edit, KeyCode::Enter) => { + msg_tx.send(Message::StartEditEntry)?; + } + + (Mode::Edit, KeyCode::Char('n')) => { + msg_tx.send(Message::NextEntryValue)?; + } + + (Mode::Edit, KeyCode::Char('p')) => { + msg_tx.send(Message::PreviousEntryValue)?; + } + + (Mode::Insert, KeyCode::Enter) => { + msg_tx.send(Message::CommitEditEntry)?; + } + + (Mode::Insert, _) => { + if let Some(input) = &mut state.edit_input { + input.handle_event(&Event::Key(key)); + } + } + (Mode::Normal, KeyCode::Char('e')) => { msg_tx.send(Message::SetMode(Mode::Edit))?; } diff --git a/crates/wayfarer/src/tui/state.rs b/crates/wayfarer/src/tui/state.rs index cffada5..c9ab44b 100644 --- a/crates/wayfarer/src/tui/state.rs +++ b/crates/wayfarer/src/tui/state.rs @@ -3,8 +3,8 @@ use std::io::Write; use std::os::unix::prelude::OsStrExt; use std::path::Path; -use anyhow::Result; -use jrny_save::Savefile; +use anyhow::{bail, Context, Result}; +use jrny_save::{RobeColor, Savefile, LEVEL_NAMES}; use ratatui::widgets::TableState; use tracing::debug; use tui_input::Input; @@ -25,11 +25,19 @@ pub enum Mode { Edit, + Insert, + ShowError(String), SelectFile, } +impl Mode { + pub fn is_editing(&self) -> bool { + self == &Self::Edit || self == &Self::Insert + } +} + #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub enum Section { @@ -43,13 +51,14 @@ pub enum Section { #[derive(Default)] pub struct State { - savefile: Option, + pub savefile: Option, original_file: Option, pub active_section: Section, pub stats_table: TableState, pub glyphs_table: TableState, pub murals_table: TableState, pub mode: Mode, + pub edit_input: Option, pub file_select: Input, #[cfg(feature = "watch")] file_watcher: Option, @@ -72,10 +81,6 @@ impl State { }) } - pub fn savefile(&self) -> Option<&Savefile> { - self.savefile.as_ref() - } - pub fn set_savefile_from_path

(&mut self, path: P) -> Result<()> where P: AsRef, @@ -105,13 +110,209 @@ impl State { } pub fn edit_current_file(&mut self) { - self.original_file = self.savefile.clone(); - self.select_section(self.active_section); + if !self.mode.is_editing() { + self.original_file = self.savefile.clone(); + self.select_section(self.active_section); + self.mode = Mode::Edit; + } + } + + pub fn start_editing_entry(&mut self) { + self.mode = Mode::Insert; + } + + #[tracing::instrument(skip_all)] + pub fn commit_entry_edit(&mut self) -> Result<()> { + debug!(section = ?self.active_section); + + match self.active_section { + Section::General => self.edit_stats_section()?, + _ => (), + } + self.mode = Mode::Edit; + + Ok(()) + } + + fn edit_stats_section(&mut self) -> Result<()> { + let Some(savefile) = &mut self.savefile else { + bail!("No savefile loaded"); + }; + + let input = self.edit_input.take().context("no edit input")?; + let value = input.value(); + + match self.stats_table.selected().context("no selection")? { + 0 => savefile.journey_count = value.parse()?, + 1 => savefile.total_companions_met = value.parse()?, + 2 => savefile.total_collected_symbols = value.parse()?, + 3 => { + let level_id = LEVEL_NAMES + .iter() + .position(|&v| v == value.trim_end()) + .context("invalid level name")?; + savefile.current_level = level_id as u64; + } + 4 => savefile.companions_met = value.parse()?, + 5 => { + let new_length = value.parse()?; + if new_length > 30 { + bail!("Max length exceeded"); + } + + savefile.scarf_length = new_length; + } + 6 => { + let next_symbol_id = value.parse()?; + + if next_symbol_id > 20 { + bail!("Symbol id out of range"); + } + + savefile.symbol.id = next_symbol_id; + } + 7 => { + let new_color = match value { + "Red" | "red" => RobeColor::Red, + "White" | "white" => RobeColor::White, + _ => bail!("invalid robe color"), + }; + savefile.set_robe_color(new_color); + } + 8 => savefile.set_robe_tier(value.parse()?), + 9 => {} + idx => debug!("unknown index {:?}", idx), + } + + Ok(()) + } + + pub fn next_entry_value(&mut self) -> Result<()> { + match self.active_section { + Section::General => self.next_stats_entry_value()?, + _ => (), + } + + Ok(()) + } + + fn next_stats_entry_value(&mut self) -> Result<()> { + let Some(savefile) = &mut self.savefile else { + bail!("No savefile loaded"); + }; + + match self.stats_table.selected().context("no selection")? { + 0 => savefile.journey_count += 1, + 1 => savefile.total_companions_met += 1, + 2 => savefile.total_collected_symbols += 1, + 3 => { + let next_level = savefile.current_level + 1; + savefile.current_level = if next_level >= LEVEL_NAMES.len() as u64 { + 0 + } else { + savefile.current_level + 1 + }; + } + 4 => savefile.companions_met += 1, + 5 => { + if savefile.scarf_length < 30 { + savefile.scarf_length += 1; + } + } + 6 => { + let next_symbol = savefile.symbol.id + 1; + savefile.symbol.id = if next_symbol > 20 { + 0 + } else { + savefile.symbol.id + 1 + }; + } + 7 => { + savefile.set_robe_color(match savefile.robe_color() { + RobeColor::Red => RobeColor::White, + RobeColor::White => RobeColor::Red, + }); + } + 8 => { + let next_tier = savefile.robe_tier() + 1; + savefile.set_robe_tier(next_tier); + } + 9 => {} + idx => debug!("unknown index {:?}", idx), + } + + Ok(()) + } + + pub fn previous_entry_value(&mut self) -> Result<()> { + match self.active_section { + Section::General => self.previous_stats_entry_value()?, + _ => (), + } + + Ok(()) + } + + fn previous_stats_entry_value(&mut self) -> Result<()> { + let Some(savefile) = &mut self.savefile else { + bail!("No savefile loaded"); + }; + + match self.stats_table.selected().context("no selection")? { + 0 => savefile.journey_count = savefile.journey_count.saturating_sub(1), + 1 => savefile.total_companions_met = savefile.total_companions_met.saturating_sub(1), + 2 => { + savefile.total_collected_symbols = + savefile.total_collected_symbols.saturating_sub(1) + } + 3 => { + let next_level: i64 = savefile.current_level as i64 - 1; + savefile.current_level = if next_level < 0 { + LEVEL_NAMES.len() as u64 - 1 + } else { + next_level as u64 + }; + } + 4 => savefile.companions_met = savefile.companions_met.saturating_sub(1), + 5 => { + if savefile.scarf_length > 0 { + savefile.scarf_length = savefile.scarf_length.saturating_sub(1); + } + } + 6 => { + let next_symbol = savefile.symbol.id as i32 - 1; + savefile.symbol.id = if next_symbol < 0 { + 20 + } else { + next_symbol as u32 + }; + } + 7 => { + savefile.set_robe_color(match savefile.robe_color() { + RobeColor::Red => RobeColor::White, + RobeColor::White => RobeColor::Red, + }); + } + 8 => { + let next_tier = savefile.robe_tier().saturating_sub(1); + savefile.set_robe_tier(next_tier); + } + 9 => {} + idx => debug!("unknown index {:?}", idx), + } + Ok(()) + } + + pub fn cancel_editing_entry(&mut self) { + if self.mode == Mode::Insert { + self.edit_input = None; + self.mode = Mode::Edit; + } } pub fn is_savefile_loaded(&self) -> bool { - self.savefile().is_some() + self.savefile.is_some() } #[cfg(feature = "watch")] diff --git a/crates/wayfarer/src/tui/view/info/companions.rs b/crates/wayfarer/src/tui/view/info/companions.rs index 2bc2fa5..2bc0023 100644 --- a/crates/wayfarer/src/tui/view/info/companions.rs +++ b/crates/wayfarer/src/tui/view/info/companions.rs @@ -8,7 +8,7 @@ use crate::tui::State; pub(super) fn render<'a>(state: &State, frame: &mut Frame, area: Rect) { - let Some(savefile) = state.savefile() else { + let Some(savefile) = &state.savefile else { return }; diff --git a/crates/wayfarer/src/tui/view/info/glyphs.rs b/crates/wayfarer/src/tui/view/info/glyphs.rs index b13ff85..1e5cd6a 100644 --- a/crates/wayfarer/src/tui/view/info/glyphs.rs +++ b/crates/wayfarer/src/tui/view/info/glyphs.rs @@ -15,7 +15,7 @@ pub(super) fn render<'a>(state: &mut State, frame: &mut Frame, area: Rect) { const FOUND_SIGN: &str = "◆"; const NOT_FOUND_SIGN: &str = "◇"; - let Some(savefile) = state.savefile() else { + let Some(savefile) = &state.savefile else { return }; diff --git a/crates/wayfarer/src/tui/view/info/murals.rs b/crates/wayfarer/src/tui/view/info/murals.rs index 47731fa..1c1c9df 100644 --- a/crates/wayfarer/src/tui/view/info/murals.rs +++ b/crates/wayfarer/src/tui/view/info/murals.rs @@ -15,7 +15,7 @@ pub(super) fn render<'a>(state: &mut State, frame: &mut Frame, area: Rect) { const FOUND_SIGN: &str = "▾"; const NOT_FOUND_SIGN: &str = "▿"; - let Some(savefile) = state.savefile() else { + let Some(savefile) = &state.savefile else { return }; diff --git a/crates/wayfarer/src/tui/view/info/stats.rs b/crates/wayfarer/src/tui/view/info/stats.rs index 9c94395..7b1ee7c 100644 --- a/crates/wayfarer/src/tui/view/info/stats.rs +++ b/crates/wayfarer/src/tui/view/info/stats.rs @@ -1,6 +1,7 @@ use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Style}; -use ratatui::widgets::{Block, Borders, Padding, Paragraph, Row, Table}; +use ratatui::widgets::{Block, Borders, Cell, Padding, Paragraph, Row, Table}; +use tui_input::Input; use crate::tui::state::{Mode, Section}; use crate::tui::view::Frame; @@ -11,11 +12,11 @@ pub const TABLE_RANGE: (usize, usize) = (0, 9); pub(super) fn render<'a>(state: &mut State, frame: &mut Frame, area: Rect) { - let Some(savefile) = state.savefile() else { + let Some(savefile) = &state.savefile else { return }; - let is_selected = state.active_section == Section::General && state.mode == Mode::Edit; + let is_selected = state.active_section == Section::General && state.mode.is_editing(); let border_style = if is_selected { Style::default().fg(Color::Blue) @@ -41,36 +42,54 @@ pub(super) fn render<'a>(state: &mut State, frame: &mut Frame, area: Rect) { Style::default() }; - let table = Table::new([ - Row::new([ - "Journeys Completed".to_string(), - savefile.journey_count.to_string(), - ]), - Row::new([ - "Total Companions Met".to_string(), + let rows = [ + ("Journeys Completed", savefile.journey_count.to_string()), + ( + "Total Companions Met", savefile.total_companions_met.to_string(), - ]), - Row::new([ - "Total Symbols Collected".to_string(), + ), + ( + "Total Symbols Collected", savefile.total_collected_symbols.to_string(), - ]), - Row::new(["Current Level", savefile.current_level_name()]), - Row::new([ - "Companions Met".to_string(), - savefile.companions_met.to_string(), - ]), - Row::new([ - "Scarf Length".to_string(), - savefile.scarf_length.to_string(), - ]), - Row::new(["Symbol Number".to_string(), savefile.symbol.id.to_string()]), - Row::new(["Robe Color".to_string(), savefile.robe_color().to_string()]), - Row::new(["Robe Tier".to_string(), savefile.robe_tier().to_string()]), - Row::new(["Last Played".to_string(), savefile.last_played.to_string()]), - ]) - .highlight_style(table_highlight) - .widths(&[Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)]) - .block(stats_block); + ), + ("Current Level", savefile.current_level_name().to_string()), + ("Companions Met", savefile.companions_met.to_string()), + ("Scarf Length", savefile.scarf_length.to_string()), + ("Symbol Number", savefile.symbol.id.to_string()), + ("Robe Color", savefile.robe_color().to_string()), + ("Robe Tier", savefile.robe_tier().to_string()), + ("Last Played", savefile.last_played.to_string()), + ] + .into_iter() + .enumerate() + .map(|(idx, (title, value))| { + let value = match state.stats_table.selected() { + Some(sel) if sel == idx => { + let value = match state.mode { + Mode::Insert => { + if state.edit_input.is_none() { + state.edit_input.replace(Input::new(value)); + } + + let input = state.edit_input.as_ref().unwrap(); + input.value().to_string() + } + Mode::Edit => format!("< {} >", value), + _ => value.to_string(), + }; + + Cell::from(value) + } + _ => Cell::from(value.to_string()), + }; + + Row::new([Cell::from(title), value]) + }); + + let table = Table::new(rows) + .highlight_style(table_highlight) + .widths(&[Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)]) + .block(stats_block); let cur_symbol_block = Block::default(); @@ -79,4 +98,15 @@ pub(super) fn render<'a>(state: &mut State, frame: &mut Frame, area: Rect) { frame.render_widget(stats_section_block, area); frame.render_widget(cur_symbol, layout[0]); frame.render_stateful_widget(table, layout[1], &mut state.stats_table); + + if state.mode == Mode::Insert { + if let Some(idx) = state.stats_table.selected() { + if let Some(input) = &state.edit_input { + frame.set_cursor( + layout[1].x + layout[1].width / 3 + (input.visual_cursor() + 1) as u16, + layout[1].y + (idx + 1 - state.stats_table.offset()) as u16, + ); + } + } + } } diff --git a/crates/wayfarer/src/tui/view/status_bar.rs b/crates/wayfarer/src/tui/view/status_bar.rs index 7e88126..2441167 100644 --- a/crates/wayfarer/src/tui/view/status_bar.rs +++ b/crates/wayfarer/src/tui/view/status_bar.rs @@ -17,8 +17,8 @@ pub fn render(state: &State, mut frame: &mut Frame, area: Rect) { frame.render_widget(error_msg, area); } - Mode::Edit => { - if let Some(savefile) = state.savefile() { + Mode::Edit | Mode::Insert => { + if let Some(savefile) = &state.savefile { let text = format!("Editing file: {}", savefile.path.display()); let status = Paragraph::new(text).block(status_block); frame.render_widget(status, area); @@ -27,22 +27,22 @@ pub fn render(state: &State, mut frame: &mut Frame, area: Rect) { #[cfg(feature = "watch")] Mode::Normal if state.is_watching_file() => { - if let Some(savefile) = state.savefile() { + if let Some(savefile) = &state.savefile { let text = format!("Watching file: {}", savefile.path.display()); let status = Paragraph::new(text).block(status_block); frame.render_widget(status, area); } } - Mode::Normal => { - if let Some(savefile) = state.savefile() { + Mode::SelectFile => render_file_select(&state, &mut frame, status_block, area), + + _ => { + if let Some(savefile) = &state.savefile { let text = format!("Showing file: {}", savefile.path.display()); let status = Paragraph::new(text).block(status_block); frame.render_widget(status, area); } } - - Mode::SelectFile => render_file_select(&state, &mut frame, status_block, area), } }