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:
98
src/ui/category_panel.rs
Normal file
98
src/ui/category_panel.rs
Normal file
@ -0,0 +1,98 @@
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::Rect,
|
||||
style::{Color, Modifier, Style},
|
||||
widgets::{Block, Borders, Widget},
|
||||
};
|
||||
|
||||
use crate::model::Model;
|
||||
use crate::view::Axis;
|
||||
use crate::ui::app::AppMode;
|
||||
|
||||
pub struct CategoryPanel<'a> {
|
||||
pub model: &'a Model,
|
||||
pub mode: &'a AppMode,
|
||||
pub cursor: usize,
|
||||
}
|
||||
|
||||
impl<'a> CategoryPanel<'a> {
|
||||
pub fn new(model: &'a Model, mode: &'a AppMode, cursor: usize) -> Self {
|
||||
Self { model, mode, cursor }
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Widget for CategoryPanel<'a> {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
let is_active = matches!(self.mode, AppMode::CategoryPanel);
|
||||
let border_style = if is_active {
|
||||
Style::default().fg(Color::Cyan)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(border_style)
|
||||
.title(" Categories [Enter] cycle axis ");
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
let view = match self.model.active_view() {
|
||||
Some(v) => v,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let cat_names: Vec<&str> = self.model.category_names();
|
||||
if cat_names.is_empty() {
|
||||
buf.set_string(inner.x, inner.y,
|
||||
"(no categories)",
|
||||
Style::default().fg(Color::DarkGray));
|
||||
return;
|
||||
}
|
||||
|
||||
for (i, cat_name) in cat_names.iter().enumerate() {
|
||||
if inner.y + i as u16 >= inner.y + inner.height { break; }
|
||||
|
||||
let axis = view.axis_of(cat_name);
|
||||
let axis_str = match axis {
|
||||
Axis::Row => "Row ↕",
|
||||
Axis::Column => "Col ↔",
|
||||
Axis::Page => "Page ☰",
|
||||
Axis::Unassigned => "none",
|
||||
};
|
||||
let axis_color = match axis {
|
||||
Axis::Row => Color::Green,
|
||||
Axis::Column => Color::Blue,
|
||||
Axis::Page => Color::Magenta,
|
||||
Axis::Unassigned => Color::DarkGray,
|
||||
};
|
||||
|
||||
let cat = self.model.category(cat_name);
|
||||
let item_count = cat.map(|c| c.items.len()).unwrap_or(0);
|
||||
|
||||
let is_selected = i == self.cursor && is_active;
|
||||
let base_style = if is_selected {
|
||||
Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
|
||||
// Background fill for selected row
|
||||
if is_selected {
|
||||
let fill = " ".repeat(inner.width as usize);
|
||||
buf.set_string(inner.x, inner.y + i as u16, &fill, base_style);
|
||||
}
|
||||
|
||||
let name_part = format!(" {cat_name} ({item_count})");
|
||||
let axis_part = format!(" [{axis_str}]");
|
||||
let available = inner.width as usize;
|
||||
|
||||
buf.set_string(inner.x, inner.y + i as u16, &name_part, base_style);
|
||||
if name_part.len() + axis_part.len() < available {
|
||||
let axis_x = inner.x + name_part.len() as u16;
|
||||
buf.set_string(axis_x, inner.y + i as u16, &axis_part,
|
||||
if is_selected { base_style } else { Style::default().fg(axis_color) });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user