UX: Replace the tui_markdown widget

This allows us to have better styling for the help text as well providing some hooks for help navigation later.
This commit is contained in:
Jeremy Wall 2025-04-07 21:58:47 -04:00 committed by GitHub
parent 0177ed0847
commit b011a4d5bf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 972 additions and 155 deletions

2
.gitignore vendored
View File

@ -1,5 +1,5 @@
/target /target
result/ /result
*.json *.json
tarpaulin-report.* tarpaulin-report.*
*.profraw *.profraw

16
Cargo.lock generated
View File

@ -1210,6 +1210,19 @@ dependencies = [
"unicase", "unicase",
] ]
[[package]]
name = "pulldown-cmark"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0"
dependencies = [
"bitflags 2.6.0",
"getopts",
"memchr",
"pulldown-cmark-escape",
"unicase",
]
[[package]] [[package]]
name = "pulldown-cmark-escape" name = "pulldown-cmark-escape"
version = "0.11.0" version = "0.11.0"
@ -1500,6 +1513,7 @@ dependencies = [
"csv", "csv",
"futures", "futures",
"ironcalc", "ironcalc",
"pulldown-cmark 0.13.0",
"ratatui", "ratatui",
"serde_json", "serde_json",
"slice-utils", "slice-utils",
@ -1758,7 +1772,7 @@ dependencies = [
"ansi-to-tui", "ansi-to-tui",
"itertools 0.13.0", "itertools 0.13.0",
"pretty_assertions", "pretty_assertions",
"pulldown-cmark", "pulldown-cmark 0.12.2",
"ratatui", "ratatui",
"rstest", "rstest",
"syntect", "syntect",

View File

@ -21,3 +21,4 @@ serde_json = "1.0.133"
colorsys = "0.6.7" colorsys = "0.6.7"
tui-markdown = { version = "0.3.1", features = [] } tui-markdown = { version = "0.3.1", features = [] }
csv = "1.3.1" csv = "1.3.1"
pulldown-cmark = "0.13.0"

View File

@ -36,7 +36,14 @@ fn test_book_default() {
#[test] #[test]
fn test_book_insert_cell_new_row() { fn test_book_insert_cell_new_row() {
let mut book = Book::default(); let mut book = Book::default();
book.update_cell(&Address { sheet: 0, row: 2, col: 1 }, "1") book.update_cell(
&Address {
sheet: 0,
row: 2,
col: 1,
},
"1",
)
.expect("failed to edit cell"); .expect("failed to edit cell");
book.evaluate(); book.evaluate();
let WorksheetDimension { let WorksheetDimension {
@ -52,7 +59,14 @@ fn test_book_insert_cell_new_row() {
#[test] #[test]
fn test_book_insert_cell_new_column() { fn test_book_insert_cell_new_column() {
let mut book = Book::default(); let mut book = Book::default();
book.update_cell(&Address { sheet: 0, row: 1, col: 2 }, "1") book.update_cell(
&Address {
sheet: 0,
row: 1,
col: 2,
},
"1",
)
.expect("failed to edit cell"); .expect("failed to edit cell");
let WorksheetDimension { let WorksheetDimension {
min_row, min_row,
@ -67,14 +81,32 @@ fn test_book_insert_cell_new_column() {
#[test] #[test]
fn test_book_insert_rows() { fn test_book_insert_rows() {
let mut book = Book::default(); let mut book = Book::default();
book.update_cell(&Address { sheet: 0, row: 2, col: 2 }, "1") book.update_cell(
&Address {
sheet: 0,
row: 2,
col: 2,
},
"1",
)
.expect("failed to edit cell"); .expect("failed to edit cell");
book.move_to(&Address { sheet: 0, row: 2, col: 2 }) book.move_to(&Address {
sheet: 0,
row: 2,
col: 2,
})
.expect("Failed to move to location"); .expect("Failed to move to location");
assert_eq!((2, 2), book.get_size().expect("Failed to get size")); assert_eq!((2, 2), book.get_size().expect("Failed to get size"));
book.insert_rows(1, 5).expect("Failed to insert rows"); book.insert_rows(1, 5).expect("Failed to insert rows");
assert_eq!((7, 2), book.get_size().expect("Failed to get size")); assert_eq!((7, 2), book.get_size().expect("Failed to get size"));
assert_eq!(Address { sheet: 0, row: 7, col: 2 }, book.location); assert_eq!(
Address {
sheet: 0,
row: 7,
col: 2
},
book.location
);
assert_eq!( assert_eq!(
"1", "1",
book.get_current_cell_rendered() book.get_current_cell_rendered()
@ -85,14 +117,32 @@ fn test_book_insert_rows() {
#[test] #[test]
fn test_book_insert_columns() { fn test_book_insert_columns() {
let mut book = Book::default(); let mut book = Book::default();
book.update_cell(&Address { sheet: 0, row: 2, col: 2 }, "1") book.update_cell(
&Address {
sheet: 0,
row: 2,
col: 2,
},
"1",
)
.expect("failed to edit cell"); .expect("failed to edit cell");
book.move_to(&Address { sheet: 0, row: 2, col: 2 }) book.move_to(&Address {
sheet: 0,
row: 2,
col: 2,
})
.expect("Failed to move to location"); .expect("Failed to move to location");
assert_eq!((2, 2), book.get_size().expect("Failed to get size")); assert_eq!((2, 2), book.get_size().expect("Failed to get size"));
book.insert_columns(1, 5).expect("Failed to insert rows"); book.insert_columns(1, 5).expect("Failed to insert rows");
assert_eq!((2, 7), book.get_size().expect("Failed to get size")); assert_eq!((2, 7), book.get_size().expect("Failed to get size"));
assert_eq!(Address { sheet: 0, row: 2, col: 7 }, book.location); assert_eq!(
Address {
sheet: 0,
row: 2,
col: 7
},
book.location
);
assert_eq!( assert_eq!(
"1", "1",
book.get_current_cell_rendered() book.get_current_cell_rendered()
@ -103,7 +153,14 @@ fn test_book_insert_columns() {
#[test] #[test]
fn test_book_col_size() { fn test_book_col_size() {
let mut book = Book::default(); let mut book = Book::default();
book.update_cell(&Address { sheet: 0, row: 2, col: 2 }, "1") book.update_cell(
&Address {
sheet: 0,
row: 2,
col: 2,
},
"1",
)
.expect("failed to edit cell"); .expect("failed to edit cell");
book.set_col_size(1, 20).expect("Failed to set column size"); book.set_col_size(1, 20).expect("Failed to set column size");
assert_eq!(20, book.get_col_size(1).expect("Failed to get column size")); assert_eq!(20, book.get_col_size(1).expect("Failed to get column size"));
@ -112,17 +169,34 @@ fn test_book_col_size() {
#[test] #[test]
fn test_book_get_exportable_rows() { fn test_book_get_exportable_rows() {
let mut book = Book::default(); let mut book = Book::default();
book.update_cell(&Address { sheet: 0, row: 1, col: 3 }, "1-3") book.update_cell(
&Address {
sheet: 0,
row: 1,
col: 3,
},
"1-3",
)
.expect("failed to edit cell"); .expect("failed to edit cell");
book.update_cell(&Address { sheet: 0, row: 3, col: 6 }, "3-6") book.update_cell(
&Address {
sheet: 0,
row: 3,
col: 6,
},
"3-6",
)
.expect("failed to edit cell"); .expect("failed to edit cell");
let rows = book.get_export_rows().expect("Failed to get export rows"); let rows = book.get_export_rows().expect("Failed to get export rows");
assert_eq!(4, rows.len()); assert_eq!(4, rows.len());
assert_eq!(rows, vec![ assert_eq!(
rows,
vec![
vec!["", "", "", "", "", "", ""], vec!["", "", "", "", "", "", ""],
vec!["", "", "", "1-3", "", "", ""], vec!["", "", "", "1-3", "", "", ""],
vec!["", "", "", "", "", "", ""], vec!["", "", "", "", "", "", ""],
vec!["", "", "", "", "", "", "3-6"], vec!["", "", "", "", "", "", "3-6"],
]); ]
);
} }

View File

@ -1,12 +1,11 @@
use ratatui::text::Text; use crate::ui::render::markdown::Markdown;
use tui_markdown;
pub fn render_topic(topic: &str) -> Text<'static> { pub fn to_widget(topic: &str) -> Markdown {
match topic { match topic {
"navigate" => tui_markdown::from_str(include_str!("../../../docs/navigation.md")), "navigate" => Markdown::from_str(include_str!("../../../docs/navigation.md")),
"edit" => tui_markdown::from_str(include_str!("../../../docs/edit.md")), "edit" => Markdown::from_str(include_str!("../../../docs/edit.md")),
"command" => tui_markdown::from_str(include_str!("../../../docs/command.md")), "command" => Markdown::from_str(include_str!("../../../docs/command.md")),
"visual" => tui_markdown::from_str(include_str!("../../../docs/visual.md")), "visual" => Markdown::from_str(include_str!("../../../docs/visual.md")),
_ => tui_markdown::from_str(include_str!("../../../docs/intro.md")), _ => Markdown::from_str(include_str!("../../../docs/intro.md")),
} }
} }

View File

@ -7,19 +7,22 @@ use anyhow::{anyhow, Result};
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use ironcalc::base::{expressions::types::Area, Model}; use ironcalc::base::{expressions::types::Area, Model};
use ratatui::{ use ratatui::{
buffer::Buffer, layout::{Constraint, Flex, Layout}, style::{Modifier, Style}, text::{Line, Text}, widgets::Block buffer::Buffer,
layout::{Constraint, Flex, Layout},
style::{Modifier, Style},
widgets::Block,
}; };
use tui_prompts::{State, Status, TextPrompt, TextState}; use tui_prompts::{State, Status, TextPrompt, TextState};
use tui_textarea::{CursorMove, TextArea}; use tui_textarea::{CursorMove, TextArea};
mod help;
mod cmd; mod cmd;
mod help;
pub mod render; pub mod render;
#[cfg(test)] #[cfg(test)]
mod test; mod test;
use cmd::Cmd; use cmd::Cmd;
use render::viewport::ViewportState; use render::{markdown::Markdown, viewport::ViewportState};
#[derive(Default, Debug, PartialEq, Clone)] #[derive(Default, Debug, PartialEq, Clone)]
pub enum Modality { pub enum Modality {
@ -80,7 +83,7 @@ pub struct AppState<'ws> {
pub range_select: RangeSelection, pub range_select: RangeSelection,
pub dialog_scroll: u16, pub dialog_scroll: u16,
dirty: bool, dirty: bool,
popup: Text<'ws>, popup: Option<Markdown>,
clipboard: Option<ClipboardContents>, clipboard: Option<ClipboardContents>,
} }
@ -299,16 +302,16 @@ impl<'ws> Workspace<'ws> {
Ok(None) Ok(None)
} }
fn render_help_text(&self) -> Text<'static> { fn render_help_text(&self) -> Markdown {
// TODO(zaphar): We should be sourcing these from our actual help documentation. // TODO(zaphar): We should be sourcing these from our actual help documentation.
// Ideally we would also render the markdown content properly. // Ideally we would also render the markdown content properly.
// https://github.com/zaphar/sheetsui/issues/22 // https://github.com/zaphar/sheetsui/issues/22
match self.state.modality() { match self.state.modality() {
Modality::Navigate => help::render_topic("navigate"), Modality::Navigate => help::to_widget("navigate"),
Modality::CellEdit => help::render_topic("edit"), Modality::CellEdit => help::to_widget("edit"),
Modality::Command => help::render_topic("command"), Modality::Command => help::to_widget("command"),
Modality::RangeSelect => help::render_topic("visual"), Modality::RangeSelect => help::to_widget("visual"),
_ => help::render_topic(""), _ => help::to_widget(""),
} }
} }
@ -334,14 +337,14 @@ impl<'ws> Workspace<'ws> {
match key.code { match key.code {
KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => { KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => {
self.exit_quit_mode()?; self.exit_quit_mode()?;
return Ok(Some(ExitCode::SUCCESS)) return Ok(Some(ExitCode::SUCCESS));
}, }
KeyCode::Char('y') | KeyCode::Char('Y') => { KeyCode::Char('y') | KeyCode::Char('Y') => {
// We have been asked to save the file first. // We have been asked to save the file first.
self.save_file()?; self.save_file()?;
self.exit_quit_mode()?; self.exit_quit_mode()?;
return Ok(Some(ExitCode::SUCCESS)); return Ok(Some(ExitCode::SUCCESS));
}, }
_ => return Ok(None), _ => return Ok(None),
} }
} }
@ -361,8 +364,10 @@ impl<'ws> Workspace<'ws> {
KeyCode::Char('k') | KeyCode::Up => { KeyCode::Char('k') | KeyCode::Up => {
self.state.dialog_scroll = self.state.dialog_scroll.saturating_sub(1); self.state.dialog_scroll = self.state.dialog_scroll.saturating_sub(1);
} }
_ => { code => {
// NOOP if let Some(widget) = &self.state.popup {
widget.handle_input(code);
}
} }
} }
} }
@ -414,7 +419,7 @@ impl<'ws> Workspace<'ws> {
Ok(None) Ok(None)
} }
Ok(Some(Cmd::Help(maybe_topic))) => { Ok(Some(Cmd::Help(maybe_topic))) => {
self.enter_dialog_mode(help::render_topic(maybe_topic.unwrap_or(""))); self.enter_dialog_mode(help::to_widget(maybe_topic.unwrap_or("")));
Ok(None) Ok(None)
} }
Ok(Some(Cmd::Write(maybe_path))) => { Ok(Some(Cmd::Write(maybe_path))) => {
@ -426,7 +431,8 @@ impl<'ws> Workspace<'ws> {
Ok(None) Ok(None)
} }
Ok(Some(Cmd::ExportCsv(path))) => { Ok(Some(Cmd::ExportCsv(path))) => {
self.book.save_sheet_to_csv(self.book.location.sheet, path)?; self.book
.save_sheet_to_csv(self.book.location.sheet, path)?;
Ok(None) Ok(None)
} }
Ok(Some(Cmd::InsertColumns(count))) => { Ok(Some(Cmd::InsertColumns(count))) => {
@ -508,11 +514,14 @@ impl<'ws> Workspace<'ws> {
Ok(None) Ok(None)
} }
Ok(None) => { Ok(None) => {
self.enter_dialog_mode(vec![Line::from(format!("Unrecognized commmand {}", cmd_text))]); self.enter_dialog_mode(Markdown::from_str(&format!(
"Unrecognized commmand {}",
cmd_text
)));
Ok(None) Ok(None)
} }
Err(msg) => { Err(msg) => {
self.enter_dialog_mode(vec![Line::from(msg.to_owned())]); self.enter_dialog_mode(Markdown::from_str(msg));
Ok(None) Ok(None)
} }
} }
@ -543,18 +552,12 @@ impl<'ws> Workspace<'ws> {
} }
KeyCode::Char('D') => { KeyCode::Char('D') => {
if let Some((start, end)) = self.state.range_select.get_range() { if let Some((start, end)) = self.state.range_select.get_range() {
self.book.clear_cell_range_all( self.book.clear_cell_range_all(start, end)?;
start,
end,
)?;
} }
} }
KeyCode::Char('d') => { KeyCode::Char('d') => {
if let Some((start, end)) = self.state.range_select.get_range() { if let Some((start, end)) = self.state.range_select.get_range() {
self.book.clear_cell_range( self.book.clear_cell_range(start, end)?;
start,
end,
)?;
} }
} }
KeyCode::Char('h') => { KeyCode::Char('h') => {
@ -775,7 +778,11 @@ impl<'ws> Workspace<'ws> {
} }
KeyCode::Char('l') if key.modifiers == KeyModifiers::CONTROL => { KeyCode::Char('l') if key.modifiers == KeyModifiers::CONTROL => {
self.run_with_prefix(|ws: &mut Workspace<'_>| -> Result<()> { self.run_with_prefix(|ws: &mut Workspace<'_>| -> Result<()> {
let Address { sheet: _, row: _, col } = &ws.book.location; let Address {
sheet: _,
row: _,
col,
} = &ws.book.location;
ws.book ws.book
.set_col_size(*col, ws.book.get_col_size(*col)? + 1)?; .set_col_size(*col, ws.book.get_col_size(*col)? + 1)?;
Ok(()) Ok(())
@ -783,7 +790,11 @@ impl<'ws> Workspace<'ws> {
} }
KeyCode::Char('h') if key.modifiers == KeyModifiers::CONTROL => { KeyCode::Char('h') if key.modifiers == KeyModifiers::CONTROL => {
self.run_with_prefix(|ws: &mut Workspace<'_>| -> Result<()> { self.run_with_prefix(|ws: &mut Workspace<'_>| -> Result<()> {
let Address { sheet: _, row: _, col } = &ws.book.location; let Address {
sheet: _,
row: _,
col,
} = &ws.book.location;
let curr_size = ws.book.get_col_size(*col)?; let curr_size = ws.book.get_col_size(*col)?;
if curr_size > 1 { if curr_size > 1 {
ws.book.set_col_size(*col, curr_size - 1)?; ws.book.set_col_size(*col, curr_size - 1)?;
@ -874,9 +885,18 @@ impl<'ws> Workspace<'ws> {
return Ok(None); return Ok(None);
} }
fn toggle_bool_style(&mut self, current_val: Option<bool>, path: &str, address: &Address) -> Result<(), anyhow::Error> { fn toggle_bool_style(
&mut self,
current_val: Option<bool>,
path: &str,
address: &Address,
) -> Result<(), anyhow::Error> {
let value = if let Some(b_val) = current_val { let value = if let Some(b_val) = current_val {
if b_val { "false" } else { "true" } if b_val {
"false"
} else {
"true"
}
} else { } else {
"true" "true"
}; };
@ -888,7 +908,8 @@ impl<'ws> Workspace<'ws> {
column: address.col as i32, column: address.col as i32,
width: 1, width: 1,
height: 1, height: 1,
})?; },
)?;
Ok(()) Ok(())
} }
@ -951,8 +972,8 @@ impl<'ws> Workspace<'ws> {
self.state.command_state.focus(); self.state.command_state.focus();
} }
fn enter_dialog_mode<T: Into<Text<'ws>>>(&mut self, msg: T) { fn enter_dialog_mode(&mut self, msg: Markdown) {
self.state.popup = msg.into(); self.state.popup = Some(msg);
self.state.modality_stack.push(Modality::Dialog); self.state.modality_stack.push(Modality::Dialog);
} }
@ -1050,9 +1071,8 @@ impl<'ws> Workspace<'ws> {
if self.enter_quit_mode() { if self.enter_quit_mode() {
return Ok(None); return Ok(None);
} }
return Ok(Some(ExitCode::SUCCESS)) return Ok(Some(ExitCode::SUCCESS));
} }
} }
fn load_book(path: &PathBuf, locale: &str, tz: &str) -> Result<Book, anyhow::Error> { fn load_book(path: &PathBuf, locale: &str, tz: &str) -> Result<Book, anyhow::Error> {

View File

@ -11,6 +11,7 @@ pub struct Dialog<'w> {
title: &'w str, title: &'w str,
bottom_title: &'w str, bottom_title: &'w str,
scroll: (u16, u16), scroll: (u16, u16),
// TODO(zaphar): Have a max margin?
} }
impl<'w> Dialog<'w> { impl<'w> Dialog<'w> {
@ -39,19 +40,29 @@ impl<'w> Widget for Dialog<'w> {
Self: Sized, Self: Sized,
{ {
// First find the center of the area. // First find the center of the area.
let content_width = self.content.width(); let content_width = 120 + 2;
let content_height = self.content.height(); let content_height = (self.content.height() + 2) as u16;
let vertical_margin = if ((content_height as u16) + 2) <= area.height { let vertical_margin = if content_height <= area.height {
area.height.saturating_sub((content_height as u16) + 2).saturating_div(2) area.height
.saturating_sub(content_height as u16)
.saturating_div(2)
} else { } else {
area.height - 2 2
};
let horizontal_margin = if content_width <= area.width {
area
.width
.saturating_sub(content_width as u16)
.saturating_div(2)
} else {
2
}; };
let horizontal_margin = area.width.saturating_sub((content_width as u16) + 2).saturating_div(2);
let [_, dialog_vertical, _] = Layout::vertical(vec![ let [_, dialog_vertical, _] = Layout::vertical(vec![
Constraint::Length(vertical_margin), Constraint::Length(vertical_margin),
Constraint::Fill(1), Constraint::Fill(1),
Constraint::Length(vertical_margin), Constraint::Length(vertical_margin),
]).areas(area); ])
.areas(area);
let [_, dialog_area, _] = Layout::horizontal(vec![ let [_, dialog_area, _] = Layout::horizontal(vec![
Constraint::Length(horizontal_margin), Constraint::Length(horizontal_margin),
Constraint::Fill(1), Constraint::Fill(1),

536
src/ui/render/markdown.rs Normal file
View File

@ -0,0 +1,536 @@
use std::collections::BTreeSet;
use crossterm::event::KeyCode;
use ratatui::{
style::{Color, Modifier, Style},
text::{Line, Span, Text},
widgets::Widget,
};
use pulldown_cmark::{Event, LinkType, Parser, Tag, TagEnd};
#[derive(Debug, Clone, PartialEq)]
pub struct Markdown {
input: String,
links: BTreeSet<String>,
parsed_text: Option<Text<'static>>,
}
/// Define the different states a markdown parser can be in
#[derive(Debug, Clone, PartialEq)]
enum MarkdownState {
Normal,
Heading(pulldown_cmark::HeadingLevel),
Strong,
Emphasis,
Code,
List(ListState),
}
/// Track list state including nesting level and type
#[derive(Debug, Clone, PartialEq)]
struct ListState {
list_type: ListType,
nesting_level: usize,
item_number: usize,
}
#[derive(Debug, Clone, PartialEq)]
enum ListType {
Ordered,
Unordered,
}
impl Markdown {
pub fn from_str(input: &str) -> Self {
let mut me = Self {
input: input.to_owned(),
links: Default::default(),
parsed_text: None,
};
me.parse();
me
}
fn parse(&mut self) {
let input = self.input.clone();
let parser = pulldown_cmark::TextMergeStream::new(Parser::new(&input));
let mut current_line = Line::default();
let mut lines: Vec<Line> = Vec::new();
let mut state_stack: Vec<MarkdownState> = vec![MarkdownState::Normal];
for event in parser {
match event {
Event::Start(tag) => {
match &tag {
Tag::Heading { level, .. } => {
if !current_line.spans.is_empty() {
lines.push(current_line);
}
// Add heading style based on level
let heading_style = match level {
pulldown_cmark::HeadingLevel::H1 => {
Style::default().add_modifier(Modifier::BOLD)
}
pulldown_cmark::HeadingLevel::H2 => {
Style::default().add_modifier(Modifier::ITALIC)
}
_ => Style::default().fg(Color::Blue),
};
current_line = Line::styled("", heading_style);
state_stack.push(MarkdownState::Heading(*level));
}
Tag::Paragraph => {
if !current_line.spans.is_empty() {
lines.push(current_line);
current_line = Line::default();
}
}
Tag::Strong => {
state_stack.push(MarkdownState::Strong);
}
Tag::Emphasis => {
state_stack.push(MarkdownState::Emphasis);
}
Tag::CodeBlock(_) => {
state_stack.push(MarkdownState::Code);
}
Tag::List(list_type) => {
if !current_line.spans.is_empty() {
lines.push(current_line);
current_line = Line::default();
}
// Determine list type and nesting level
let list_type = match list_type {
Some(_) => ListType::Ordered,
None => ListType::Unordered,
};
// Calculate nesting level based on existing lists in the stack
let nesting_level = state_stack
.iter()
.filter(|state| matches!(state, MarkdownState::List(_)))
.count();
state_stack.push(MarkdownState::List(ListState {
list_type,
nesting_level,
item_number: 0,
}));
}
Tag::Item => {
if !current_line.spans.is_empty() {
lines.push(current_line);
current_line = Line::default();
}
// Find the current list state and increment its item number
for state in state_stack.iter_mut().rev() {
if let MarkdownState::List(list_state) = state {
list_state.item_number += 1;
// Add appropriate indentation based on nesting level
let indent = " ".repeat(list_state.nesting_level);
// Add appropriate marker based on list type
let marker = match list_state.list_type {
ListType::Unordered => "* ".to_string(),
ListType::Ordered => {
format!("{}. ", list_state.item_number)
}
};
current_line
.spans
.push(Span::raw(format!("{}{}", indent, marker)));
break;
}
}
}
Tag::Link {
link_type: _,
dest_url: _,
title: _,
id: _,
} => {
self.handle_link_tag(&tag);
}
Tag::BlockQuote(_) => todo!(),
Tag::Strikethrough => todo!(),
Tag::Superscript => todo!(),
Tag::Subscript => todo!(),
_ => {
// noop
}
}
}
Event::End(tag) => {
match tag {
TagEnd::Heading { .. } => {
lines.push(current_line);
lines.push(Line::default()); // Add empty line after heading
current_line = Line::default();
state_stack.pop();
}
TagEnd::Paragraph => {
lines.push(current_line);
lines.push(Line::default()); // Add empty line after paragraph
current_line = Line::default();
}
TagEnd::Strong => {
state_stack.pop();
}
TagEnd::Emphasis => {
state_stack.pop();
}
TagEnd::CodeBlock => {
state_stack.pop();
}
TagEnd::Item => {
// Push the current line to preserve the list item
if !current_line.spans.is_empty() {
lines.push(current_line);
current_line = Line::default();
}
}
TagEnd::List(_) => {
state_stack.pop();
// Only add an empty line if we're back to the root level
if state_stack
.iter()
.filter(|state| matches!(state, MarkdownState::List(_))).count() == 0
{
//lines.push(Line::default()); // Add empty line after list
}
}
_ => {}
}
}
Event::InlineMath(text)
| Event::Code(text)
| Event::InlineHtml(text)
| Event::DisplayMath(text)
| Event::Html(text)
| Event::Text(text) => {
let mut style = Style::default();
// Apply style based on current state
for state in state_stack.iter().rev() {
match state {
MarkdownState::Heading(_) => {
// Style already applied to the line
break;
}
MarkdownState::Strong => {
style = style.add_modifier(Modifier::BOLD);
}
MarkdownState::Emphasis => {
style = style.add_modifier(Modifier::ITALIC);
}
//MarkdownState::Code => {
// style = style.fg(Color::Yellow);
//}
_ => {
}
}
}
// Add the text with appropriate styling
current_line
.spans
.push(Span::styled(text.to_string(), style));
}
Event::SoftBreak => {
current_line.spans.push(Span::raw(" "));
}
Event::HardBreak => {
lines.push(current_line);
current_line = Line::default();
}
Event::FootnoteReference(_) => {},
Event::Rule => {},
Event::TaskListMarker(_) => {},
}
}
// Add any remaining content
if !current_line.spans.is_empty() {
lines.push(current_line);
}
self.parsed_text = Some(Text::from(lines));
}
fn handle_link_tag(&mut self, tag: &Tag<'_>) {
match tag {
Tag::Link {
link_type,
dest_url,
title,
id,
} => {
let dest = match link_type {
// [foo](bar)
LinkType::Inline => format!("({})", dest_url),
// [foo][bar]
LinkType::Reference => format!("[{}]", id),
// [foo]
LinkType::Shortcut => format!("[{}]", title),
// These are unsupported right now
LinkType::ReferenceUnknown => String::from("[unknown]"),
LinkType::Collapsed => String::from("[collapsed]"),
LinkType::CollapsedUnknown => String::from("[collapsed unknown]"),
LinkType::ShortcutUnknown => String::from("[shortcut unknown]"),
LinkType::Autolink => dest_url.to_string(),
LinkType::Email => dest_url.to_string(),
LinkType::WikiLink { has_pothole: _ } => String::from("[wiki]"),
};
self.links.insert(dest);
}
_ => { /* noop */ }
}
}
pub fn handle_input(&self, code: KeyCode) -> Option<String> {
let num = match code {
KeyCode::Char('0') => 0,
KeyCode::Char('1') => 1,
KeyCode::Char('2') => 2,
KeyCode::Char('3') => 3,
KeyCode::Char('4') => 4,
KeyCode::Char('5') => 5,
KeyCode::Char('6') => 6,
KeyCode::Char('7') => 7,
KeyCode::Char('8') => 8,
KeyCode::Char('9') => 9,
_ => return None,
};
self.links.iter().nth(num).cloned()
}
pub fn get_text(&self) -> Text {
if let Some(ref parsed) = self.parsed_text {
parsed.clone()
} else {
Text::raw(&self.input)
}
}
}
impl Widget for Markdown {
fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer)
where
Self: Sized,
{
if let Some(parsed) = self.parsed_text {
parsed.render(area, buf);
} else {
let text = Text::raw(self.input);
text.render(area, buf);
}
}
}
// TODO(zaphar): Move this into a proper test file.
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_markdown() {
let md = Markdown::from_str("");
let text = md.get_text();
assert_eq!(text.lines.len(), 0);
}
#[test]
fn test_simple_paragraph() {
let md = Markdown::from_str("This is a simple paragraph.");
let text = md.get_text();
assert_eq!(text.lines.len(), 2); // Paragraph + empty line
assert_eq!(text.lines[0].spans[0].content, "This is a simple paragraph.");
}
#[test]
fn test_headings() {
let md = Markdown::from_str("# Heading 1\n## Heading 2\n### Heading 3");
let text = md.get_text();
// Should have 3 headings and 6 lines
assert_eq!(text.lines.len(), 6);
// Check content
assert_eq!(text.lines[0].spans[0].content, "Heading 1");
assert_eq!(text.lines[1].spans.len(), 0);
assert_eq!(text.lines[2].spans[0].content, "Heading 2");
assert_eq!(text.lines[3].spans.len(), 0);
assert_eq!(text.lines[4].spans[0].content, "Heading 3");
assert_eq!(text.lines[5].spans.len(), 0);
assert!(text.lines[0].style != text.lines[1].style);
}
#[test]
fn test_emphasis() {
let md = Markdown::from_str("Normal *italic* **bold** text");
let text = md.get_text();
assert_eq!(text.lines.len(), 2); // Paragraph + empty line
// Check spans - should have 4 spans: normal, italic, bold, normal
assert_eq!(text.lines[0].spans.len(), 5);
assert_eq!(text.lines[0].spans[0].content, "Normal ");
assert_eq!(text.lines[0].spans[1].content, "italic");
assert_eq!(text.lines[0].spans[2].content, " ");
assert_eq!(text.lines[0].spans[3].content, "bold");
assert_eq!(text.lines[0].spans[4].content, " text");
// Check that styles are different
assert!(text.lines[0].spans[0].style != text.lines[0].spans[1].style);
assert!(text.lines[0].spans[1].style != text.lines[0].spans[2].style);
}
#[test]
fn test_unordered_list() {
let md = Markdown::from_str("* Item 1\n* Item 2\n* Item 3");
let text = md.get_text();
// Should have 4 lines: 3 items + empty line after list
assert_eq!(text.lines.len(), 3);
// Check content with markers
assert_eq!(text.lines[0].spans[0].content, "* ");
assert_eq!(text.lines[0].spans[1].content, "Item 1");
assert_eq!(text.lines[1].spans[0].content, "* ");
assert_eq!(text.lines[1].spans[1].content, "Item 2");
assert_eq!(text.lines[2].spans[0].content, "* ");
assert_eq!(text.lines[2].spans[1].content, "Item 3");
}
#[test]
fn test_ordered_list() {
let md = Markdown::from_str("1. First item\n2. Second item\n3. Third item");
let text = md.get_text();
// Should have 4 lines: 3 items + empty line after list
assert_eq!(text.lines.len(), 3);
// Check content with markers
assert_eq!(text.lines[0].spans[0].content, "1. ");
assert_eq!(text.lines[0].spans[1].content, "First item");
assert_eq!(text.lines[1].spans[0].content, "2. ");
assert_eq!(text.lines[1].spans[1].content, "Second item");
assert_eq!(text.lines[2].spans[0].content, "3. ");
assert_eq!(text.lines[2].spans[1].content, "Third item");
}
#[test]
fn test_nested_lists() {
let md = Markdown::from_str("* Item 1\n * Nested 1\n * Nested 2\n* Item 2");
let text = md.get_text();
// Should have 5 lines: 4 items + empty line after list
assert_eq!(text.lines.len(), 4);
// Check indentation and markers
assert_eq!(text.lines[0].spans[0].content, "* ");
assert_eq!(text.lines[0].spans[1].content, "Item 1");
assert_eq!(text.lines[1].spans[0].content, " * ");
assert_eq!(text.lines[1].spans[1].content, "Nested 1");
assert_eq!(text.lines[2].spans[0].content, " * ");
assert_eq!(text.lines[2].spans[1].content, "Nested 2");
assert_eq!(text.lines[3].spans[0].content, "* ");
assert_eq!(text.lines[3].spans[1].content, "Item 2");
}
#[test]
fn test_mixed_list_types() {
let md = Markdown::from_str("1. First\n * Nested bullet\n2. Second");
let text = md.get_text();
// Should have 4 lines: 3 items + empty line after list
assert_eq!(text.lines.len(), 3);
assert_eq!(text.lines[0].spans[0].content, "1. ");
assert_eq!(text.lines[0].spans[1].content, "First");
assert_eq!(text.lines[1].spans[0].content, " * ");
assert_eq!(text.lines[1].spans[1].content, "Nested bullet");
assert_eq!(text.lines[2].spans[0].content, "2. ");
assert_eq!(text.lines[2].spans[1].content, "Second");
}
#[test]
fn test_links() {
let md = Markdown::from_str("[Link text](https://example.com)");
let text = md.get_text();
// Should have 2 lines: paragraph + empty line
assert_eq!(text.lines.len(), 2);
// Check link text is rendered
assert_eq!(text.lines[0].spans[0].content, "Link text");
// Check link is stored
assert!(md.links.contains(&String::from("(https://example.com)")));
}
#[test]
fn test_handle_input() {
let md = Markdown::from_str("[Link 1](https://example1.com)\n[Link 2](https://example2.com)");
// Test valid key input
let link1 = md.handle_input(KeyCode::Char('0'));
let link2 = md.handle_input(KeyCode::Char('1'));
assert!(link1.is_some());
assert!(link2.is_some());
// Test invalid key input
let invalid = md.handle_input(KeyCode::Enter);
assert!(invalid.is_none());
}
#[test]
fn test_complex_document() {
let markdown = r#"
# Main Heading
This is a paragraph with *italic* and **bold** text.
## Subheading
* List item 1
* List item 2
* Nested item 1
* Nested item 2
* List item 3
1. Ordered item 1
2. Ordered item 2
[Link to example](https://example.com)
"#;
let md = Markdown::from_str(markdown);
let text = md.get_text();
// Basic validation that parsing worked
assert!(text.lines.len() > 10);
// Check link is stored
assert!(md.links.contains(&String::from("(https://example.com)")));
}
}

View File

@ -11,6 +11,7 @@ use super::*;
pub mod viewport; pub mod viewport;
pub use viewport::Viewport; pub use viewport::Viewport;
pub mod dialog; pub mod dialog;
pub mod markdown;
#[cfg(test)] #[cfg(test)]
mod test; mod test;
@ -99,11 +100,17 @@ impl<'widget, 'ws: 'widget> Widget for &'widget mut Workspace<'ws> {
Self: Sized, Self: Sized,
{ {
if self.state.modality() == &Modality::Dialog { if self.state.modality() == &Modality::Dialog {
let lines = Text::from_iter(self.state.popup.iter().cloned()); let lines = self
.state
.popup
.as_ref()
.map(|md| md.get_text())
.unwrap_or_else(|| Text::raw("Popup message here"));
let popup = dialog::Dialog::new(lines, "Help").scroll(self.state.dialog_scroll); let popup = dialog::Dialog::new(lines, "Help").scroll(self.state.dialog_scroll);
popup.render(area, buf); popup.render(area, buf);
} else if self.state.modality() == &Modality::Quit { } else if self.state.modality() == &Modality::Quit {
let popup = dialog::Dialog::new(Text::raw("File is not yet saved. Save it first?"), "Quit") let popup =
dialog::Dialog::new(Text::raw("File is not yet saved. Save it first?"), "Quit")
.with_bottom_title("Y/N"); .with_bottom_title("Y/N");
popup.render(area, buf); popup.render(area, buf);
} else { } else {

View File

@ -14,8 +14,11 @@ fn test_viewport_get_visible_columns() {
let default_size = book.get_col_size(1).expect("Failed to get column size"); let default_size = book.get_col_size(1).expect("Failed to get column size");
let width = default_size * 12 / 2; let width = default_size * 12 / 2;
let app_state = AppState::default(); let app_state = AppState::default();
let viewport = Viewport::new(&book, Some(&app_state.range_select)) let viewport = Viewport::new(&book, Some(&app_state.range_select)).with_selected(Address {
.with_selected(Address { sheet: 0, row: 1, col: 17 }); sheet: 0,
row: 1,
col: 17,
});
let cols = viewport let cols = viewport
.get_visible_columns((width + 5) as u16, &mut state) .get_visible_columns((width + 5) as u16, &mut state)
.expect("Failed to get visible columns"); .expect("Failed to get visible columns");
@ -31,8 +34,11 @@ fn test_viewport_get_visible_rows() {
); );
let height = 6; let height = 6;
let app_state = AppState::default(); let app_state = AppState::default();
let viewport = Viewport::new(&book, Some(&app_state.range_select)) let viewport = Viewport::new(&book, Some(&app_state.range_select)).with_selected(Address {
.with_selected(Address { sheet: 0, row: 17, col: 1 }); sheet: 0,
row: 17,
col: 1,
});
let rows = viewport.get_visible_rows(height as u16, &mut state); let rows = viewport.get_visible_rows(height as u16, &mut state);
assert_eq!(height - 1, rows.len()); assert_eq!(height - 1, rows.len());
assert_eq!( assert_eq!(
@ -52,8 +58,11 @@ fn test_viewport_visible_columns_after_length_change() {
let width = default_size * 12 / 2; let width = default_size * 12 / 2;
{ {
let app_state = AppState::default(); let app_state = AppState::default();
let viewport = Viewport::new(&book, Some(&app_state.range_select)) let viewport = Viewport::new(&book, Some(&app_state.range_select)).with_selected(Address {
.with_selected(Address { sheet: 0, row: 1, col: 17 }); sheet: 0,
row: 1,
col: 17,
});
let cols = viewport let cols = viewport
.get_visible_columns((width + 5) as u16, &mut state) .get_visible_columns((width + 5) as u16, &mut state)
.expect("Failed to get visible columns"); .expect("Failed to get visible columns");
@ -65,8 +74,11 @@ fn test_viewport_visible_columns_after_length_change() {
.expect("Failed to set column size"); .expect("Failed to set column size");
{ {
let app_state = AppState::default(); let app_state = AppState::default();
let viewport = Viewport::new(&book, Some(&app_state.range_select)) let viewport = Viewport::new(&book, Some(&app_state.range_select)).with_selected(Address {
.with_selected(Address { sheet: 0, row: 1, col: 1 }); sheet: 0,
row: 1,
col: 1,
});
let cols = viewport let cols = viewport
.get_visible_columns((width + 5) as u16, &mut state) .get_visible_columns((width + 5) as u16, &mut state)
.expect("Failed to get visible columns"); .expect("Failed to get visible columns");

View File

@ -7,8 +7,8 @@ use ratatui::{
widgets::{Block, Cell, Row, StatefulWidget, Table, Widget}, widgets::{Block, Cell, Row, StatefulWidget, Table, Widget},
}; };
use crate::book;
use super::{Address, Book, RangeSelection}; use super::{Address, Book, RangeSelection};
use crate::book;
/// A visible column to show in our Viewport. /// A visible column to show in our Viewport.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@ -150,7 +150,11 @@ impl<'ws> Viewport<'ws> {
|VisibleColumn { idx: ci, length: _ }| { |VisibleColumn { idx: ci, length: _ }| {
let content = self let content = self
.book .book
.get_cell_addr_rendered(&Address { row: ri, col: *ci, sheet: self.book.location.sheet}) .get_cell_addr_rendered(&Address {
row: ri,
col: *ci,
sheet: self.book.location.sheet,
})
.unwrap(); .unwrap();
self.compute_cell_style(ri, *ci, Cell::new(Text::raw(content))) self.compute_cell_style(ri, *ci, Cell::new(Text::raw(content)))
}, },
@ -192,29 +196,27 @@ impl<'ws> Viewport<'ws> {
mut cell: Cell<'widget>, mut cell: Cell<'widget>,
) -> Cell<'widget> { ) -> Cell<'widget> {
// TODO(zaphar): Should probably create somekind of formatter abstraction. // TODO(zaphar): Should probably create somekind of formatter abstraction.
if let Some(style) = self if let Some(style) = self.book.get_cell_style(&Address {
.book sheet: self.book.location.sheet,
.get_cell_style(&Address { sheet: self.book.location.sheet, row: ri, col: ci }) { row: ri,
col: ci,
}) {
cell = self.compute_cell_colors(&style, ri, ci, cell); cell = self.compute_cell_colors(&style, ri, ci, cell);
cell = if style.font.b { cell = if style.font.b { cell.bold() } else { cell };
cell.bold() cell = if style.font.i { cell.italic() } else { cell };
} else { cell };
cell = if style.font.i {
cell.italic()
} else { cell };
} }
cell cell
} }
fn compute_cell_colors<'widget>(&self, style: &ironcalc::base::types::Style, ri: usize, ci: usize, mut cell: Cell<'widget>) -> Cell<'widget> { fn compute_cell_colors<'widget>(
let bg_color = map_color( &self,
style.fill.bg_color.as_ref(), style: &ironcalc::base::types::Style,
Color::Rgb(35, 33, 54), ri: usize,
); ci: usize,
let fg_color = map_color( mut cell: Cell<'widget>,
style.fill.fg_color.as_ref(), ) -> Cell<'widget> {
Color::White, let bg_color = map_color(style.fill.bg_color.as_ref(), Color::Rgb(35, 33, 54));
); let fg_color = map_color(style.fill.fg_color.as_ref(), Color::White);
if let Some((start, end)) = &self.range_selection.map_or(None, |r| r.get_range()) { if let Some((start, end)) = &self.range_selection.map_or(None, |r| r.get_range()) {
if ri >= start.row && ri <= end.row && ci >= start.col && ci <= end.col { if ri >= start.row && ri <= end.row && ci >= start.col && ci <= end.col {
// This is a selected range // This is a selected range

View File

@ -395,7 +395,7 @@ macro_rules! assert_help_dialog {
.run(&mut ws) .run(&mut ws)
.expect("Failed to handle 'alt-h' key event"); .expect("Failed to handle 'alt-h' key event");
assert_eq!(Some(&Modality::Dialog), ws.state.modality_stack.last()); assert_eq!(Some(&Modality::Dialog), ws.state.modality_stack.last());
assert_eq!(edit_help, ws.state.popup); assert_eq!(Some(edit_help), ws.state.popup);
$exit.run(&mut ws).expect("Failed to handle key event"); $exit.run(&mut ws).expect("Failed to handle key event");
assert_eq!(Some(&Modality::CellEdit), ws.state.modality_stack.last()); assert_eq!(Some(&Modality::CellEdit), ws.state.modality_stack.last());
}}; }};
@ -431,7 +431,7 @@ fn test_navigation_mode_help_keycode() {
.run(&mut ws) .run(&mut ws)
.expect("Failed to handle 'alt-h' key event"); .expect("Failed to handle 'alt-h' key event");
assert_eq!(Some(&Modality::Dialog), ws.state.modality_stack.last()); assert_eq!(Some(&Modality::Dialog), ws.state.modality_stack.last());
assert_eq!(help_text, ws.state.popup); assert_eq!(Some(help_text), ws.state.popup);
} }
#[test] #[test]
@ -449,7 +449,7 @@ fn test_command_mode_help_keycode() {
.run(&mut ws) .run(&mut ws)
.expect("Failed to handle 'alt-h' key event"); .expect("Failed to handle 'alt-h' key event");
assert_eq!(Some(&Modality::Dialog), ws.state.modality_stack.last()); assert_eq!(Some(&Modality::Dialog), ws.state.modality_stack.last());
assert_eq!(edit_help, ws.state.popup); assert_eq!(Some(edit_help), ws.state.popup);
} }
#[test] #[test]
@ -554,9 +554,7 @@ fn test_range_copy() {
assert_eq!(Some(&Modality::Navigate), ws.state.modality_stack.last()); assert_eq!(Some(&Modality::Navigate), ws.state.modality_stack.last());
let address = Address::default(); let address = Address::default();
ws.book ws.book.move_to(&address).expect("Failed to move to row");
.move_to(&address)
.expect("Failed to move to row");
let original_loc = ws.book.location.clone(); let original_loc = ws.book.location.clone();
script() script()
.ctrl('r') .ctrl('r')
@ -576,7 +574,11 @@ fn test_range_copy() {
.run(&mut ws) .run(&mut ws)
.expect("Failed to handle key sequence"); .expect("Failed to handle key sequence");
assert_eq!( assert_eq!(
Some(Address { sheet: 0, row: 1, col: 2 }), Some(Address {
sheet: 0,
row: 1,
col: 2
}),
ws.state.range_select.start ws.state.range_select.start
); );
@ -588,18 +590,40 @@ fn test_range_copy() {
assert!(ws.state.range_select.original_location.is_none()); assert!(ws.state.range_select.original_location.is_none());
assert_eq!( assert_eq!(
Some(Address { sheet: 0, row: 1, col: 2 }), Some(Address {
sheet: 0,
row: 1,
col: 2
}),
ws.state.range_select.start ws.state.range_select.start
); );
assert_eq!(Some(Address { sheet: 0, row: 2, col: 2 }), ws.state.range_select.end); assert_eq!(
Some(Address {
sheet: 0,
row: 2,
col: 2
}),
ws.state.range_select.end
);
assert_eq!(original_loc, ws.book.location); assert_eq!(original_loc, ws.book.location);
assert_eq!(Some(&Modality::Navigate), ws.state.modality_stack.last()); assert_eq!(Some(&Modality::Navigate), ws.state.modality_stack.last());
ws.book ws.book
.move_to(&Address { sheet: 0, row: 5, col: 5 }) .move_to(&Address {
sheet: 0,
row: 5,
col: 5,
})
.expect("Failed to move to row"); .expect("Failed to move to row");
let original_loc_2 = ws.book.location.clone(); let original_loc_2 = ws.book.location.clone();
assert_eq!(Address { sheet: 0, row: 5, col: 5 }, original_loc_2); assert_eq!(
Address {
sheet: 0,
row: 5,
col: 5
},
original_loc_2
);
script() script()
.char('v') .char('v')
@ -619,7 +643,11 @@ fn test_range_copy() {
.run(&mut ws) .run(&mut ws)
.expect("Failed to handle key sequence"); .expect("Failed to handle key sequence");
assert_eq!( assert_eq!(
Some(Address { sheet: 0, row: 5, col: 5 }), Some(Address {
sheet: 0,
row: 5,
col: 5
}),
ws.state.range_select.start ws.state.range_select.start
); );
@ -631,11 +659,29 @@ fn test_range_copy() {
assert!(ws.state.range_select.original_location.is_none()); assert!(ws.state.range_select.original_location.is_none());
assert_eq!( assert_eq!(
Some(Address { sheet: 0, row: 5, col: 5 }), Some(Address {
sheet: 0,
row: 5,
col: 5
}),
ws.state.range_select.start ws.state.range_select.start
); );
assert_eq!(Some(Address { sheet: 0, row: 5, col: 4 }), ws.state.range_select.end); assert_eq!(
assert_eq!(Address { sheet: 0, row: 4, col: 5 }, ws.book.location); Some(Address {
sheet: 0,
row: 5,
col: 4
}),
ws.state.range_select.end
);
assert_eq!(
Address {
sheet: 0,
row: 4,
col: 5
},
ws.book.location
);
assert_eq!(Some(&Modality::Navigate), ws.state.modality_stack.last()); assert_eq!(Some(&Modality::Navigate), ws.state.modality_stack.last());
} }
@ -664,14 +710,28 @@ fn test_gg_movement() {
.char('j') .char('j')
.run(&mut ws) .run(&mut ws)
.expect("failed to handle event sequence"); .expect("failed to handle event sequence");
assert_eq!(ws.book.location, Address { sheet: 0, row: 3, col: 1 }); assert_eq!(
ws.book.location,
Address {
sheet: 0,
row: 3,
col: 1
}
);
script() script()
.char('l') .char('l')
.char('g') .char('g')
.char('g') .char('g')
.run(&mut ws) .run(&mut ws)
.expect("failed to handle event sequence"); .expect("failed to handle event sequence");
assert_eq!(ws.book.location, Address { sheet: 0, row: 1, col: 2 }); assert_eq!(
ws.book.location,
Address {
sheet: 0,
row: 1,
col: 2
}
);
} }
#[test] #[test]
@ -684,14 +744,28 @@ fn test_h_j_k_l_movement() {
.char('l') .char('l')
.run(&mut ws) .run(&mut ws)
.expect("failed to handle event sequence"); .expect("failed to handle event sequence");
assert_eq!(ws.book.location, Address { sheet: 0, row: 3, col: 2 }); assert_eq!(
ws.book.location,
Address {
sheet: 0,
row: 3,
col: 2
}
);
script() script()
.char('h') .char('h')
.char('2') .char('2')
.char('k') .char('k')
.run(&mut ws) .run(&mut ws)
.expect("failed to handle event sequence"); .expect("failed to handle event sequence");
assert_eq!(ws.book.location, Address { sheet: 0, row: 1, col: 1 }); assert_eq!(
ws.book.location,
Address {
sheet: 0,
row: 1,
col: 1
}
);
} }
macro_rules! assert_copy_paste { macro_rules! assert_copy_paste {
@ -930,8 +1004,16 @@ fn test_command_mode_enter() {
fn test_edit_mode_paste() { fn test_edit_mode_paste() {
let mut ws = new_workspace(); let mut ws = new_workspace();
assert_eq!(Some(&Modality::Navigate), ws.state.modality_stack.last()); assert_eq!(Some(&Modality::Navigate), ws.state.modality_stack.last());
ws.state.range_select.start = Some(Address { sheet: 0, row: 1, col: 1 }); ws.state.range_select.start = Some(Address {
ws.state.range_select.end = Some(Address { sheet: 0, row: 2, col: 2 }); sheet: 0,
row: 1,
col: 1,
});
ws.state.range_select.end = Some(Address {
sheet: 0,
row: 2,
col: 2,
});
script() script()
.char('e') .char('e')
.ctrl('p') .ctrl('p')
@ -979,8 +1061,16 @@ macro_rules! assert_range_clear {
($script : expr) => {{ ($script : expr) => {{
let mut ws = new_workspace(); let mut ws = new_workspace();
assert_eq!(Some(&Modality::Navigate), ws.state.modality_stack.last()); assert_eq!(Some(&Modality::Navigate), ws.state.modality_stack.last());
let first_corner = Address { sheet: 0, row: 1, col: 1 }; let first_corner = Address {
let second_corner = Address { sheet: 0, row: 2, col: 2 }; sheet: 0,
row: 1,
col: 1,
};
let second_corner = Address {
sheet: 0,
row: 2,
col: 2,
};
ws.book ws.book
.update_cell(&first_corner, "foo") .update_cell(&first_corner, "foo")
.expect("Failed to update cell"); .expect("Failed to update cell");
@ -1050,7 +1140,14 @@ fn test_range_select_movement() {
.char('k') .char('k')
.run(&mut ws) .run(&mut ws)
.expect("failed to run script"); .expect("failed to run script");
assert_eq!(&Address { sheet: 0, row: 3, col: 3 }, &ws.book.location); assert_eq!(
&Address {
sheet: 0,
row: 3,
col: 3
},
&ws.book.location
);
script() script()
.ctrl('n') .ctrl('n')
.run(&mut ws) .run(&mut ws)
@ -1071,8 +1168,16 @@ fn test_range_select_clear_lower_d() {
macro_rules! assert_range_copy { macro_rules! assert_range_copy {
($script: expr) => {{ ($script: expr) => {{
let mut ws = new_workspace(); let mut ws = new_workspace();
let top_left_addr = Address { sheet: 0, row: 2, col: 2 }; let top_left_addr = Address {
let bot_right_addr = Address { sheet: 0, row: 4, col: 4 }; sheet: 0,
row: 2,
col: 2,
};
let bot_right_addr = Address {
sheet: 0,
row: 4,
col: 4,
};
ws.book ws.book
.update_cell(&top_left_addr, "top_left") .update_cell(&top_left_addr, "top_left")
.expect("Failed to update top left"); .expect("Failed to update top left");
@ -1111,7 +1216,11 @@ macro_rules! assert_range_copy {
.expect("Didn't find a start of range") .expect("Didn't find a start of range")
); );
assert_eq!( assert_eq!(
&Address { sheet: 0, row: 1, col: 1 }, &Address {
sheet: 0,
row: 1,
col: 1
},
ws.state ws.state
.range_select .range_select
.original_location .original_location
@ -1179,7 +1288,11 @@ fn test_extend_to_range() {
.expect("Unable to run script"); .expect("Unable to run script");
let extended_cell = ws let extended_cell = ws
.book .book
.get_cell_addr_contents(&Address { sheet: 0, row: 2, col: 1 }) .get_cell_addr_contents(&Address {
sheet: 0,
row: 2,
col: 1,
})
.expect("Failed to get cell contents"); .expect("Failed to get cell contents");
assert_eq!("=B2+1".to_string(), extended_cell); assert_eq!("=B2+1".to_string(), extended_cell);
} }
@ -1199,7 +1312,11 @@ fn test_color_cells() {
for ci in 1..=3 { for ci in 1..=3 {
let style = ws let style = ws
.book .book
.get_cell_style(&Address { sheet: ws.book.location.sheet, row: ri, col: ci }) .get_cell_style(&Address {
sheet: ws.book.location.sheet,
row: ri,
col: ci,
})
.expect("failed to get style"); .expect("failed to get style");
assert_eq!( assert_eq!(
"#800000", "#800000",
@ -1225,7 +1342,11 @@ fn test_color_row() {
for ci in [1, book::LAST_COLUMN] { for ci in [1, book::LAST_COLUMN] {
let style = ws let style = ws
.book .book
.get_cell_style(&Address { sheet: ws.book.location.sheet, row: 1, col: ci as usize }) .get_cell_style(&Address {
sheet: ws.book.location.sheet,
row: 1,
col: ci as usize,
})
.expect("failed to get style"); .expect("failed to get style");
assert_eq!( assert_eq!(
"#800000", "#800000",
@ -1250,7 +1371,11 @@ fn test_color_col() {
for ri in [1, book::LAST_ROW] { for ri in [1, book::LAST_ROW] {
let style = ws let style = ws
.book .book
.get_cell_style(&Address { sheet: ws.book.location.sheet, row: ri as usize, col: 1 }) .get_cell_style(&Address {
sheet: ws.book.location.sheet,
row: ri as usize,
col: 1,
})
.expect("failed to get style"); .expect("failed to get style");
assert_eq!( assert_eq!(
"#800000", "#800000",
@ -1268,7 +1393,11 @@ fn test_bold_text() {
let mut ws = new_workspace(); let mut ws = new_workspace();
let before_style = ws let before_style = ws
.book .book
.get_cell_style(&Address { sheet: 0, row: 1, col: 1 }) .get_cell_style(&Address {
sheet: 0,
row: 1,
col: 1,
})
.expect("Failed to get style"); .expect("Failed to get style");
assert!(!before_style.font.b); assert!(!before_style.font.b);
script() script()
@ -1277,7 +1406,11 @@ fn test_bold_text() {
.expect("Unable to run script"); .expect("Unable to run script");
let style = ws let style = ws
.book .book
.get_cell_style(&Address { sheet: 0, row: 1, col: 1 }) .get_cell_style(&Address {
sheet: 0,
row: 1,
col: 1,
})
.expect("Failed to get style"); .expect("Failed to get style");
assert!(style.font.b); assert!(style.font.b);
script() script()
@ -1292,7 +1425,11 @@ fn test_italic_text() {
let mut ws = new_workspace(); let mut ws = new_workspace();
let before_style = ws let before_style = ws
.book .book
.get_cell_style(&Address { sheet: 0, row: 1, col: 1 }) .get_cell_style(&Address {
sheet: 0,
row: 1,
col: 1,
})
.expect("Failed to get style"); .expect("Failed to get style");
assert!(!before_style.font.i); assert!(!before_style.font.i);
script() script()
@ -1301,7 +1438,11 @@ fn test_italic_text() {
.expect("Unable to run script"); .expect("Unable to run script");
let style = ws let style = ws
.book .book
.get_cell_style(&Address { sheet: 0, row: 1, col: 1 }) .get_cell_style(&Address {
sheet: 0,
row: 1,
col: 1,
})
.expect("Failed to get style"); .expect("Failed to get style");
assert!(style.font.i); assert!(style.font.i);
script() script()