refactor(ui): improve rendering, feedback, and help system

Improve UI rendering and feedback:

- `src/draw.rs` :
    - Automatically enter Help mode if the model is empty.
    - Render the `HelpWidget` with the current help page.
    - Render the `WhichKeyWidget` when a transient keymap is active.
- `src/ui/tile_bar.rs` : Use more descriptive labels for axes (e.g., "Row",
  "Col", "Pag").
- `src/ui/view_panel.rs` :
    - Include an axis summary (e.g., "R:Region C:Product") next to view
      names.
    - Refactor `ViewContent` to hold a reference to the `Model` .
- `src/ui/effect.rs` : Add error feedback to `AddItem` , `AddItemInGroup` ,
  and `AddFormula` when operations fail.
- `src/ui/help.rs` : Refactor `HelpWidget` into a multi-page system.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
This commit is contained in:
Edward Langley
2026-04-08 22:27:37 -07:00
parent 2b1f42d8bf
commit 7dd9d906c1
5 changed files with 663 additions and 113 deletions

View File

@ -26,6 +26,7 @@ use crate::ui::import_wizard_ui::ImportWizardWidget;
use crate::ui::panel::Panel; use crate::ui::panel::Panel;
use crate::ui::tile_bar::TileBar; use crate::ui::tile_bar::TileBar;
use crate::ui::view_panel::ViewContent; use crate::ui::view_panel::ViewContent;
use crate::ui::which_key::WhichKeyWidget;
struct TuiContext<'a> { struct TuiContext<'a> {
terminal: Terminal<CrosstermBackend<&'a mut Stdout>>, terminal: Terminal<CrosstermBackend<&'a mut Stdout>>,
@ -60,6 +61,8 @@ pub fn run_tui(
if let Some(json) = import_value { if let Some(json) = import_value {
app.start_import_wizard(json); app.start_import_wizard(json);
} else if app.is_empty_model() {
app.mode = AppMode::Help;
} }
loop { loop {
@ -162,7 +165,7 @@ fn draw(f: &mut Frame, app: &App) {
// Overlays (rendered last so they appear on top) // 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::new(app.help_page), size);
} }
if matches!(app.mode, AppMode::ImportWizard) { if matches!(app.mode, AppMode::ImportWizard) {
if let Some(wizard) = &app.wizard { 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 { .. }) { if app.is_empty_model() && matches!(app.mode, AppMode::Normal | AppMode::CommandMode { .. }) {
draw_welcome(f, main_chunks[1]); 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) { fn draw_title(f: &mut Frame, area: Rect, app: &App) {

View File

@ -35,6 +35,8 @@ impl Effect for AddItem {
fn apply(&self, app: &mut App) { fn apply(&self, app: &mut App) {
if let Some(cat) = app.model.category_mut(&self.category) { if let Some(cat) = app.model.category_mut(&self.category) {
cat.add_item(&self.item); 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) { fn apply(&self, app: &mut App) {
if let Some(cat) = app.model.category_mut(&self.category) { if let Some(cat) = app.model.category_mut(&self.category) {
cat.add_item_in_group(&self.item, &self.group); 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 { impl Effect for AddFormula {
fn apply(&self, app: &mut App) { fn apply(&self, app: &mut App) {
if let Ok(formula) = crate::formula::parse_formula(&self.raw, &self.target_category) { match crate::formula::parse_formula(&self.raw, &self.target_category) {
app.model.add_formula(formula); Ok(formula) => {
app.model.add_formula(formula);
}
Err(e) => {
app.status_msg = format!("Formula error: {e}");
}
} }
} }
} }

View File

@ -5,121 +5,613 @@ use ratatui::{
widgets::{Block, Borders, Clear, Widget}, 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<HelpLine> {
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<HelpLine> {
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<HelpLine> {
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<HelpLine> {
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 <cat> <item>",
"Restore a hidden item",
s,
),
]
}
fn page_commands(s: &HelpStyles) -> Vec<HelpLine> {
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 <path>", "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 <name>", "Add a new category", s),
HelpLine::key(" :add-item <cat> <item>", "Add one item to a category", s),
HelpLine::key(" :add-items <cat> a b c ...", "Add multiple items at once", s),
HelpLine::key(" :formula <cat> <Name=expr>", "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 <fmt>", "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<HelpLine> {
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 { impl Widget for HelpWidget {
fn render(self, area: Rect, buf: &mut Buffer) { fn render(self, area: Rect, buf: &mut Buffer) {
let popup_w = 66u16.min(area.width); // Use most of the screen, leaving a small margin
let popup_h = 36u16.min(area.height); let margin_x = if area.width > 90 { 4 } else { 1 };
let x = area.x + area.width.saturating_sub(popup_w) / 2; let margin_y = if area.height > 30 { 2 } else { 1 };
let y = area.y + area.height.saturating_sub(popup_h) / 2; 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); 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(" improvise — key reference (any key to close) ") .title(" improvise — help ")
.border_style(Style::default().fg(Color::Blue)); .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 head = Style::default() if inner.height < 4 || inner.width < 20 {
.fg(Color::Blue) return;
.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) // ── Tab bar ─────────────────────────────────────────────────────
let rows: &[(&str, &str, Style)] = &[ let tab_y = inner.y;
("Navigation", "", head), render_tab_bar(buf, inner.x, tab_y, inner.width, self.page);
(" 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 <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 one item to a category",
key,
),
(
" :add-items <cat> a b c…",
"Add multiple items at once",
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),
];
let key_col_w = 32usize; // ── Separator line ──────────────────────────────────────────────
for (i, (k, d, style)) in rows.iter().enumerate() { let sep_y = tab_y + 1;
if i >= inner.height as usize { 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; break;
} }
let y = inner.y + i as u16; let ly = content_start_y + i as u16;
if d.is_empty() {
buf.set_string(inner.x, y, k, *style); if line.desc.is_empty() {
// Single-column line (headings, text, blanks)
buf.set_string(inner.x, ly, line.key, line.style);
} else { } else {
buf.set_string(inner.x, y, k, *style); // Two-column line: key on the left, description on the right
let dx = inner.x + key_col_w as u16; buf.set_string(inner.x, ly, line.key, line.style);
if dx < inner.x + inner.width { let desc_x = inner.x + key_col_width as u16;
buf.set_string(dx, y, d, norm); 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;
} }
} }

View File

@ -26,10 +26,10 @@ impl<'a> TileBar<'a> {
} }
fn axis_display(axis: Axis) -> (&'static str, Color) { fn axis_display(axis: Axis) -> (&'static str, Color) {
match axis { match axis {
Axis::Row => ("|", Color::Green), Axis::Row => ("Row", Color::Green),
Axis::Column => ("-", Color::Blue), Axis::Column => ("Col", Color::Blue),
Axis::Page => ("=", Color::Magenta), Axis::Page => ("Pag", Color::Magenta),
Axis::None => (".", Color::DarkGray), Axis::None => ("·", Color::DarkGray),
} }
} }
} }

View File

@ -7,24 +7,53 @@ use ratatui::{
use crate::model::Model; use crate::model::Model;
use crate::ui::app::AppMode; use crate::ui::app::AppMode;
use crate::ui::panel::PanelContent; use crate::ui::panel::PanelContent;
use crate::view::Axis;
pub struct ViewContent { pub struct ViewContent<'a> {
view_names: Vec<String>, view_names: Vec<String>,
active_view: String, active_view: String,
model: &'a Model,
} }
impl ViewContent { impl<'a> ViewContent<'a> {
pub fn new(model: &Model) -> Self { pub fn new(model: &'a Model) -> Self {
let view_names: Vec<String> = model.views.keys().cloned().collect(); let view_names: Vec<String> = model.views.keys().cloned().collect();
let active_view = model.active_view.clone(); let active_view = model.active_view.clone();
Self { Self {
view_names, view_names,
active_view, 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 { fn is_active(&self, mode: &AppMode) -> bool {
matches!(mode, AppMode::ViewPanel) matches!(mode, AppMode::ViewPanel)
} }
@ -63,11 +92,22 @@ impl PanelContent for ViewContent {
}; };
let prefix = if is_active_view { "" } else { " " }; let prefix = if is_active_view { "" } else { " " };
buf.set_string( let name_text = format!("{prefix}{view_name}");
inner.x, let y = inner.y + index as u16;
inner.y + index as u16, buf.set_string(inner.x, y, &name_text, style);
format!("{prefix}{view_name}"),
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);
}
}
} }
} }