diff --git a/.gitignore b/.gitignore index 7137221..def140c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ /target -result/ +/result *.json tarpaulin-report.* *.profraw diff --git a/Cargo.lock b/Cargo.lock index ffdc001..a0f76e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1210,6 +1210,19 @@ dependencies = [ "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]] name = "pulldown-cmark-escape" version = "0.11.0" @@ -1500,6 +1513,7 @@ dependencies = [ "csv", "futures", "ironcalc", + "pulldown-cmark 0.13.0", "ratatui", "serde_json", "slice-utils", @@ -1758,7 +1772,7 @@ dependencies = [ "ansi-to-tui", "itertools 0.13.0", "pretty_assertions", - "pulldown-cmark", + "pulldown-cmark 0.12.2", "ratatui", "rstest", "syntect", diff --git a/Cargo.toml b/Cargo.toml index d310e68..ca39db7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,3 +21,4 @@ serde_json = "1.0.133" colorsys = "0.6.7" tui-markdown = { version = "0.3.1", features = [] } csv = "1.3.1" +pulldown-cmark = "0.13.0" diff --git a/src/book/test.rs b/src/book/test.rs index 6ece6b7..240a288 100644 --- a/src/book/test.rs +++ b/src/book/test.rs @@ -36,8 +36,15 @@ fn test_book_default() { #[test] fn test_book_insert_cell_new_row() { let mut book = Book::default(); - book.update_cell(&Address { sheet: 0, row: 2, col: 1 }, "1") - .expect("failed to edit cell"); + book.update_cell( + &Address { + sheet: 0, + row: 2, + col: 1, + }, + "1", + ) + .expect("failed to edit cell"); book.evaluate(); let WorksheetDimension { min_row, @@ -52,8 +59,15 @@ fn test_book_insert_cell_new_row() { #[test] fn test_book_insert_cell_new_column() { let mut book = Book::default(); - book.update_cell(&Address { sheet: 0, row: 1, col: 2 }, "1") - .expect("failed to edit cell"); + book.update_cell( + &Address { + sheet: 0, + row: 1, + col: 2, + }, + "1", + ) + .expect("failed to edit cell"); let WorksheetDimension { min_row, max_row, @@ -67,14 +81,32 @@ fn test_book_insert_cell_new_column() { #[test] fn test_book_insert_rows() { let mut book = Book::default(); - book.update_cell(&Address { sheet: 0, row: 2, col: 2 }, "1") - .expect("failed to edit cell"); - book.move_to(&Address { sheet: 0, row: 2, col: 2 }) - .expect("Failed to move to location"); + book.update_cell( + &Address { + sheet: 0, + row: 2, + col: 2, + }, + "1", + ) + .expect("failed to edit cell"); + book.move_to(&Address { + sheet: 0, + row: 2, + col: 2, + }) + .expect("Failed to move to location"); assert_eq!((2, 2), book.get_size().expect("Failed to get size")); book.insert_rows(1, 5).expect("Failed to insert rows"); 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!( "1", book.get_current_cell_rendered() @@ -85,14 +117,32 @@ fn test_book_insert_rows() { #[test] fn test_book_insert_columns() { let mut book = Book::default(); - book.update_cell(&Address { sheet: 0, row: 2, col: 2 }, "1") - .expect("failed to edit cell"); - book.move_to(&Address { sheet: 0, row: 2, col: 2 }) - .expect("Failed to move to location"); + book.update_cell( + &Address { + sheet: 0, + row: 2, + col: 2, + }, + "1", + ) + .expect("failed to edit cell"); + book.move_to(&Address { + sheet: 0, + row: 2, + col: 2, + }) + .expect("Failed to move to location"); assert_eq!((2, 2), book.get_size().expect("Failed to get size")); book.insert_columns(1, 5).expect("Failed to insert rows"); 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!( "1", book.get_current_cell_rendered() @@ -103,8 +153,15 @@ fn test_book_insert_columns() { #[test] fn test_book_col_size() { let mut book = Book::default(); - book.update_cell(&Address { sheet: 0, row: 2, col: 2 }, "1") - .expect("failed to edit cell"); + book.update_cell( + &Address { + sheet: 0, + row: 2, + col: 2, + }, + "1", + ) + .expect("failed to edit cell"); 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")); } @@ -112,17 +169,34 @@ fn test_book_col_size() { #[test] fn test_book_get_exportable_rows() { let mut book = Book::default(); - book.update_cell(&Address { sheet: 0, row: 1, col: 3 }, "1-3") - .expect("failed to edit cell"); - book.update_cell(&Address { sheet: 0, row: 3, col: 6 }, "3-6") - .expect("failed to edit cell"); + book.update_cell( + &Address { + sheet: 0, + row: 1, + col: 3, + }, + "1-3", + ) + .expect("failed to edit cell"); + book.update_cell( + &Address { + sheet: 0, + row: 3, + col: 6, + }, + "3-6", + ) + .expect("failed to edit cell"); let rows = book.get_export_rows().expect("Failed to get export rows"); assert_eq!(4, rows.len()); - assert_eq!(rows, vec![ - vec!["", "" , "", "", "", "", ""], - vec!["", "" , "", "1-3", "", "", ""], - vec!["", "" , "", "", "", "", ""], - vec!["", "" , "", "", "", "", "3-6"], - ]); + assert_eq!( + rows, + vec![ + vec!["", "", "", "", "", "", ""], + vec!["", "", "", "1-3", "", "", ""], + vec!["", "", "", "", "", "", ""], + vec!["", "", "", "", "", "", "3-6"], + ] + ); } diff --git a/src/ui/help/mod.rs b/src/ui/help/mod.rs index 898253c..beaddf8 100644 --- a/src/ui/help/mod.rs +++ b/src/ui/help/mod.rs @@ -1,12 +1,11 @@ -use ratatui::text::Text; -use tui_markdown; +use crate::ui::render::markdown::Markdown; -pub fn render_topic(topic: &str) -> Text<'static> { +pub fn to_widget(topic: &str) -> Markdown { match topic { - "navigate" => tui_markdown::from_str(include_str!("../../../docs/navigation.md")), - "edit" => tui_markdown::from_str(include_str!("../../../docs/edit.md")), - "command" => tui_markdown::from_str(include_str!("../../../docs/command.md")), - "visual" => tui_markdown::from_str(include_str!("../../../docs/visual.md")), - _ => tui_markdown::from_str(include_str!("../../../docs/intro.md")), + "navigate" => Markdown::from_str(include_str!("../../../docs/navigation.md")), + "edit" => Markdown::from_str(include_str!("../../../docs/edit.md")), + "command" => Markdown::from_str(include_str!("../../../docs/command.md")), + "visual" => Markdown::from_str(include_str!("../../../docs/visual.md")), + _ => Markdown::from_str(include_str!("../../../docs/intro.md")), } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 604064a..5bba2a0 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -7,19 +7,22 @@ use anyhow::{anyhow, Result}; use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; use ironcalc::base::{expressions::types::Area, Model}; 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_textarea::{CursorMove, TextArea}; -mod help; mod cmd; +mod help; pub mod render; #[cfg(test)] mod test; use cmd::Cmd; -use render::viewport::ViewportState; +use render::{markdown::Markdown, viewport::ViewportState}; #[derive(Default, Debug, PartialEq, Clone)] pub enum Modality { @@ -80,7 +83,7 @@ pub struct AppState<'ws> { pub range_select: RangeSelection, pub dialog_scroll: u16, dirty: bool, - popup: Text<'ws>, + popup: Option, clipboard: Option, } @@ -299,16 +302,16 @@ impl<'ws> Workspace<'ws> { 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. // Ideally we would also render the markdown content properly. // https://github.com/zaphar/sheetsui/issues/22 match self.state.modality() { - Modality::Navigate => help::render_topic("navigate"), - Modality::CellEdit => help::render_topic("edit"), - Modality::Command => help::render_topic("command"), - Modality::RangeSelect => help::render_topic("visual"), - _ => help::render_topic(""), + Modality::Navigate => help::to_widget("navigate"), + Modality::CellEdit => help::to_widget("edit"), + Modality::Command => help::to_widget("command"), + Modality::RangeSelect => help::to_widget("visual"), + _ => help::to_widget(""), } } @@ -332,16 +335,16 @@ impl<'ws> Workspace<'ws> { 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') => { + KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => { self.exit_quit_mode()?; - return Ok(Some(ExitCode::SUCCESS)) - }, + return Ok(Some(ExitCode::SUCCESS)); + } KeyCode::Char('y') | KeyCode::Char('Y') => { // We have been asked to save the file first. self.save_file()?; self.exit_quit_mode()?; return Ok(Some(ExitCode::SUCCESS)); - }, + } _ => return Ok(None), } } @@ -361,8 +364,10 @@ impl<'ws> Workspace<'ws> { KeyCode::Char('k') | KeyCode::Up => { self.state.dialog_scroll = self.state.dialog_scroll.saturating_sub(1); } - _ => { - // NOOP + code => { + if let Some(widget) = &self.state.popup { + widget.handle_input(code); + } } } } @@ -414,7 +419,7 @@ impl<'ws> Workspace<'ws> { Ok(None) } 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(Some(Cmd::Write(maybe_path))) => { @@ -426,7 +431,8 @@ impl<'ws> Workspace<'ws> { Ok(None) } 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(Some(Cmd::InsertColumns(count))) => { @@ -508,11 +514,14 @@ impl<'ws> Workspace<'ws> { 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) } Err(msg) => { - self.enter_dialog_mode(vec![Line::from(msg.to_owned())]); + self.enter_dialog_mode(Markdown::from_str(msg)); Ok(None) } } @@ -543,18 +552,12 @@ impl<'ws> Workspace<'ws> { } KeyCode::Char('D') => { if let Some((start, end)) = self.state.range_select.get_range() { - self.book.clear_cell_range_all( - start, - end, - )?; + self.book.clear_cell_range_all(start, end)?; } } KeyCode::Char('d') => { if let Some((start, end)) = self.state.range_select.get_range() { - self.book.clear_cell_range( - start, - end, - )?; + self.book.clear_cell_range(start, end)?; } } KeyCode::Char('h') => { @@ -775,7 +778,11 @@ impl<'ws> Workspace<'ws> { } KeyCode::Char('l') if key.modifiers == KeyModifiers::CONTROL => { 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 .set_col_size(*col, ws.book.get_col_size(*col)? + 1)?; Ok(()) @@ -783,7 +790,11 @@ impl<'ws> Workspace<'ws> { } KeyCode::Char('h') if key.modifiers == KeyModifiers::CONTROL => { 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)?; if curr_size > 1 { ws.book.set_col_size(*col, curr_size - 1)?; @@ -874,21 +885,31 @@ impl<'ws> Workspace<'ws> { return Ok(None); } - fn toggle_bool_style(&mut self, current_val: Option, path: &str, address: &Address) -> Result<(), anyhow::Error> { + fn toggle_bool_style( + &mut self, + current_val: Option, + path: &str, + address: &Address, + ) -> Result<(), anyhow::Error> { let value = if let Some(b_val) = current_val { - if b_val { "false" } else { "true" } + if b_val { + "false" + } else { + "true" + } } else { "true" }; self.book.set_cell_style( &[(path, value)], &Area { - sheet: address.sheet, - row: address.row as i32, - column: address.col as i32, - width: 1, - height: 1, - })?; + sheet: address.sheet, + row: address.row as i32, + column: address.col as i32, + width: 1, + height: 1, + }, + )?; Ok(()) } @@ -951,8 +972,8 @@ impl<'ws> Workspace<'ws> { self.state.command_state.focus(); } - fn enter_dialog_mode>>(&mut self, msg: T) { - self.state.popup = msg.into(); + fn enter_dialog_mode(&mut self, msg: Markdown) { + self.state.popup = Some(msg); self.state.modality_stack.push(Modality::Dialog); } @@ -981,7 +1002,7 @@ impl<'ws> Workspace<'ws> { 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(); @@ -1045,14 +1066,13 @@ impl<'ws> Workspace<'ws> { 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)) + 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 6c78612..c9bec90 100644 --- a/src/ui/render/dialog.rs +++ b/src/ui/render/dialog.rs @@ -11,6 +11,7 @@ pub struct Dialog<'w> { title: &'w str, bottom_title: &'w str, scroll: (u16, u16), + // TODO(zaphar): Have a max margin? } impl<'w> Dialog<'w> { @@ -39,19 +40,29 @@ impl<'w> Widget for Dialog<'w> { Self: Sized, { // First find the center of the area. - let content_width = self.content.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) + let content_width = 120 + 2; + let content_height = (self.content.height() + 2) as u16; + let vertical_margin = if content_height <= area.height { + area.height + .saturating_sub(content_height as u16) + .saturating_div(2) } 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![ Constraint::Length(vertical_margin), Constraint::Fill(1), Constraint::Length(vertical_margin), - ]).areas(area); + ]) + .areas(area); let [_, dialog_area, _] = Layout::horizontal(vec![ Constraint::Length(horizontal_margin), Constraint::Fill(1), diff --git a/src/ui/render/markdown.rs b/src/ui/render/markdown.rs new file mode 100644 index 0000000..bedee6f --- /dev/null +++ b/src/ui/render/markdown.rs @@ -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, + parsed_text: Option>, +} + +/// 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 = Vec::new(); + let mut state_stack: Vec = 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 { + 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)"))); + } +} diff --git a/src/ui/render/mod.rs b/src/ui/render/mod.rs index 8984c6e..162d710 100644 --- a/src/ui/render/mod.rs +++ b/src/ui/render/mod.rs @@ -11,6 +11,7 @@ use super::*; pub mod viewport; pub use viewport::Viewport; pub mod dialog; +pub mod markdown; #[cfg(test)] mod test; @@ -99,12 +100,18 @@ impl<'widget, 'ws: 'widget> Widget for &'widget mut Workspace<'ws> { Self: Sized, { 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); 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"); + 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() diff --git a/src/ui/render/test.rs b/src/ui/render/test.rs index a37994d..a92bb2f 100644 --- a/src/ui/render/test.rs +++ b/src/ui/render/test.rs @@ -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 width = default_size * 12 / 2; let app_state = AppState::default(); - let viewport = Viewport::new(&book, Some(&app_state.range_select)) - .with_selected(Address { sheet: 0, row: 1, col: 17 }); + let viewport = Viewport::new(&book, Some(&app_state.range_select)).with_selected(Address { + sheet: 0, + row: 1, + col: 17, + }); let cols = viewport .get_visible_columns((width + 5) as u16, &mut state) .expect("Failed to get visible columns"); @@ -31,8 +34,11 @@ fn test_viewport_get_visible_rows() { ); let height = 6; let app_state = AppState::default(); - let viewport = Viewport::new(&book, Some(&app_state.range_select)) - .with_selected(Address { sheet: 0, row: 17, col: 1 }); + let viewport = Viewport::new(&book, Some(&app_state.range_select)).with_selected(Address { + sheet: 0, + row: 17, + col: 1, + }); let rows = viewport.get_visible_rows(height as u16, &mut state); assert_eq!(height - 1, rows.len()); assert_eq!( @@ -52,8 +58,11 @@ fn test_viewport_visible_columns_after_length_change() { let width = default_size * 12 / 2; { let app_state = AppState::default(); - let viewport = Viewport::new(&book, Some(&app_state.range_select)) - .with_selected(Address { sheet: 0, row: 1, col: 17 }); + let viewport = Viewport::new(&book, Some(&app_state.range_select)).with_selected(Address { + sheet: 0, + row: 1, + col: 17, + }); let cols = viewport .get_visible_columns((width + 5) as u16, &mut state) .expect("Failed to get visible columns"); @@ -65,8 +74,11 @@ fn test_viewport_visible_columns_after_length_change() { .expect("Failed to set column size"); { let app_state = AppState::default(); - let viewport = Viewport::new(&book, Some(&app_state.range_select)) - .with_selected(Address { sheet: 0, row: 1, col: 1 }); + let viewport = Viewport::new(&book, Some(&app_state.range_select)).with_selected(Address { + sheet: 0, + row: 1, + col: 1, + }); let cols = viewport .get_visible_columns((width + 5) as u16, &mut state) .expect("Failed to get visible columns"); diff --git a/src/ui/render/viewport.rs b/src/ui/render/viewport.rs index 1c74084..0e29a73 100644 --- a/src/ui/render/viewport.rs +++ b/src/ui/render/viewport.rs @@ -7,8 +7,8 @@ use ratatui::{ widgets::{Block, Cell, Row, StatefulWidget, Table, Widget}, }; -use crate::book; use super::{Address, Book, RangeSelection}; +use crate::book; /// A visible column to show in our Viewport. #[derive(Clone, Debug)] @@ -150,7 +150,11 @@ impl<'ws> Viewport<'ws> { |VisibleColumn { idx: ci, length: _ }| { let content = self .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(); self.compute_cell_style(ri, *ci, Cell::new(Text::raw(content))) }, @@ -192,29 +196,27 @@ impl<'ws> Viewport<'ws> { mut cell: Cell<'widget>, ) -> Cell<'widget> { // TODO(zaphar): Should probably create somekind of formatter abstraction. - if let Some(style) = self - .book - .get_cell_style(&Address { sheet: self.book.location.sheet, row: ri, col: ci }) { + if let Some(style) = self.book.get_cell_style(&Address { + sheet: self.book.location.sheet, + row: ri, + col: ci, + }) { cell = self.compute_cell_colors(&style, ri, ci, cell); - cell = if style.font.b { - cell.bold() - } else { cell }; - cell = if style.font.i { - cell.italic() - } else { cell }; + cell = if style.font.b { cell.bold() } else { cell }; + cell = if style.font.i { cell.italic() } else { cell }; } cell } - fn compute_cell_colors<'widget>(&self, style: &ironcalc::base::types::Style, ri: usize, ci: usize, mut cell: Cell<'widget>) -> Cell<'widget> { - 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, - ); + fn compute_cell_colors<'widget>( + &self, + style: &ironcalc::base::types::Style, + ri: usize, + ci: usize, + mut cell: Cell<'widget>, + ) -> Cell<'widget> { + 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 ri >= start.row && ri <= end.row && ci >= start.col && ci <= end.col { // This is a selected range diff --git a/src/ui/test.rs b/src/ui/test.rs index d75bf28..cb9c0a9 100644 --- a/src/ui/test.rs +++ b/src/ui/test.rs @@ -395,7 +395,7 @@ macro_rules! assert_help_dialog { .run(&mut ws) .expect("Failed to handle 'alt-h' key event"); 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"); assert_eq!(Some(&Modality::CellEdit), ws.state.modality_stack.last()); }}; @@ -431,7 +431,7 @@ fn test_navigation_mode_help_keycode() { .run(&mut ws) .expect("Failed to handle 'alt-h' key event"); 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] @@ -449,7 +449,7 @@ fn test_command_mode_help_keycode() { .run(&mut ws) .expect("Failed to handle 'alt-h' key event"); 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] @@ -554,9 +554,7 @@ fn test_range_copy() { assert_eq!(Some(&Modality::Navigate), ws.state.modality_stack.last()); let address = Address::default(); - ws.book - .move_to(&address) - .expect("Failed to move to row"); + ws.book.move_to(&address).expect("Failed to move to row"); let original_loc = ws.book.location.clone(); script() .ctrl('r') @@ -576,7 +574,11 @@ fn test_range_copy() { .run(&mut ws) .expect("Failed to handle key sequence"); assert_eq!( - Some(Address { sheet: 0, row: 1, col: 2 }), + Some(Address { + sheet: 0, + row: 1, + col: 2 + }), ws.state.range_select.start ); @@ -588,18 +590,40 @@ fn test_range_copy() { assert!(ws.state.range_select.original_location.is_none()); assert_eq!( - Some(Address { sheet: 0, row: 1, col: 2 }), + Some(Address { + sheet: 0, + row: 1, + col: 2 + }), 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!(Some(&Modality::Navigate), ws.state.modality_stack.last()); 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"); 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() .char('v') @@ -619,7 +643,11 @@ fn test_range_copy() { .run(&mut ws) .expect("Failed to handle key sequence"); assert_eq!( - Some(Address { sheet: 0, row: 5, col: 5 }), + Some(Address { + sheet: 0, + row: 5, + col: 5 + }), ws.state.range_select.start ); @@ -631,11 +659,29 @@ fn test_range_copy() { assert!(ws.state.range_select.original_location.is_none()); assert_eq!( - Some(Address { sheet: 0, row: 5, col: 5 }), + Some(Address { + sheet: 0, + row: 5, + col: 5 + }), ws.state.range_select.start ); - assert_eq!(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(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()); } @@ -664,14 +710,28 @@ fn test_gg_movement() { .char('j') .run(&mut ws) .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() .char('l') .char('g') .char('g') .run(&mut ws) .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] @@ -684,14 +744,28 @@ fn test_h_j_k_l_movement() { .char('l') .run(&mut ws) .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() .char('h') .char('2') .char('k') .run(&mut ws) .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 { @@ -930,8 +1004,16 @@ fn test_command_mode_enter() { fn test_edit_mode_paste() { let mut ws = new_workspace(); 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.end = Some(Address { sheet: 0, row: 2, col: 2 }); + ws.state.range_select.start = Some(Address { + sheet: 0, + row: 1, + col: 1, + }); + ws.state.range_select.end = Some(Address { + sheet: 0, + row: 2, + col: 2, + }); script() .char('e') .ctrl('p') @@ -979,8 +1061,16 @@ macro_rules! assert_range_clear { ($script : expr) => {{ let mut ws = new_workspace(); assert_eq!(Some(&Modality::Navigate), ws.state.modality_stack.last()); - let first_corner = Address { sheet: 0, row: 1, col: 1 }; - let second_corner = Address { sheet: 0, row: 2, col: 2 }; + let first_corner = Address { + sheet: 0, + row: 1, + col: 1, + }; + let second_corner = Address { + sheet: 0, + row: 2, + col: 2, + }; ws.book .update_cell(&first_corner, "foo") .expect("Failed to update cell"); @@ -1050,7 +1140,14 @@ fn test_range_select_movement() { .char('k') .run(&mut ws) .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() .ctrl('n') .run(&mut ws) @@ -1071,8 +1168,16 @@ fn test_range_select_clear_lower_d() { macro_rules! assert_range_copy { ($script: expr) => {{ let mut ws = new_workspace(); - let top_left_addr = Address { sheet: 0, row: 2, col: 2 }; - let bot_right_addr = Address { sheet: 0, row: 4, col: 4 }; + let top_left_addr = Address { + sheet: 0, + row: 2, + col: 2, + }; + let bot_right_addr = Address { + sheet: 0, + row: 4, + col: 4, + }; ws.book .update_cell(&top_left_addr, "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") ); assert_eq!( - &Address { sheet: 0, row: 1, col: 1 }, + &Address { + sheet: 0, + row: 1, + col: 1 + }, ws.state .range_select .original_location @@ -1179,7 +1288,11 @@ fn test_extend_to_range() { .expect("Unable to run script"); let extended_cell = ws .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"); assert_eq!("=B2+1".to_string(), extended_cell); } @@ -1199,7 +1312,11 @@ fn test_color_cells() { for ci in 1..=3 { let style = ws .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"); assert_eq!( "#800000", @@ -1225,7 +1342,11 @@ fn test_color_row() { for ci in [1, book::LAST_COLUMN] { let style = ws .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"); assert_eq!( "#800000", @@ -1250,7 +1371,11 @@ fn test_color_col() { for ri in [1, book::LAST_ROW] { let style = ws .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"); assert_eq!( "#800000", @@ -1268,7 +1393,11 @@ fn test_bold_text() { let mut ws = new_workspace(); let before_style = ws .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"); assert!(!before_style.font.b); script() @@ -1277,7 +1406,11 @@ fn test_bold_text() { .expect("Unable to run script"); let style = ws .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"); assert!(style.font.b); script() @@ -1292,7 +1425,11 @@ fn test_italic_text() { let mut ws = new_workspace(); let before_style = ws .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"); assert!(!before_style.font.i); script() @@ -1301,7 +1438,11 @@ fn test_italic_text() { .expect("Unable to run script"); let style = ws .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"); assert!(style.font.i); script() @@ -1331,7 +1472,7 @@ fn test_quit_dialog() { .run(&mut ws) .expect("Failed to run input script"); assert!(result.is_some()); - + script() .chars("efoo") .enter() @@ -1344,7 +1485,7 @@ fn test_quit_dialog() { .expect("Failed to run input script"); assert!(!result.is_some()); assert_eq!(ws.state.modality(), &Modality::Quit); - + script() .char('n') .run(&mut ws)