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:
11
src/draw.rs
11
src/draw.rs
@ -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) {
|
||||||
|
|||||||
@ -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,9 +80,14 @@ 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) {
|
||||||
|
Ok(formula) => {
|
||||||
app.model.add_formula(formula);
|
app.model.add_formula(formula);
|
||||||
}
|
}
|
||||||
|
Err(e) => {
|
||||||
|
app.status_msg = format!("Formula error: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
684
src/ui/help.rs
684
src/ui/help.rs
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PanelContent for ViewContent {
|
/// 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<'_> {
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user