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:
21
src/view/axis.rs
Normal file
21
src/view/axis.rs
Normal file
@ -0,0 +1,21 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Axis {
|
||||
Row,
|
||||
Column,
|
||||
Page,
|
||||
/// Not yet assigned
|
||||
Unassigned,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Axis {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Axis::Row => write!(f, "Row ↕"),
|
||||
Axis::Column => write!(f, "Col ↔"),
|
||||
Axis::Page => write!(f, "Page ☰"),
|
||||
Axis::Unassigned => write!(f, "─"),
|
||||
}
|
||||
}
|
||||
}
|
||||
5
src/view/mod.rs
Normal file
5
src/view/mod.rs
Normal file
@ -0,0 +1,5 @@
|
||||
pub mod view;
|
||||
pub mod axis;
|
||||
|
||||
pub use view::View;
|
||||
pub use axis::Axis;
|
||||
127
src/view/view.rs
Normal file
127
src/view/view.rs
Normal file
@ -0,0 +1,127 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use indexmap::IndexMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::axis::Axis;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct View {
|
||||
pub name: String,
|
||||
/// Axis assignment for each category
|
||||
pub category_axes: IndexMap<String, Axis>,
|
||||
/// For page axis: selected item per category
|
||||
pub page_selections: HashMap<String, String>,
|
||||
/// Hidden items per category
|
||||
pub hidden_items: HashMap<String, HashSet<String>>,
|
||||
/// Collapsed groups per category
|
||||
pub collapsed_groups: HashMap<String, HashSet<String>>,
|
||||
/// Number format string (e.g. ",.0f" for comma-separated integer)
|
||||
pub number_format: String,
|
||||
/// Scroll offset for grid
|
||||
pub row_offset: usize,
|
||||
pub col_offset: usize,
|
||||
/// Selected cell (row_idx, col_idx)
|
||||
pub selected: (usize, usize),
|
||||
}
|
||||
|
||||
impl View {
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
category_axes: IndexMap::new(),
|
||||
page_selections: HashMap::new(),
|
||||
hidden_items: HashMap::new(),
|
||||
collapsed_groups: HashMap::new(),
|
||||
number_format: ",.0".to_string(),
|
||||
row_offset: 0,
|
||||
col_offset: 0,
|
||||
selected: (0, 0),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_category_added(&mut self, cat_name: &str) {
|
||||
if !self.category_axes.contains_key(cat_name) {
|
||||
// Auto-assign: first → Row, second → Column, rest → Page
|
||||
let rows = self.categories_on(Axis::Row).len();
|
||||
let cols = self.categories_on(Axis::Column).len();
|
||||
let axis = if rows == 0 {
|
||||
Axis::Row
|
||||
} else if cols == 0 {
|
||||
Axis::Column
|
||||
} else {
|
||||
Axis::Page
|
||||
};
|
||||
self.category_axes.insert(cat_name.to_string(), axis);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_axis(&mut self, cat_name: &str, axis: Axis) {
|
||||
if let Some(a) = self.category_axes.get_mut(cat_name) {
|
||||
*a = axis;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn axis_of(&self, cat_name: &str) -> Axis {
|
||||
self.category_axes.get(cat_name).copied().unwrap_or(Axis::Unassigned)
|
||||
}
|
||||
|
||||
pub fn categories_on(&self, axis: Axis) -> Vec<&str> {
|
||||
self.category_axes.iter()
|
||||
.filter(|(_, &a)| a == axis)
|
||||
.map(|(n, _)| n.as_str())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn set_page_selection(&mut self, cat_name: &str, item: &str) {
|
||||
self.page_selections.insert(cat_name.to_string(), item.to_string());
|
||||
}
|
||||
|
||||
pub fn page_selection(&self, cat_name: &str) -> Option<&str> {
|
||||
self.page_selections.get(cat_name).map(|s| s.as_str())
|
||||
}
|
||||
|
||||
pub fn toggle_group_collapse(&mut self, cat_name: &str, group_name: &str) {
|
||||
let set = self.collapsed_groups.entry(cat_name.to_string()).or_default();
|
||||
if set.contains(group_name) {
|
||||
set.remove(group_name);
|
||||
} else {
|
||||
set.insert(group_name.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_group_collapsed(&self, cat_name: &str, group_name: &str) -> bool {
|
||||
self.collapsed_groups
|
||||
.get(cat_name)
|
||||
.map(|s| s.contains(group_name))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn hide_item(&mut self, cat_name: &str, item_name: &str) {
|
||||
self.hidden_items.entry(cat_name.to_string()).or_default().insert(item_name.to_string());
|
||||
}
|
||||
|
||||
pub fn show_item(&mut self, cat_name: &str, item_name: &str) {
|
||||
if let Some(set) = self.hidden_items.get_mut(cat_name) {
|
||||
set.remove(item_name);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_hidden(&self, cat_name: &str, item_name: &str) -> bool {
|
||||
self.hidden_items.get(cat_name).map(|s| s.contains(item_name)).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Cycle axis for a category: Row → Column → Page → Row
|
||||
pub fn cycle_axis(&mut self, cat_name: &str) {
|
||||
let current = self.axis_of(cat_name);
|
||||
let next = match current {
|
||||
Axis::Row => Axis::Column,
|
||||
Axis::Column => Axis::Page,
|
||||
Axis::Page => Axis::Row,
|
||||
Axis::Unassigned => Axis::Row,
|
||||
};
|
||||
self.set_axis(cat_name, next);
|
||||
self.selected = (0, 0);
|
||||
self.row_offset = 0;
|
||||
self.col_offset = 0;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user