diff --git a/src/draw.rs b/src/draw.rs index c958398..e15a18d 100644 --- a/src/draw.rs +++ b/src/draw.rs @@ -26,6 +26,7 @@ use crate::ui::import_wizard_ui::ImportWizardWidget; use crate::ui::panel::Panel; use crate::ui::tile_bar::TileBar; use crate::ui::view_panel::ViewContent; +use crate::ui::which_key::WhichKeyWidget; struct TuiContext<'a> { terminal: Terminal>, @@ -60,6 +61,8 @@ pub fn run_tui( if let Some(json) = import_value { app.start_import_wizard(json); + } else if app.is_empty_model() { + app.mode = AppMode::Help; } loop { @@ -162,7 +165,7 @@ fn draw(f: &mut Frame, app: &App) { // Overlays (rendered last so they appear on top) if matches!(app.mode, AppMode::Help) { - f.render_widget(HelpWidget, size); + f.render_widget(HelpWidget::new(app.help_page), size); } if matches!(app.mode, AppMode::ImportWizard) { if let Some(wizard) = &app.wizard { @@ -173,6 +176,12 @@ fn draw(f: &mut Frame, app: &App) { if app.is_empty_model() && matches!(app.mode, AppMode::Normal | AppMode::CommandMode { .. }) { draw_welcome(f, main_chunks[1]); } + + // Which-key popup: show available completions after a prefix key + if let Some(ref km) = app.transient_keymap { + let hints = km.binding_hints(); + f.render_widget(WhichKeyWidget::new(&hints), size); + } } fn draw_title(f: &mut Frame, area: Rect, app: &App) { diff --git a/src/ui/effect.rs b/src/ui/effect.rs index 0aa827c..65fa5b3 100644 --- a/src/ui/effect.rs +++ b/src/ui/effect.rs @@ -35,6 +35,8 @@ impl Effect for AddItem { fn apply(&self, app: &mut App) { if let Some(cat) = app.model.category_mut(&self.category) { cat.add_item(&self.item); + } else { + app.status_msg = format!("Unknown category '{}'", self.category); } } } @@ -49,6 +51,8 @@ impl Effect for AddItemInGroup { fn apply(&self, app: &mut App) { if let Some(cat) = app.model.category_mut(&self.category) { cat.add_item_in_group(&self.item, &self.group); + } else { + app.status_msg = format!("Unknown category '{}'", self.category); } } } @@ -76,8 +80,13 @@ pub struct AddFormula { } impl Effect for AddFormula { fn apply(&self, app: &mut App) { - if let Ok(formula) = crate::formula::parse_formula(&self.raw, &self.target_category) { - app.model.add_formula(formula); + match crate::formula::parse_formula(&self.raw, &self.target_category) { + Ok(formula) => { + app.model.add_formula(formula); + } + Err(e) => { + app.status_msg = format!("Formula error: {e}"); + } } } } diff --git a/src/ui/help.rs b/src/ui/help.rs index 05ed98a..aae910d 100644 --- a/src/ui/help.rs +++ b/src/ui/help.rs @@ -5,121 +5,613 @@ use ratatui::{ widgets::{Block, Borders, Clear, Widget}, }; -pub struct HelpWidget; +/// Number of help pages available. +pub const HELP_PAGE_COUNT: usize = 5; + +/// Style presets used throughout help pages. +struct HelpStyles { + heading: Style, + key: Style, + dim: Style, + normal: Style, + accent: Style, + banner: Style, +} + +impl HelpStyles { + fn new() -> Self { + Self { + heading: Style::default() + .fg(Color::Blue) + .add_modifier(Modifier::BOLD), + key: Style::default().fg(Color::Cyan), + dim: Style::default().fg(Color::DarkGray), + normal: Style::default(), + accent: Style::default().fg(Color::Green), + banner: Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + } + } +} + +/// A styled line in a help page: two columns (key + description) plus a style. +/// When `desc` is empty the `key` text spans the full width. +struct HelpLine { + key: &'static str, + desc: &'static str, + style: Style, +} + +impl HelpLine { + fn heading(text: &'static str, styles: &HelpStyles) -> Self { + Self { + key: text, + desc: "", + style: styles.heading, + } + } + + fn key(k: &'static str, d: &'static str, styles: &HelpStyles) -> Self { + Self { + key: k, + desc: d, + style: styles.key, + } + } + + fn dim(k: &'static str, d: &'static str, styles: &HelpStyles) -> Self { + Self { + key: k, + desc: d, + style: styles.dim, + } + } + + fn text(t: &'static str, styles: &HelpStyles) -> Self { + Self { + key: t, + desc: "", + style: styles.normal, + } + } + + fn accent(t: &'static str, styles: &HelpStyles) -> Self { + Self { + key: t, + desc: "", + style: styles.accent, + } + } + + fn banner(t: &'static str, styles: &HelpStyles) -> Self { + Self { + key: t, + desc: "", + style: styles.banner, + } + } + + fn blank() -> Self { + Self { + key: "", + desc: "", + style: Style::default(), + } + } +} + +/// The page titles shown in the tab bar. +const PAGE_TITLES: [&str; HELP_PAGE_COUNT] = [ + "Welcome", + "Navigation", + "Editing", + "Panels & Views", + "Commands", +]; + +// ── Page content builders ─────────────────────────────────────────────────── + +fn page_welcome(s: &HelpStyles) -> Vec { + vec![ + HelpLine::blank(), + HelpLine::banner(" improvise", s), + HelpLine::text( + " A multi-dimensional data modeling tool in your terminal.", + s, + ), + HelpLine::blank(), + HelpLine::text( + " improvise lets you build spreadsheet-like models organized", + s, + ), + HelpLine::text( + " by categories (dimensions). Each category has items, and", + s, + ), + HelpLine::text( + " every combination of items across categories forms a cell.", + s, + ), + HelpLine::text( + " Think of it like a pivot table you can build from scratch.", + s, + ), + HelpLine::blank(), + HelpLine::heading("Quick start", s), + HelpLine::blank(), + HelpLine::text(" 1. Create categories (dimensions) for your model:", s), + HelpLine::accent(" :add-cat Region :add-cat Product", s), + HelpLine::blank(), + HelpLine::text(" 2. Add items to each category:", s), + HelpLine::accent( + " :add-items Region North South East West", + s, + ), + HelpLine::accent( + " :add-items Product Widget Gadget", + s, + ), + HelpLine::blank(), + HelpLine::text(" 3. Navigate with hjkl or arrow keys and press i to edit cells.", s), + HelpLine::blank(), + HelpLine::text(" 4. Add formulas to compute values automatically:", s), + HelpLine::accent( + " :formula Product Total = Widget + Gadget", + s, + ), + HelpLine::blank(), + HelpLine::text(" 5. Save your work:", s), + HelpLine::accent(" :w mymodel.improv", s), + HelpLine::blank(), + HelpLine::heading("Core concepts", s), + HelpLine::blank(), + HelpLine::text( + " Category A dimension of your data (e.g. Region, Time, Product).", + s, + ), + HelpLine::text( + " Item A member of a category (e.g. North, Q1, Widget).", + s, + ), + HelpLine::text( + " View A saved layout: which categories go on rows, columns, or pages.", + s, + ), + HelpLine::text( + " Tile The row/column/page assignment of a category.", + s, + ), + HelpLine::text( + " Formula A computed item: derives its value from other items.", + s, + ), + HelpLine::blank(), + HelpLine::dim( + " Tip: press Tab or l/n to go to the next page.", + "", + s, + ), + ] +} + +fn page_navigation(s: &HelpStyles) -> Vec { + vec![ + HelpLine::blank(), + HelpLine::heading("Cursor movement", s), + HelpLine::blank(), + HelpLine::key(" hjkl / Arrow keys", "Move one cell", s), + HelpLine::key(" gg", "Jump to first row", s), + HelpLine::key(" G", "Jump to last row", s), + HelpLine::key(" 0 / Home", "Jump to first column", s), + HelpLine::key(" $ / End", "Jump to last column", s), + HelpLine::blank(), + HelpLine::heading("Scrolling", s), + HelpLine::blank(), + HelpLine::key(" Ctrl+D", "Scroll half-page down", s), + HelpLine::key(" Ctrl+U", "Scroll half-page up", s), + HelpLine::key(" PageDown", "Scroll three-quarters page down", s), + HelpLine::key(" PageUp", "Scroll three-quarters page up", s), + HelpLine::blank(), + HelpLine::heading("Page-axis cycling", s), + HelpLine::blank(), + HelpLine::text( + " When a category is on the Page axis, only one item is", + s, + ), + HelpLine::text( + " visible at a time. Use [ and ] to cycle through them.", + s, + ), + HelpLine::blank(), + HelpLine::key(" [", "Previous page-axis item", s), + HelpLine::key(" ]", "Next page-axis item", s), + HelpLine::blank(), + HelpLine::heading("Search", s), + HelpLine::blank(), + HelpLine::key(" /", "Start search — type a pattern, matching cells highlight", s), + HelpLine::key(" n", "Jump to next match", s), + HelpLine::key(" N", "Jump to previous match", s), + HelpLine::key(" Esc or Enter", "Exit search mode", s), + HelpLine::blank(), + HelpLine::heading("View history", s), + HelpLine::blank(), + HelpLine::key(" >", "Drill into selected cell (record view)", s), + HelpLine::key(" <", "Go back to previous view", s), + ] +} + +fn page_editing(s: &HelpStyles) -> Vec { + vec![ + HelpLine::blank(), + HelpLine::heading("Entering edit mode", s), + HelpLine::blank(), + HelpLine::key(" i / a", "Edit current cell (insert mode)", s), + HelpLine::key(" Enter", "Edit current cell (same as i)", s), + HelpLine::key(" Esc", "Cancel edit, return to Normal mode", s), + HelpLine::blank(), + HelpLine::heading("While editing", s), + HelpLine::blank(), + HelpLine::text(" Type normally to enter a value. Values can be:", s), + HelpLine::accent(" Numbers: 42 3.14 -100", s), + HelpLine::accent(" Text: hello world", s), + HelpLine::blank(), + HelpLine::key(" Enter", "Commit value and move down", s), + HelpLine::key(" Tab", "Commit value and move right (stay in edit mode)", s), + HelpLine::key(" Esc", "Discard edits and return to Normal", s), + HelpLine::blank(), + HelpLine::heading("Copy and paste", s), + HelpLine::blank(), + HelpLine::key(" yy", "Yank (copy) the current cell value", s), + HelpLine::key(" p", "Paste the yanked value into the current cell", s), + HelpLine::blank(), + HelpLine::heading("Cell operations", s), + HelpLine::blank(), + HelpLine::key(" x", "Clear the current cell", s), + HelpLine::blank(), + HelpLine::heading("Formulas", s), + HelpLine::blank(), + HelpLine::text( + " Formulas are computed items. A formula belongs to a category", + s, + ), + HelpLine::text( + " and derives its value from other items in that category.", + s, + ), + HelpLine::blank(), + HelpLine::text(" Example: in a Product category with items Widget and Gadget:", s), + HelpLine::accent(" :formula Product Total = Widget + Gadget", s), + HelpLine::blank(), + HelpLine::text(" Supported operators: + - * /", s), + HelpLine::text( + " Formula cells update automatically when source values change.", + s, + ), + ] +} + +fn page_panels(s: &HelpStyles) -> Vec { + vec![ + HelpLine::blank(), + HelpLine::heading("Side panels", s), + HelpLine::blank(), + HelpLine::text( + " Panels open on the right side of the screen and give you", + s, + ), + HelpLine::text( + " quick access to formulas, categories, and views.", + s, + ), + HelpLine::blank(), + HelpLine::key(" F", "Toggle Formula panel", s), + HelpLine::dim(" n", "New formula", s), + HelpLine::dim(" d", "Delete selected formula", s), + HelpLine::blank(), + HelpLine::key(" C", "Toggle Category panel", s), + HelpLine::dim(" n", "New category", s), + HelpLine::dim(" a", "Add items to selected category", s), + HelpLine::dim(" d", "Delete selected category/item", s), + HelpLine::blank(), + HelpLine::key(" N", "Quick-add a new category (from anywhere)", s), + HelpLine::blank(), + HelpLine::key(" V", "Toggle View panel", s), + HelpLine::dim(" n", "New view", s), + HelpLine::dim(" d", "Delete selected view", s), + HelpLine::dim(" Enter", "Switch to selected view", s), + HelpLine::blank(), + HelpLine::key(" Tab", "Cycle focus between open panels", s), + HelpLine::blank(), + HelpLine::heading("Tile select mode (T)", s), + HelpLine::blank(), + HelpLine::text( + " Tiles control which axis each category is placed on.", + s, + ), + HelpLine::text( + " Press T to enter tile-select mode, then:", + s, + ), + HelpLine::blank(), + HelpLine::key(" h / l (← →)", "Select previous / next category tile", s), + HelpLine::key(" Space / Enter", "Cycle axis: Row → Col → Page", s), + HelpLine::key(" r", "Set axis to Row", s), + HelpLine::key(" c", "Set axis to Col", s), + HelpLine::key(" p", "Set axis to Page", s), + HelpLine::key(" Esc", "Exit tile-select mode", s), + HelpLine::blank(), + HelpLine::heading("Groups and visibility", s), + HelpLine::blank(), + HelpLine::key(" z", "Toggle collapse of nearest group above cursor", s), + HelpLine::key(" H", "Hide current row item", s), + HelpLine::dim( + " :show-item ", + "Restore a hidden item", + s, + ), + ] +} + +fn page_commands(s: &HelpStyles) -> Vec { + vec![ + HelpLine::blank(), + HelpLine::heading("Command line ( : )", s), + HelpLine::blank(), + HelpLine::text( + " Press : to open the command line. Commands are entered", + s, + ), + HelpLine::text( + " vim-style and executed with Enter. Esc cancels.", + s, + ), + HelpLine::blank(), + HelpLine::heading("File operations", s), + HelpLine::blank(), + HelpLine::key(" :w [path]", "Save (path optional after first save)", s), + HelpLine::key(" :wq", "Save and quit", s), + HelpLine::key(" :q", "Quit (warns if unsaved changes)", s), + HelpLine::key(" :q!", "Force quit without saving", s), + HelpLine::key(" ZZ", "Save and quit (same as :wq)", s), + HelpLine::key(" Ctrl+S", "Save (same as :w)", s), + HelpLine::blank(), + HelpLine::heading("Import and export", s), + HelpLine::blank(), + HelpLine::key(" :import ", "Open the JSON/CSV import wizard", s), + HelpLine::key(" :export [path.csv]", "Export the active view to CSV", s), + HelpLine::blank(), + HelpLine::heading("Model building", s), + HelpLine::blank(), + HelpLine::key(" :add-cat ", "Add a new category", s), + HelpLine::key(" :add-item ", "Add one item to a category", s), + HelpLine::key(" :add-items a b c ...", "Add multiple items at once", s), + HelpLine::key(" :formula ", "Add a formula", s), + HelpLine::blank(), + HelpLine::heading("Views", s), + HelpLine::blank(), + HelpLine::key(" :add-view [name]", "Create a new view", s), + HelpLine::blank(), + HelpLine::heading("Display", s), + HelpLine::blank(), + HelpLine::key(" :set-format ", "Set number format (e.g. ',.2')", s), + HelpLine::blank(), + HelpLine::heading("Other keys", s), + HelpLine::blank(), + HelpLine::key(" ? or F1", "Open this help screen", s), + HelpLine::key(" :help", "Open this help screen", s), + ] +} + +/// Builds the content lines for a given page index. +fn page_content(page: usize) -> Vec { + let styles = HelpStyles::new(); + match page { + 0 => page_welcome(&styles), + 1 => page_navigation(&styles), + 2 => page_editing(&styles), + 3 => page_panels(&styles), + 4 => page_commands(&styles), + _ => page_welcome(&styles), + } +} + +// ── Widget ────────────────────────────────────────────────────────────────── + +pub struct HelpWidget { + page: usize, +} + +impl HelpWidget { + pub fn new(page: usize) -> Self { + // Clamp to valid range + let page = page.min(HELP_PAGE_COUNT - 1); + Self { page } + } +} impl Widget for HelpWidget { fn render(self, area: Rect, buf: &mut Buffer) { - 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; + // Use most of the screen, leaving a small margin + let margin_x = if area.width > 90 { 4 } else { 1 }; + let margin_y = if area.height > 30 { 2 } else { 1 }; + let popup_w = area.width.saturating_sub(margin_x * 2); + let popup_h = area.height.saturating_sub(margin_y * 2); + let x = area.x + margin_x; + let y = area.y + margin_y; let popup_area = Rect::new(x, y, popup_w, popup_h); Clear.render(popup_area, buf); + let block = Block::default() .borders(Borders::ALL) - .title(" improvise — key reference (any key to close) ") + .title(" improvise — help ") .border_style(Style::default().fg(Color::Blue)); let inner = block.inner(popup_area); block.render(popup_area, buf); - 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(); + if inner.height < 4 || inner.width < 20 { + return; + } - // (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 (n:new-cat a:add-items)", - key, - ), - (" N", "New category quick-add (from anywhere)", key), - ( - " V", - "Toggle View panel (n:new d:del Enter:switch)", - key, - ), - (" Tab", "Focus next open panel", key), - ("", "", norm), - ("Pivot / Tiles / Groups", "", head), - (" z", "Toggle collapse nearest group above cursor", key), - ( - " H", - "Hide current row item (:show-item cat item to restore)", - key, - ), - (" 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 one item to a category", - key, - ), - ( - " :add-items a b c…", - "Add multiple items at once", - 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), - ]; + // ── Tab bar ───────────────────────────────────────────────────── + let tab_y = inner.y; + render_tab_bar(buf, inner.x, tab_y, inner.width, self.page); - let key_col_w = 32usize; - for (i, (k, d, style)) in rows.iter().enumerate() { - if i >= inner.height as usize { + // ── Separator line ────────────────────────────────────────────── + let sep_y = tab_y + 1; + let sep_line: String = "─".repeat(inner.width as usize); + buf.set_string( + inner.x, + sep_y, + &sep_line, + Style::default().fg(Color::DarkGray), + ); + + // ── Page content ──────────────────────────────────────────────── + let content_start_y = sep_y + 1; + let content_height = inner.height.saturating_sub(4); // tab + sep + footer_sep + footer + let lines = page_content(self.page); + let key_col_width = 32usize; + let normal_style = Style::default(); + + for (i, line) in lines.iter().enumerate() { + if i >= content_height as usize { break; } - let y = inner.y + i as u16; - if d.is_empty() { - buf.set_string(inner.x, y, k, *style); + let ly = content_start_y + i as u16; + + if line.desc.is_empty() { + // Single-column line (headings, text, blanks) + buf.set_string(inner.x, ly, line.key, line.style); } else { - 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); + // Two-column line: key on the left, description on the right + buf.set_string(inner.x, ly, line.key, line.style); + let desc_x = inner.x + key_col_width as u16; + if desc_x < inner.x + inner.width { + buf.set_string(desc_x, ly, line.desc, normal_style); } } } + + // ── Footer separator ──────────────────────────────────────────── + let footer_sep_y = inner.y + inner.height - 2; + let footer_sep: String = "─".repeat(inner.width as usize); + buf.set_string( + inner.x, + footer_sep_y, + &footer_sep, + Style::default().fg(Color::DarkGray), + ); + + // ── Footer ───────────────────────────────────────────────────── + let footer_y = inner.y + inner.height - 1; + render_footer(buf, inner.x, footer_y, inner.width, self.page); + } +} + +/// Renders the tab bar showing all page titles with the active page highlighted. +fn render_tab_bar(buf: &mut Buffer, x: u16, y: u16, width: u16, active_page: usize) { + let inactive_style = Style::default().fg(Color::DarkGray); + let active_style = Style::default() + .fg(Color::White) + .bg(Color::Blue) + .add_modifier(Modifier::BOLD); + let separator_style = Style::default().fg(Color::DarkGray); + + let mut col = x; + let max_col = x + width; + + for (i, title) in PAGE_TITLES.iter().enumerate() { + if col >= max_col { + break; + } + + // Separator between tabs + if i > 0 { + if col + 3 >= max_col { + break; + } + buf.set_string(col, y, " │ ", separator_style); + col += 3; + } + + let style = if i == active_page { + active_style + } else { + inactive_style + }; + + let label = format!(" {} ", title); + let label_width = label.len() as u16; + if col + label_width > max_col { + break; + } + buf.set_string(col, y, &label, style); + col += label_width; + } +} + +/// Renders the footer with page navigation hints. +fn render_footer(buf: &mut Buffer, x: u16, y: u16, width: u16, page: usize) { + let dim = Style::default().fg(Color::DarkGray); + let key_style = Style::default().fg(Color::Cyan); + + let page_indicator = format!(" page {} of {} ", page + 1, HELP_PAGE_COUNT); + + let nav_parts: Vec<(&str, Style)> = if page == 0 && HELP_PAGE_COUNT > 1 { + vec![ + (" ", dim), + ("l", key_style), + ("/", dim), + ("Tab", key_style), + (": next", dim), + ] + } else if page >= HELP_PAGE_COUNT - 1 { + vec![ + (" ", dim), + ("h", key_style), + ("/", dim), + ("S-Tab", key_style), + (": prev", dim), + ] + } else { + vec![ + (" ", dim), + ("h", key_style), + (": prev ", dim), + ("l", key_style), + (": next", dim), + ] + }; + + let close_parts: Vec<(&str, Style)> = vec![ + (" ", dim), + ("q", key_style), + ("/", dim), + ("Esc", key_style), + (": close", dim), + ]; + + // Render page indicator on the left + buf.set_string(x, y, &page_indicator, dim); + + // Render navigation hints after the page indicator + let mut col = x + page_indicator.len() as u16; + for (text, style) in &nav_parts { + if col >= x + width { + break; + } + buf.set_string(col, y, text, *style); + col += text.len() as u16; + } + + // Render close hint + for (text, style) in &close_parts { + if col >= x + width { + break; + } + buf.set_string(col, y, text, *style); + col += text.len() as u16; } } diff --git a/src/ui/tile_bar.rs b/src/ui/tile_bar.rs index 16ce4fb..da7f433 100644 --- a/src/ui/tile_bar.rs +++ b/src/ui/tile_bar.rs @@ -26,10 +26,10 @@ impl<'a> TileBar<'a> { } fn axis_display(axis: Axis) -> (&'static str, Color) { match axis { - Axis::Row => ("|", Color::Green), - Axis::Column => ("-", Color::Blue), - Axis::Page => ("=", Color::Magenta), - Axis::None => (".", Color::DarkGray), + Axis::Row => ("Row", Color::Green), + Axis::Column => ("Col", Color::Blue), + Axis::Page => ("Pag", Color::Magenta), + Axis::None => ("·", Color::DarkGray), } } } diff --git a/src/ui/view_panel.rs b/src/ui/view_panel.rs index a185c9d..4defa57 100644 --- a/src/ui/view_panel.rs +++ b/src/ui/view_panel.rs @@ -7,24 +7,53 @@ use ratatui::{ use crate::model::Model; use crate::ui::app::AppMode; use crate::ui::panel::PanelContent; +use crate::view::Axis; -pub struct ViewContent { +pub struct ViewContent<'a> { view_names: Vec, active_view: String, + model: &'a Model, } -impl ViewContent { - pub fn new(model: &Model) -> Self { +impl<'a> ViewContent<'a> { + pub fn new(model: &'a Model) -> Self { let view_names: Vec = model.views.keys().cloned().collect(); let active_view = model.active_view.clone(); Self { view_names, active_view, + model, } } + + /// Build a short axis summary for a view, e.g. "R:Region C:Product P:Time" + fn axis_summary(&self, view_name: &str) -> String { + let Some(view) = self.model.views.get(view_name) else { + return String::new(); + }; + let mut parts = Vec::new(); + for axis in [Axis::Row, Axis::Column, Axis::Page] { + let cats = view.categories_on(axis); + // Filter out virtual categories + let cats: Vec<&str> = cats + .into_iter() + .filter(|c| !c.starts_with('_')) + .collect(); + if !cats.is_empty() { + let prefix = match axis { + Axis::Row => "R", + Axis::Column => "C", + Axis::Page => "P", + Axis::None => "", + }; + parts.push(format!("{}:{}", prefix, cats.join(","))); + } + } + parts.join(" ") + } } -impl PanelContent for ViewContent { +impl PanelContent for ViewContent<'_> { fn is_active(&self, mode: &AppMode) -> bool { matches!(mode, AppMode::ViewPanel) } @@ -63,11 +92,22 @@ impl PanelContent for ViewContent { }; let prefix = if is_active_view { "▶ " } else { " " }; - buf.set_string( - inner.x, - inner.y + index as u16, - format!("{prefix}{view_name}"), - style, - ); + let name_text = format!("{prefix}{view_name}"); + let y = inner.y + index as u16; + buf.set_string(inner.x, y, &name_text, style); + + // Axis summary after the name, in dim text + let summary = self.axis_summary(view_name); + if !summary.is_empty() { + let summary_x = inner.x + name_text.len() as u16 + 1; + if summary_x < inner.x + inner.width { + let summary_style = if is_selected { + style + } else { + Style::default().fg(Color::DarkGray) + }; + buf.set_string(summary_x, y, &summary, summary_style); + } + } } }