diff --git a/src/book/mod.rs b/src/book/mod.rs index f532252..a8b04a1 100644 --- a/src/book/mod.rs +++ b/src/book/mod.rs @@ -16,15 +16,13 @@ use crate::ui::Address; #[cfg(test)] mod test; -const COL_PIXELS: f64 = 10.0; +const COL_PIXELS: f64 = 5.0; /// A spreadsheet book with some internal state tracking. pub struct Book { pub(crate) model: Model, pub current_sheet: u32, pub location: crate::ui::Address, - // TODO(zaphar): Because the ironcalc model is sparse we need to track our render size - // separately } impl Book { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 259e419..851528f 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -6,11 +6,10 @@ use crate::book::Book; use anyhow::Result; use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; use ratatui::{ - self, buffer::Buffer, layout::{Constraint, Flex, Layout, Rect}, style::{Modifier, Style}, - widgets::{Block, Table, TableState, Widget}, + widgets::{Block, Widget}, }; use tui_prompts::{State, Status, TextPrompt, TextState}; use tui_textarea::{CursorMove, TextArea}; @@ -21,6 +20,7 @@ pub mod render; mod test; use cmd::Cmd; +use render::{viewport::ViewportState, Viewport}; #[derive(Default, Debug, PartialEq, Clone)] pub enum Modality { @@ -34,7 +34,7 @@ pub enum Modality { #[derive(Debug)] pub struct AppState<'ws> { pub modality_stack: Vec, - pub table_state: TableState, + pub viewport_state: ViewportState, pub command_state: TextState<'ws>, dirty: bool, popup: Vec, @@ -44,7 +44,7 @@ impl<'ws> Default for AppState<'ws> { fn default() -> Self { AppState { modality_stack: vec![Modality::default()], - table_state: Default::default(), + viewport_state: Default::default(), command_state: Default::default(), dirty: Default::default(), popup: Default::default(), @@ -123,8 +123,7 @@ impl<'ws> Workspace<'ws> { /// Move a row down in the current sheet. pub fn move_down(&mut self) -> Result<()> { let mut loc = self.book.location.clone(); - let (row_count, _) = self.book.get_size()?; - if loc.row < row_count { + if loc.row < render::viewport::LAST_ROW { loc.row += 1; self.book.move_to(&loc)?; } @@ -154,8 +153,7 @@ impl<'ws> Workspace<'ws> { /// Move a column to the left in the current sheet. pub fn move_right(&mut self) -> Result<()> { let mut loc = self.book.location.clone(); - let (_, col_count) = self.book.get_size()?; - if loc.col < col_count { + if loc.col < render::viewport::LAST_COLUMN { loc.col += 1; self.book.move_to(&loc)?; } @@ -164,8 +162,6 @@ impl<'ws> Workspace<'ws> { /// Handle input in our ui loop. pub fn handle_input(&mut self, evt: Event) -> Result> { - // TODO(jwall): We probably want to separate this out into - // a pure function so we can script various testing scenarios. if let Event::Key(key) = evt { let result = match self.state.modality() { Modality::Navigate => self.handle_navigation_input(key)?, @@ -459,20 +455,13 @@ impl<'ws> Workspace<'ws> { ) -> Vec<(Rect, Box)> { use ratatui::widgets::StatefulWidget; let mut cs = vec![Constraint::Fill(4), Constraint::Fill(30)]; - let Address { row, col } = self.book.location; let mut rs: Vec> = vec![ Box::new(|rect: Rect, buf: &mut Buffer, ws: &mut Self| ws.text_area.render(rect, buf)), Box::new(move |rect: Rect, buf: &mut Buffer, ws: &mut Self| { let sheet_name = ws.book.get_sheet_name().unwrap_or("Unknown"); - // Table widget display let table_block = Block::bordered().title_top(sheet_name); - let table_inner: Table = TryFrom::try_from(&ws.book).expect(""); - let table = table_inner.block(table_block); - // https://docs.rs/ratatui/latest/ratatui/widgets/struct.TableState.html - // TODO(zaphar): Apparently scrolling by columns doesn't work? - ws.state.table_state.select_cell(Some((row, col))); - ws.state.table_state.select_column(Some(col)); - StatefulWidget::render(table, rect, buf, &mut ws.state.table_state); + let viewport = Viewport::new(&ws.book).with_selected(ws.book.location.clone()).block(table_block); + StatefulWidget::render(viewport, rect, buf, &mut ws.state.viewport_state); }), ]; diff --git a/src/ui/render.rs b/src/ui/render.rs deleted file mode 100644 index 34cbdd2..0000000 --- a/src/ui/render.rs +++ /dev/null @@ -1,116 +0,0 @@ -use ratatui::{ - self, - layout::{Constraint, Flex, Rect}, - style::{Color, Stylize}, - text::{Line, Text}, - widgets::{Block, Cell, Row, Table, Widget}, - Frame, -}; -use tui_popup::Popup; - -use super::*; - -impl<'widget, 'ws: 'widget> Widget for &'widget mut Workspace<'ws> { - fn render(self, area: Rect, buf: &mut ratatui::prelude::Buffer) - where - Self: Sized, - { - let outer_block = Block::bordered() - .title(Line::from( - self.name - .file_name() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|| String::from("Unknown")), - )) - .title_bottom(match self.state.modality() { - Modality::Navigate => "navigate", - Modality::CellEdit => "edit", - Modality::Command => "command", - Modality::Dialog => "", - }) - .title_bottom( - Line::from(format!( - "{},{}", - self.book.location.row, self.book.location.col - )) - .right_aligned(), - ); - - for (rect, f) in self.get_render_parts(area.clone()) { - f(rect, buf, self); - } - - outer_block.render(area, buf); - - if self.state.modality() == &Modality::Dialog { - let lines = Text::from_iter(self.state.popup.iter().cloned()); - let popup = Popup::new(lines); - popup.render(area, buf); - } - } -} - -const COLNAMES: [&'static str; 26] = [ - "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", - "T", "U", "V", "W", "X", "Y", "Z", -]; - -impl<'t, 'book: 't> TryFrom<&'book Book> for Table<'t> { - fn try_from(value: &'book Book) -> std::result::Result { - // TODO(zaphar): This is apparently expensive. Maybe we can cache it somehow? - // We should do the correct thing here if this fails - let (row_count, col_count) = value.get_size()?; - let rows: Vec = (1..=row_count) - .into_iter() - .map(|ri| { - let mut cells = vec![Cell::new(Text::from(ri.to_string()))]; - cells.extend((1..=col_count).into_iter().map(|ci| { - // TODO(zaphar): Is this safe? - let content = value - .get_cell_addr_rendered(&Address { row: ri, col: ci }) - .unwrap(); - let cell = Cell::new(Text::raw(content)); - match (value.location.row == ri, value.location.col == ci) { - (true, true) => cell.fg(Color::White).underlined(), - _ => cell - .bg(if ri % 2 == 0 { - Color::Rgb(57, 61, 71) - } else { - Color::Rgb(165, 169, 160) - }) - .fg(if ri % 2 == 0 { - Color::White - } else { - Color::Rgb(31, 32, 34) - }), - } - .bold() - })); - Row::new(cells) - }) - .collect(); - let mut constraints: Vec = Vec::new(); - constraints.push(Constraint::Max(5)); - for col_idx in 0..col_count { - let size = value.get_col_size(col_idx + 1)?; - constraints.push(Constraint::Length(size as u16)); - } - let mut header = Vec::with_capacity(col_count as usize); - - header.push(Cell::new("")); - header.extend((0..(col_count as usize)).map(|i| { - let count = (i / 26) + 1; - Cell::new(COLNAMES[i % 26].repeat(count)) - })); - Ok(Table::new(rows, constraints) - .header(Row::new(header).underlined()) - .column_spacing(1) - .flex(Flex::Start)) - } - - type Error = anyhow::Error; -} - -pub fn draw(frame: &mut Frame, ws: &mut Workspace) { - frame.render_widget(ws, frame.area()); -} diff --git a/src/ui/render/mod.rs b/src/ui/render/mod.rs new file mode 100644 index 0000000..facf854 --- /dev/null +++ b/src/ui/render/mod.rs @@ -0,0 +1,60 @@ +use ratatui::{ + self, + layout::Rect, + text::{Line, Text}, + widgets::{Block, Widget}, + Frame, +}; +use tui_popup::Popup; + +use super::*; + +pub mod viewport; +pub use viewport::Viewport; + +#[cfg(test)] +mod test; + +impl<'widget, 'ws: 'widget> Widget for &'widget mut Workspace<'ws> { + fn render(self, area: Rect, buf: &mut ratatui::prelude::Buffer) + where + Self: Sized, + { + let outer_block = Block::bordered() + .title(Line::from( + self.name + .file_name() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| String::from("Unknown")), + )) + .title_bottom(match self.state.modality() { + Modality::Navigate => "navigate", + Modality::CellEdit => "edit", + Modality::Command => "command", + Modality::Dialog => "", + }) + .title_bottom( + Line::from(format!( + "{},{}", + self.book.location.row, self.book.location.col + )) + .right_aligned(), + ); + + for (rect, f) in self.get_render_parts(area.clone()) { + f(rect, buf, self); + } + + outer_block.render(area, buf); + + if self.state.modality() == &Modality::Dialog { + let lines = Text::from_iter(self.state.popup.iter().cloned()); + let popup = Popup::new(lines); + popup.render(area, buf); + } + } +} + +pub fn draw(frame: &mut Frame, ws: &mut Workspace) { + frame.render_widget(ws, frame.area()); +} diff --git a/src/ui/render/test.rs b/src/ui/render/test.rs new file mode 100644 index 0000000..68a5006 --- /dev/null +++ b/src/ui/render/test.rs @@ -0,0 +1,49 @@ +use ironcalc::base::Model; + +use super::{Address, Book, Viewport, ViewportState}; + +#[test] +fn test_viewport_get_visible_columns() { + let mut state = ViewportState::default(); + let book = Book::new(Model::new_empty("test", "en", "America/New_York").expect("Failed to make model")); + let default_size = book.get_col_size(1).expect("Failed to get column size"); + let width = dbg!(dbg!(default_size) * 12 / 2); + let viewport = Viewport::new(&book).with_selected(Address { row: 1, col: 17 }); + let cols = viewport.get_visible_columns((width + 5) as u16, &mut state).expect("Failed to get visible columns"); + assert_eq!(5, cols.len()); + assert_eq!(17, cols.last().expect("Failed to get last column").idx); +} + +#[test] +fn test_viewport_get_visible_rows() { + let mut state = dbg!(ViewportState::default()); + let book = Book::new(Model::new_empty("test", "en", "America/New_York").expect("Failed to make model")); + let height = 6; + let viewport = Viewport::new(&book).with_selected(Address { row: 17, col: 1 }); + let rows = dbg!(viewport.get_visible_rows(height as u16, &mut state)); + assert_eq!(height - 1, rows.len()); + assert_eq!(17 - (height - 2), *rows.first().expect("Failed to get first row")); + assert_eq!(17, *rows.last().expect("Failed to get last row")); +} + +#[test] +fn test_viewport_visible_columns_after_length_change() { + let mut state = ViewportState::default(); + let mut book = Book::new(Model::new_empty("test", "en", "America/New_York").expect("Failed to make model")); + let default_size = book.get_col_size(1).expect("Failed to get column size"); + let width = dbg!(dbg!(default_size) * 12 / 2); + { + let viewport = Viewport::new(&book).with_selected(Address { row: 1, col: 17 }); + let cols = viewport.get_visible_columns((width + 5) as u16, &mut state).expect("Failed to get visible columns"); + assert_eq!(5, cols.len()); + assert_eq!(17, cols.last().expect("Failed to get last column").idx); + } + + book.set_col_size(1, default_size * 6).expect("Failed to set column size"); + { + let viewport = Viewport::new(&book).with_selected(Address { row: 1, col: 1 }); + let cols = viewport.get_visible_columns((width + 5) as u16, &mut state).expect("Failed to get visible columns"); + assert_eq!(1, cols.len()); + assert_eq!(1, cols.last().expect("Failed to get last column").idx); + } +} diff --git a/src/ui/render/viewport.rs b/src/ui/render/viewport.rs new file mode 100644 index 0000000..0c478c9 --- /dev/null +++ b/src/ui/render/viewport.rs @@ -0,0 +1,213 @@ +use anyhow::Result; +use ratatui::{ + buffer::Buffer, + layout::{Constraint, Flex, Rect}, + style::{Color, Stylize}, + text::Text, + widgets::{Block, Cell, Row, StatefulWidget, Table, Widget}, +}; + +use super::{Address, Book}; + +// TODO(zaphar): Move this to the book module. +// NOTE(zaphar): This is stolen from ironcalc but ironcalc doesn't expose it +// publically. +pub(crate) const LAST_COLUMN: usize = 16_384; +pub(crate) const LAST_ROW: usize = 1_048_576; + +/// A visible column to show in our Viewport. +#[derive(Clone, Debug)] +pub struct VisibleColumn { + pub idx: usize, + pub length: u16, +} + +impl<'a> From<&'a VisibleColumn> for Constraint { + fn from(value: &'a VisibleColumn) -> Self { + Constraint::Length(value.length as u16) + } +} + +#[derive(Debug, Default)] +pub struct ViewportState { + prev_corner: Address, +} + +/// A renderable viewport over a book. +pub struct Viewport<'book> { + pub(crate) selected: Address, + book: &'book Book, + block: Option>, +} + +pub(crate) const COLNAMES: [&'static str; 26] = [ + "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", + "T", "U", "V", "W", "X", "Y", "Z", +]; + +impl<'book> Viewport<'book> { + pub fn new(book: &'book Book) -> Self { + Self { + book, + selected: Default::default(), + block: None, + } + } + + pub fn with_selected(mut self, location: Address) -> Self { + self.selected = location; + self + } + + pub(crate) fn get_visible_rows(&self, height: u16, state: &ViewportState) -> Vec { + // NOTE(jeremy): For now the row default height is 1. We'll have + // to adjust that if this changes. + let mut length = 1; + let start_row = std::cmp::min(self.selected.row, state.prev_corner.row); + let mut start = start_row; + let mut end = start_row; + for row_idx in start_row..=LAST_ROW { + let updated_length = length + 1; + if updated_length <= height { + length = updated_length; + end = row_idx; + } else if self.selected.row >= row_idx { + start = start + 1; + end = row_idx; + } else { + //dbg!(&start); + //dbg!(&end); + break; + } + } + return (start..=end).collect(); + } + + pub(crate) fn get_visible_columns( + &self, + width: u16, + state: &ViewportState, + ) -> Result> { + let mut visible = Vec::new(); + // TODO(zaphar): This should be a shared constant with our first column. + // We start out with a length of 5 already reserved + let mut length = 5; + let start_idx = std::cmp::min(self.selected.col, state.prev_corner.col); + for idx in start_idx..=LAST_COLUMN { + let size = self.book.get_col_size(idx)? as u16; + let updated_length = length + size; + let col = VisibleColumn { idx, length: size }; + if updated_length < width { + length = updated_length; + visible.push(col); + } else if self.selected.col >= col.idx { + // We need a sliding window now + if let Some(first) = visible.first() { + // subtract the first columns size. + length = length - first.length; + // remove the first column. + // TODO(jwall): This is a bit inefficient. Can we do better? + visible = visible.into_iter().skip(1).collect(); + } + // Add this col to the visible. + length += size; + visible.push(col); + // What if the length is still too long? + if length > width { + if let Some(first) = visible.first() { + // subtract the first columns size. + length = length - first.length; + } + visible = visible.into_iter().skip(1).collect(); + } + } else { + break; + } + } + return Ok(visible); + } + + pub fn block(mut self, block: Block<'book>) -> Self { + self.block = Some(block); + self + } + + pub(crate) fn to_table<'widget>( + &self, + width: u16, + height: u16, + state: &mut ViewportState, + ) -> Result> { + let visible_columns = self.get_visible_columns(width, state)?; + let visible_rows = self.get_visible_rows(height, state); + if let Some(vc) = visible_columns.first() { + state.prev_corner.col = vc.idx + } + if let Some(vr) = visible_rows.first() { + state.prev_corner.row = *vr; + } + let rows: Vec = + visible_rows + .into_iter() + .map(|ri| { + let mut cells = vec![Cell::new(Text::from(ri.to_string()))]; + cells.extend(visible_columns.iter().map( + |VisibleColumn { idx: ci, length: _ }| { + // TODO(zaphar): Is this safe? + let content = self + .book + .get_cell_addr_rendered(&Address { row: ri, col: *ci }) + .unwrap(); + let cell = Cell::new(Text::raw(content)); + match (self.book.location.row == ri, self.book.location.col == *ci) { + (true, true) => cell.fg(Color::White).bg(Color::Rgb(57, 61, 71)), + _ => cell, + } + .bold() + }, + )); + Row::new(cells) + }) + .collect(); + let constraints: Vec = visible_columns + .iter() + .map(|vc| Constraint::from(vc)) + .collect(); + let end_idx = visible_columns.last().unwrap().idx; + let mut header = Vec::with_capacity(constraints.len()); + header.push(Cell::new("")); + header.extend((state.prev_corner.col..=end_idx).map(|i| { + let count = if i == 26 { 1 } else { (i / 26) + 1 }; + let even = i % 2 == 0; + Cell::new(COLNAMES[(i - 1) % 26].repeat(count)) + .bg(if even { + Color::Rgb(57, 61, 71) + } else { + Color::Rgb(165, 169, 160) + }) + .fg(if even { Color::White } else { Color::Black }).bold() + })); + let mut col_constraints = vec![Constraint::Length(5)]; + col_constraints.extend(constraints.into_iter()); + Ok(Table::new(rows, col_constraints) + .header(Row::new(header).underlined()) + .column_spacing(0) + .flex(Flex::Start)) + } +} + +impl<'book> StatefulWidget for Viewport<'book> { + type State = ViewportState; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + // The block surrounding this table adds 2 additional rows and columns + // to the available rect for rendering this table. + let mut table = self + .to_table(area.width - 2, area.height - 2, state) + .expect("Failed to turn viewport into a table."); + if let Some(block) = self.block { + table = table.block(block); + } + Widget::render(table, area, buf); + } +} diff --git a/tmp.bash b/tmp.bash new file mode 100644 index 0000000..11fd8f4 --- /dev/null +++ b/tmp.bash @@ -0,0 +1,9 @@ +set -ue +set -o pipefail +export BUILD_ARG_one="widget" +export BUILD_ARG_two="acme" +VALS=() +printenv | grep BUILD_ARG | while read line;do + VALS+=($line) +done +echo "${VALS[*]}"