Improve UX: welcome screen, vim keybindings, command mode

- Welcome overlay shown when model has no categories, listing common
  commands and navigation hints to orient new users
- Vim-style keybindings:
  - i / a → Insert mode (edit cell); Esc → Normal
  - x → clear cell; yy / p → yank / paste
  - G / gg → last / first row; 0 / $ → first / last col
  - Ctrl+D / Ctrl+U → half-page scroll
  - n / N → next / prev search match
  - T → tile-select mode (single key, no Ctrl needed)
  - ZZ → save + quit
  - F / C / V → toggle panels (no Ctrl needed)
  - ? → help (in addition to F1)
- Command mode (:) for vim-style commands:
  :q  :q!  :w [path]  :wq  ZZ
  :import <file.json>  :export [path]
  :add-cat <name>  :add-item <cat> <item>
  :formula <cat> <Name=expr>  :add-view [name]  :help
- Status bar now context-sensitive: shows mode-specific hint text
  instead of always showing the same generic shortcuts
- Mode label changed: "Editing" → "INSERT" to match vim convention
- Title bar shows filename in parentheses when model is backed by a file
- Help widget updated with full key reference in two-column layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ed L
2026-03-21 22:41:35 -07:00
parent eae00522e2
commit 66dfdf705f
5 changed files with 748 additions and 394 deletions

BIN
context/.SPEC.md.swp Normal file

Binary file not shown.

View File

