mirror of
https://github.com/zaphar/sheetsui.git
synced 2025-07-23 05:19:48 -04:00
feat: keyboard navigation
This commit is contained in:
parent
68469c9afd
commit
a5177bda18
112
Cargo.lock
generated
112
Cargo.lock
generated
@ -197,6 +197,7 @@ checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"crossterm_winapi",
|
||||
"futures-core",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"rustix",
|
||||
@ -273,6 +274,95 @@ version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f81ec6369c545a7d40e4589b5597581fa1c441fe1cce96dd1de43159910a36a2"
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-executor"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"memchr",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gimli"
|
||||
version = "0.31.1"
|
||||
@ -442,6 +532,18 @@ version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "915a1e146535de9163f3987b8944ed8cf49a18bb0056bcebcdcece385cece4ff"
|
||||
|
||||
[[package]]
|
||||
name = "pin-utils"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.89"
|
||||
@ -555,6 +657,7 @@ dependencies = [
|
||||
"clap",
|
||||
"crossterm",
|
||||
"csvx",
|
||||
"futures",
|
||||
"ratatui",
|
||||
"thiserror",
|
||||
]
|
||||
@ -589,6 +692,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.13.2"
|
||||
|
@ -8,7 +8,8 @@ edition = "2021"
|
||||
[dependencies]
|
||||
anyhow = { version = "1.0.91", features = ["backtrace"] }
|
||||
clap = { version = "4.5.20", features = ["derive"] }
|
||||
crossterm = "0.28.1"
|
||||
crossterm = { version = "0.28.1", features = ["event-stream"] }
|
||||
csvx = "0.1.17"
|
||||
futures = "0.3.31"
|
||||
ratatui = "0.29.0"
|
||||
thiserror = "1.0.65"
|
||||
|
17
src/main.rs
17
src/main.rs
@ -1,8 +1,8 @@
|
||||
use std::path::PathBuf;
|
||||
use std::{path::PathBuf, process::ExitCode};
|
||||
|
||||
use clap::Parser;
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
|
||||
use ratatui;
|
||||
use ui::Workspace;
|
||||
|
||||
mod sheet;
|
||||
mod ui;
|
||||
@ -14,18 +14,17 @@ pub struct Args {
|
||||
workbook: PathBuf,
|
||||
}
|
||||
|
||||
fn run(terminal: &mut ratatui::DefaultTerminal, name: PathBuf) -> std::io::Result<()> {
|
||||
fn run(terminal: &mut ratatui::DefaultTerminal, name: PathBuf) -> anyhow::Result<ExitCode> {
|
||||
let mut ws = Workspace::load(&name)?;
|
||||
loop {
|
||||
terminal.draw(|frame| ui::draw(frame, &name))?;
|
||||
if let Event::Key(key) = event::read()? {
|
||||
if key.kind == KeyEventKind::Press && key.code == KeyCode::Char('q') {
|
||||
return Ok(());
|
||||
}
|
||||
terminal.draw(|frame| ui::draw(frame, &mut ws))?;
|
||||
if let Some(code) = ws.handle_event()? {
|
||||
return Ok(code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> std::io::Result<()> {
|
||||
fn main() -> anyhow::Result<ExitCode> {
|
||||
let args = Args::parse();
|
||||
|
||||
let mut terminal = ratatui::init();
|
||||
|
@ -46,10 +46,10 @@ impl CellValue {
|
||||
}
|
||||
|
||||
/// The Address in a [Tbl].
|
||||
#[derive(Default, Debug, PartialEq, PartialOrd, Ord, Eq)]
|
||||
#[derive(Default, Debug, PartialEq, PartialOrd, Ord, Eq, Clone)]
|
||||
pub struct Address {
|
||||
row: usize,
|
||||
col: usize,
|
||||
pub row: usize,
|
||||
pub col: usize,
|
||||
}
|
||||
|
||||
impl Address {
|
||||
@ -61,6 +61,7 @@ impl Address {
|
||||
/// A single table of addressable computable values.
|
||||
pub struct Tbl {
|
||||
pub csv: csvx::Table,
|
||||
pub location: Address,
|
||||
}
|
||||
|
||||
impl Tbl {
|
||||
@ -82,9 +83,19 @@ impl Tbl {
|
||||
Ok(Self {
|
||||
csv: csvx::Table::new(input)
|
||||
.map_err(|e| anyhow!("Error parsing table from csv text: {}", e))?,
|
||||
location: Address::default(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn move_to(&mut self, addr: Address) -> Result<()> {
|
||||
let (row, col) = self.dimensions();
|
||||
if addr.row >= row || addr.col >= col {
|
||||
return Err(anyhow!("Invalid address to move to: {:?}", addr));
|
||||
}
|
||||
self.location = addr;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_entry(&mut self, address: Address, value: CellValue) -> Result<()> {
|
||||
// TODO(zaphar): At some point we'll need to store the graph of computation
|
||||
let (row, col) = self.dimensions();
|
||||
|
198
src/ui/mod.rs
198
src/ui/mod.rs
@ -1,22 +1,39 @@
|
||||
//! Ui rendering logic
|
||||
|
||||
use std::{fs::File, io::Read, path::PathBuf};
|
||||
use std::{fs::File, io::Read, path::PathBuf, process::ExitCode};
|
||||
|
||||
use super::sheet::Tbl;
|
||||
use super::sheet::{Address, Tbl};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
|
||||
use ratatui::{
|
||||
self,
|
||||
layout::{Constraint, Flex, Layout},
|
||||
layout::{Constraint, Flex},
|
||||
style::{Color, Stylize},
|
||||
text::Text,
|
||||
widgets::{Block, Cell, Row, Table, Tabs, Widget},
|
||||
text::{Line, Text},
|
||||
widgets::{Block, Cell, Row, Table, Widget},
|
||||
Frame,
|
||||
};
|
||||
|
||||
#[derive(Default, Debug, PartialEq)]
|
||||
pub enum Modality {
|
||||
#[default]
|
||||
Navigate,
|
||||
CellEdit,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
pub struct AppState {
|
||||
pub modality: Modality,
|
||||
}
|
||||
|
||||
// Interaction Modalities
|
||||
// * Navigate
|
||||
// * Edit
|
||||
pub struct Workspace {
|
||||
name: String,
|
||||
tbl: Tbl,
|
||||
state: AppState,
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
@ -24,6 +41,7 @@ impl Workspace {
|
||||
Self {
|
||||
tbl,
|
||||
name: name.into(),
|
||||
state: AppState::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -31,25 +49,142 @@ impl Workspace {
|
||||
let mut f = File::open(path)?;
|
||||
let mut buf = Vec::new();
|
||||
let _ = f.read_to_end(&mut buf)?;
|
||||
let input = String::from_utf8(buf)
|
||||
.context(format!("Error reading file: {:?}", path))?;
|
||||
let tbl = Tbl::from_str(input)?;
|
||||
Ok(Workspace::new(tbl, path.file_name().map(|p| p.to_string_lossy().to_string()).unwrap_or_else(|| "Unknown".to_string())))
|
||||
let input = String::from_utf8(buf).context(format!("Error reading file: {:?}", path))?;
|
||||
let mut tbl = Tbl::from_str(input)?;
|
||||
tbl.move_to(Address { row: 0, col: 0 })?;
|
||||
Ok(Workspace::new(
|
||||
tbl,
|
||||
path.file_name()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "Unknown".to_string()),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn move_down(&mut self) -> Result<()> {
|
||||
// TODO(jwall): Add a row automatically if necessary?
|
||||
let mut loc = self.tbl.location.clone();
|
||||
let (row, _) = self.tbl.dimensions();
|
||||
if loc.row < row-1 {
|
||||
loc.row += 1;
|
||||
self.tbl.move_to(loc)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
pub fn move_right(&mut self) -> Result<()> {
|
||||
// TODO(jwall): Add a column automatically if necessary?
|
||||
let mut loc = self.tbl.location.clone();
|
||||
let (_, col) = self.tbl.dimensions();
|
||||
if loc.col < col-1 {
|
||||
loc.col += 1;
|
||||
self.tbl.move_to(loc)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn handle_event(&mut self) -> Result<Option<ExitCode>> {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
return Ok(match self.state.modality {
|
||||
Modality::Navigate => self.handle_navigation_event(key)?,
|
||||
Modality::CellEdit => self.handle_edit_event(key)?,
|
||||
});
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn handle_edit_event(&mut self, key: event::KeyEvent) -> Result<Option<ExitCode>> {
|
||||
if key.kind == KeyEventKind::Press {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.state.modality = Modality::Navigate;
|
||||
},
|
||||
KeyCode::Char('j') => {
|
||||
self.move_down()?;
|
||||
},
|
||||
KeyCode::Char('k') => {
|
||||
self.move_up()?;
|
||||
},
|
||||
KeyCode::Char('h') => {
|
||||
self.move_left()?;
|
||||
},
|
||||
KeyCode::Char('l') => {
|
||||
self.move_right()?;
|
||||
},
|
||||
_ => {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn handle_navigation_event(&mut self, key: event::KeyEvent) -> Result<Option<ExitCode>> {
|
||||
if key.kind == KeyEventKind::Press {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.state.modality = Modality::Navigate;
|
||||
},
|
||||
KeyCode::Char('q') => {
|
||||
return Ok(Some(ExitCode::SUCCESS));
|
||||
},
|
||||
KeyCode::Char('j') => {
|
||||
self.move_down()?;
|
||||
},
|
||||
KeyCode::Char('k') => {
|
||||
self.move_up()?;
|
||||
},
|
||||
KeyCode::Char('h') => {
|
||||
self.move_left()?;
|
||||
},
|
||||
KeyCode::Char('l') => {
|
||||
self.move_right()?;
|
||||
},
|
||||
KeyCode::Char('e') => {
|
||||
self.state.modality = Modality::CellEdit;
|
||||
},
|
||||
_ => {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// navigation methods left, right, up, down
|
||||
}
|
||||
|
||||
impl Widget for Workspace {
|
||||
impl<'a> Widget for &'a Workspace {
|
||||
fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer)
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
use Constraint::{Min, Percentage};
|
||||
let rects = Layout::vertical([Min(1), Percentage(90)]).split(area);
|
||||
let table = Table::from(&self.tbl);
|
||||
let tabs = Tabs::new(vec![self.name.clone()]).select(0);
|
||||
|
||||
tabs.render(rects[0], buf);
|
||||
table.render(rects[1], buf);
|
||||
let block = Block::bordered()
|
||||
.title(Line::from(self.name.as_str()))
|
||||
.title_bottom(match &self.state.modality {
|
||||
Modality::Navigate => "navigate",
|
||||
Modality::CellEdit => "edit",
|
||||
})
|
||||
.title_bottom(Line::from(format!("{},{}", self.tbl.location.row, self.tbl.location.col)).right_aligned());
|
||||
let table = Table::from(&self.tbl).block(block);
|
||||
table.render(area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
@ -66,24 +201,29 @@ impl<'t> From<&Tbl> for Table<'t> {
|
||||
.get_calculated_table()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, r)| {
|
||||
let cells = vec![Cell::new(format!("{}", i))]
|
||||
.map(|(ri, r)| {
|
||||
let cells =
|
||||
vec![Cell::new(format!("{}", ri))]
|
||||
.into_iter()
|
||||
.chain(r.iter().map(|v| {
|
||||
.chain(r.iter().enumerate().map(|(ci, v)| {
|
||||
let content = format!("{}", v);
|
||||
Cell::new(Text::raw(content))
|
||||
.bg(if i % 2 == 0 {
|
||||
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 i % 2 == 0 {
|
||||
.fg(if ri % 2 == 0 {
|
||||
Color::White
|
||||
} else {
|
||||
Color::Rgb(31,32,34)
|
||||
})
|
||||
.underlined()
|
||||
.bold()
|
||||
Color::Rgb(31, 32, 34)
|
||||
}),
|
||||
}.bold()
|
||||
}));
|
||||
Row::new(cells)
|
||||
})
|
||||
@ -103,8 +243,6 @@ impl<'t> From<&Tbl> for Table<'t> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw(frame: &mut Frame, name: &PathBuf) {
|
||||
let ws = Workspace::load(name).unwrap();
|
||||
|
||||
pub fn draw(frame: &mut Frame, ws: &Workspace) {
|
||||
frame.render_widget(ws, frame.area());
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user