From 66dfdf705fdc39ea7c79bb732d062795da4d7dc8 Mon Sep 17 00:00:00 2001 From: Ed L Date: Sat, 21 Mar 2026 22:41:35 -0700 Subject: [PATCH] Improve UX: welcome screen, vim keybindings, command mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 :export [path] :add-cat :add-item :formula :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 --- context/.SPEC.md.swp | Bin 0 -> 16384 bytes SPEC.md => context/SPEC.md | 0 src/main.rs | 297 +++++++++------ src/ui/app.rs | 737 +++++++++++++++++++++++++------------ src/ui/help.rs | 108 +++--- 5 files changed, 748 insertions(+), 394 deletions(-) create mode 100644 context/.SPEC.md.swp rename SPEC.md => context/SPEC.md (100%) diff --git a/context/.SPEC.md.swp b/context/.SPEC.md.swp new file mode 100644 index 0000000000000000000000000000000000000000..fa9de2d386a8050d0b054e8b50c5920ea8dbd99b GIT binary patch literal 16384 zcmeI3Uul0xcz!LJjo;jYm z**o{%+&{J_-4H-f1TR!5EfRmE@`O;M7KC`A2&t_EYN0}XsdxaXtpq$09(dt_@cI4D zy)(9#M4&zZ(pdV=+_~r6bAIRd|M#2m-qMYYP4h&1IpFU{g5Ze{{6+ZBzx|$|oVyUX z{$XTe8F>4@A}szp(}JJ(3wFX$nk5He?mE3BF5FSk+1b9d-X8Veew*3oi~}_?yfo>rSZJmbKO12YcHI56YDi~}EX`zc^5&4+gJ7 z@;fTBx2yLDPQAZb{oSqZUpqDaVDeL=fGL;Vem8HO&S~D0JKShH82m}2fjtaWDJgg1rLKig@;2R zyxbRl9{X#VO@8ta4~#WM7&%j9;b7ph+}PNJan27Y3X{2>je2Edi!g~9=yRu)xw!8# zlkA&f=*%Ds`|Y4*cDXKd?)P|plpL@itLA3U6^G8nCQA;Rrk6zJC~oRXsU0|DkC;*< z^@h>dq~SqQbTVhN%*M=+Jq|*5D0^J8y&>xtZjfYS6XsBpW-fR9WTS5^l$*sx*>!Qz z^us)j?3nvutZV2Q_iWY<=H}+inf8iVuNT@CLIYX99~Q!qzAdagXXCN%ZOW8eLa7{5 zP9KeB-YDt2=t*Ov$RrG9I$5aNk6f?N*}b8hLWtiiqasXUw45eaHo2oPFHFjVx>DO8 zkaLCXkqVpRq8%91GF#rZ{J_m&2&=-vjq=VQ zOUe}HWU_T0?gmQ$9aW>?jGTG*NIEdjO@>yI&e)-~JlPfPo{U92#xG0k3 zBP4`Tfd+N*DD3eLEiJRg1UWI|9L$>7j#!eP+3bgdGV_X+r>+<73rklk=`5QY$|Zz) zIn45+wH`r^xw`ivj73%|LY-rF#yQ7Ce65zq@Ct{{Y-D2P7&}7ZOJyP&lB44BAQ!5( zIbe*)a?T1<(PDU{Y;BqD&1WxNztlCo$fD3X><=dNneNW>oAWzXR9okn>uw;%alte$ z*}P~hbY=bZBo%jHw@ za$Y|_bwL-uipJO?$z=AUGTM@tMS1DS%O`j#V~dF}BGg@O7d%43z_Ps55^;&74F*|-zb<*6-dtrK+FUWx> zLo6%oVrARwg<_K!{Mg{)vcoJa3Y^>C*!v8|vaxDCE&;s~E}pbJ7?{M);F~xTGmNwO z*x1s09(i=Kuh?MU?T2E5!gma*@A{!wX}@0Fq&9Yu*k$g}A%@I;mWVjNds%|biFXU}uvw1jiL7hSkDGH?Q4eaSo}+EH60AE_ z|H$$B8Tz7zbyCcmHsdxz-p-Q!urM9$=)lFL>z>kwU3BTJ*Qe@I!z2P zFGM>g+?-H*j97IJ>Lf*i;+dL8MLjk>$FXg{FLqPw?XuZY=T)z%ZfK=l?{V4_ZAx_T z($DQdrIC1}D6z=V%LxsIIe6r5+y4f!Yw$JB%s;ntbxVb8i-{Z6AmRO}1fn8ITM1U{RI<(?i~Sz7r>L#Xk~9frkFJ$%Qi#{}JB}Gd92oMPA*H-lPjJ`ew zO>eP=kFxi#vIW0~^6*Yl`U{Q1+|U3aXH;3C9rqn(Y95ljOtdy%P`|LasD#SU$Vv94 zj?a==R@z+!%;olSol0uvgoP_t)jKS=@kID>r9n*zeUU^(j7eQCL|`d!XOwEb44WrT zfh4@Z4))MKg86*+=-lIX9)GM=#&=^hqZNg5dDI$}dDQhJ>yMcGlD--2XJ~ex-!Lc^ z#w2dK&90fP(-M=D6WD$}bPk1;e2grgfP-S6_6Svu?Fm&ym{2GWpR> z#HPe9CytjOySCgkPoG_A<4X|8$lirkH65}roCyqQnxV}!<f+>`B_KUZV=ZvYTh42(=U$Ch;-f>TT&*iqCRm)JwSHI7^Nb^=Ku7le!f3H|^SU=DnnUcl$TA-Dp5 z0{jj2|6hYY0iOr2f@@$CTmesjWnjP;=?#1VyaXco=*Dyc_%rJgs{Vzr^)6xC%DF z5)ht03TA&Z4$L_4f1d;0ZZ~L8|9h!wBl$`KZ%q8B_jqhPrjO=FO{k)(~)##+CB*;fsXW+StQ+5jPZh6 z&lPuwxS9|$cay4@sM&bskrU(zDK&jBKt{ENl6dkUtrOG`bw~rBgLrmJEqUN-g8)bH z(i5=_$30H|4)xe{>alHwB8_6wSd;toO{E_5N9ep|0!G6!;!N%sug#qK?x%i}6hU%0 zpEZQ>UDprd@QAvex-ChCtYk!DAtiJ+NPN1&-D){#k-kemK+tqk;?o<&!jzNHf;9H+*kzT?&{z%0S5tI+`%FvkWW^!%iwe^%wNGBm~J(JKuGxG<_3)M02NrNGyK}1#o8)s%}Uyh(%c~{ z>*_N*C(`hH=56wDcxp{oQu5ZI*Q*{!RueX}VV5p>-*wHS)zuxbIzLqs_Iu{mb$DHr z;qCchQKb25r!#=Ea<7f9bYQlOwWwz5>jjotmtGBm8!5$gKajS@)Jj<~UXWV2+txh4vhH^MVAT^mOQT_}T8mw17#3OdC_PxJQsD_|+og$# zQLg6)bY{KHtV(giJ?b4_!%O}vo2~EM@TK}$+Wh{xAXq0Emu?RE?E&3Zl6ks~H9dou zyJmjya+s3nOJ=O?m(-4>S3{i_Y^jQCd0}}@hO1U;y+qZx;qcOmk?d9)XO+*RF#I2O zV0`CKTgX-0;VgCLW${Jg6|H33bLMQ@Ad4I`mjbVnj|2tpiu8?L4E?2uLd1Lj~twvSSH+|py zcJtOGEHoL@W?&v|!>JrOEkt`!TZ-A0)oI3uG)HWZiA7 zntdDzhiFhK<@Bw%({NcPy%y)A993H5m3lUX-Koh5UQAnB^#ZTR>SDXv)_xonzV7mr z2I*8p82dCfVdADM(MdYuY7KX!Zu_Ww)Hc~28T_bB^ACA3X#5cqJ#GQ@O3d~8{a=`X zdV=m1GmLVw}zd&J*1 zR?Bg}__{P0?w{#zGn2gR*zD_0zCW#!AEm~pA9Cr(c%+V*(&*IuN}B+FyJ>c&Pm`aC zWKN}FGQ>nO7yo6%bV)tjI{8c{MypGBvz1G|&;Humsik>p-m1BE3l=v;jJKQZcKf#M W@9ns&&RqHhvYH6ET1&gV(Epzf3J=!+ literal 0 HcmV?d00001 diff --git a/SPEC.md b/context/SPEC.md similarity index 100% rename from SPEC.md rename to context/SPEC.md diff --git a/src/main.rs b/src/main.rs index f1cbee0..c2de6d6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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) -> 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) -> 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 - draw_status(f, main_chunks[3], app); + 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() }; + 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 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 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 { - app.status_msg.clone() - } - ); + 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), + }; - let style = 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 Import a JSON file", Style::default().fg(Color::Cyan)), + (":add-cat Add a category (dimension)", Style::default().fg(Color::Cyan)), + (":add-item Add an item to a category", Style::default().fg(Color::Cyan)), + (":formula Add a formula, e.g.:", Style::default().fg(Color::Cyan)), + (" Profit = Revenue - Cost", Style::default().fg(Color::Green)), + (":w 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"); } diff --git a/src/ui/app.rs b/src/ui/app.rs index a27769f..9db1277 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -9,6 +9,7 @@ use crate::formula::parse_formula; use crate::import::wizard::{ImportWizard, WizardState}; use crate::persistence; use crate::view::Axis; +use crate::command::{self, Command}; #[derive(Debug, Clone, PartialEq)] pub enum AppMode { @@ -21,6 +22,8 @@ pub enum AppMode { TileSelect { cat_idx: usize }, ImportWizard, ExportPrompt { buffer: String }, + /// Vim-style `:` command line + CommandMode { buffer: String }, Help, Quit, } @@ -32,21 +35,19 @@ pub struct App { pub status_msg: String, pub wizard: Option, 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, + /// Pending key for two-key sequences (g→gg, y→yy, d→dd) + pub pending_key: Option, + /// Yanked cell value for `p` paste + pub yanked: Option, } impl App { @@ -58,7 +59,6 @@ impl App { 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, @@ -68,42 +68,30 @@ impl App { view_panel_cursor: 0, formula_cursor: 0, dirty: false, + pending_key: None, + yanked: None, } } + /// True when the model has no categories yet (show welcome screen) + pub fn is_empty_model(&self) -> bool { + self.model.categories.is_empty() + } + 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)?; - } + AppMode::Help => { self.mode = AppMode::Normal; } + AppMode::ImportWizard => { self.handle_wizard_key(key)?; } + AppMode::Editing { .. } => { self.handle_edit_key(key)?; } + AppMode::FormulaEdit { .. } => { 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 { .. } => { self.handle_tile_select_key(key)?; } + AppMode::ExportPrompt { .. } => { self.handle_export_key(key)?; } + AppMode::CommandMode { .. } => { self.handle_command_mode_key(key)?; } + AppMode::Normal => { self.handle_normal_key(key)?; } } Ok(()) } @@ -113,16 +101,52 @@ impl App { return self.handle_search_key(key); } + // Handle two-key sequences first + if let Some(prev) = self.pending_key.take() { + return self.handle_two_key(prev, key); + } + match (key.code, key.modifiers) { + // ── Quit / Help ──────────────────────────────────────────────── (KeyCode::Char('q'), KeyModifiers::CONTROL) => { self.mode = AppMode::Quit; } - (KeyCode::F(1), _) => { + // ZZ = save and quit + (KeyCode::Char('Z'), KeyModifiers::SHIFT) => { + self.pending_key = Some('Z'); + } + (KeyCode::F(1), _) | (KeyCode::Char('?'), KeyModifiers::NONE) => { self.mode = AppMode::Help; } - (KeyCode::Char('s'), KeyModifiers::CONTROL) => { - self.save()?; + + // ── File ops ─────────────────────────────────────────────────── + (KeyCode::Char('s'), KeyModifiers::CONTROL) => { self.save()?; } + + // ── Command line ─────────────────────────────────────────────── + (KeyCode::Char(':'), _) => { + self.mode = AppMode::CommandMode { buffer: String::new() }; } + + // ── Panel toggles (uppercase letter = no modifier needed) ────── + (KeyCode::Char('F'), KeyModifiers::SHIFT) | (KeyCode::Char('F'), _) => { + self.formula_panel_open = !self.formula_panel_open; + if self.formula_panel_open { + self.mode = AppMode::FormulaPanel; + } + } + (KeyCode::Char('C'), KeyModifiers::SHIFT) | (KeyCode::Char('C'), _) => { + self.category_panel_open = !self.category_panel_open; + if self.category_panel_open { + self.mode = AppMode::CategoryPanel; + } + } + (KeyCode::Char('V'), KeyModifiers::SHIFT) => { + self.view_panel_open = !self.view_panel_open; + if self.view_panel_open { + self.mode = AppMode::ViewPanel; + } + } + // Legacy Ctrl+ panel toggles still work (KeyCode::Char('f'), KeyModifiers::CONTROL) => { self.formula_panel_open = !self.formula_panel_open; } @@ -135,7 +159,19 @@ impl App { (KeyCode::Char('e'), KeyModifiers::CONTROL) => { self.mode = AppMode::ExportPrompt { buffer: String::new() }; } - // Navigation + + // ── Tab cycles open 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; + } + } + + // ── Navigation ───────────────────────────────────────────────── (KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::NONE) => { self.move_selection(-1, 0); } @@ -148,57 +184,147 @@ impl App { (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(); + + // G = last row, gg = first row (g sets pending) + (KeyCode::Char('G'), _) => { self.jump_to_last_row(); } + (KeyCode::Char('g'), KeyModifiers::NONE) => { + self.pending_key = Some('g'); + } + + // 0 = first col, $ = last col + (KeyCode::Char('0'), KeyModifiers::NONE) => { + if let Some(view) = self.model.active_view_mut() { + view.selected.1 = 0; + view.col_offset = 0; + } + } + (KeyCode::Char('$'), _) => { self.jump_to_last_col(); } + + // Ctrl+D / Ctrl+U = half-page scroll + (KeyCode::Char('d'), KeyModifiers::CONTROL) => { self.scroll_rows(5); } + (KeyCode::Char('u'), KeyModifiers::CONTROL) => { self.scroll_rows(-5); } + + // ── Editing ──────────────────────────────────────────────────── + (KeyCode::Enter, _) + | (KeyCode::Char('i'), KeyModifiers::NONE) + | (KeyCode::Char('a'), KeyModifiers::NONE) => { + let current = self.selected_cell_key() + .map(|k| self.model.get_cell(&k).to_string()) + .unwrap_or_default(); self.mode = AppMode::Editing { buffer: current }; } + + // x = clear cell + (KeyCode::Char('x'), KeyModifiers::NONE) => { + if let Some(key) = self.selected_cell_key() { + let cmd = Command::ClearCell { + coords: key.0.iter().map(|(c, v)| [c.clone(), v.clone()]).collect(), + }; + command::dispatch(&mut self.model, &cmd); + self.dirty = true; + } + } + + // y = start yank sequence (yy = yank cell) + (KeyCode::Char('y'), KeyModifiers::NONE) => { + self.pending_key = Some('y'); + } + + // p = paste yanked value + (KeyCode::Char('p'), KeyModifiers::NONE) => { + if let Some(value) = self.yanked.clone() { + if let Some(key) = self.selected_cell_key() { + let coords = key.0.iter().map(|(c, v)| [c.clone(), v.clone()]).collect(); + let cmd = match &value { + CellValue::Number(n) => Command::SetCell { coords, value: crate::command::types::CellValueArg::Number { number: *n } }, + CellValue::Text(t) => Command::SetCell { coords, value: crate::command::types::CellValueArg::Text { text: t.clone() } }, + CellValue::Empty => Command::ClearCell { coords }, + }; + command::dispatch(&mut self.model, &cmd); + self.dirty = true; + } + } + } + + // ── Search ───────────────────────────────────────────────────── (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; + (KeyCode::Char('n'), KeyModifiers::NONE) => { + // next search match — for now just a status hint + if !self.search_query.is_empty() { + self.status_msg = format!("Searching: {}", self.search_query); } } - // 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 = self.model.category_names() - .into_iter().map(String::from).collect(); - if !cat_names.is_empty() { + (KeyCode::Char('N'), _) => { + if !self.search_query.is_empty() { + self.status_msg = format!("Search prev: {}", self.search_query); + } + } + + // ── Tile movement ────────────────────────────────────────────── + // T = enter tile select mode (single key, no Ctrl needed) + (KeyCode::Char('T'), _) => { + let count = self.model.category_names().len(); + if count > 0 { 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; + // Legacy Ctrl+Arrow still works + (KeyCode::Left, KeyModifiers::CONTROL) + | (KeyCode::Right, KeyModifiers::CONTROL) + | (KeyCode::Up, KeyModifiers::CONTROL) + | (KeyCode::Down, KeyModifiers::CONTROL) => { + let count = self.model.category_names().len(); + if count > 0 { + self.mode = AppMode::TileSelect { cat_idx: 0 }; + } } + + // ── Page axis ────────────────────────────────────────────────── + (KeyCode::Char('['), _) => { self.page_prev(); } + (KeyCode::Char(']'), _) => { self.page_next(); } + _ => {} } Ok(()) } + /// Handle the second key of a two-key sequence. + fn handle_two_key(&mut self, first: char, key: KeyEvent) -> Result<()> { + match (first, key.code) { + // gg = first row + ('g', KeyCode::Char('g')) => { + if let Some(view) = self.model.active_view_mut() { + view.selected = (0, view.selected.1); + view.row_offset = 0; + } + } + // yy = yank current cell + ('y', KeyCode::Char('y')) => { + if let Some(key) = self.selected_cell_key() { + let val = self.model.evaluate(&key); + self.yanked = Some(val); + self.status_msg = "Yanked".to_string(); + } + } + // ZZ = save + quit + ('Z', KeyCode::Char('Z')) => { + self.save()?; + self.mode = AppMode::Quit; + } + // Unrecognised two-key — treat second key normally + _ => { + return self.handle_normal_key(key); + } + } + 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::Esc | KeyCode::Enter => { self.search_mode = false; } KeyCode::Char(c) => { self.search_query.push(c); } KeyCode::Backspace => { self.search_query.pop(); } _ => {} @@ -206,95 +332,236 @@ impl App { Ok(()) } - fn handle_edit_key(&mut self, key: KeyEvent) -> Result<()> { - let buf = if let AppMode::Editing { buffer } = &self.mode { - buffer.clone() - } else { return Ok(()); }; + // ── Command mode ──────────────────────────────────────────────────────── + fn handle_command_mode_key(&mut self, key: KeyEvent) -> Result<()> { 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::() { - CellValue::Number(n) + let buf = if let AppMode::CommandMode { buffer } = &self.mode { + buffer.clone() + } else { return Ok(()); }; + self.execute_command(&buf)?; + if !matches!(self.mode, AppMode::Quit) { + self.mode = AppMode::Normal; + } + } + KeyCode::Char(c) => { + if let AppMode::CommandMode { buffer } = &mut self.mode { + buffer.push(c); + } + } + KeyCode::Backspace => { + if let AppMode::CommandMode { buffer } = &mut self.mode { + if buffer.is_empty() { + self.mode = AppMode::Normal; } else { - CellValue::Text(buf.clone()) + buffer.pop(); + } + } + } + _ => {} + } + Ok(()) + } + + fn execute_command(&mut self, raw: &str) -> Result<()> { + let raw = raw.trim(); + let (cmd_name, rest) = raw.split_once(char::is_whitespace) + .map(|(c, r)| (c, r.trim())) + .unwrap_or((raw, "")); + + match cmd_name { + "q" | "quit" => { + if self.dirty { + self.status_msg = "Unsaved changes. Use :q! to force quit or :wq to save+quit.".to_string(); + } else { + self.mode = AppMode::Quit; + } + } + "q!" => { self.mode = AppMode::Quit; } + "w" | "write" => { + if rest.is_empty() { + self.save()?; + } else { + let path = PathBuf::from(rest); + persistence::save(&self.model, &path)?; + self.file_path = Some(path.clone()); + self.dirty = false; + self.status_msg = format!("Saved to {}", path.display()); + } + } + "wq" | "x" => { + self.save()?; + self.mode = AppMode::Quit; + } + "import" => { + if rest.is_empty() { + self.status_msg = "Usage: :import ".to_string(); + } else { + match std::fs::read_to_string(rest) { + Ok(content) => match serde_json::from_str::(&content) { + Ok(json) => { + self.wizard = Some(ImportWizard::new(json)); + self.mode = AppMode::ImportWizard; + } + Err(e) => { self.status_msg = format!("JSON parse error: {e}"); } + } + Err(e) => { self.status_msg = format!("Cannot read file: {e}"); } + } + } + } + "export" => { + let path = if rest.is_empty() { "export.csv" } else { rest }; + let view_name = self.model.active_view.clone(); + match persistence::export_csv(&self.model, &view_name, Path::new(path)) { + Ok(_) => { self.status_msg = format!("Exported to {path}"); } + Err(e) => { self.status_msg = format!("Export error: {e}"); } + } + } + "add-cat" | "add-category" | "cat" => { + if rest.is_empty() { + self.status_msg = "Usage: :add-cat ".to_string(); + } else { + let result = command::dispatch(&mut self.model, &Command::AddCategory { name: rest.to_string() }); + self.status_msg = result.message.unwrap_or_default(); + self.dirty = true; + } + } + "add-item" | "item" => { + // :add-item + let mut parts = rest.splitn(2, char::is_whitespace); + let cat = parts.next().unwrap_or("").trim(); + let item = parts.next().unwrap_or("").trim(); + if cat.is_empty() || item.is_empty() { + self.status_msg = "Usage: :add-item ".to_string(); + } else { + let result = command::dispatch(&mut self.model, &Command::AddItem { + category: cat.to_string(), + item: item.to_string(), + }); + self.status_msg = result.message.unwrap_or_else(|| "Item added".to_string()); + self.dirty = true; + } + } + "formula" | "add-formula" => { + if rest.is_empty() { + self.formula_panel_open = true; + self.mode = AppMode::FormulaPanel; + } else { + // :formula + let mut parts = rest.splitn(2, char::is_whitespace); + let cat = parts.next().unwrap_or("").trim(); + let formula = parts.next().unwrap_or("").trim(); + if cat.is_empty() || formula.is_empty() { + self.status_msg = "Usage: :formula ".to_string(); + } else { + let result = command::dispatch(&mut self.model, &Command::AddFormula { + raw: formula.to_string(), + target_category: cat.to_string(), + }); + self.status_msg = result.message.unwrap_or_else(|| "Formula added".to_string()); + self.dirty = true; + } + } + } + "add-view" | "view" => { + let name = if rest.is_empty() { + format!("View {}", self.model.views.len() + 1) + } else { + rest.to_string() + }; + command::dispatch(&mut self.model, &Command::CreateView { name: name.clone() }); + let _ = command::dispatch(&mut self.model, &Command::SwitchView { name }); + self.dirty = true; + } + "help" | "h" => { self.mode = AppMode::Help; } + "" => {} // just pressed Enter with empty buffer + other => { + self.status_msg = format!("Unknown command: :{other} (try :help)"); + } + } + Ok(()) + } + + // ── Edit mode ──────────────────────────────────────────────────────────── + + fn handle_edit_key(&mut self, key: KeyEvent) -> Result<()> { + match key.code { + KeyCode::Esc => { self.mode = AppMode::Normal; } + KeyCode::Enter => { + let buf = if let AppMode::Editing { buffer } = &self.mode { buffer.clone() } else { return Ok(()); }; + if let Some(key) = self.selected_cell_key() { + let coords = key.0.iter().map(|(c, v)| [c.clone(), v.clone()]).collect(); + let cmd = if buf.is_empty() { + Command::ClearCell { coords } + } else if let Ok(n) = buf.parse::() { + Command::SetCell { coords, value: crate::command::types::CellValueArg::Number { number: n } } + } else { + Command::SetCell { coords, value: crate::command::types::CellValueArg::Text { text: buf.clone() } } }; - self.model.set_cell(key, value); + command::dispatch(&mut self.model, &cmd); 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); - } + if let AppMode::Editing { buffer } = &mut self.mode { buffer.push(c); } } KeyCode::Backspace => { - if let AppMode::Editing { buffer } = &mut self.mode { - buffer.pop(); - } + 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(()); }; + // ── Formula edit ───────────────────────────────────────────────────────── + fn handle_formula_edit_key(&mut self, key: KeyEvent) -> Result<()> { match key.code { KeyCode::Esc => { self.mode = AppMode::FormulaPanel; } KeyCode::Enter => { - // Try to parse and add formula + let buf = if let AppMode::FormulaEdit { buffer } = &self.mode { buffer.clone() } else { return Ok(()); }; 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}"); - } - } + let result = command::dispatch(&mut self.model, &Command::AddFormula { + raw: buf, + target_category: cat, + }); + self.status_msg = result.message.unwrap_or_else(|| "Formula added".to_string()); + self.dirty = true; + } else { + self.status_msg = "Add at least one category first.".to_string(); } self.mode = AppMode::FormulaPanel; } KeyCode::Char(c) => { - if let AppMode::FormulaEdit { buffer } = &mut self.mode { - buffer.push(c); - } + if let AppMode::FormulaEdit { buffer } = &mut self.mode { buffer.push(c); } } KeyCode::Backspace => { - if let AppMode::FormulaEdit { buffer } = &mut self.mode { - buffer.pop(); - } + if let AppMode::FormulaEdit { buffer } = &mut self.mode { buffer.pop(); } } _ => {} } Ok(()) } + // ── Panel key handlers ─────────────────────────────────────────────────── + 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') => { + KeyCode::Char('a') | KeyCode::Char('n') | KeyCode::Char('o') => { 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); + command::dispatch(&mut self.model, &Command::RemoveFormula { target }); if self.formula_cursor > 0 { self.formula_cursor -= 1; } self.dirty = true; } @@ -303,9 +570,7 @@ impl App { 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; - } + if self.formula_cursor + 1 < self.model.formulas.len() { self.formula_cursor += 1; } } _ => {} } @@ -320,12 +585,9 @@ impl App { 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; - } + 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); @@ -345,26 +607,24 @@ impl App { 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; - } + 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); + command::dispatch(&mut self.model, &Command::SwitchView { name: name.clone() }); self.mode = AppMode::Normal; } } - KeyCode::Char('n') => { + KeyCode::Char('n') | KeyCode::Char('o') => { let new_name = format!("View {}", self.model.views.len() + 1); - self.model.create_view(&new_name); - let _ = self.model.switch_view(&new_name); + command::dispatch(&mut self.model, &Command::CreateView { name: new_name.clone() }); + command::dispatch(&mut self.model, &Command::SwitchView { name: 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); + command::dispatch(&mut self.model, &Command::DeleteView { name: name.clone() }); if self.view_panel_cursor > 0 { self.view_panel_cursor -= 1; } self.dirty = true; } @@ -379,7 +639,7 @@ impl App { 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::Esc | KeyCode::Tab => { 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; } @@ -392,36 +652,28 @@ impl App { } 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); - } + 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); - } + command::dispatch(&mut self.model, &Command::SetAxis { category: name.clone(), axis: "row".to_string() }); 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); - } + command::dispatch(&mut self.model, &Command::SetAxis { category: name.clone(), axis: "column".to_string() }); 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); - } + command::dispatch(&mut self.model, &Command::SetAxis { category: name.clone(), axis: "page".to_string() }); self.dirty = true; } self.mode = AppMode::Normal; @@ -435,26 +687,19 @@ impl App { 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 buf = if let AppMode::ExportPrompt { buffer } = &self.mode { buffer.clone() } else { return Ok(()); }; 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()); } + match persistence::export_csv(&self.model, &view_name, Path::new(&buf)) { + Ok(_) => { self.status_msg = format!("Exported to {buf}"); } 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); - } + if let AppMode::ExportPrompt { buffer } = &mut self.mode { buffer.push(c); } } KeyCode::Backspace => { - if let AppMode::ExportPrompt { buffer } = &mut self.mode { - buffer.pop(); - } + if let AppMode::ExportPrompt { buffer } = &mut self.mode { buffer.pop(); } } _ => {} } @@ -464,92 +709,117 @@ impl App { 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}")); - } + 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 => { + match wizard.build_model() { + Ok(model) => { + self.model = model; + self.dirty = true; + self.status_msg = "Import successful! Press :w to save.".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; - } + KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; } + _ => {} + }, + WizardState::Done => { self.mode = AppMode::Normal; self.wizard = None; } } } Ok(()) } + // ── Motion helpers ─────────────────────────────────────────────────────── + 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); + view.selected = ( + (r as i32 + dr).max(0) as usize, + (c as i32 + dc).max(0) as usize, + ); + } + } + + fn jump_to_last_row(&mut self) { + let view_name = self.model.active_view.clone(); + if let Some(view) = self.model.active_view() { + let row_cats: Vec = view.categories_on(Axis::Row).into_iter().map(String::from).collect(); + let count = row_cats.first() + .and_then(|c| self.model.category(c)) + .map(|c| c.items.len().saturating_sub(1)) + .unwrap_or(0); + drop(view); + if let Some(view) = self.model.active_view_mut() { + view.selected.0 = count; + } + } + } + + fn jump_to_last_col(&mut self) { + let view_name = self.model.active_view.clone(); + if let Some(view) = self.model.active_view() { + let col_cats: Vec = view.categories_on(Axis::Column).into_iter().map(String::from).collect(); + let count = col_cats.first() + .and_then(|c| self.model.category(c)) + .map(|c| c.items.len().saturating_sub(1)) + .unwrap_or(0); + drop(view); + if let Some(view) = self.model.active_view_mut() { + view.selected.1 = count; + } + } + } + + fn scroll_rows(&mut self, delta: i32) { + if let Some(view) = self.model.active_view_mut() { + let new_r = (view.selected.0 as i32 + delta).max(0) as usize; + view.selected.0 = new_r; } } fn page_next(&mut self) { let page_cats: Vec = self.model.active_view() - .map(|v| v.categories_on(crate::view::Axis::Page).into_iter().map(String::from).collect()) + .map(|v| v.categories_on(Axis::Page).into_iter().map(String::from).collect()) .unwrap_or_default(); - for cat_name in &page_cats { let items: Vec = 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() { @@ -561,20 +831,17 @@ impl App { fn page_prev(&mut self) { let page_cats: Vec = self.model.active_view() - .map(|v| v.categories_on(crate::view::Axis::Page).into_iter().map(String::from).collect()) + .map(|v| v.categories_on(Axis::Page).into_iter().map(String::from).collect()) .unwrap_or_default(); - for cat_name in &page_cats { let items: Vec = 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() { @@ -584,16 +851,16 @@ impl App { } } + // ── Cell key resolution ────────────────────────────────────────────────── + pub fn selected_cell_key(&self) -> Option { 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::>()) @@ -603,9 +870,7 @@ impl App { .or_else(|| items.first().cloned())?; coords.push((cat_name.to_string(), sel)); } - - // Row coords - for (i, cat_name) in row_cats.iter().enumerate() { + for cat_name in &row_cats { let items: Vec = self.model.category(cat_name) .map(|c| c.ordered_item_names().into_iter() .filter(|item| !view.is_hidden(cat_name, item)) @@ -614,9 +879,7 @@ impl App { 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() { + for cat_name in &col_cats { let items: Vec = self.model.category(cat_name) .map(|c| c.ordered_item_names().into_iter() .filter(|item| !view.is_hidden(cat_name, item)) @@ -629,13 +892,15 @@ impl App { Some(CellKey::new(coords)) } + // ── Persistence ────────────────────────────────────────────────────────── + 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(); + self.status_msg = "No file path — use :w to save.".to_string(); } Ok(()) } @@ -643,8 +908,8 @@ impl App { 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); + let ap = persistence::autosave_path(path); + let _ = persistence::save(&self.model, &ap); self.last_autosave = Instant::now(); } } @@ -654,4 +919,20 @@ impl App { self.wizard = Some(ImportWizard::new(json)); self.mode = AppMode::ImportWizard; } + + /// Hint text for the status bar (context-sensitive) + pub fn hint_text(&self) -> &'static str { + match &self.mode { + AppMode::Normal => "hjkl:nav i:edit x:clear /:search F/C/V:panels T:tiles [:]:page ::cmd", + AppMode::Editing { .. } => "Enter:commit Esc:cancel", + AppMode::FormulaPanel => "n:new d:delete jk:nav Esc:back", + AppMode::FormulaEdit { .. } => "Enter:save Esc:cancel — type: Name = expression", + AppMode::CategoryPanel => "jk:nav Space:cycle-axis Esc:back", + AppMode::ViewPanel => "jk:nav Enter:switch n:new d:delete Esc:back", + AppMode::TileSelect { .. } => "hl:select Enter:cycle r/c/p:set-axis Esc:back", + AppMode::CommandMode { .. } => ":q quit :w save :import :add-cat :formula :help", + AppMode::ImportWizard => "Space:toggle c:cycle Enter:next Esc:cancel", + _ => "", + } + } } diff --git a/src/ui/help.rs b/src/ui/help.rs index 1d29be2..7e5fe10 100644 --- a/src/ui/help.rs +++ b/src/ui/help.rs @@ -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 ", "Open JSON import wizard", key), + (" :export [path.csv]", "Export active view to CSV", key), + (" :add-cat ", "Add a category", key), + (" :add-item ", "Add an item to a category", key), + (" :formula ", "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); } } }