Initial implementation of Improvise TUI
Multi-dimensional data modeling terminal application with:
- Core data model: categories, items, groups, sparse cell store
- Formula system: recursive-descent parser, named formulas, WHERE clauses
- View system: Row/Column/Page axes, tile-based pivot, page slicing
- JSON import wizard (interactive TUI + headless auto-mode)
- Command layer: all mutations via typed Command enum for headless replay
- TUI: Ratatui grid, tile bar, formula/category/view panels, help overlay
- Persistence: .improv (JSON), .improv.gz (gzip), CSV export, autosave
- Static binary via x86_64-unknown-linux-musl + nix flake devShell
- Headless mode: --cmd '{"op":"..."}' and --script file.jsonl
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
158
src/model/cell.rs
Normal file
158
src/model/cell.rs
Normal file
@ -0,0 +1,158 @@
|
||||
use std::collections::HashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A cell key is a sorted vector of (category_name, item_name) pairs.
|
||||
/// Sorted by category name for canonical form.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct CellKey(pub Vec<(String, String)>);
|
||||
|
||||
impl CellKey {
|
||||
pub fn new(mut coords: Vec<(String, String)>) -> Self {
|
||||
coords.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
Self(coords)
|
||||
}
|
||||
|
||||
pub fn get(&self, category: &str) -> Option<&str> {
|
||||
self.0.iter().find(|(c, _)| c == category).map(|(_, v)| v.as_str())
|
||||
}
|
||||
|
||||
pub fn with(mut self, category: impl Into<String>, item: impl Into<String>) -> Self {
|
||||
let cat = category.into();
|
||||
let itm = item.into();
|
||||
if let Some(pos) = self.0.iter().position(|(c, _)| c == &cat) {
|
||||
self.0[pos].1 = itm;
|
||||
} else {
|
||||
self.0.push((cat, itm));
|
||||
self.0.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn without(&self, category: &str) -> Self {
|
||||
Self(self.0.iter().filter(|(c, _)| c != category).cloned().collect())
|
||||
}
|
||||
|
||||
pub fn matches_partial(&self, partial: &[(String, String)]) -> bool {
|
||||
partial.iter().all(|(cat, item)| self.get(cat) == Some(item.as_str()))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CellKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let parts: Vec<_> = self.0.iter().map(|(c, v)| format!("{c}={v}")).collect();
|
||||
write!(f, "{{{}}}", parts.join(", "))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum CellValue {
|
||||
Number(f64),
|
||||
Text(String),
|
||||
Empty,
|
||||
}
|
||||
|
||||
impl CellValue {
|
||||
pub fn as_f64(&self) -> Option<f64> {
|
||||
match self {
|
||||
CellValue::Number(n) => Some(*n),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
matches!(self, CellValue::Empty)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CellValue {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
CellValue::Number(n) => {
|
||||
if n.fract() == 0.0 && n.abs() < 1e15 {
|
||||
write!(f, "{}", *n as i64)
|
||||
} else {
|
||||
write!(f, "{n:.4}")
|
||||
}
|
||||
}
|
||||
CellValue::Text(s) => write!(f, "{s}"),
|
||||
CellValue::Empty => write!(f, ""),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CellValue {
|
||||
fn default() -> Self {
|
||||
CellValue::Empty
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialized as a list of (key, value) pairs so CellKey doesn't need
|
||||
/// to implement the `Serialize`-as-string requirement for JSON object keys.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DataStore {
|
||||
cells: HashMap<CellKey, CellValue>,
|
||||
}
|
||||
|
||||
impl Serialize for DataStore {
|
||||
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
use serde::ser::SerializeSeq;
|
||||
let mut seq = s.serialize_seq(Some(self.cells.len()))?;
|
||||
for (k, v) in &self.cells {
|
||||
seq.serialize_element(&(k, v))?;
|
||||
}
|
||||
seq.end()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for DataStore {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let pairs: Vec<(CellKey, CellValue)> = Vec::deserialize(d)?;
|
||||
let cells: HashMap<CellKey, CellValue> = pairs.into_iter().collect();
|
||||
Ok(DataStore { cells })
|
||||
}
|
||||
}
|
||||
|
||||
impl DataStore {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn set(&mut self, key: CellKey, value: CellValue) {
|
||||
if value.is_empty() {
|
||||
self.cells.remove(&key);
|
||||
} else {
|
||||
self.cells.insert(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &CellKey) -> &CellValue {
|
||||
self.cells.get(key).unwrap_or(&CellValue::Empty)
|
||||
}
|
||||
|
||||
pub fn get_mut(&mut self, key: &CellKey) -> Option<&mut CellValue> {
|
||||
self.cells.get_mut(key)
|
||||
}
|
||||
|
||||
pub fn cells(&self) -> &HashMap<CellKey, CellValue> {
|
||||
&self.cells
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, key: &CellKey) {
|
||||
self.cells.remove(key);
|
||||
}
|
||||
|
||||
/// Sum all cells matching partial coordinates
|
||||
pub fn sum_matching(&self, partial: &[(String, String)]) -> f64 {
|
||||
self.cells.iter()
|
||||
.filter(|(key, _)| key.matches_partial(partial))
|
||||
.filter_map(|(_, v)| v.as_f64())
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// All cells where partial coords match
|
||||
pub fn matching_cells(&self, partial: &[(String, String)]) -> Vec<(&CellKey, &CellValue)> {
|
||||
self.cells.iter()
|
||||
.filter(|(key, _)| key.matches_partial(partial))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user