From 2bf2f792bda80c7cdafd29a809ad9c87bde97f79 Mon Sep 17 00:00:00 2001 From: Jeremy Wall Date: Mon, 3 Mar 2025 09:57:44 -0500 Subject: [PATCH 1/2] wip: feat: prompt for saving if file is not saved yet --- src/book/mod.rs | 25 +++++++++++++-- src/ui/mod.rs | 52 ++++++++++++++++++++++++++----- src/ui/render/dialog.rs | 8 ++++- src/ui/render/mod.rs | 7 +++-- src/ui/test.rs | 68 ++++++++++++++++++++++++++++++++++------- 5 files changed, 137 insertions(+), 23 deletions(-) diff --git a/src/book/mod.rs b/src/book/mod.rs index 691be4a..89ef8c0 100644 --- a/src/book/mod.rs +++ b/src/book/mod.rs @@ -86,6 +86,7 @@ impl<'book> AddressRange<'book> { pub struct Book { pub(crate) model: UserModel, pub location: crate::ui::Address, + pub dirty: bool, } impl Book { @@ -94,6 +95,7 @@ impl Book { Self { model, location: Address::default(), + dirty: false, } } @@ -122,12 +124,13 @@ impl Book { } /// Save book to an xlsx file. - pub fn save_to_xlsx(&self, path: &str) -> Result<()> { - // TODO(zaphar): Currently overwrites. Should we prompty in this case? + pub fn save_to_xlsx(&mut self, path: &str) -> Result<()> { + // TODO(zaphar): Currently overwrites. Should we prompt in this case? let file_path = std::path::Path::new(path); let file = std::fs::File::create(file_path)?; let writer = std::io::BufWriter::new(file); save_xlsx_to_writer(self.model.get_model(), writer)?; + self.dirty = false; Ok(()) } @@ -150,6 +153,7 @@ impl Book { self.model .rename_sheet(idx, sheet_name) .map_err(|e| anyhow!(e))?; + self.dirty = true; Ok(()) } @@ -162,6 +166,7 @@ impl Book { self.model .set_selected_sheet(self.location.sheet) .map_err(|e| anyhow!(e))?; + self.dirty = true; Ok(()) } @@ -175,6 +180,7 @@ impl Book { // FIXME(zaphar): Check that this is safe first. self.location.row = *row; self.location.col = *col; + self.dirty = true; Ok(()) } @@ -209,18 +215,22 @@ impl Book { .map_err(|e| anyhow!(e))?; } self.evaluate(); + self.dirty = true; Ok(()) } pub fn clear_current_cell(&mut self) -> Result<()> { + self.dirty = true; self.clear_cell_contents(self.location.clone()) } pub fn clear_current_cell_all(&mut self) -> Result<()> { + self.dirty = true; self.clear_cell_all(self.location.clone()) } pub fn clear_cell_contents(&mut self, Address { sheet, row, col }: Address) -> Result<()> { + self.dirty = true; Ok(self .model .range_clear_contents(&Area { @@ -238,10 +248,12 @@ impl Book { self.model .range_clear_contents(&area) .map_err(|s| anyhow!("Unable to clear cell contents {}", s))?; + self.dirty = true; Ok(()) } pub fn clear_cell_all(&mut self, Address { sheet, row, col }: Address) -> Result<()> { + self.dirty = true; Ok(self .model .range_clear_all(&Area { @@ -259,6 +271,7 @@ impl Book { self.model .range_clear_all(&area) .map_err(|s| anyhow!("Unable to clear cell contents {}", s))?; + self.dirty = true; Ok(()) } @@ -299,6 +312,7 @@ impl Book { .update_range_style(area, path, val) .map_err(|s| anyhow!("Unable to format cell {}", s))?; } + self.dirty = true; Ok(()) } @@ -343,6 +357,7 @@ impl Book { ) -> Result<()> { let area = self.get_col_range(sheet, col_idx); self.set_cell_style(style, &area)?; + self.dirty = true; Ok(()) } @@ -367,6 +382,7 @@ impl Book { ) -> Result<()> { let area = self.get_row_range(sheet, row_idx); self.set_cell_style(style, &area)?; + self.dirty = true; Ok(()) } @@ -401,6 +417,7 @@ impl Book { /// Update the current cell in a book. /// This update won't be reflected until you call `Book::evaluate`. pub fn edit_current_cell>(&mut self, value: S) -> Result<()> { + self.dirty = true; self.update_cell(&self.location.clone(), value)?; Ok(()) } @@ -417,6 +434,7 @@ impl Book { value.as_ref(), ) .map_err(|e| anyhow!("Invalid cell contents: {}", e))?; + self.dirty = true; Ok(()) } @@ -434,6 +452,7 @@ impl Book { col: self.location.col, })?; } + self.dirty = true; Ok(()) } @@ -451,6 +470,7 @@ impl Book { col: self.location.col + count, })?; } + self.dirty = true; Ok(()) } @@ -489,6 +509,7 @@ impl Book { self.model .set_column_width(sheet, col as i32, width as f64 * COL_PIXELS) .map_err(|e| anyhow!("Error setting column width: {:?}", e))?; + self.dirty = true; Ok(()) } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 7409750..7240f63 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,5 +1,5 @@ //! Ui rendering logic -use std::{path::PathBuf, process::ExitCode}; +use std::{path::PathBuf, process::ExitCode, str::FromStr}; use crate::book::{self, AddressRange, Book}; @@ -29,6 +29,7 @@ pub enum Modality { Command, Dialog, RangeSelect, + Quit, } #[derive(Debug, Default)] @@ -93,7 +94,7 @@ impl<'ws> Default for AppState<'ws> { char_queue: Default::default(), range_select: Default::default(), dialog_scroll: 0, - dirty: Default::default(), + dirty: false, popup: Default::default(), clipboard: Default::default(), } @@ -188,7 +189,7 @@ impl<'ws> Workspace<'ws> { pub fn new_empty(locale: &str, tz: &str) -> Result { Ok(Self::new( Book::from_model(Model::new_empty("", locale, tz).map_err(|e| anyhow!("{}", e))?), - PathBuf::default(), + PathBuf::from_str("Untitled.xlsx").unwrap(), )) } @@ -291,6 +292,7 @@ impl<'ws> Workspace<'ws> { Modality::Command => self.handle_command_input(key)?, Modality::Dialog => self.handle_dialog_input(key)?, Modality::RangeSelect => self.handle_range_select_input(key)?, + Modality::Quit => self.handle_quit_dialog(key)?, }; return Ok(result); } @@ -327,6 +329,21 @@ impl<'ws> Workspace<'ws> { Ok(None) } + fn handle_quit_dialog(&mut self, key: event::KeyEvent) -> Result> { + if key.kind == KeyEventKind::Press { + match key.code { + KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => return Ok(Some(ExitCode::SUCCESS)), + KeyCode::Char('y') | KeyCode::Char('Y') => { + // We have been asked to save the file first. + self.save_file()?; + return Ok(Some(ExitCode::SUCCESS)); + }, + _ => return Ok(None), + } + } + Ok(None) + } + fn handle_dialog_input(&mut self, key: event::KeyEvent) -> Result> { if key.kind == KeyEventKind::Press { match key.code { @@ -433,7 +450,7 @@ impl<'ws> Workspace<'ws> { self.book.select_sheet_by_name(name); Ok(None) } - Ok(Some(Cmd::Quit)) => Ok(Some(ExitCode::SUCCESS)), + Ok(Some(Cmd::Quit)) => self.quit_app(), Ok(Some(Cmd::ColorRows(count, color))) => { let row_count = count.unwrap_or(1); let row = self.book.location.row; @@ -767,7 +784,7 @@ impl<'ws> Workspace<'ws> { })?; } KeyCode::Char('q') => { - return Ok(Some(ExitCode::SUCCESS)); + return self.quit_app(); } KeyCode::Char('j') | KeyCode::Down if key.modifiers != KeyModifiers::CONTROL => { self.run_with_prefix(|ws: &mut Workspace<'_>| -> Result<()> { @@ -911,6 +928,14 @@ impl<'ws> Workspace<'ws> { Ok(()) } + fn enter_quit_mode(&mut self) -> bool { + if self.book.dirty { + self.state.modality_stack.push(Modality::Quit); + return true; + } + return false; + } + fn enter_command_mode(&mut self) { self.state.modality_stack.push(Modality::Command); self.state.command_state.truncate(); @@ -944,6 +969,11 @@ impl<'ws> Workspace<'ws> { self.text_area.move_cursor(CursorMove::End); } + fn exit_quit_mode(&mut self) -> Result> { + self.state.pop_modality(); + Ok(None) + } + fn exit_command_mode(&mut self) -> Result> { let cmd = self.state.command_state.value().to_owned(); self.state.command_state.blur(); @@ -997,16 +1027,24 @@ impl<'ws> Workspace<'ws> { self.text_area = reset_text_area(contents); } - fn save_file(&self) -> Result<()> { + fn save_file(&mut self) -> Result<()> { self.book .save_to_xlsx(&self.name.to_string_lossy().to_string())?; Ok(()) } - fn save_to>(&self, path: S) -> Result<()> { + fn save_to>(&mut self, path: S) -> Result<()> { self.book.save_to_xlsx(path.into().as_str())?; Ok(()) } + + fn quit_app(&mut self) -> std::result::Result, anyhow::Error> { + if self.enter_quit_mode() { + return Ok(None); + } + return Ok(Some(ExitCode::SUCCESS)) + } + } fn load_book(path: &PathBuf, locale: &str, tz: &str) -> Result { diff --git a/src/ui/render/dialog.rs b/src/ui/render/dialog.rs index 8be6626..6a19f40 100644 --- a/src/ui/render/dialog.rs +++ b/src/ui/render/dialog.rs @@ -1,7 +1,7 @@ use ratatui::{ self, layout::{Constraint, Layout, Rect}, - style::{Color, Style, Stylize}, + style::{Style, Stylize}, text::Text, widgets::{Block, Clear, Paragraph, Widget, Wrap}, }; @@ -9,6 +9,7 @@ use ratatui::{ pub struct Dialog<'w> { content: Text<'w>, title: &'w str, + bottom_title: &'w str, scroll: (u16, u16), } @@ -17,10 +18,15 @@ impl<'w> Dialog<'w> { Self { content, title, + bottom_title: "j,k or up,down to scroll", scroll: (0, 0), } } + pub fn with_bottom_title(mut self, title: &'w str) -> Self { + self.bottom_title = title; + self + } pub fn scroll(mut self, line: u16) -> Self { self.scroll.0 = line; self diff --git a/src/ui/render/mod.rs b/src/ui/render/mod.rs index e2b79af..8984c6e 100644 --- a/src/ui/render/mod.rs +++ b/src/ui/render/mod.rs @@ -99,10 +99,12 @@ impl<'widget, 'ws: 'widget> Widget for &'widget mut Workspace<'ws> { Self: Sized, { if self.state.modality() == &Modality::Dialog { - // Use a popup here. let lines = Text::from_iter(self.state.popup.iter().cloned()); let popup = dialog::Dialog::new(lines, "Help").scroll(self.state.dialog_scroll); - //let popup = Paragraph::new(lines); + popup.render(area, buf); + } else if self.state.modality() == &Modality::Quit { + let popup = dialog::Dialog::new(Text::raw("File is not yet saved. Save it first?"), "Quit") + .with_bottom_title("Y/N"); popup.render(area, buf); } else { let outer_block = Block::bordered() @@ -118,6 +120,7 @@ impl<'widget, 'ws: 'widget> Widget for &'widget mut Workspace<'ws> { Modality::Command => "command", Modality::Dialog => "", Modality::RangeSelect => "range-copy", + Modality::Quit => "", }) .title_bottom( Line::from(format!( diff --git a/src/ui/test.rs b/src/ui/test.rs index d07e338..f1a36e5 100644 --- a/src/ui/test.rs +++ b/src/ui/test.rs @@ -861,17 +861,6 @@ fn test_sheet_column_sizing() { ); } -#[test] -fn test_quit() { - let mut ws = - Workspace::new_empty("en", "America/New_York").expect("Failed to get empty workbook"); - let result = script() - .char('q') - .run(&mut ws) - .expect("Failed to run input script"); - assert!(result.is_some()); -} - #[test] fn test_cell_replace() { let mut ws = new_workspace(); @@ -1311,6 +1300,63 @@ fn test_italic_text() { assert!(!before_style.font.i); } +//#[test] +//fn test_quit() { +// let mut ws = +// Workspace::new_empty("en", "America/New_York").expect("Failed to get empty workbook"); +// let result = script() +// .char('q') +// .run(&mut ws) +// .expect("Failed to run input script"); +// assert!(result.is_some()); +//} + +#[test] +fn test_quit_dialog() { + let mut ws = new_workspace(); + assert!(!ws.book.dirty); + let mut result = script() + .char('q') + .run(&mut ws) + .expect("Failed to run input script"); + assert!(result.is_some()); + + script() + .chars("efoo") + .enter() + .run(&mut ws) + .expect("Failed to modify book"); + assert!(ws.book.dirty); + result = script() + .char('q') + .run(&mut ws) + .expect("Failed to run input script"); + assert!(!result.is_some()); + assert_eq!(ws.state.modality(), &Modality::Quit); + assert!(result.is_some()); + + script() + .char('n') + .run(&mut ws) + .expect("Failed to run input script"); + assert_eq!(ws.state.modality(), &Modality::default()); + assert!(ws.book.dirty); + script() + .char('q') + .esc() + .run(&mut ws) + .expect("Failed to run input script"); + assert_eq!(ws.state.modality(), &Modality::default()); + assert!(ws.book.dirty); + // TODO(zaphar): The below will write to disk. so commenting it out for now. + //script() + // .char('q') + // .char('y') + // .run(&mut ws) + // .expect("Failed to run input script"); + //assert!(!ws.book.dirty); +} + fn new_workspace<'a>() -> Workspace<'a> { Workspace::new_empty("en", "America/New_York").expect("Failed to get empty workbook") } From f7449e8c654dd9ffc92c811032768019f8febefe Mon Sep 17 00:00:00 2001 From: Jeremy Wall Date: Mon, 3 Mar 2025 09:57:44 -0500 Subject: [PATCH 2/2] wip: dialog rendering improvements --- src/ui/render/dialog.rs | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/ui/render/dialog.rs b/src/ui/render/dialog.rs index 6a19f40..6c78612 100644 --- a/src/ui/render/dialog.rs +++ b/src/ui/render/dialog.rs @@ -40,18 +40,29 @@ impl<'w> Widget for Dialog<'w> { { // First find the center of the area. let content_width = self.content.width(); - let sidebar_width = (area.width - (content_width as u16) + 2) / 2; - let [_, dialog_area, _] = Layout::horizontal(vec![ - Constraint::Length(sidebar_width), + let content_height = self.content.height(); + let vertical_margin = if ((content_height as u16) + 2) <= area.height { + area.height.saturating_sub((content_height as u16) + 2).saturating_div(2) + } else { + area.height - 2 + }; + let horizontal_margin = area.width.saturating_sub((content_width as u16) + 2).saturating_div(2); + let [_, dialog_vertical, _] = Layout::vertical(vec![ + Constraint::Length(vertical_margin), Constraint::Fill(1), - Constraint::Length(sidebar_width), + Constraint::Length(vertical_margin), + ]).areas(area); + let [_, dialog_area, _] = Layout::horizontal(vec![ + Constraint::Length(horizontal_margin), + Constraint::Fill(1), + Constraint::Length(horizontal_margin), ]) - .areas(area); + .areas(dialog_vertical); Clear.render(dialog_area, buf); let dialog_block = Block::bordered() .title_top(self.title) - .title_bottom("j,k or up,down to scroll") + .title_bottom(self.bottom_title) .style(Style::default().on_black()); let dialog = Paragraph::new(self.content.clone()) .wrap(Wrap::default())