2024-10-29 19:47:50 -04:00
|
|
|
//! Ui rendering logic
|
|
|
|
|
2024-11-03 14:08:46 -05:00
|
|
|
use std::{
|
|
|
|
fs::File,
|
|
|
|
io::Read,
|
|
|
|
path::PathBuf,
|
|
|
|
process::ExitCode,
|
|
|
|
time::{Duration, Instant},
|
|
|
|
};
|
2024-10-29 19:47:50 -04:00
|
|
|
|
2024-10-30 14:34:45 -04:00
|
|
|
use super::sheet::{Address, Tbl};
|
2024-10-29 19:47:50 -04:00
|
|
|
|
2024-11-03 14:08:46 -05:00
|
|
|
use anyhow::{anyhow, Context, Result};
|
|
|
|
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, Row, Table, Widget},
|
2024-10-29 19:47:50 -04:00
|
|
|
Frame,
|
|
|
|
};
|
2024-11-03 14:08:46 -05:00
|
|
|
use tui_textarea::{CursorMove, TextArea};
|
|
|
|
|
|
|
|
const DEFAULT_KEY_TIMEOUT: Duration = Duration::from_millis(30);
|
2024-10-29 19:47:50 -04:00
|
|
|
|
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,
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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-10-29 19:47:50 -04:00
|
|
|
tbl: Tbl,
|
2024-10-30 14:34:45 -04:00
|
|
|
state: AppState,
|
2024-11-02 21:42:17 -04:00
|
|
|
text_area: TextArea<'ws>,
|
|
|
|
dirty: bool,
|
2024-10-29 19:47:50 -04:00
|
|
|
}
|
|
|
|
|
2024-11-02 21:42:17 -04:00
|
|
|
impl<'ws> Workspace<'ws> {
|
2024-11-03 14:08:46 -05:00
|
|
|
pub fn new(tbl: Tbl, name: PathBuf) -> Self {
|
2024-11-02 21:42:17 -04:00
|
|
|
let mut ws = Self {
|
2024-10-29 19:47:50 -04:00
|
|
|
tbl,
|
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,
|
|
|
|
};
|
|
|
|
ws.handle_movement_change();
|
|
|
|
ws
|
2024-10-29 19:47:50 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
pub fn load(path: &PathBuf) -> Result<Self> {
|
2024-11-03 14:08:46 -05:00
|
|
|
let input = if path.exists() {
|
|
|
|
if path.is_file() {
|
|
|
|
let mut f = File::open(path)?;
|
|
|
|
let mut buf = Vec::new();
|
|
|
|
let _ = f.read_to_end(&mut buf)?;
|
|
|
|
String::from_utf8(buf).context(format!("Error reading file: {:?}", path))?
|
|
|
|
} else {
|
|
|
|
return Err(anyhow!("Not a valid path: {}", path.to_string_lossy().to_string()));
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
String::from(",,,\n,,,\n")
|
|
|
|
};
|
2024-10-30 14:34:45 -04:00
|
|
|
let mut tbl = Tbl::from_str(input)?;
|
|
|
|
tbl.move_to(Address { row: 0, col: 0 })?;
|
|
|
|
Ok(Workspace::new(
|
|
|
|
tbl,
|
2024-11-03 14:08:46 -05:00
|
|
|
path.clone(),
|
2024-10-30 14:34:45 -04:00
|
|
|
))
|
|
|
|
}
|
|
|
|
|
|
|
|
pub fn move_down(&mut self) -> Result<()> {
|
|
|
|
let mut loc = self.tbl.location.clone();
|
|
|
|
let (row, _) = self.tbl.dimensions();
|
2024-11-02 21:42:17 -04:00
|
|
|
if loc.row < row - 1 {
|
2024-10-30 14:34:45 -04:00
|
|
|
loc.row += 1;
|
|
|
|
self.tbl.move_to(loc)?;
|
|
|
|
}
|
|
|
|
Ok(())
|
|
|
|
}
|
2024-11-02 21:42:17 -04:00
|
|
|
|
2024-10-30 14:34:45 -04:00
|
|
|
pub fn move_up(&mut self) -> Result<()> {
|
|
|
|
let mut loc = self.tbl.location.clone();
|
|
|
|
if loc.row > 0 {
|
|
|
|
loc.row -= 1;
|
|
|
|
self.tbl.move_to(loc)?;
|
|
|
|
}
|
|
|
|
Ok(())
|
|
|
|
}
|
2024-11-02 21:42:17 -04:00
|
|
|
|
2024-10-30 14:34:45 -04:00
|
|
|
pub fn move_left(&mut self) -> Result<()> {
|
|
|
|
let mut loc = self.tbl.location.clone();
|
|
|
|
if loc.col > 0 {
|
|
|
|
loc.col -= 1;
|
|
|
|
self.tbl.move_to(loc)?;
|
|
|
|
}
|
|
|
|
Ok(())
|
|
|
|
}
|
2024-11-02 21:42:17 -04:00
|
|
|
|
2024-10-30 14:34:45 -04:00
|
|
|
pub fn move_right(&mut self) -> Result<()> {
|
|
|
|
let mut loc = self.tbl.location.clone();
|
|
|
|
let (_, col) = self.tbl.dimensions();
|
2024-11-02 21:42:17 -04:00
|
|
|
if loc.col < col - 1 {
|
|
|
|
loc.col += 1;
|
2024-10-30 14:34:45 -04:00
|
|
|
self.tbl.move_to(loc)?;
|
|
|
|
}
|
|
|
|
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-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 {
|
2024-11-02 21:42:17 -04:00
|
|
|
if let KeyCode::Esc = key.code {
|
|
|
|
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 {
|
|
|
|
let loc = self.tbl.location.clone();
|
|
|
|
self.tbl.update_entry(&loc, contents)?;
|
2024-10-30 14:34:45 -04:00
|
|
|
}
|
2024-11-02 21:42:17 -04:00
|
|
|
return Ok(None);
|
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-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));
|
2024-11-03 14:08:46 -05:00
|
|
|
self.text_area.move_cursor(CursorMove::Bottom);
|
|
|
|
self.text_area.move_cursor(CursorMove::End);
|
2024-11-02 21:42:17 -04:00
|
|
|
}
|
2024-11-03 14:08:46 -05:00
|
|
|
KeyCode::Char('s') if key.modifiers == KeyModifiers::CONTROL => {
|
|
|
|
self.save_file()?;
|
|
|
|
}
|
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}
|
|
|
|
// * Insert row
|
|
|
|
// * Insert column
|
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) {
|
|
|
|
let contents = self.tbl.get_raw_value(&self.tbl.location);
|
|
|
|
self.text_area = reset_text_area(contents);
|
|
|
|
}
|
2024-11-03 14:08:46 -05:00
|
|
|
|
|
|
|
fn save_file(&self) -> Result<()> {
|
|
|
|
let contents = self.tbl.csv.export_raw_table().map_err(|e| anyhow::anyhow!("Error serializing to csv: {:?}", e))?;
|
|
|
|
std::fs::write(&self.name, contents)?;
|
|
|
|
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
|
2024-10-29 19:47:50 -04:00
|
|
|
}
|
|
|
|
|
2024-11-02 21:42:17 -04:00
|
|
|
impl<'widget, 'ws: 'widget> Widget for &'widget 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-03 14:08:46 -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!(
|
|
|
|
"{},{}",
|
|
|
|
self.tbl.location.row, self.tbl.location.col
|
|
|
|
))
|
|
|
|
.right_aligned(),
|
|
|
|
);
|
|
|
|
let [edit_rect, table_rect] =
|
|
|
|
Layout::vertical(&[Constraint::Fill(1), Constraint::Fill(20)])
|
|
|
|
.vertical_margin(2)
|
|
|
|
.horizontal_margin(2)
|
|
|
|
.flex(Flex::Legacy)
|
|
|
|
.areas(area.clone());
|
|
|
|
outer_block.render(area, buf);
|
|
|
|
self.text_area.render(edit_rect, buf);
|
|
|
|
let table_block = Block::bordered();
|
|
|
|
let table = Table::from(&self.tbl).block(table_block);
|
|
|
|
table.render(table_rect, buf);
|
2024-10-29 19:47:50 -04:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
const COLNAMES: [&'static str; 27] = [
|
|
|
|
"", "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> From<&Tbl> for Table<'t> {
|
|
|
|
fn from(value: &Tbl) -> Self {
|
|
|
|
let (_, cols) = value.dimensions();
|
|
|
|
let rows: Vec<Row> = value
|
|
|
|
.csv
|
|
|
|
.get_calculated_table()
|
|
|
|
.iter()
|
|
|
|
.enumerate()
|
2024-10-30 14:34:45 -04:00
|
|
|
.map(|(ri, r)| {
|
|
|
|
let cells =
|
|
|
|
vec![Cell::new(format!("{}", ri))]
|
|
|
|
.into_iter()
|
|
|
|
.chain(r.iter().enumerate().map(|(ci, v)| {
|
|
|
|
let content = format!("{}", v);
|
|
|
|
let cell = Cell::new(Text::raw(content));
|
|
|
|
match (value.location.row == ri, value.location.col == ci) {
|
2024-11-02 21:42:17 -04:00
|
|
|
(true, true) => cell.fg(Color::White).underlined(),
|
|
|
|
_ => cell
|
|
|
|
.bg(if ri % 2 == 0 {
|
2024-10-30 14:34:45 -04:00
|
|
|
Color::Rgb(57, 61, 71)
|
|
|
|
} else {
|
|
|
|
Color::Rgb(165, 169, 160)
|
|
|
|
})
|
|
|
|
.fg(if ri % 2 == 0 {
|
|
|
|
Color::White
|
|
|
|
} else {
|
|
|
|
Color::Rgb(31, 32, 34)
|
|
|
|
}),
|
2024-11-02 21:42:17 -04:00
|
|
|
}
|
|
|
|
.bold()
|
2024-10-30 14:34:45 -04:00
|
|
|
}));
|
2024-10-29 19:47:50 -04:00
|
|
|
Row::new(cells)
|
|
|
|
})
|
|
|
|
.collect();
|
|
|
|
// TODO(zaphar): Handle the double letter column names
|
|
|
|
let header: Vec<Cell> = (0..=cols).map(|i| Cell::new(COLNAMES[i % 26])).collect();
|
|
|
|
let mut constraints: Vec<Constraint> = Vec::new();
|
|
|
|
constraints.push(Constraint::Max(5));
|
|
|
|
for _ in 0..cols {
|
|
|
|
constraints.push(Constraint::Min(5));
|
|
|
|
}
|
|
|
|
Table::new(rows, constraints)
|
|
|
|
.block(Block::bordered())
|
|
|
|
.header(Row::new(header).underlined())
|
|
|
|
.column_spacing(1)
|
|
|
|
.flex(Flex::SpaceAround)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-10-30 14:34:45 -04:00
|
|
|
pub fn draw(frame: &mut Frame, ws: &Workspace) {
|
2024-10-29 19:47:50 -04:00
|
|
|
frame.render_widget(ws, frame.area());
|
|
|
|
}
|