wip: feat: prompt for saving if file is not saved yet

This commit is contained in:
Jeremy Wall 2025-03-03 09:57:44 -05:00
parent 56979103d6
commit 2bf2f792bd
5 changed files with 137 additions and 23 deletions

View File

@ -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<S: AsRef<str>>(&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(())
}

View File

@ -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<Self> {
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<Option<ExitCode>> {
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<Option<ExitCode>> {
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<Option<ExitCode>> {
self.state.pop_modality();
Ok(None)
}
fn exit_command_mode(&mut self) -> Result<Option<ExitCode>> {
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<S: Into<String>>(&self, path: S) -> Result<()> {
fn save_to<S: Into<String>>(&mut self, path: S) -> Result<()> {
self.book.save_to_xlsx(path.into().as_str())?;
Ok(())
}
fn quit_app(&mut self) -> std::result::Result<Option<ExitCode>, 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<Book, anyhow::Error> {

View File

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

View File

@ -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!(

View File

@ -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")
}