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:
BIN
context/.SPEC.md.swp
Normal file
BIN
context/.SPEC.md.swp
Normal file
Binary file not shown.
299
src/main.rs
299
src/main.rs
@ -20,8 +20,7 @@ use ratatui::{
|
|||||||
backend::CrosstermBackend,
|
backend::CrosstermBackend,
|
||||||
layout::{Constraint, Direction, Layout, Rect},
|
layout::{Constraint, Direction, Layout, Rect},
|
||||||
style::{Color, Modifier, Style},
|
style::{Color, Modifier, Style},
|
||||||
text::{Line, Span},
|
widgets::{Block, Borders, Clear, Paragraph},
|
||||||
widgets::{Block, Borders, Paragraph},
|
|
||||||
Frame, Terminal,
|
Frame, Terminal,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -86,15 +85,13 @@ fn main() -> Result<()> {
|
|||||||
Model::new("New Model")
|
Model::new("New Model")
|
||||||
};
|
};
|
||||||
|
|
||||||
// Headless mode: run command(s) and print results
|
// Headless mode
|
||||||
if !headless_cmds.is_empty() || headless_script.is_some() {
|
if !headless_cmds.is_empty() || headless_script.is_some() {
|
||||||
return run_headless(&mut model, file_path, headless_cmds, headless_script);
|
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 {
|
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 {
|
let cmd = command::Command::ImportJson {
|
||||||
path: path.to_string_lossy().to_string(),
|
path: path.to_string_lossy().to_string(),
|
||||||
model_name: None,
|
model_name: None,
|
||||||
@ -106,7 +103,6 @@ fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TUI mode
|
|
||||||
run_tui(model, file_path)
|
run_tui(model, file_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -132,8 +128,8 @@ fn run_headless(
|
|||||||
let parsed: command::Command = match serde_json::from_str(raw_cmd) {
|
let parsed: command::Command = match serde_json::from_str(raw_cmd) {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let result = command::CommandResult::err(format!("JSON parse error: {e}"));
|
let r = command::CommandResult::err(format!("JSON parse error: {e}"));
|
||||||
println!("{}", serde_json::to_string(&result)?);
|
println!("{}", serde_json::to_string(&r)?);
|
||||||
exit_code = 1;
|
exit_code = 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -143,7 +139,6 @@ fn run_headless(
|
|||||||
println!("{}", serde_json::to_string(&result)?);
|
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 {
|
if let Some(path) = file_path {
|
||||||
persistence::save(model, &path)?;
|
persistence::save(model, &path)?;
|
||||||
}
|
}
|
||||||
@ -163,7 +158,7 @@ fn run_tui(model: Model, file_path: Option<PathBuf>) -> Result<()> {
|
|||||||
loop {
|
loop {
|
||||||
terminal.draw(|f| draw(f, &app))?;
|
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()? {
|
if let Event::Key(key) = event::read()? {
|
||||||
app.handle_key(key)?;
|
app.handle_key(key)?;
|
||||||
}
|
}
|
||||||
@ -181,34 +176,34 @@ fn run_tui(model: Model, file_path: Option<PathBuf>) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Drawing ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn draw(f: &mut Frame, app: &App) {
|
fn draw(f: &mut Frame, app: &App) {
|
||||||
let size = f.area();
|
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()
|
let main_chunks = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([
|
.constraints([
|
||||||
Constraint::Length(1), // title bar
|
Constraint::Length(1), // title bar
|
||||||
Constraint::Min(0), // content
|
Constraint::Min(0), // content
|
||||||
Constraint::Length(1), // tile bar
|
Constraint::Length(1), // tile bar
|
||||||
Constraint::Length(1), // status bar
|
Constraint::Length(1), // status / command bar
|
||||||
])
|
])
|
||||||
.split(size);
|
.split(size);
|
||||||
|
|
||||||
// Title bar
|
|
||||||
draw_title(f, main_chunks[0], app);
|
draw_title(f, main_chunks[0], app);
|
||||||
|
draw_content(f, main_chunks[1], app);
|
||||||
// Content area: grid + optional panels
|
|
||||||
let content_area = main_chunks[1];
|
|
||||||
draw_content(f, content_area, app);
|
|
||||||
|
|
||||||
// Tile bar
|
|
||||||
draw_tile_bar(f, main_chunks[2], 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);
|
draw_status(f, main_chunks[3], app);
|
||||||
|
}
|
||||||
|
|
||||||
// Overlays
|
// Overlays (rendered last so they appear on top)
|
||||||
if matches!(app.mode, AppMode::Help) {
|
if matches!(app.mode, AppMode::Help) {
|
||||||
f.render_widget(HelpWidget, size);
|
f.render_widget(HelpWidget, size);
|
||||||
}
|
}
|
||||||
@ -220,67 +215,63 @@ fn draw(f: &mut Frame, app: &App) {
|
|||||||
if matches!(app.mode, AppMode::ExportPrompt { .. }) {
|
if matches!(app.mode, AppMode::ExportPrompt { .. }) {
|
||||||
draw_export_prompt(f, size, app);
|
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) {
|
fn draw_title(f: &mut Frame, area: Rect, app: &App) {
|
||||||
let dirty = if app.dirty { " [*]" } else { "" };
|
let dirty = if app.dirty { " [+]" } else { "" };
|
||||||
let title = format!(" Improvise | Model: {}{} ", app.model.name, dirty);
|
let file = app.file_path.as_ref()
|
||||||
let help_hint = " [F1 Help] [Ctrl+Q Quit] ";
|
.and_then(|p| p.file_name())
|
||||||
let padding = " ".repeat(
|
.and_then(|n| n.to_str())
|
||||||
(area.width as usize).saturating_sub(title.len() + help_hint.len())
|
.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) {
|
fn draw_content(f: &mut Frame, area: Rect, app: &App) {
|
||||||
let has_formula = app.formula_panel_open;
|
let side_open = app.formula_panel_open || app.category_panel_open || app.view_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;
|
|
||||||
|
|
||||||
if side_open {
|
if side_open {
|
||||||
let side_width = 30u16;
|
let side_w = 32u16;
|
||||||
let chunks = Layout::default()
|
let chunks = Layout::default()
|
||||||
.direction(Direction::Horizontal)
|
.direction(Direction::Horizontal)
|
||||||
.constraints([
|
.constraints([Constraint::Min(40), Constraint::Length(side_w)])
|
||||||
Constraint::Min(40),
|
|
||||||
Constraint::Length(side_width),
|
|
||||||
])
|
|
||||||
.split(area);
|
.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 = chunks[1];
|
||||||
let side_area = chunks[1];
|
let panel_count = [app.formula_panel_open, app.category_panel_open, app.view_panel_open]
|
||||||
let panel_count = [has_formula, has_category, has_view].iter().filter(|&&b| b).count();
|
.iter().filter(|&&b| b).count() as u16;
|
||||||
let panel_height = side_area.height / panel_count.max(1) as u16;
|
let ph = side.height / panel_count.max(1);
|
||||||
|
let mut y = side.y;
|
||||||
|
|
||||||
let mut y = side_area.y;
|
if app.formula_panel_open {
|
||||||
if has_formula {
|
let a = Rect::new(side.x, y, side.width, ph);
|
||||||
let a = Rect::new(side_area.x, y, side_area.width, panel_height);
|
|
||||||
f.render_widget(FormulaPanel::new(&app.model, &app.mode, app.formula_cursor), a);
|
f.render_widget(FormulaPanel::new(&app.model, &app.mode, app.formula_cursor), a);
|
||||||
y += panel_height;
|
y += ph;
|
||||||
}
|
}
|
||||||
if has_category {
|
if app.category_panel_open {
|
||||||
let a = Rect::new(side_area.x, y, side_area.width, panel_height);
|
let a = Rect::new(side.x, y, side.width, ph);
|
||||||
f.render_widget(CategoryPanel::new(&app.model, &app.mode, app.cat_panel_cursor), a);
|
f.render_widget(CategoryPanel::new(&app.model, &app.mode, app.cat_panel_cursor), a);
|
||||||
y += panel_height;
|
y += ph;
|
||||||
}
|
}
|
||||||
if has_view {
|
if app.view_panel_open {
|
||||||
let a = Rect::new(side_area.x, y, side_area.width, panel_height);
|
let a = Rect::new(side.x, y, side.width, ph);
|
||||||
f.render_widget(ViewPanel::new(&app.model, &app.mode, app.view_panel_cursor), a);
|
f.render_widget(ViewPanel::new(&app.model, &app.mode, app.view_panel_cursor), a);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
f.render_widget(
|
f.render_widget(GridWidget::new(&app.model, &app.mode, &app.search_query), area);
|
||||||
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) {
|
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::Normal => "NORMAL",
|
||||||
AppMode::Editing { .. } => "EDIT",
|
AppMode::Editing { .. } => "INSERT",
|
||||||
AppMode::FormulaEdit { .. } => "FORMULA",
|
AppMode::FormulaEdit { .. } => "FORMULA",
|
||||||
AppMode::FormulaPanel => "FORMULA PANEL",
|
AppMode::FormulaPanel => "FORMULAS",
|
||||||
AppMode::CategoryPanel => "CATEGORY PANEL",
|
AppMode::CategoryPanel => "CATEGORIES",
|
||||||
AppMode::ViewPanel => "VIEW PANEL",
|
AppMode::ViewPanel => "VIEWS",
|
||||||
AppMode::TileSelect { .. } => "TILE SELECT",
|
AppMode::TileSelect { .. } => "TILES",
|
||||||
AppMode::ImportWizard => "IMPORT",
|
AppMode::ImportWizard => "IMPORT",
|
||||||
AppMode::ExportPrompt { .. } => "EXPORT",
|
AppMode::ExportPrompt { .. } => "EXPORT",
|
||||||
|
AppMode::CommandMode { .. } => "COMMAND",
|
||||||
AppMode::Help => "HELP",
|
AppMode::Help => "HELP",
|
||||||
AppMode::Quit => "QUIT",
|
AppMode::Quit => "QUIT",
|
||||||
};
|
};
|
||||||
|
|
||||||
let search_part = if app.search_mode {
|
let search_part = if app.search_mode {
|
||||||
format!(" [Search: {}]", app.search_query)
|
format!(" /{}▌", 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()
|
|
||||||
} else {
|
} 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(
|
f.render_widget(
|
||||||
Paragraph::new(status).style(style),
|
Paragraph::new(line).style(Style::default().fg(Color::White).bg(Color::Black)),
|
||||||
area,
|
area,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_export_prompt(f: &mut Frame, area: Rect, app: &App) {
|
fn draw_export_prompt(f: &mut Frame, area: Rect, app: &App) {
|
||||||
let buf = if let AppMode::ExportPrompt { buffer } = &app.mode { buffer } else { return };
|
let buf = if let AppMode::ExportPrompt { buffer } = &app.mode { buffer.as_str() } else { "" };
|
||||||
let popup_w = 60u16.min(area.width);
|
let popup_w = 64u16.min(area.width);
|
||||||
let popup_h = 3u16;
|
|
||||||
let x = area.x + area.width.saturating_sub(popup_w) / 2;
|
let x = area.x + area.width.saturating_sub(popup_w) / 2;
|
||||||
let y = area.y + area.height / 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);
|
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);
|
let inner = block.inner(popup_area);
|
||||||
f.render_widget(block, popup_area);
|
f.render_widget(block, popup_area);
|
||||||
f.render_widget(
|
f.render_widget(
|
||||||
Paragraph::new(format!("> {buf}█")).style(Style::default().fg(Color::Green)),
|
Paragraph::new(format!("{buf}▌"))
|
||||||
|
.style(Style::default().fg(Color::Green)),
|
||||||
inner,
|
inner,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_usage() {
|
fn draw_welcome(f: &mut Frame, area: Rect, _app: &App) {
|
||||||
println!("improvise — multi-dimensional data modeling TUI");
|
let w = 58u16.min(area.width.saturating_sub(4));
|
||||||
println!();
|
let h = 20u16.min(area.height.saturating_sub(2));
|
||||||
println!("USAGE:");
|
let x = area.x + area.width.saturating_sub(w) / 2;
|
||||||
println!(" improvise [file.improv] Open or create a model file");
|
let y = area.y + area.height.saturating_sub(h) / 2;
|
||||||
println!(" improvise --import data.json Import JSON, then open TUI");
|
let popup = Rect::new(x, y, w, h);
|
||||||
println!(" improvise --cmd '{{...}}' Run a single JSON command (headless)");
|
|
||||||
println!(" improvise --script cmds.jsonl Run commands from file (headless)");
|
f.render_widget(Clear, popup);
|
||||||
println!();
|
|
||||||
println!("HEADLESS COMMANDS (JSON object with 'op' field):");
|
let block = Block::default()
|
||||||
println!(" {{\"op\":\"AddCategory\",\"name\":\"Region\"}}");
|
.borders(Borders::ALL)
|
||||||
println!(" {{\"op\":\"AddItem\",\"category\":\"Region\",\"item\":\"East\"}}");
|
.border_style(Style::default().fg(Color::Blue))
|
||||||
println!(" {{\"op\":\"SetCell\",\"coords\":[[\"Region\",\"East\"],[\"Measure\",\"Revenue\"]],\"number\":1200}}");
|
.title(" Welcome to improvise ");
|
||||||
println!(" {{\"op\":\"AddFormula\",\"raw\":\"Profit = Revenue - Cost\",\"target_category\":\"Measure\"}}");
|
let inner = block.inner(popup);
|
||||||
println!(" {{\"op\":\"Save\",\"path\":\"model.improv\"}}");
|
f.render_widget(block, popup);
|
||||||
println!(" {{\"op\":\"ImportJson\",\"path\":\"data.json\"}}");
|
|
||||||
println!();
|
let lines: &[(&str, Style)] = &[
|
||||||
println!("TUI SHORTCUTS:");
|
("Multi-dimensional data modeling — in your terminal.", Style::default().fg(Color::White)),
|
||||||
println!(" F1 Help");
|
("", Style::default()),
|
||||||
println!(" Ctrl+Q Quit");
|
("Getting started", Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD)),
|
||||||
println!(" Ctrl+S Save");
|
("", Style::default()),
|
||||||
println!(" Ctrl+F Formula panel");
|
(":import <file.json> Import a JSON file", Style::default().fg(Color::Cyan)),
|
||||||
println!(" Ctrl+C Category panel");
|
(":add-cat <name> Add a category (dimension)", Style::default().fg(Color::Cyan)),
|
||||||
println!(" Ctrl+V View panel");
|
(":add-item <cat> <name> Add an item to a category", Style::default().fg(Color::Cyan)),
|
||||||
println!(" Enter Edit cell");
|
(":formula <cat> <expr> Add a formula, e.g.:", Style::default().fg(Color::Cyan)),
|
||||||
println!(" Ctrl+Arrow Tile select mode");
|
(" Profit = Revenue - Cost", Style::default().fg(Color::Green)),
|
||||||
println!(" [ / ] Prev/next page item");
|
(":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");
|
||||||
}
|
}
|
||||||
|
|||||||
675
src/ui/app.rs
675
src/ui/app.rs
File diff suppressed because it is too large
Load Diff
108
src/ui/help.rs
108
src/ui/help.rs
@ -9,67 +9,83 @@ pub struct HelpWidget;
|
|||||||
|
|
||||||
impl Widget for HelpWidget {
|
impl Widget for HelpWidget {
|
||||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
// Center popup
|
let popup_w = 66u16.min(area.width);
|
||||||
let popup_w = 60u16.min(area.width);
|
let popup_h = 36u16.min(area.height);
|
||||||
let popup_h = 30u16.min(area.height);
|
|
||||||
let x = area.x + area.width.saturating_sub(popup_w) / 2;
|
let x = area.x + area.width.saturating_sub(popup_w) / 2;
|
||||||
let y = area.y + area.height.saturating_sub(popup_h) / 2;
|
let y = area.y + area.height.saturating_sub(popup_h) / 2;
|
||||||
let popup_area = Rect::new(x, y, popup_w, popup_h);
|
let popup_area = Rect::new(x, y, popup_w, popup_h);
|
||||||
|
|
||||||
Clear.render(popup_area, buf);
|
Clear.render(popup_area, buf);
|
||||||
|
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.title(" Help — Improvise ")
|
.title(" improvise — key reference (any key to close) ")
|
||||||
.border_style(Style::default().fg(Color::Yellow));
|
.border_style(Style::default().fg(Color::Blue));
|
||||||
let inner = block.inner(popup_area);
|
let inner = block.inner(popup_area);
|
||||||
block.render(popup_area, buf);
|
block.render(popup_area, buf);
|
||||||
|
|
||||||
let help_text = [
|
let head = Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD);
|
||||||
("Navigation", ""),
|
let key = Style::default().fg(Color::Cyan);
|
||||||
(" ↑/↓/←/→ or hjkl", "Move cursor"),
|
let dim = Style::default().fg(Color::DarkGray);
|
||||||
(" Enter", "Edit selected cell"),
|
let norm = Style::default();
|
||||||
(" /", "Search in grid"),
|
|
||||||
(" [ / ]", "Prev/next page item"),
|
// (key_col, desc_col, style)
|
||||||
("", ""),
|
let rows: &[(&str, &str, Style)] = &[
|
||||||
("Panels", ""),
|
("Navigation", "", head),
|
||||||
(" Ctrl+F", "Toggle formula panel"),
|
(" hjkl / ↑↓←→", "Move cursor", key),
|
||||||
(" Ctrl+C", "Toggle category panel"),
|
(" gg / G", "First / last row", key),
|
||||||
(" Ctrl+V", "Toggle view panel"),
|
(" 0 / $", "First / last column", key),
|
||||||
(" Tab", "Focus next open panel"),
|
(" Ctrl+D / Ctrl+U", "Scroll ½-page down / up", key),
|
||||||
("", ""),
|
(" [ / ]", "Cycle page-axis filter", key),
|
||||||
("Tiles / Pivot", ""),
|
("", "", norm),
|
||||||
(" Ctrl+Arrow", "Enter tile select mode"),
|
("Editing", "", head),
|
||||||
(" Enter/Space", "Cycle axis (Row→Col→Page)"),
|
(" i / a / Enter", "Enter Insert mode", key),
|
||||||
(" r / c / p", "Set axis to Row/Col/Page"),
|
(" Esc", "Return to Normal mode", key),
|
||||||
("", ""),
|
(" x", "Clear cell", key),
|
||||||
("File", ""),
|
(" yy", "Yank (copy) cell value", key),
|
||||||
(" Ctrl+S", "Save model"),
|
(" p", "Paste yanked value", key),
|
||||||
(" Ctrl+E", "Export CSV"),
|
("", "", norm),
|
||||||
("", ""),
|
("Search", "", head),
|
||||||
("Headless / Batch", ""),
|
(" /", "Enter search, highlight matches", key),
|
||||||
(" --cmd '{...}'", "Run a single JSON command"),
|
(" n / N", "Next / previous match", key),
|
||||||
(" --script file", "Run commands from file"),
|
(" Esc or Enter", "Exit search", key),
|
||||||
("", ""),
|
("", "", norm),
|
||||||
(" F1", "This help"),
|
("Panels", "", head),
|
||||||
(" Ctrl+Q", "Quit"),
|
(" F", "Toggle Formula panel (n:new d:del)", key),
|
||||||
("", ""),
|
(" C", "Toggle Category panel (Space:cycle-axis)", key),
|
||||||
(" Any key to close", ""),
|
(" 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; }
|
if i >= inner.height as usize { break; }
|
||||||
let y = inner.y + i as u16;
|
let y = inner.y + i as u16;
|
||||||
if key.is_empty() {
|
if d.is_empty() {
|
||||||
continue;
|
buf.set_string(inner.x, y, k, *style);
|
||||||
}
|
|
||||||
if desc.is_empty() {
|
|
||||||
buf.set_string(inner.x, y, key, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
|
||||||
} else {
|
} else {
|
||||||
buf.set_string(inner.x, y, key, Style::default().fg(Color::Cyan));
|
buf.set_string(inner.x, y, k, *style);
|
||||||
let desc_x = inner.x + 26;
|
let dx = inner.x + key_col_w as u16;
|
||||||
if desc_x < inner.x + inner.width {
|
if dx < inner.x + inner.width {
|
||||||
buf.set_string(desc_x, y, desc, Style::default());
|
buf.set_string(dx, y, d, norm);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user