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:
657
src/ui/app.rs
Normal file
657
src/ui/app.rs
Normal file
@ -0,0 +1,657 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
use anyhow::Result;
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
use crate::model::Model;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::formula::parse_formula;
|
||||
use crate::import::wizard::{ImportWizard, WizardState};
|
||||
use crate::persistence;
|
||||
use crate::view::Axis;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum AppMode {
|
||||
Normal,
|
||||
Editing { buffer: String },
|
||||
FormulaEdit { buffer: String },
|
||||
FormulaPanel,
|
||||
CategoryPanel,
|
||||
ViewPanel,
|
||||
TileSelect { cat_idx: usize },
|
||||
ImportWizard,
|
||||
ExportPrompt { buffer: String },
|
||||
Help,
|
||||
Quit,
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
pub model: Model,
|
||||
pub file_path: Option<PathBuf>,
|
||||
pub mode: AppMode,
|
||||
pub status_msg: String,
|
||||
pub wizard: Option<ImportWizard>,
|
||||
pub last_autosave: Instant,
|
||||
/// Input buffer for command-line style input
|
||||
pub input_buffer: String,
|
||||
/// Search query
|
||||
pub search_query: String,
|
||||
pub search_mode: bool,
|
||||
pub formula_panel_open: bool,
|
||||
pub category_panel_open: bool,
|
||||
pub view_panel_open: bool,
|
||||
/// Category panel cursor
|
||||
pub cat_panel_cursor: usize,
|
||||
/// View panel cursor
|
||||
pub view_panel_cursor: usize,
|
||||
/// Formula panel cursor
|
||||
pub formula_cursor: usize,
|
||||
pub dirty: bool,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new(model: Model, file_path: Option<PathBuf>) -> Self {
|
||||
Self {
|
||||
model,
|
||||
file_path,
|
||||
mode: AppMode::Normal,
|
||||
status_msg: String::new(),
|
||||
wizard: None,
|
||||
last_autosave: Instant::now(),
|
||||
input_buffer: String::new(),
|
||||
search_query: String::new(),
|
||||
search_mode: false,
|
||||
formula_panel_open: false,
|
||||
category_panel_open: false,
|
||||
view_panel_open: false,
|
||||
cat_panel_cursor: 0,
|
||||
view_panel_cursor: 0,
|
||||
formula_cursor: 0,
|
||||
dirty: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> Result<()> {
|
||||
match &self.mode.clone() {
|
||||
AppMode::Quit => {}
|
||||
AppMode::Help => {
|
||||
self.mode = AppMode::Normal;
|
||||
}
|
||||
AppMode::ImportWizard => {
|
||||
self.handle_wizard_key(key)?;
|
||||
}
|
||||
AppMode::Editing { buffer } => {
|
||||
self.handle_edit_key(key)?;
|
||||
}
|
||||
AppMode::FormulaEdit { buffer } => {
|
||||
self.handle_formula_edit_key(key)?;
|
||||
}
|
||||
AppMode::FormulaPanel => {
|
||||
self.handle_formula_panel_key(key)?;
|
||||
}
|
||||
AppMode::CategoryPanel => {
|
||||
self.handle_category_panel_key(key)?;
|
||||
}
|
||||
AppMode::ViewPanel => {
|
||||
self.handle_view_panel_key(key)?;
|
||||
}
|
||||
AppMode::TileSelect { cat_idx } => {
|
||||
self.handle_tile_select_key(key)?;
|
||||
}
|
||||
AppMode::ExportPrompt { buffer } => {
|
||||
self.handle_export_key(key)?;
|
||||
}
|
||||
AppMode::Normal => {
|
||||
self.handle_normal_key(key)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_normal_key(&mut self, key: KeyEvent) -> Result<()> {
|
||||
if self.search_mode {
|
||||
return self.handle_search_key(key);
|
||||
}
|
||||
|
||||
match (key.code, key.modifiers) {
|
||||
(KeyCode::Char('q'), KeyModifiers::CONTROL) => {
|
||||
self.mode = AppMode::Quit;
|
||||
}
|
||||
(KeyCode::F(1), _) => {
|
||||
self.mode = AppMode::Help;
|
||||
}
|
||||
(KeyCode::Char('s'), KeyModifiers::CONTROL) => {
|
||||
self.save()?;
|
||||
}
|
||||
(KeyCode::Char('f'), KeyModifiers::CONTROL) => {
|
||||
self.formula_panel_open = !self.formula_panel_open;
|
||||
}
|
||||
(KeyCode::Char('c'), KeyModifiers::CONTROL) => {
|
||||
self.category_panel_open = !self.category_panel_open;
|
||||
}
|
||||
(KeyCode::Char('v'), KeyModifiers::CONTROL) => {
|
||||
self.view_panel_open = !self.view_panel_open;
|
||||
}
|
||||
(KeyCode::Char('e'), KeyModifiers::CONTROL) => {
|
||||
self.mode = AppMode::ExportPrompt { buffer: String::new() };
|
||||
}
|
||||
// Navigation
|
||||
(KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::NONE) => {
|
||||
self.move_selection(-1, 0);
|
||||
}
|
||||
(KeyCode::Down, _) | (KeyCode::Char('j'), KeyModifiers::NONE) => {
|
||||
self.move_selection(1, 0);
|
||||
}
|
||||
(KeyCode::Left, _) | (KeyCode::Char('h'), KeyModifiers::NONE) => {
|
||||
self.move_selection(0, -1);
|
||||
}
|
||||
(KeyCode::Right, _) | (KeyCode::Char('l'), KeyModifiers::NONE) => {
|
||||
self.move_selection(0, 1);
|
||||
}
|
||||
(KeyCode::Enter, _) => {
|
||||
let cell_key = self.selected_cell_key();
|
||||
let current = cell_key.as_ref().map(|k| {
|
||||
self.model.get_cell(k).to_string()
|
||||
}).unwrap_or_default();
|
||||
self.mode = AppMode::Editing { buffer: current };
|
||||
}
|
||||
(KeyCode::Char('/'), _) => {
|
||||
self.search_mode = true;
|
||||
self.search_query.clear();
|
||||
}
|
||||
// Tab cycles panels
|
||||
(KeyCode::Tab, _) => {
|
||||
if self.formula_panel_open {
|
||||
self.mode = AppMode::FormulaPanel;
|
||||
} else if self.category_panel_open {
|
||||
self.mode = AppMode::CategoryPanel;
|
||||
} else if self.view_panel_open {
|
||||
self.mode = AppMode::ViewPanel;
|
||||
}
|
||||
}
|
||||
// Tile movement with Ctrl+Arrow
|
||||
(KeyCode::Left, KeyModifiers::CONTROL) | (KeyCode::Right, KeyModifiers::CONTROL)
|
||||
| (KeyCode::Up, KeyModifiers::CONTROL) | (KeyCode::Down, KeyModifiers::CONTROL) => {
|
||||
let cat_names: Vec<String> = self.model.category_names()
|
||||
.into_iter().map(String::from).collect();
|
||||
if !cat_names.is_empty() {
|
||||
self.mode = AppMode::TileSelect { cat_idx: 0 };
|
||||
}
|
||||
}
|
||||
// Page axis navigation with [ ]
|
||||
(KeyCode::Char('['), _) => {
|
||||
self.page_prev();
|
||||
}
|
||||
(KeyCode::Char(']'), _) => {
|
||||
self.page_next();
|
||||
}
|
||||
// Formula panel shortcut
|
||||
(KeyCode::Char('F'), KeyModifiers::NONE) => {
|
||||
self.formula_panel_open = true;
|
||||
self.mode = AppMode::FormulaPanel;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_search_key(&mut self, key: KeyEvent) -> Result<()> {
|
||||
match key.code {
|
||||
KeyCode::Esc => { self.search_mode = false; }
|
||||
KeyCode::Enter => { self.search_mode = false; }
|
||||
KeyCode::Char(c) => { self.search_query.push(c); }
|
||||
KeyCode::Backspace => { self.search_query.pop(); }
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_edit_key(&mut self, key: KeyEvent) -> Result<()> {
|
||||
let buf = if let AppMode::Editing { buffer } = &self.mode {
|
||||
buffer.clone()
|
||||
} else { return Ok(()); };
|
||||
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.mode = AppMode::Normal;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
// Commit value
|
||||
if let Some(key) = self.selected_cell_key() {
|
||||
let value = if buf.is_empty() {
|
||||
CellValue::Empty
|
||||
} else if let Ok(n) = buf.parse::<f64>() {
|
||||
CellValue::Number(n)
|
||||
} else {
|
||||
CellValue::Text(buf.clone())
|
||||
};
|
||||
self.model.set_cell(key, value);
|
||||
self.dirty = true;
|
||||
}
|
||||
self.mode = AppMode::Normal;
|
||||
self.move_selection(1, 0);
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
if let AppMode::Editing { buffer } = &mut self.mode {
|
||||
buffer.push(c);
|
||||
}
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if let AppMode::Editing { buffer } = &mut self.mode {
|
||||
buffer.pop();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_formula_edit_key(&mut self, key: KeyEvent) -> Result<()> {
|
||||
let buf = if let AppMode::FormulaEdit { buffer } = &self.mode {
|
||||
buffer.clone()
|
||||
} else { return Ok(()); };
|
||||
|
||||
match key.code {
|
||||
KeyCode::Esc => { self.mode = AppMode::FormulaPanel; }
|
||||
KeyCode::Enter => {
|
||||
// Try to parse and add formula
|
||||
let first_cat = self.model.category_names().into_iter().next().map(String::from);
|
||||
if let Some(cat) = first_cat {
|
||||
match parse_formula(&buf, &cat) {
|
||||
Ok(formula) => {
|
||||
self.model.add_formula(formula);
|
||||
self.status_msg = "Formula added".to_string();
|
||||
self.dirty = true;
|
||||
}
|
||||
Err(e) => {
|
||||
self.status_msg = format!("Formula error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
self.mode = AppMode::FormulaPanel;
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
if let AppMode::FormulaEdit { buffer } = &mut self.mode {
|
||||
buffer.push(c);
|
||||
}
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if let AppMode::FormulaEdit { buffer } = &mut self.mode {
|
||||
buffer.pop();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_formula_panel_key(&mut self, key: KeyEvent) -> Result<()> {
|
||||
match key.code {
|
||||
KeyCode::Esc | KeyCode::Tab => { self.mode = AppMode::Normal; }
|
||||
KeyCode::Char('a') | KeyCode::Char('n') => {
|
||||
self.mode = AppMode::FormulaEdit { buffer: String::new() };
|
||||
}
|
||||
KeyCode::Char('d') | KeyCode::Delete => {
|
||||
if self.formula_cursor < self.model.formulas.len() {
|
||||
let target = self.model.formulas[self.formula_cursor].target.clone();
|
||||
self.model.remove_formula(&target);
|
||||
if self.formula_cursor > 0 { self.formula_cursor -= 1; }
|
||||
self.dirty = true;
|
||||
}
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if self.formula_cursor > 0 { self.formula_cursor -= 1; }
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if self.formula_cursor + 1 < self.model.formulas.len() {
|
||||
self.formula_cursor += 1;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_category_panel_key(&mut self, key: KeyEvent) -> Result<()> {
|
||||
let cat_names: Vec<String> = self.model.category_names().into_iter().map(String::from).collect();
|
||||
match key.code {
|
||||
KeyCode::Esc | KeyCode::Tab => { self.mode = AppMode::Normal; }
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if self.cat_panel_cursor > 0 { self.cat_panel_cursor -= 1; }
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if self.cat_panel_cursor + 1 < cat_names.len() {
|
||||
self.cat_panel_cursor += 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Enter | KeyCode::Char(' ') => {
|
||||
// Cycle axis for selected category
|
||||
if let Some(cat_name) = cat_names.get(self.cat_panel_cursor) {
|
||||
if let Some(view) = self.model.active_view_mut() {
|
||||
view.cycle_axis(cat_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_view_panel_key(&mut self, key: KeyEvent) -> Result<()> {
|
||||
let view_names: Vec<String> = self.model.views.keys().cloned().collect();
|
||||
match key.code {
|
||||
KeyCode::Esc | KeyCode::Tab => { self.mode = AppMode::Normal; }
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if self.view_panel_cursor > 0 { self.view_panel_cursor -= 1; }
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if self.view_panel_cursor + 1 < view_names.len() {
|
||||
self.view_panel_cursor += 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Some(name) = view_names.get(self.view_panel_cursor) {
|
||||
let _ = self.model.switch_view(name);
|
||||
self.mode = AppMode::Normal;
|
||||
}
|
||||
}
|
||||
KeyCode::Char('n') => {
|
||||
let new_name = format!("View {}", self.model.views.len() + 1);
|
||||
self.model.create_view(&new_name);
|
||||
let _ = self.model.switch_view(&new_name);
|
||||
self.dirty = true;
|
||||
self.mode = AppMode::Normal;
|
||||
}
|
||||
KeyCode::Delete | KeyCode::Char('d') => {
|
||||
if let Some(name) = view_names.get(self.view_panel_cursor) {
|
||||
let _ = self.model.delete_view(name);
|
||||
if self.view_panel_cursor > 0 { self.view_panel_cursor -= 1; }
|
||||
self.dirty = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_tile_select_key(&mut self, key: KeyEvent) -> Result<()> {
|
||||
let cat_names: Vec<String> = self.model.category_names().into_iter().map(String::from).collect();
|
||||
let cat_idx = if let AppMode::TileSelect { cat_idx } = self.mode { cat_idx } else { 0 };
|
||||
|
||||
match key.code {
|
||||
KeyCode::Esc => { self.mode = AppMode::Normal; }
|
||||
KeyCode::Left | KeyCode::Char('h') => {
|
||||
if let AppMode::TileSelect { ref mut cat_idx } = self.mode {
|
||||
if *cat_idx > 0 { *cat_idx -= 1; }
|
||||
}
|
||||
}
|
||||
KeyCode::Right | KeyCode::Char('l') => {
|
||||
if let AppMode::TileSelect { ref mut cat_idx } = self.mode {
|
||||
if *cat_idx + 1 < cat_names.len() { *cat_idx += 1; }
|
||||
}
|
||||
}
|
||||
KeyCode::Enter | KeyCode::Char(' ') => {
|
||||
if let Some(name) = cat_names.get(cat_idx) {
|
||||
if let Some(view) = self.model.active_view_mut() {
|
||||
view.cycle_axis(name);
|
||||
}
|
||||
self.dirty = true;
|
||||
}
|
||||
self.mode = AppMode::Normal;
|
||||
}
|
||||
KeyCode::Char('r') => {
|
||||
if let Some(name) = cat_names.get(cat_idx) {
|
||||
if let Some(view) = self.model.active_view_mut() {
|
||||
view.set_axis(name, Axis::Row);
|
||||
}
|
||||
self.dirty = true;
|
||||
}
|
||||
self.mode = AppMode::Normal;
|
||||
}
|
||||
KeyCode::Char('c') => {
|
||||
if let Some(name) = cat_names.get(cat_idx) {
|
||||
if let Some(view) = self.model.active_view_mut() {
|
||||
view.set_axis(name, Axis::Column);
|
||||
}
|
||||
self.dirty = true;
|
||||
}
|
||||
self.mode = AppMode::Normal;
|
||||
}
|
||||
KeyCode::Char('p') => {
|
||||
if let Some(name) = cat_names.get(cat_idx) {
|
||||
if let Some(view) = self.model.active_view_mut() {
|
||||
view.set_axis(name, Axis::Page);
|
||||
}
|
||||
self.dirty = true;
|
||||
}
|
||||
self.mode = AppMode::Normal;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_export_key(&mut self, key: KeyEvent) -> Result<()> {
|
||||
match key.code {
|
||||
KeyCode::Esc => { self.mode = AppMode::Normal; }
|
||||
KeyCode::Enter => {
|
||||
let buf = if let AppMode::ExportPrompt { buffer } = &self.mode {
|
||||
buffer.clone()
|
||||
} else { return Ok(()); };
|
||||
let path = PathBuf::from(buf);
|
||||
let view_name = self.model.active_view.clone();
|
||||
match persistence::export_csv(&self.model, &view_name, &path) {
|
||||
Ok(_) => { self.status_msg = format!("Exported to {}", path.display()); }
|
||||
Err(e) => { self.status_msg = format!("Export error: {e}"); }
|
||||
}
|
||||
self.mode = AppMode::Normal;
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
if let AppMode::ExportPrompt { buffer } = &mut self.mode {
|
||||
buffer.push(c);
|
||||
}
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if let AppMode::ExportPrompt { buffer } = &mut self.mode {
|
||||
buffer.pop();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_wizard_key(&mut self, key: KeyEvent) -> Result<()> {
|
||||
if let Some(wizard) = &mut self.wizard {
|
||||
match &wizard.state.clone() {
|
||||
WizardState::Preview => {
|
||||
match key.code {
|
||||
KeyCode::Enter | KeyCode::Char(' ') => wizard.advance(),
|
||||
KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; }
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
WizardState::SelectArrayPath => {
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => wizard.move_cursor(-1),
|
||||
KeyCode::Down | KeyCode::Char('j') => wizard.move_cursor(1),
|
||||
KeyCode::Enter => wizard.confirm_path(),
|
||||
KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; }
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
WizardState::ReviewProposals => {
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => wizard.move_cursor(-1),
|
||||
KeyCode::Down | KeyCode::Char('j') => wizard.move_cursor(1),
|
||||
KeyCode::Char(' ') => wizard.toggle_proposal(),
|
||||
KeyCode::Char('c') => wizard.cycle_proposal_kind(),
|
||||
KeyCode::Enter => wizard.advance(),
|
||||
KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; }
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
WizardState::NameModel => {
|
||||
match key.code {
|
||||
KeyCode::Char(c) => wizard.push_name_char(c),
|
||||
KeyCode::Backspace => wizard.pop_name_char(),
|
||||
KeyCode::Enter => {
|
||||
let result = wizard.build_model();
|
||||
match result {
|
||||
Ok(model) => {
|
||||
self.model = model;
|
||||
self.dirty = true;
|
||||
self.status_msg = "Import successful!".to_string();
|
||||
self.mode = AppMode::Normal;
|
||||
self.wizard = None;
|
||||
}
|
||||
Err(e) => {
|
||||
if let Some(w) = &mut self.wizard {
|
||||
w.message = Some(format!("Error: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; }
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
WizardState::Done => {
|
||||
self.mode = AppMode::Normal;
|
||||
self.wizard = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn move_selection(&mut self, dr: i32, dc: i32) {
|
||||
if let Some(view) = self.model.active_view_mut() {
|
||||
let (r, c) = view.selected;
|
||||
let new_r = (r as i32 + dr).max(0) as usize;
|
||||
let new_c = (c as i32 + dc).max(0) as usize;
|
||||
view.selected = (new_r, new_c);
|
||||
}
|
||||
}
|
||||
|
||||
fn page_next(&mut self) {
|
||||
let page_cats: Vec<String> = self.model.active_view()
|
||||
.map(|v| v.categories_on(crate::view::Axis::Page).into_iter().map(String::from).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
for cat_name in &page_cats {
|
||||
let items: Vec<String> = self.model.category(cat_name)
|
||||
.map(|c| c.ordered_item_names().into_iter().map(String::from).collect())
|
||||
.unwrap_or_default();
|
||||
if items.is_empty() { continue; }
|
||||
|
||||
let current = self.model.active_view()
|
||||
.and_then(|v| v.page_selection(cat_name))
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| items[0].clone());
|
||||
|
||||
let idx = items.iter().position(|i| i == ¤t).unwrap_or(0);
|
||||
let next_idx = (idx + 1).min(items.len() - 1);
|
||||
if let Some(view) = self.model.active_view_mut() {
|
||||
view.set_page_selection(cat_name, &items[next_idx]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
fn page_prev(&mut self) {
|
||||
let page_cats: Vec<String> = self.model.active_view()
|
||||
.map(|v| v.categories_on(crate::view::Axis::Page).into_iter().map(String::from).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
for cat_name in &page_cats {
|
||||
let items: Vec<String> = self.model.category(cat_name)
|
||||
.map(|c| c.ordered_item_names().into_iter().map(String::from).collect())
|
||||
.unwrap_or_default();
|
||||
if items.is_empty() { continue; }
|
||||
|
||||
let current = self.model.active_view()
|
||||
.and_then(|v| v.page_selection(cat_name))
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| items[0].clone());
|
||||
|
||||
let idx = items.iter().position(|i| i == ¤t).unwrap_or(0);
|
||||
let prev_idx = idx.saturating_sub(1);
|
||||
if let Some(view) = self.model.active_view_mut() {
|
||||
view.set_page_selection(cat_name, &items[prev_idx]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn selected_cell_key(&self) -> Option<CellKey> {
|
||||
let view = self.model.active_view()?;
|
||||
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);
|
||||
|
||||
let (sel_row, sel_col) = view.selected;
|
||||
let mut coords = vec![];
|
||||
|
||||
// Page coords
|
||||
for cat_name in &page_cats {
|
||||
let items = self.model.category(cat_name)
|
||||
.map(|c| c.ordered_item_names().into_iter().map(String::from).collect::<Vec<_>>())
|
||||
.unwrap_or_default();
|
||||
let sel = view.page_selection(cat_name)
|
||||
.map(String::from)
|
||||
.or_else(|| items.first().cloned())?;
|
||||
coords.push((cat_name.to_string(), sel));
|
||||
}
|
||||
|
||||
// Row coords
|
||||
for (i, cat_name) in row_cats.iter().enumerate() {
|
||||
let items: Vec<String> = self.model.category(cat_name)
|
||||
.map(|c| c.ordered_item_names().into_iter()
|
||||
.filter(|item| !view.is_hidden(cat_name, item))
|
||||
.map(String::from).collect())
|
||||
.unwrap_or_default();
|
||||
let item = items.get(sel_row)?.clone();
|
||||
coords.push((cat_name.to_string(), item));
|
||||
}
|
||||
|
||||
// Col coords
|
||||
for (i, cat_name) in col_cats.iter().enumerate() {
|
||||
let items: Vec<String> = self.model.category(cat_name)
|
||||
.map(|c| c.ordered_item_names().into_iter()
|
||||
.filter(|item| !view.is_hidden(cat_name, item))
|
||||
.map(String::from).collect())
|
||||
.unwrap_or_default();
|
||||
let item = items.get(sel_col)?.clone();
|
||||
coords.push((cat_name.to_string(), item));
|
||||
}
|
||||
|
||||
Some(CellKey::new(coords))
|
||||
}
|
||||
|
||||
pub fn save(&mut self) -> Result<()> {
|
||||
if let Some(path) = &self.file_path.clone() {
|
||||
persistence::save(&self.model, path)?;
|
||||
self.dirty = false;
|
||||
self.status_msg = format!("Saved to {}", path.display());
|
||||
} else {
|
||||
self.status_msg = "No file path set. Use Ctrl+E to export.".to_string();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn autosave_if_needed(&mut self) {
|
||||
if self.dirty && self.last_autosave.elapsed() > Duration::from_secs(30) {
|
||||
if let Some(path) = &self.file_path.clone() {
|
||||
let autosave_path = persistence::autosave_path(path);
|
||||
let _ = persistence::save(&self.model, &autosave_path);
|
||||
self.last_autosave = Instant::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_import_wizard(&mut self, json: serde_json::Value) {
|
||||
self.wizard = Some(ImportWizard::new(json));
|
||||
self.mode = AppMode::ImportWizard;
|
||||
}
|
||||
}
|
||||
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) });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
76
src/ui/formula_panel.rs
Normal file
76
src/ui/formula_panel.rs
Normal file
@ -0,0 +1,76 @@
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::Rect,
|
||||
style::{Color, Modifier, Style},
|
||||
widgets::{Block, Borders, Widget},
|
||||
};
|
||||
|
||||
use crate::model::Model;
|
||||
use crate::ui::app::AppMode;
|
||||
|
||||
pub struct FormulaPanel<'a> {
|
||||
pub model: &'a Model,
|
||||
pub mode: &'a AppMode,
|
||||
pub cursor: usize,
|
||||
}
|
||||
|
||||
impl<'a> FormulaPanel<'a> {
|
||||
pub fn new(model: &'a Model, mode: &'a AppMode, cursor: usize) -> Self {
|
||||
Self { model, mode, cursor }
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Widget for FormulaPanel<'a> {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
let is_active = matches!(self.mode, AppMode::FormulaPanel | AppMode::FormulaEdit { .. });
|
||||
let border_style = if is_active {
|
||||
Style::default().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(border_style)
|
||||
.title(" Formulas [n]ew [d]elete ");
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
let formulas = &self.model.formulas;
|
||||
|
||||
if formulas.is_empty() {
|
||||
buf.set_string(inner.x, inner.y,
|
||||
"(no formulas — press 'n' to add)",
|
||||
Style::default().fg(Color::DarkGray));
|
||||
return;
|
||||
}
|
||||
|
||||
for (i, formula) in formulas.iter().enumerate() {
|
||||
if inner.y + i as u16 >= inner.y + inner.height { break; }
|
||||
let is_selected = i == self.cursor && is_active;
|
||||
let style = if is_selected {
|
||||
Style::default().fg(Color::Black).bg(Color::Yellow).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::Green)
|
||||
};
|
||||
let text = format!(" {} = {:?}", formula.target, formula.raw);
|
||||
let truncated = if text.len() > inner.width as usize {
|
||||
format!("{}…", &text[..inner.width as usize - 1])
|
||||
} else {
|
||||
text
|
||||
};
|
||||
buf.set_string(inner.x, inner.y + i as u16, &truncated, style);
|
||||
}
|
||||
|
||||
// Formula edit mode
|
||||
if let AppMode::FormulaEdit { buffer } = self.mode {
|
||||
let y = inner.y + inner.height.saturating_sub(2);
|
||||
buf.set_string(inner.x, y,
|
||||
"┄ Enter formula (Name = expr): ",
|
||||
Style::default().fg(Color::Yellow));
|
||||
let y = y + 1;
|
||||
let prompt = format!("> {buffer}█");
|
||||
buf.set_string(inner.x, y, &prompt, Style::default().fg(Color::Green));
|
||||
}
|
||||
}
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
77
src/ui/help.rs
Normal file
77
src/ui/help.rs
Normal file
@ -0,0 +1,77 @@
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::Rect,
|
||||
style::{Color, Modifier, Style},
|
||||
widgets::{Block, Borders, Clear, Widget},
|
||||
};
|
||||
|
||||
pub struct HelpWidget;
|
||||
|
||||
impl Widget for HelpWidget {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
// Center popup
|
||||
let popup_w = 60u16.min(area.width);
|
||||
let popup_h = 30u16.min(area.height);
|
||||
let x = area.x + area.width.saturating_sub(popup_w) / 2;
|
||||
let y = area.y + area.height.saturating_sub(popup_h) / 2;
|
||||
let popup_area = Rect::new(x, y, popup_w, popup_h);
|
||||
|
||||
Clear.render(popup_area, buf);
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Help — Improvise ")
|
||||
.border_style(Style::default().fg(Color::Yellow));
|
||||
let inner = block.inner(popup_area);
|
||||
block.render(popup_area, buf);
|
||||
|
||||
let help_text = [
|
||||
("Navigation", ""),
|
||||
(" ↑/↓/←/→ or hjkl", "Move cursor"),
|
||||
(" Enter", "Edit selected cell"),
|
||||
(" /", "Search in grid"),
|
||||
(" [ / ]", "Prev/next page item"),
|
||||
("", ""),
|
||||
("Panels", ""),
|
||||
(" Ctrl+F", "Toggle formula panel"),
|
||||
(" Ctrl+C", "Toggle category panel"),
|
||||
(" Ctrl+V", "Toggle view panel"),
|
||||
(" Tab", "Focus next open panel"),
|
||||
("", ""),
|
||||
("Tiles / Pivot", ""),
|
||||
(" Ctrl+Arrow", "Enter tile select mode"),
|
||||
(" Enter/Space", "Cycle axis (Row→Col→Page)"),
|
||||
(" r / c / p", "Set axis to Row/Col/Page"),
|
||||
("", ""),
|
||||
("File", ""),
|
||||
(" Ctrl+S", "Save model"),
|
||||
(" Ctrl+E", "Export CSV"),
|
||||
("", ""),
|
||||
("Headless / Batch", ""),
|
||||
(" --cmd '{...}'", "Run a single JSON command"),
|
||||
(" --script file", "Run commands from file"),
|
||||
("", ""),
|
||||
(" F1", "This help"),
|
||||
(" Ctrl+Q", "Quit"),
|
||||
("", ""),
|
||||
(" Any key to close", ""),
|
||||
];
|
||||
|
||||
for (i, (key, desc)) in help_text.iter().enumerate() {
|
||||
if i >= inner.height as usize { break; }
|
||||
let y = inner.y + i as u16;
|
||||
if key.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if desc.is_empty() {
|
||||
buf.set_string(inner.x, y, key, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
||||
} else {
|
||||
buf.set_string(inner.x, y, key, Style::default().fg(Color::Cyan));
|
||||
let desc_x = inner.x + 26;
|
||||
if desc_x < inner.x + inner.width {
|
||||
buf.set_string(desc_x, y, desc, Style::default());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
147
src/ui/import_wizard_ui.rs
Normal file
147
src/ui/import_wizard_ui.rs
Normal file
@ -0,0 +1,147 @@
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::Rect,
|
||||
style::{Color, Modifier, Style},
|
||||
widgets::{Block, Borders, Clear, Widget},
|
||||
};
|
||||
|
||||
use crate::import::wizard::{ImportWizard, WizardState};
|
||||
use crate::import::analyzer::FieldKind;
|
||||
|
||||
pub struct ImportWizardWidget<'a> {
|
||||
pub wizard: &'a ImportWizard,
|
||||
}
|
||||
|
||||
impl<'a> ImportWizardWidget<'a> {
|
||||
pub fn new(wizard: &'a ImportWizard) -> Self {
|
||||
Self { wizard }
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Widget for ImportWizardWidget<'a> {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
let popup_w = area.width.min(80);
|
||||
let popup_h = area.height.min(30);
|
||||
let x = area.x + area.width.saturating_sub(popup_w) / 2;
|
||||
let y = area.y + area.height.saturating_sub(popup_h) / 2;
|
||||
let popup_area = Rect::new(x, y, popup_w, popup_h);
|
||||
|
||||
Clear.render(popup_area, buf);
|
||||
|
||||
let title = match self.wizard.state {
|
||||
WizardState::Preview => " Import Wizard — Step 1: Preview ",
|
||||
WizardState::SelectArrayPath => " Import Wizard — Step 2: Select Array ",
|
||||
WizardState::ReviewProposals => " Import Wizard — Step 3: Review Fields ",
|
||||
WizardState::NameModel => " Import Wizard — Step 4: Name Model ",
|
||||
WizardState::Done => " Import Wizard — Done ",
|
||||
};
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Magenta))
|
||||
.title(title);
|
||||
let inner = block.inner(popup_area);
|
||||
block.render(popup_area, buf);
|
||||
|
||||
let mut y = inner.y;
|
||||
let x = inner.x;
|
||||
let w = inner.width as usize;
|
||||
|
||||
match &self.wizard.state {
|
||||
WizardState::Preview => {
|
||||
let summary = self.wizard.preview_summary();
|
||||
buf.set_string(x, y, truncate(&summary, w), Style::default());
|
||||
y += 2;
|
||||
buf.set_string(x, y,
|
||||
"Press Enter to continue…",
|
||||
Style::default().fg(Color::Yellow));
|
||||
}
|
||||
WizardState::SelectArrayPath => {
|
||||
buf.set_string(x, y,
|
||||
"Select the path containing records:",
|
||||
Style::default().fg(Color::Yellow));
|
||||
y += 1;
|
||||
for (i, path) in self.wizard.array_paths.iter().enumerate() {
|
||||
if y >= inner.y + inner.height { break; }
|
||||
let is_sel = i == self.wizard.cursor;
|
||||
let style = if is_sel {
|
||||
Style::default().fg(Color::Black).bg(Color::Magenta).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
let label = format!(" {}", if path.is_empty() { "(root)" } else { path });
|
||||
buf.set_string(x, y, truncate(&label, w), style);
|
||||
y += 1;
|
||||
}
|
||||
y += 1;
|
||||
buf.set_string(x, y, "↑↓ select Enter confirm", Style::default().fg(Color::DarkGray));
|
||||
}
|
||||
WizardState::ReviewProposals => {
|
||||
buf.set_string(x, y,
|
||||
"Review field proposals (Space toggle, c cycle kind):",
|
||||
Style::default().fg(Color::Yellow));
|
||||
y += 1;
|
||||
let header = format!(" {:<20} {:<22} {:<6}", "Field", "Kind", "Accept");
|
||||
buf.set_string(x, y, truncate(&header, w), Style::default().fg(Color::Gray).add_modifier(Modifier::UNDERLINED));
|
||||
y += 1;
|
||||
|
||||
for (i, proposal) in self.wizard.proposals.iter().enumerate() {
|
||||
if y >= inner.y + inner.height - 2 { break; }
|
||||
let is_sel = i == self.wizard.cursor;
|
||||
|
||||
let kind_color = match proposal.kind {
|
||||
FieldKind::Category => Color::Green,
|
||||
FieldKind::Measure => Color::Cyan,
|
||||
FieldKind::TimeCategory => Color::Magenta,
|
||||
FieldKind::Label => Color::DarkGray,
|
||||
};
|
||||
|
||||
let accept_str = if proposal.accepted { "[✓]" } else { "[ ]" };
|
||||
let row = format!(" {:<20} {:<22} {}",
|
||||
truncate(&proposal.field, 20),
|
||||
truncate(proposal.kind_label(), 22),
|
||||
accept_str);
|
||||
|
||||
let style = if is_sel {
|
||||
Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD)
|
||||
} else if proposal.accepted {
|
||||
Style::default().fg(kind_color)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
|
||||
buf.set_string(x, y, truncate(&row, w), style);
|
||||
y += 1;
|
||||
}
|
||||
let hint_y = inner.y + inner.height - 1;
|
||||
buf.set_string(x, hint_y, "Enter: next Space: toggle c: cycle kind Esc: cancel",
|
||||
Style::default().fg(Color::DarkGray));
|
||||
}
|
||||
WizardState::NameModel => {
|
||||
buf.set_string(x, y, "Model name:", Style::default().fg(Color::Yellow));
|
||||
y += 1;
|
||||
let name_str = format!("> {}█", self.wizard.model_name);
|
||||
buf.set_string(x, y, truncate(&name_str, w),
|
||||
Style::default().fg(Color::Green));
|
||||
y += 2;
|
||||
buf.set_string(x, y, "Enter to import, Esc to cancel",
|
||||
Style::default().fg(Color::DarkGray));
|
||||
|
||||
if let Some(msg) = &self.wizard.message {
|
||||
let msg_y = inner.y + inner.height - 1;
|
||||
buf.set_string(x, msg_y, truncate(msg, w),
|
||||
Style::default().fg(Color::Red));
|
||||
}
|
||||
}
|
||||
WizardState::Done => {
|
||||
buf.set_string(x, y, "Import complete!", Style::default().fg(Color::Green));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max { s.to_string() }
|
||||
else if max > 1 { format!("{}…", &s[..max-1]) }
|
||||
else { s[..max].to_string() }
|
||||
}
|
||||
10
src/ui/mod.rs
Normal file
10
src/ui/mod.rs
Normal file
@ -0,0 +1,10 @@
|
||||
pub mod app;
|
||||
pub mod grid;
|
||||
pub mod formula_panel;
|
||||
pub mod category_panel;
|
||||
pub mod view_panel;
|
||||
pub mod tile_bar;
|
||||
pub mod import_wizard_ui;
|
||||
pub mod help;
|
||||
|
||||
pub use app::{App, AppMode};
|
||||
82
src/ui/tile_bar.rs
Normal file
82
src/ui/tile_bar.rs
Normal file
@ -0,0 +1,82 @@
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::Rect,
|
||||
style::{Color, Modifier, Style},
|
||||
widgets::Widget,
|
||||
};
|
||||
|
||||
use crate::model::Model;
|
||||
use crate::view::Axis;
|
||||
use crate::ui::app::AppMode;
|
||||
|
||||
pub struct TileBar<'a> {
|
||||
pub model: &'a Model,
|
||||
pub mode: &'a AppMode,
|
||||
}
|
||||
|
||||
impl<'a> TileBar<'a> {
|
||||
pub fn new(model: &'a Model, mode: &'a AppMode) -> Self {
|
||||
Self { model, mode }
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Widget for TileBar<'a> {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
let view = match self.model.active_view() {
|
||||
Some(v) => v,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let selected_cat_idx = if let AppMode::TileSelect { cat_idx } = self.mode {
|
||||
Some(*cat_idx)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut x = area.x + 1;
|
||||
buf.set_string(area.x, area.y, " Tiles: ", Style::default().fg(Color::Gray));
|
||||
x += 8;
|
||||
|
||||
let cat_names: Vec<&str> = self.model.category_names();
|
||||
for (i, cat_name) in cat_names.iter().enumerate() {
|
||||
let axis = view.axis_of(cat_name);
|
||||
let axis_symbol = match axis {
|
||||
Axis::Row => "↕",
|
||||
Axis::Column => "↔",
|
||||
Axis::Page => "☰",
|
||||
Axis::Unassigned => "─",
|
||||
};
|
||||
|
||||
let label = format!(" [{cat_name} {axis_symbol}] ");
|
||||
let is_selected = selected_cat_idx == Some(i);
|
||||
|
||||
let style = if is_selected {
|
||||
Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
match axis {
|
||||
Axis::Row => Style::default().fg(Color::Green),
|
||||
Axis::Column => Style::default().fg(Color::Blue),
|
||||
Axis::Page => Style::default().fg(Color::Magenta),
|
||||
Axis::Unassigned => Style::default().fg(Color::DarkGray),
|
||||
}
|
||||
};
|
||||
|
||||
if x + label.len() as u16 > area.x + area.width { break; }
|
||||
buf.set_string(x, area.y, &label, style);
|
||||
x += label.len() as u16;
|
||||
}
|
||||
|
||||
// Hint
|
||||
if matches!(self.mode, AppMode::TileSelect { .. }) {
|
||||
let hint = " [Enter] cycle axis [r/c/p] set axis [←→] select [Esc] cancel";
|
||||
if x + hint.len() as u16 <= area.x + area.width {
|
||||
buf.set_string(x, area.y, hint, Style::default().fg(Color::DarkGray));
|
||||
}
|
||||
} else {
|
||||
let hint = " Ctrl+↑↓←→ to move tiles";
|
||||
if x + hint.len() as u16 <= area.x + area.width {
|
||||
buf.set_string(x, area.y, hint, Style::default().fg(Color::DarkGray));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
62
src/ui/view_panel.rs
Normal file
62
src/ui/view_panel.rs
Normal file
@ -0,0 +1,62 @@
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::Rect,
|
||||
style::{Color, Modifier, Style},
|
||||
widgets::{Block, Borders, Widget},
|
||||
};
|
||||
|
||||
use crate::model::Model;
|
||||
use crate::ui::app::AppMode;
|
||||
|
||||
pub struct ViewPanel<'a> {
|
||||
pub model: &'a Model,
|
||||
pub mode: &'a AppMode,
|
||||
pub cursor: usize,
|
||||
}
|
||||
|
||||
impl<'a> ViewPanel<'a> {
|
||||
pub fn new(model: &'a Model, mode: &'a AppMode, cursor: usize) -> Self {
|
||||
Self { model, mode, cursor }
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Widget for ViewPanel<'a> {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
let is_active = matches!(self.mode, AppMode::ViewPanel);
|
||||
let border_style = if is_active {
|
||||
Style::default().fg(Color::Blue)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(border_style)
|
||||
.title(" Views [Enter] switch [n]ew [d]elete ");
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
let view_names: Vec<&str> = self.model.views.keys().map(|s| s.as_str()).collect();
|
||||
let active = &self.model.active_view;
|
||||
|
||||
for (i, view_name) in view_names.iter().enumerate() {
|
||||
if inner.y + i as u16 >= inner.y + inner.height { break; }
|
||||
|
||||
let is_selected = i == self.cursor && is_active;
|
||||
let is_active_view = *view_name == active.as_str();
|
||||
|
||||
let style = if is_selected {
|
||||
Style::default().fg(Color::Black).bg(Color::Blue).add_modifier(Modifier::BOLD)
|
||||
} else if is_active_view {
|
||||
Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
|
||||
let prefix = if is_active_view { "▶ " } else { " " };
|
||||
buf.set_string(inner.x, inner.y + i as u16,
|
||||
format!("{prefix}{view_name}"),
|
||||
style);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user