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:
321
src/ui/grid.rs
Normal file
321
src/ui/grid.rs
Normal file
@ -0,0 +1,321 @@
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::Rect,
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Widget},
|
||||
};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::model::Model;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::view::Axis;
|
||||
use crate::ui::app::AppMode;
|
||||
|
||||
const ROW_HEADER_WIDTH: u16 = 16;
|
||||
const COL_WIDTH: u16 = 10;
|
||||
const MIN_COL_WIDTH: u16 = 6;
|
||||
|
||||
pub struct GridWidget<'a> {
|
||||
pub model: &'a Model,
|
||||
pub mode: &'a AppMode,
|
||||
pub search_query: &'a str,
|
||||
}
|
||||
|
||||
impl<'a> GridWidget<'a> {
|
||||
pub fn new(model: &'a Model, mode: &'a AppMode, search_query: &'a str) -> Self {
|
||||
Self { model, mode, search_query }
|
||||
}
|
||||
|
||||
fn render_grid(&self, area: Rect, buf: &mut Buffer) {
|
||||
let view = match self.model.active_view() {
|
||||
Some(v) => v,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let row_cats: Vec<&str> = view.categories_on(Axis::Row);
|
||||
let col_cats: Vec<&str> = view.categories_on(Axis::Column);
|
||||
let page_cats: Vec<&str> = view.categories_on(Axis::Page);
|
||||
|
||||
// Gather row items
|
||||
let row_items: Vec<Vec<String>> = if row_cats.is_empty() {
|
||||
vec![vec![]]
|
||||
} else {
|
||||
let cat_name = row_cats[0];
|
||||
let cat = match self.model.category(cat_name) {
|
||||
Some(c) => c,
|
||||
None => return,
|
||||
};
|
||||
cat.ordered_item_names().into_iter()
|
||||
.filter(|item| !view.is_hidden(cat_name, item))
|
||||
.map(|item| vec![item.to_string()])
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Gather col items
|
||||
let col_items: Vec<Vec<String>> = if col_cats.is_empty() {
|
||||
vec![vec![]]
|
||||
} else {
|
||||
let cat_name = col_cats[0];
|
||||
let cat = match self.model.category(cat_name) {
|
||||
Some(c) => c,
|
||||
None => return,
|
||||
};
|
||||
cat.ordered_item_names().into_iter()
|
||||
.filter(|item| !view.is_hidden(cat_name, item))
|
||||
.map(|item| vec![item.to_string()])
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Page filter coords
|
||||
let page_coords: Vec<(String, String)> = page_cats.iter().map(|cat_name| {
|
||||
let items: Vec<String> = self.model.category(cat_name)
|
||||
.map(|c| c.ordered_item_names().into_iter().map(String::from).collect())
|
||||
.unwrap_or_default();
|
||||
let sel = view.page_selection(cat_name)
|
||||
.map(String::from)
|
||||
.or_else(|| items.first().cloned())
|
||||
.unwrap_or_default();
|
||||
(cat_name.to_string(), sel)
|
||||
}).collect();
|
||||
|
||||
let (sel_row, sel_col) = view.selected;
|
||||
let row_offset = view.row_offset;
|
||||
let col_offset = view.col_offset;
|
||||
|
||||
// Available cols
|
||||
let available_cols = ((area.width.saturating_sub(ROW_HEADER_WIDTH)) / COL_WIDTH) as usize;
|
||||
let visible_col_items: Vec<_> = col_items.iter()
|
||||
.skip(col_offset)
|
||||
.take(available_cols.max(1))
|
||||
.collect();
|
||||
|
||||
let available_rows = area.height.saturating_sub(2) as usize; // header + border
|
||||
let visible_row_items: Vec<_> = row_items.iter()
|
||||
.skip(row_offset)
|
||||
.take(available_rows.max(1))
|
||||
.collect();
|
||||
|
||||
let mut y = area.y;
|
||||
|
||||
// Column headers
|
||||
let header_style = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD);
|
||||
let row_header_col = format!("{:<width$}", "", width = ROW_HEADER_WIDTH as usize);
|
||||
buf.set_string(area.x, y, &row_header_col, Style::default());
|
||||
let mut x = area.x + ROW_HEADER_WIDTH;
|
||||
for (ci, col_item) in visible_col_items.iter().enumerate() {
|
||||
let abs_ci = ci + col_offset;
|
||||
let label = col_item.join("/");
|
||||
let styled = if abs_ci == sel_col {
|
||||
header_style.add_modifier(Modifier::UNDERLINED)
|
||||
} else {
|
||||
header_style
|
||||
};
|
||||
let truncated = truncate(&label, COL_WIDTH as usize);
|
||||
buf.set_string(x, y, format!("{:>width$}", truncated, width = COL_WIDTH as usize), styled);
|
||||
x += COL_WIDTH;
|
||||
if x >= area.x + area.width { break; }
|
||||
}
|
||||
y += 1;
|
||||
|
||||
// Separator
|
||||
let sep = "─".repeat(area.width as usize);
|
||||
buf.set_string(area.x, y, &sep, Style::default().fg(Color::DarkGray));
|
||||
y += 1;
|
||||
|
||||
// Data rows
|
||||
for (ri, row_item) in visible_row_items.iter().enumerate() {
|
||||
let abs_ri = ri + row_offset;
|
||||
if y >= area.y + area.height { break; }
|
||||
|
||||
let row_label = row_item.join("/");
|
||||
let row_style = if abs_ri == sel_row {
|
||||
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
let row_header_str = truncate(&row_label, ROW_HEADER_WIDTH as usize - 1);
|
||||
buf.set_string(area.x, y,
|
||||
format!("{:<width$}", row_header_str, width = ROW_HEADER_WIDTH as usize),
|
||||
row_style);
|
||||
|
||||
let mut x = area.x + ROW_HEADER_WIDTH;
|
||||
for (ci, col_item) in visible_col_items.iter().enumerate() {
|
||||
let abs_ci = ci + col_offset;
|
||||
if x >= area.x + area.width { break; }
|
||||
|
||||
let mut coords = page_coords.clone();
|
||||
for (cat, item) in row_cats.iter().zip(row_item.iter()) {
|
||||
coords.push((cat.to_string(), item.clone()));
|
||||
}
|
||||
for (cat, item) in col_cats.iter().zip(col_item.iter()) {
|
||||
coords.push((cat.to_string(), item.clone()));
|
||||
}
|
||||
let key = CellKey::new(coords);
|
||||
let value = self.model.evaluate(&key);
|
||||
|
||||
let cell_str = format_value(&value);
|
||||
let is_selected = abs_ri == sel_row && abs_ci == sel_col;
|
||||
let is_search_match = !self.search_query.is_empty()
|
||||
&& cell_str.to_lowercase().contains(&self.search_query.to_lowercase());
|
||||
|
||||
let cell_style = if is_selected {
|
||||
Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD)
|
||||
} else if is_search_match {
|
||||
Style::default().fg(Color::Black).bg(Color::Yellow)
|
||||
} else if matches!(value, CellValue::Empty) {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
|
||||
let formatted = format!("{:>width$}", truncate(&cell_str, COL_WIDTH as usize), width = COL_WIDTH as usize);
|
||||
buf.set_string(x, y, formatted, cell_style);
|
||||
x += COL_WIDTH;
|
||||
}
|
||||
|
||||
// Edit indicator
|
||||
if matches!(self.mode, AppMode::Editing { .. }) && abs_ri == sel_row {
|
||||
if let AppMode::Editing { buffer } = self.mode {
|
||||
let edit_x = area.x + ROW_HEADER_WIDTH + (sel_col.saturating_sub(col_offset)) as u16 * COL_WIDTH;
|
||||
let edit_str = format!("{:<width$}", buffer, width = COL_WIDTH as usize);
|
||||
buf.set_string(edit_x, y,
|
||||
truncate(&edit_str, COL_WIDTH as usize),
|
||||
Style::default().fg(Color::Green).add_modifier(Modifier::UNDERLINED));
|
||||
}
|
||||
}
|
||||
|
||||
y += 1;
|
||||
}
|
||||
|
||||
// Total row
|
||||
if !col_items.is_empty() && !row_items.is_empty() {
|
||||
if y < area.y + area.height {
|
||||
let sep = "─".repeat(area.width as usize);
|
||||
buf.set_string(area.x, y, &sep, Style::default().fg(Color::DarkGray));
|
||||
y += 1;
|
||||
}
|
||||
if y < area.y + area.height {
|
||||
buf.set_string(area.x, y,
|
||||
format!("{:<width$}", "Total", width = ROW_HEADER_WIDTH as usize),
|
||||
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
||||
|
||||
let mut x = area.x + ROW_HEADER_WIDTH;
|
||||
for (ci, col_item) in visible_col_items.iter().enumerate() {
|
||||
if x >= area.x + area.width { break; }
|
||||
let mut coords = page_coords.clone();
|
||||
for (cat, item) in col_cats.iter().zip(col_item.iter()) {
|
||||
coords.push((cat.to_string(), item.clone()));
|
||||
}
|
||||
let total: f64 = row_items.iter().map(|ri| {
|
||||
let mut c = coords.clone();
|
||||
for (cat, item) in row_cats.iter().zip(ri.iter()) {
|
||||
c.push((cat.to_string(), item.clone()));
|
||||
}
|
||||
let key = CellKey::new(c);
|
||||
self.model.evaluate(&key).as_f64().unwrap_or(0.0)
|
||||
}).sum();
|
||||
|
||||
let total_str = format_f64(total);
|
||||
buf.set_string(x, y,
|
||||
format!("{:>width$}", truncate(&total_str, COL_WIDTH as usize), width = COL_WIDTH as usize),
|
||||
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
||||
x += COL_WIDTH;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Widget for GridWidget<'a> {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
let view_name = self.model.active_view
|
||||
.clone();
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(format!(" View: {} ", view_name));
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
// Page axis bar
|
||||
let view = self.model.active_view();
|
||||
if let Some(view) = view {
|
||||
let page_cats: Vec<&str> = view.categories_on(Axis::Page);
|
||||
if !page_cats.is_empty() && inner.height > 0 {
|
||||
let page_info: Vec<String> = page_cats.iter().map(|cat_name| {
|
||||
let items: Vec<String> = self.model.category(cat_name)
|
||||
.map(|c| c.ordered_item_names().into_iter().map(String::from).collect())
|
||||
.unwrap_or_default();
|
||||
let sel = view.page_selection(cat_name)
|
||||
.map(String::from)
|
||||
.or_else(|| items.first().cloned())
|
||||
.unwrap_or_else(|| "(none)".to_string());
|
||||
format!("{cat_name} = {sel}")
|
||||
}).collect();
|
||||
let page_str = format!(" [{}] ", page_info.join(" | "));
|
||||
buf.set_string(inner.x, inner.y,
|
||||
&page_str,
|
||||
Style::default().fg(Color::Magenta));
|
||||
|
||||
let grid_area = Rect {
|
||||
y: inner.y + 1,
|
||||
height: inner.height.saturating_sub(1),
|
||||
..inner
|
||||
};
|
||||
self.render_grid(grid_area, buf);
|
||||
} else {
|
||||
self.render_grid(inner, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_value(v: &CellValue) -> String {
|
||||
match v {
|
||||
CellValue::Number(n) => format_f64(*n),
|
||||
CellValue::Text(s) => s.clone(),
|
||||
CellValue::Empty => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_f64(n: f64) -> String {
|
||||
if n == 0.0 {
|
||||
return "0".to_string();
|
||||
}
|
||||
if n.fract() == 0.0 && n.abs() < 1e12 {
|
||||
// Integer with comma formatting
|
||||
let i = n as i64;
|
||||
let s = i.to_string();
|
||||
let is_neg = s.starts_with('-');
|
||||
let digits = if is_neg { &s[1..] } else { &s[..] };
|
||||
let mut result = String::new();
|
||||
for (idx, c) in digits.chars().rev().enumerate() {
|
||||
if idx > 0 && idx % 3 == 0 { result.push(','); }
|
||||
result.push(c);
|
||||
}
|
||||
if is_neg { result.push('-'); }
|
||||
result.chars().rev().collect()
|
||||
} else {
|
||||
format!("{n:.2}")
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max_width: usize) -> String {
|
||||
let w = s.width();
|
||||
if w <= max_width {
|
||||
s.to_string()
|
||||
} else if max_width > 1 {
|
||||
let mut result = String::new();
|
||||
let mut width = 0;
|
||||
for c in s.chars() {
|
||||
let cw = unicode_width::UnicodeWidthChar::width(c).unwrap_or(1);
|
||||
if width + cw + 1 > max_width { break; }
|
||||
result.push(c);
|
||||
width += cw;
|
||||
}
|
||||
result.push('…');
|
||||
result
|
||||
} else {
|
||||
s.chars().take(max_width).collect()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user