sheetsui/src/ui/mod.rs

413 lines
14 KiB
Rust
Raw Normal View History

2024-10-29 19:47:50 -04:00
//! Ui rendering logic
2024-11-16 10:51:38 -05:00
use std::{path::PathBuf, process::ExitCode};
2024-10-29 19:47:50 -04:00
2024-11-16 10:51:38 -05:00
use crate::book::Book;
2024-10-29 19:47:50 -04:00
2024-11-16 10:51:38 -05:00
use anyhow::Result;
2024-11-03 14:08:46 -05:00
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
2024-10-29 19:47:50 -04:00
use ratatui::{
self,
2024-11-02 21:42:17 -04:00
layout::{Constraint, Flex, Layout},
style::{Color, Modifier, Style, Stylize},
2024-10-30 14:34:45 -04:00
text::{Line, Text},
widgets::{Block, Cell, Paragraph, Row, Table, TableState, Widget},
2024-10-29 19:47:50 -04:00
Frame,
};
2024-11-03 14:08:46 -05:00
use tui_textarea::{CursorMove, TextArea};
2024-10-30 14:34:45 -04:00
#[derive(Default, Debug, PartialEq)]
pub enum Modality {
#[default]
Navigate,
CellEdit,
2024-11-03 14:08:46 -05:00
// TODO(zaphar): Command Mode?
2024-10-30 14:34:45 -04:00
}
#[derive(Default, Debug)]
pub struct AppState {
pub modality: Modality,
pub table_state: TableState,
2024-10-30 14:34:45 -04:00
}
2024-11-16 10:51:38 -05:00
// TODO(jwall): This should probably move to a different module.
/// The Address in a Table.
#[derive(Debug, PartialEq, PartialOrd, Ord, Eq, Clone)]
pub struct Address {
pub row: usize,
pub col: usize,
}
impl Address {
pub fn new(row: usize, col: usize) -> Self {
Self { row, col }
}
}
impl Default for Address {
fn default() -> Self {
Address::new(1, 1)
}
}
2024-10-30 14:34:45 -04:00
// Interaction Modalities
// * Navigate
// * Edit
2024-11-02 21:42:17 -04:00
pub struct Workspace<'ws> {
2024-11-03 14:08:46 -05:00
name: PathBuf,
2024-11-16 10:51:38 -05:00
book: Book,
2024-10-30 14:34:45 -04:00
state: AppState,
2024-11-02 21:42:17 -04:00
text_area: TextArea<'ws>,
dirty: bool,
show_help: bool,
2024-10-29 19:47:50 -04:00
}
2024-11-02 21:42:17 -04:00
impl<'ws> Workspace<'ws> {
2024-11-16 10:51:38 -05:00
pub fn new(book: Book, name: PathBuf) -> Self {
2024-11-02 21:42:17 -04:00
let mut ws = Self {
2024-11-16 10:51:38 -05:00
book,
2024-11-03 14:08:46 -05:00
name,
2024-10-30 14:34:45 -04:00
state: AppState::default(),
2024-11-02 21:42:17 -04:00
text_area: reset_text_area("".to_owned()),
dirty: false,
show_help: false,
2024-11-02 21:42:17 -04:00
};
ws.handle_movement_change();
ws
2024-10-29 19:47:50 -04:00
}
2024-11-16 10:51:38 -05:00
pub fn load(path: &PathBuf, locale: &str, tz: &str) -> Result<Self> {
let book = if path.exists() {
Book::new_from_xlsx_with_locale(&path.to_string_lossy().to_string(), locale, tz)?
2024-11-03 14:08:46 -05:00
} else {
2024-11-16 10:51:38 -05:00
Book::default()
2024-11-03 14:08:46 -05:00
};
2024-11-16 10:51:38 -05:00
Ok(Workspace::new(book, path.clone()))
2024-10-30 14:34:45 -04:00
}
pub fn move_down(&mut self) -> Result<()> {
2024-11-16 10:51:38 -05:00
let mut loc = self.book.location.clone();
2024-11-18 18:00:16 -05:00
let (row_count, _) = self.book.get_size()?;
if loc.row < row_count {
2024-10-30 14:34:45 -04:00
loc.row += 1;
2024-11-16 10:51:38 -05:00
self.book.move_to(loc)?;
2024-10-30 14:34:45 -04:00
}
Ok(())
}
2024-11-02 21:42:17 -04:00
2024-10-30 14:34:45 -04:00
pub fn move_up(&mut self) -> Result<()> {
2024-11-16 10:51:38 -05:00
let mut loc = self.book.location.clone();
2024-11-18 18:00:16 -05:00
if loc.row > 1 {
2024-10-30 14:34:45 -04:00
loc.row -= 1;
2024-11-16 10:51:38 -05:00
self.book.move_to(loc)?;
2024-10-30 14:34:45 -04:00
}
Ok(())
}
2024-11-02 21:42:17 -04:00
2024-10-30 14:34:45 -04:00
pub fn move_left(&mut self) -> Result<()> {
2024-11-16 10:51:38 -05:00
let mut loc = self.book.location.clone();
2024-11-18 18:00:16 -05:00
if loc.col > 1 {
2024-10-30 14:34:45 -04:00
loc.col -= 1;
2024-11-16 10:51:38 -05:00
self.book.move_to(loc)?;
2024-10-30 14:34:45 -04:00
}
Ok(())
}
2024-11-02 21:42:17 -04:00
2024-10-30 14:34:45 -04:00
pub fn move_right(&mut self) -> Result<()> {
2024-11-16 10:51:38 -05:00
let mut loc = self.book.location.clone();
2024-11-18 18:00:16 -05:00
let (_, col_count) = self.book.get_size()?;
if loc.col < col_count {
2024-11-02 21:42:17 -04:00
loc.col += 1;
2024-11-16 10:51:38 -05:00
self.book.move_to(loc)?;
2024-10-30 14:34:45 -04:00
}
Ok(())
2024-10-29 19:47:50 -04:00
}
2024-10-30 14:34:45 -04:00
2024-11-03 14:08:46 -05:00
pub fn handle_input(&mut self) -> Result<Option<ExitCode>> {
2024-10-30 14:34:45 -04:00
if let Event::Key(key) = event::read()? {
2024-11-03 14:08:46 -05:00
let result = match self.state.modality {
Modality::Navigate => self.handle_navigation_input(key)?,
Modality::CellEdit => self.handle_edit_input(key)?,
};
return Ok(result);
2024-10-30 14:34:45 -04:00
}
Ok(None)
}
2024-11-05 15:35:51 -05:00
fn render_help_text(&self) -> impl Widget {
let info_block = Block::bordered().title("Help");
Paragraph::new(match self.state.modality {
Modality::Navigate => Text::from(vec![
"Navigate Mode:".into(),
"* e: Enter edit mode for current cell".into(),
"* h,j,k,l: vim style navigation".into(),
2024-11-05 16:34:30 -05:00
"* CTRl-r: Add a row".into(),
"* CTRl-c: Add a column".into(),
"* q exit".into(),
"* Ctrl-S Save sheet".into(),
]),
Modality::CellEdit => Text::from(vec![
"Edit Mode:".into(),
"* ESC: Exit edit mode".into(),
"Otherwise edit as normal".into(),
]),
2024-11-16 10:51:38 -05:00
})
.block(info_block)
2024-11-05 15:35:51 -05:00
}
2024-11-03 14:08:46 -05:00
fn handle_edit_input(&mut self, key: event::KeyEvent) -> Result<Option<ExitCode>> {
2024-10-30 14:34:45 -04:00
if key.kind == KeyEventKind::Press {
match key.code {
KeyCode::Char('h') if key.modifiers == KeyModifiers::CONTROL => {
self.show_help = !self.show_help;
}
KeyCode::Esc => self.exit_edit_mode()?,
KeyCode::Enter => self.exit_edit_mode()?,
_ => {
// NOOP
}
2024-10-30 14:34:45 -04:00
}
}
2024-11-03 14:08:46 -05:00
// TODO(zaphar): Some specialized editing keybinds
// * Select All
// * Copy
// * Paste
2024-11-02 21:42:17 -04:00
if self.text_area.input(key) {
self.dirty = true;
}
2024-10-30 14:34:45 -04:00
Ok(None)
}
2024-11-18 18:22:36 -05:00
fn exit_edit_mode(&mut self) -> Result<(), anyhow::Error> {
self.state.modality = Modality::Navigate;
self.text_area.set_cursor_line_style(Style::default());
self.text_area.set_cursor_style(Style::default());
let contents = self.text_area.lines().join("\n");
if self.dirty {
self.book.edit_current_cell(contents)?;
self.book.evaluate();
}
Ok(())
}
2024-11-03 14:08:46 -05:00
fn handle_navigation_input(&mut self, key: event::KeyEvent) -> Result<Option<ExitCode>> {
2024-10-30 14:34:45 -04:00
if key.kind == KeyEventKind::Press {
match key.code {
2024-11-02 21:42:17 -04:00
KeyCode::Char('e') => {
self.state.modality = Modality::CellEdit;
self.text_area
.set_cursor_line_style(Style::default().add_modifier(Modifier::UNDERLINED));
self.text_area
.set_cursor_style(Style::default().add_modifier(Modifier::SLOW_BLINK));
self.text_area.move_cursor(CursorMove::Bottom);
self.text_area.move_cursor(CursorMove::End);
2024-11-02 21:42:17 -04:00
}
KeyCode::Char('h') if key.modifiers == KeyModifiers::CONTROL => {
self.show_help = !self.show_help;
}
2024-11-03 14:08:46 -05:00
KeyCode::Char('s') if key.modifiers == KeyModifiers::CONTROL => {
self.save_file()?;
}
2024-11-05 16:34:30 -05:00
KeyCode::Char('r') if key.modifiers == KeyModifiers::CONTROL => {
2024-11-16 20:25:35 -05:00
let (row_count, _) = self.book.get_size()?;
self.book.update_entry(&Address {row: row_count+1, col: 1 }, "")?;
let (row, _) = self.book.get_size()?;
2024-11-16 10:51:38 -05:00
let mut loc = self.book.location.clone();
2024-11-16 20:25:35 -05:00
if loc.row < row as usize {
loc.row = row as usize;
2024-11-16 10:51:38 -05:00
self.book.move_to(loc)?;
}
self.handle_movement_change();
2024-11-05 16:34:30 -05:00
}
2024-11-18 18:00:16 -05:00
KeyCode::Char('t') if key.modifiers == KeyModifiers::CONTROL => {
2024-11-16 20:25:35 -05:00
let (_, col_count) = self.book.get_size()?;
self.book.update_entry(&Address {row: 1, col: col_count+1 }, "")?;
2024-11-05 16:34:30 -05:00
}
2024-10-30 14:34:45 -04:00
KeyCode::Char('q') => {
return Ok(Some(ExitCode::SUCCESS));
2024-11-02 21:42:17 -04:00
}
2024-10-30 14:34:45 -04:00
KeyCode::Char('j') => {
self.move_down()?;
2024-11-02 21:42:17 -04:00
self.handle_movement_change();
}
2024-10-30 14:34:45 -04:00
KeyCode::Char('k') => {
self.move_up()?;
2024-11-02 21:42:17 -04:00
self.handle_movement_change();
}
2024-10-30 14:34:45 -04:00
KeyCode::Char('h') => {
self.move_left()?;
2024-11-02 21:42:17 -04:00
self.handle_movement_change();
}
2024-10-30 14:34:45 -04:00
KeyCode::Char('l') => {
self.move_right()?;
2024-11-02 21:42:17 -04:00
self.handle_movement_change();
}
2024-10-30 14:34:45 -04:00
_ => {
// noop
}
}
}
2024-11-03 14:08:46 -05:00
// TODO(jeremy): Handle some useful navigation operations.
// * Copy Cell reference
// * Copy Cell Range reference
// * Extend Cell {down,up}
// * Goto location. (Command modality?)
2024-10-30 14:34:45 -04:00
return Ok(None);
}
2024-11-02 21:42:17 -04:00
fn handle_movement_change(&mut self) {
2024-11-16 10:51:38 -05:00
let contents = self
.book
.get_current_cell_contents()
.expect("Unexpected failure getting current cell contents");
2024-11-02 21:42:17 -04:00
self.text_area = reset_text_area(contents);
}
2024-11-03 14:08:46 -05:00
fn save_file(&self) -> Result<()> {
2024-11-16 10:51:38 -05:00
self.book.save_to_xlsx(&self.name.to_string_lossy().to_string())?;
2024-11-03 14:08:46 -05:00
Ok(())
}
2024-11-02 21:42:17 -04:00
}
fn reset_text_area<'a>(content: String) -> TextArea<'a> {
let mut text_area = TextArea::from(content.lines());
text_area.set_cursor_line_style(Style::default());
text_area.set_cursor_style(Style::default());
text_area.set_block(Block::bordered());
2024-11-02 21:42:17 -04:00
text_area
2024-10-29 19:47:50 -04:00
}
impl<'widget, 'ws: 'widget> Widget for &'widget mut Workspace<'ws> {
2024-10-29 19:47:50 -04:00
fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer)
where
Self: Sized,
{
2024-11-02 21:42:17 -04:00
let outer_block = Block::bordered()
2024-11-16 10:51:38 -05:00
.title(Line::from(
self.name
.file_name()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| String::from("Unknown")),
))
2024-10-30 14:34:45 -04:00
.title_bottom(match &self.state.modality {
Modality::Navigate => "navigate",
Modality::CellEdit => "edit",
})
2024-11-02 21:42:17 -04:00
.title_bottom(
Line::from(format!(
"{},{}",
2024-11-16 10:51:38 -05:00
self.book.location.row, self.book.location.col
2024-11-02 21:42:17 -04:00
))
.right_aligned(),
);
let [edit_rect, table_rect] = if self.show_help {
let [edit_rect, table_rect, info_rect] = Layout::vertical(&[
Constraint::Fill(4),
Constraint::Fill(30),
Constraint::Fill(9),
])
.vertical_margin(2)
.horizontal_margin(2)
.flex(Flex::Legacy)
.areas(area.clone());
// Help panel widget display
let info_para = self.render_help_text();
info_para.render(info_rect, buf);
[edit_rect, table_rect]
} else {
let [edit_rect, table_rect] = Layout::vertical(&[
Constraint::Fill(4),
Constraint::Fill(30),
])
.vertical_margin(2)
.horizontal_margin(2)
.flex(Flex::Legacy)
.areas(area.clone());
[edit_rect, table_rect]
};
2024-11-02 21:42:17 -04:00
outer_block.render(area, buf);
// Input widget display
2024-11-02 21:42:17 -04:00
self.text_area.render(edit_rect, buf);
// Table widget display
2024-11-02 21:42:17 -04:00
let table_block = Block::bordered();
2024-11-16 10:51:38 -05:00
let table_inner: Table = TryFrom::try_from(&self.book).expect("");
let table = table_inner.block(table_block);
// https://docs.rs/ratatui/latest/ratatui/widgets/struct.TableState.html
2024-11-16 10:51:38 -05:00
let Address { row, col } = self.book.location;
// TODO(zaphar): Apparently scrolling by columns doesn't work?
self.state.table_state.select_cell(Some((row, col)));
self.state.table_state.select_column(Some(col));
use ratatui::widgets::StatefulWidget;
StatefulWidget::render(table, table_rect, buf, &mut self.state.table_state);
2024-10-29 19:47:50 -04:00
}
}
const COLNAMES: [&'static str; 26] = [
2024-11-16 10:51:38 -05:00
"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",
2024-10-29 19:47:50 -04:00
];
2024-11-16 10:51:38 -05:00
impl<'t, 'book: 't> TryFrom<&'book Book> for Table<'t> {
fn try_from(value: &'book Book) -> std::result::Result<Self, Self::Error> {
// TODO(zaphar): This is apparently expensive. Maybe we can cache it somehow?
// We should do the correct thing here if this fails
2024-11-16 20:25:35 -05:00
let (row_count, col_count) = value.get_size()?;
2024-11-16 10:51:38 -05:00
let rows: Vec<Row> = (1..=row_count)
.into_iter()
.map(|ri| {
2024-11-16 20:25:35 -05:00
let mut cells = vec![Cell::new(Text::from(ri.to_string()))];
cells.extend((1..=col_count)
2024-11-16 10:51:38 -05:00
.into_iter()
.map(|ci| {
// TODO(zaphar): Is this safe?
let content = value.get_cell_addr_rendered(ri, 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()
2024-11-16 20:25:35 -05:00
}));
2024-10-29 19:47:50 -04:00
Row::new(cells)
})
.collect();
let mut constraints: Vec<Constraint> = Vec::new();
constraints.push(Constraint::Max(5));
2024-11-16 10:51:38 -05:00
for _ in 0..col_count {
2024-10-29 19:47:50 -04:00
constraints.push(Constraint::Min(5));
}
2024-11-16 10:51:38 -05:00
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)
2024-10-29 19:47:50 -04:00
.block(Block::bordered())
.header(Row::new(header).underlined())
.column_spacing(1)
2024-11-16 10:51:38 -05:00
.flex(Flex::SpaceAround))
2024-10-29 19:47:50 -04:00
}
2024-11-16 10:51:38 -05:00
type Error = anyhow::Error;
2024-10-29 19:47:50 -04:00
}
pub fn draw(frame: &mut Frame, ws: &mut Workspace) {
2024-10-29 19:47:50 -04:00
frame.render_widget(ws, frame.area());
}