@ -20,8 +20,7 @@ use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
widgets::{Block, Borders, Clear, Paragraph},
Frame, Terminal,
};
@ -86,15 +85,13 @@ fn main() -> Result<()> {
Model::new("New Model")
};
// Headless mode: run command(s) and print results
// Headless mode
if !headless_cmds.is_empty() || headless_script.is_some() {
return run_headless(&mut model, file_path, headless_cmds, headless_script);
}
// Import mode before TUI
// Pre-TUI import
if let Some(ref path) = import_path {
let content = std::fs::read_to_string(path)?;
let json: serde_json::Value = serde_json::from_str(&content)?;
let cmd = command::Command::ImportJson {
path: path.to_string_lossy().to_string(),
model_name: None,
@ -106,7 +103,6 @@ fn main() -> Result<()> {
}
}
// TUI mode
run_tui(model, file_path)
}
@ -132,8 +128,8 @@ fn run_headless(
let parsed: command::Command = match serde_json::from_str(raw_cmd) {
Ok(c) => c,
Err(e) => {
let result = command::CommandResult::err(format!("JSON parse error: {e}"));
println!("{}", serde_json::to_string(&result)?);
let r = command::CommandResult::err(format!("JSON parse error: {e}"));
println!("{}", serde_json::to_string(&r)?);
exit_code = 1;
continue;
}
@ -143,7 +139,6 @@ fn run_headless(
println!("{}", serde_json::to_string(&result)?);
}
// Auto-save if we have a file path and model was potentially modified
if let Some(path) = file_path {
persistence::save(model, &path)?;
}
@ -163,7 +158,7 @@ fn run_tui(model: Model, file_path: Option<PathBuf>) -> Result<()> {
loop {
terminal.draw(|f| draw(f, &app))?;
if event::poll(Duration::from_millis(200))? {
if event::poll(Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
app.handle_key(key)?;
}
@ -181,34 +176,34 @@ fn run_tui(model: Model, file_path: Option<PathBuf>) -> Result<()> {
Ok(())
}
// ── Drawing ──────────────────────────────────────────────────────────────────
fn draw(f: &mut Frame, app: &App) {
let size = f.area();
// Main layout: title bar + content + status bar
let is_cmd_mode = matches!(app.mode, AppMode::CommandMode { .. });
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // title bar
Constraint::Min(0), // content
Constraint::Length(1), // tile bar
Constraint::Length(1), // status bar
Constraint::Length(1), // status / command bar
])
.split(size);
// Title bar
draw_title(f, main_chunks[0], app);
// Content area: grid + optional panels
let content_area = main_chunks[1];
draw_content(f, content_area, app);
// Tile bar
draw_content(f, main_chunks[1], app);
draw_tile_bar(f, main_chunks[2], app);
// Status bar
if is_cmd_mode {
draw_command_bar(f, main_chunks[3], app);
} else {
draw_status(f, main_chunks[3], app);
}
// Overlays
// Overlays (rendered last so they appear on top)
if matches!(app.mode, AppMode::Help) {
f.render_widget(HelpWidget, size);
}
@ -220,67 +215,63 @@ fn draw(f: &mut Frame, app: &App) {
if matches!(app.mode, AppMode::ExportPrompt { .. }) {
draw_export_prompt(f, size, app);
}
if app.is_empty_model() && matches!(app.mode, AppMode::Normal | AppMode::CommandMode { .. }) {
draw_welcome(f, main_chunks[1], app);
}
}
fn draw_title(f: &mut Frame, area: Rect, app: &App) {
let dirty = if app.dirty { " [*]" } else { "" };
let title = format!(" Improvise | Model: {}{} ", app.model.name, dirty);
let help_hint = " [F1 Help] [Ctrl+Q Quit] ";
let padding = " ".repeat(
(area.width as usize).saturating_sub(title.len() + help_hint.len())
let dirty = if app.dirty { " [+]" } else { "" };
let file = app.file_path.as_ref()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.map(|n| format!(" ({n})"))
.unwrap_or_default();
let title = format!(" improvise · {}{}{} ", app.model.name, file, dirty);
let right = " ?:help :q quit ";
let pad = " ".repeat((area.width as usize).saturating_sub(title.len() + right.len()));
let line = format!("{title}{pad}{right}");
f.render_widget(
Paragraph::new(line)
.style(Style::default().fg(Color::Black).bg(Color::Blue).add_modifier(Modifier::BOLD)),
area,
);
let full = format!("{title}{padding}{help_hint}");
let style = Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD);
f.render_widget(Paragraph::new(full).style(style), area);
}
fn draw_content(f: &mut Frame, area: Rect, app: &App) {
let has_formula = app.formula_panel_open;
let has_category = app.category_panel_open;
let has_view = app.view_panel_open;
let side_open = has_formula || has_category || has_view;
let side_open = app.formula_panel_open || app.category_panel_open || app.view_panel_open;
if side_open {
let side_width = 30u16;
let side_w = 32u16;
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Min(40),
Constraint::Length(side_width),
])
.constraints([Constraint::Min(40), Constraint::Length(side_w)])
.split(area);
// Grid
f.render_widget(
GridWidget::new(&app.model, &app.mode, &app.search_query),
chunks[0],
);
f.render_widget(GridWidget::new(&app.model, &app.mode, &app.search_query), chunks[0]);
// Side panels stacked
let side_area = chunks[1];
let panel_count = [has_formula, has_category, has_view].iter().filter(|&&b| b).count();
let panel_height = side_area.height / panel_count.max(1) as u16;
let side = chunks[1];
let panel_count = [app.formula_panel_open, app.category_panel_open, app.view_panel_open]
.iter().filter(|&&b| b).count() as u16;
let ph = side.height / panel_count.max(1);
let mut y = side.y;
let mut y = side_area.y;
if has_formula {
let a = Rect::new(side_area.x, y, side_area.width, panel_height);
if app.formula_panel_open {
let a = Rect::new(side.x, y, side.width, ph);
f.render_widget(FormulaPanel::new(&app.model, &app.mode, app.formula_cursor), a);
y += panel_height;
y += ph;
}
if has_category {
let a = Rect::new(side_area.x, y, side_area.width, panel_height);
if app.category_panel_open {
let a = Rect::new(side.x, y, side.width, ph);
f.render_widget(CategoryPanel::new(&app.model, &app.mode, app.cat_panel_cursor), a);
y += panel_height;
y += ph;
}
if has_view {
let a = Rect::new(side_area.x, y, side_area.width, panel_height);
if app.view_panel_open {
let a = Rect::new(side.x, y, side.width, ph);
f.render_widget(ViewPanel::new(&app.model, &app.mode, app.view_panel_cursor), a);
}
} else {
f.render_widget(
GridWidget::new(&app.model, &app.mode, &app.search_query),
area,
);
f.render_widget(GridWidget::new(&app.model, &app.mode, &app.search_query), area);
}
}
@ -289,92 +280,158 @@ fn draw_tile_bar(f: &mut Frame, area: Rect, app: &App) {
}
fn draw_status(f: &mut Frame, area: Rect, app: &App) {
let mode_str = match &app.mode {
let mode_badge = match &app.mode {
AppMode::Normal => "NORMAL",
AppMode::Editing { .. } => "EDIT",
AppMode::Editing { .. } => "INSERT",
AppMode::FormulaEdit { .. } => "FORMULA",
AppMode::FormulaPanel => "FORMULA PANEL",
AppMode::CategoryPanel => "CATEGORY PANEL",
AppMode::ViewPanel => "VIEW PANEL",
AppMode::TileSelect { .. } => "TILE SELECT",
AppMode::FormulaPanel => "FORMULAS",
AppMode::CategoryPanel => "CATEGORIES",
AppMode::ViewPanel => "VIEWS",
AppMode::TileSelect { .. } => "TILES",
AppMode::ImportWizard => "IMPORT",
AppMode::ExportPrompt { .. } => "EXPORT",
AppMode::CommandMode { .. } => "COMMAND",
AppMode::Help => "HELP",
AppMode::Quit => "QUIT",
};
let search_part = if app.search_mode {
format!(" [Search: {}]", app.search_query)
} else { String::new() };
let panels = format!(
"{}{}{}",
if app.formula_panel_open { " [F]" } else { "" },
if app.category_panel_open { " [C]" } else { "" },
if app.view_panel_open { " [V]" } else { "" },
);
let status = format!(
" {mode_str}{search_part}{panels} | {} | {}",
app.model.active_view,
if app.status_msg.is_empty() {
"Ctrl+F:formulas Ctrl+C:categories Ctrl+V:views Ctrl+S:save".to_string()
format!(" /{}", app.search_query)
} else {
app.status_msg.clone()
}
);
String::new()
};
let style = Style::default().fg(Color::Black).bg(Color::DarkGray);
let msg = if !app.status_msg.is_empty() {
app.status_msg.as_str()
} else {
app.hint_text()
};
let yank_indicator = if app.yanked.is_some() { " [yank]" } else { "" };
let view_badge = format!(" {}{} ", app.model.active_view, yank_indicator);
let left = format!(" {mode_badge}{search_part} {msg}");
let right = view_badge;
let pad = " ".repeat(
(area.width as usize)
.saturating_sub(left.len() + right.len()),
);
let line = format!("{left}{pad}{right}");
let badge_style = match &app.mode {
AppMode::Editing { .. } => Style::default().fg(Color::Black).bg(Color::Green),
AppMode::CommandMode { .. } => Style::default().fg(Color::Black).bg(Color::Yellow),
AppMode::TileSelect { .. } => Style::default().fg(Color::Black).bg(Color::Magenta),
_ => Style::default().fg(Color::Black).bg(Color::DarkGray),
};
f.render_widget(Paragraph::new(line).style(badge_style), area);
}
fn draw_command_bar(f: &mut Frame, area: Rect, app: &App) {
let buf = if let AppMode::CommandMode { buffer } = &app.mode { buffer.as_str() } else { "" };
let line = format!(":{buf}");
f.render_widget(
Paragraph::new(status).style(style),
Paragraph::new(line).style(Style::default().fg(Color::White).bg(Color::Black)),
area,
);
}
fn draw_export_prompt(f: &mut Frame, area: Rect, app: &App) {
let buf = if let AppMode::ExportPrompt { buffer } = &app.mode { buffer } else { return };
let popup_w = 60u16.min(area.width);
let popup_h = 3u16;
let buf = if let AppMode::ExportPrompt { buffer } = &app.mode { buffer.as_str() } else { "" };
let popup_w = 64u16.min(area.width);
let x = area.x + area.width.saturating_sub(popup_w) / 2;
let y = area.y + area.height / 2;
let popup_area = Rect::new(x, y, popup_w, popup_h);
let popup_area = Rect::new(x, y, popup_w, 3);
use ratatui::widgets::Clear;
f.render_widget(Clear, popup_area);
let block = Block::default().borders(Borders::ALL).title(" Export CSV — enter path (Esc cancel) ");
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow))
.title(" Export CSV — path (Esc cancel) ");
let inner = block.inner(popup_area);
f.render_widget(block, popup_area);
f.render_widget(
Paragraph::new(format!("> {buf}")).style(Style::default().fg(Color::Green)),
Paragraph::new(format!("{buf}"))
.style(Style::default().fg(Color::Green)),
inner,
);
}
fn print_usage() {
println!("improvise — multi-dimensional data modeling TUI");
println!();
println!("USAGE:");
println!(" improvise [file.improv] Open or create a model file");
println!(" improvise --import data.json Import JSON, then open TUI");
println!(" improvise --cmd '{{...}}' Run a single JSON command (headless)");
println!(" improvise --script cmds.jsonl Run commands from file (headless)");
println!();
println!("HEADLESS COMMANDS (JSON object with 'op' field):");
println!(" {{\"op\":\"AddCategory\",\"name\":\"Region\"}}");
println!(" {{\"op\":\"AddItem\",\"category\":\"Region\",\"item\":\"East\"}}");
println!(" {{\"op\":\"SetCell\",\"coords\":[[\"Region\",\"East\"],[\"Measure\",\"Revenue\"]],\"number\":1200}}");
println!(" {{\"op\":\"AddFormula\",\"raw\":\"Profit = Revenue - Cost\",\"target_category\":\"Measure\"}}");
println!(" {{\"op\":\"Save\",\"path\":\"model.improv\"}}");
println!(" {{\"op\":\"ImportJson\",\"path\":\"data.json\"}}");
println!();
println!("TUI SHORTCUTS:");
println!(" F1 Help");
println!(" Ctrl+Q Quit");
println!(" Ctrl+S Save");
println!(" Ctrl+F Formula panel");
println!(" Ctrl+C Category panel");
println!(" Ctrl+V View panel");
println!(" Enter Edit cell");
println!(" Ctrl+Arrow Tile select mode");
println!(" [ / ] Prev/next page item");
fn draw_welcome(f: &mut Frame, area: Rect, _app: &App) {
let w = 58u16.min(area.width.saturating_sub(4));
let h = 20u16.min(area.height.saturating_sub(2));
let x = area.x + area.width.saturating_sub(w) / 2;
let y = area.y + area.height.saturating_sub(h) / 2;
let popup = Rect::new(x, y, w, h);
f.render_widget(Clear, popup);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Blue))
.title(" Welcome to improvise ");
let inner = block.inner(popup);
f.render_widget(block, popup);
let lines: &[(&str, Style)] = &[
("Multi-dimensional data modeling — in your terminal.", Style::default().fg(Color::White)),
("", Style::default()),
("Getting started", Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD)),
("", Style::default()),
(":import <file.json> Import a JSON file", Style::default().fg(Color::Cyan)),
(":add-cat <name> Add a category (dimension)", Style::default().fg(Color::Cyan)),
(":add-item <cat> <name> Add an item to a category", Style::default().fg(Color::Cyan)),
(":formula <cat> <expr> Add a formula, e.g.:", Style::default().fg(Color::Cyan)),
(" Profit = Revenue - Cost", Style::default().fg(Color::Green)),
(":w <file.improv> Save your model", Style::default().fg(Color::Cyan)),
("", Style::default()),
("Navigation", Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD)),
("", Style::default()),
("F C V Open panels (Formulas/Categories/Views)", Style::default()),
("T Tile-select: pivot rows ↔ cols ↔ page", Style::default()),
("i Enter Edit a cell", Style::default()),
("[ ] Cycle the page-axis filter", Style::default()),
("? or :help Full key reference", Style::default()),
(":q Quit", Style::default()),
];
for (i, (text, style)) in lines.iter().enumerate() {
if i >= inner.height as usize { break; }
f.render_widget(
Paragraph::new(*text).style(*style),
Rect::new(inner.x + 1, inner.y + i as u16, inner.width.saturating_sub(2), 1),
);
}
}
// ── Help text ─────────────────────────────────────────────────────────────────
// (HelpWidget is in src/ui/help.rs — updated separately)
// ── Usage ─────────────────────────────────────────────────────────────────────
fn print_usage() {
println!("improvise — multi-dimensional data modeling TUI\n");
println!("USAGE:");
println!(" improvise [file.improv] Open or create a model");
println!(" improvise --import data.json Import JSON then open TUI");
println!(" improvise --cmd '{{...}}' Run a JSON command (headless, repeatable)");
println!(" improvise --script cmds.jsonl Run commands from file (headless)");
println!("\nTUI KEYS (vim-style):");
println!(" : Command mode (:q :w :import :add-cat :formula …)");
println!(" hjkl / ↑↓←→ Navigate grid");
println!(" i / Enter Edit cell (Insert mode)");
println!(" Esc Return to Normal mode");
println!(" x Clear cell");
println!(" yy / p Yank / paste cell value");
println!(" gg / G First / last row");
println!(" 0 / $ First / last column");
println!(" Ctrl+D/U Scroll half-page down / up");
println!(" / n N Search / next / prev");
println!(" [ ] Cycle page-axis filter");
println!(" T Tile-select (pivot) mode");
println!(" F C V Toggle Formulas / Categories / Views panel");
println!(" ZZ Save and quit");
println!(" ? Help");
}

File diff suppressed because it is too large Load Diff

View File

@ -9,67 +9,83 @@ 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 popup_w = 66u16.min(area.width);
let popup_h = 36u16.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));
.title(" improvise — key reference (any key to close) ")
.border_style(Style::default().fg(Color::Blue));
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", ""),
let head = Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD);
let key = Style::default().fg(Color::Cyan);
let dim = Style::default().fg(Color::DarkGray);
let norm = Style::default();
// (key_col, desc_col, style)
let rows: &[(&str, &str, Style)] = &[
("Navigation", "", head),
(" hjkl / ↑↓←→", "Move cursor", key),
(" gg / G", "First / last row", key),
(" 0 / $", "First / last column", key),
(" Ctrl+D / Ctrl+U", "Scroll ½-page down / up", key),
(" [ / ]", "Cycle page-axis filter", key),
("", "", norm),
("Editing", "", head),
(" i / a / Enter", "Enter Insert mode", key),
(" Esc", "Return to Normal mode", key),
(" x", "Clear cell", key),
(" yy", "Yank (copy) cell value", key),
(" p", "Paste yanked value", key),
("", "", norm),
("Search", "", head),
(" /", "Enter search, highlight matches", key),
(" n / N", "Next / previous match", key),
(" Esc or Enter", "Exit search", key),
("", "", norm),
("Panels", "", head),
(" F", "Toggle Formula panel (n:new d:del)", key),
(" C", "Toggle Category panel (Space:cycle-axis)", key),
(" V", "Toggle View panel (n:new d:del Enter:switch)", key),
(" Tab", "Focus next open panel", key),
("", "", norm),
("Pivot / Tiles", "", head),
(" T", "Tile-select mode", key),
(" ← h / → l", "Select previous/next tile", dim),
(" Space / Enter", "Cycle axis (Row→Col→Page)", dim),
(" r / c / p", "Set axis to Row / Col / Page", dim),
("", "", norm),
("Command line ( : )", "", head),
(" :q :q! :wq ZZ", "Quit / force-quit / save+quit", key),
(" :w [path]", "Save (path optional)", key),
(" :import <path.json>", "Open JSON import wizard", key),
(" :export [path.csv]", "Export active view to CSV", key),
(" :add-cat <name>", "Add a category", key),
(" :add-item <cat> <item>", "Add an item to a category", key),
(" :formula <cat> <Name=expr>", "Add a formula", key),
(" :add-view [name]", "Create a new view", key),
("", "", norm),
(" ? or F1", "This help", key),
(" Ctrl+S", "Save (same as :w)", key),
];
for (i, (key, desc)) in help_text.iter().enumerate() {
let key_col_w = 32usize;
for (i, (k, d, style)) in rows.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));
if d.is_empty() {
buf.set_string(inner.x, y, k, *style);
} 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());
buf.set_string(inner.x, y, k, *style);
let dx = inner.x + key_col_w as u16;
if dx < inner.x + inner.width {
buf.set_string(dx, y, d, norm);
}
}
}