refactor: introduce draw module
This commit is contained in:
392
src/draw.rs
Normal file
392
src/draw.rs
Normal file
@ -0,0 +1,392 @@
|
|||||||
|
use std::io::{self, Stdout};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use crossterm::{
|
||||||
|
event::{self, Event},
|
||||||
|
execute,
|
||||||
|
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||||
|
};
|
||||||
|
use ratatui::{
|
||||||
|
backend::CrosstermBackend,
|
||||||
|
layout::{Constraint, Direction, Layout, Rect},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
widgets::{Block, Borders, Clear, Paragraph},
|
||||||
|
Frame, Terminal,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::model::Model;
|
||||||
|
use crate::ui::app::{App, AppMode};
|
||||||
|
use crate::ui::category_panel::CategoryPanel;
|
||||||
|
use crate::ui::formula_panel::FormulaPanel;
|
||||||
|
use crate::ui::grid::GridWidget;
|
||||||
|
use crate::ui::help::HelpWidget;
|
||||||
|
use crate::ui::import_wizard_ui::ImportWizardWidget;
|
||||||
|
use crate::ui::tile_bar::TileBar;
|
||||||
|
use crate::ui::view_panel::ViewPanel;
|
||||||
|
|
||||||
|
|
||||||
|
struct TuiContext<'a> {
|
||||||
|
terminal: Terminal<CrosstermBackend<&'a mut Stdout>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> TuiContext<'a> {
|
||||||
|
fn enter(out: &'a mut Stdout) -> Result<Self> {
|
||||||
|
enable_raw_mode()?;
|
||||||
|
execute!(out, EnterAlternateScreen)?;
|
||||||
|
let backend = CrosstermBackend::new(out);
|
||||||
|
let terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
|
Ok(Self { terminal })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Drop for TuiContext<'a> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen);
|
||||||
|
let _ = disable_raw_mode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_tui(
|
||||||
|
model: Model,
|
||||||
|
file_path: Option<PathBuf>,
|
||||||
|
import_value: Option<serde_json::Value>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
let mut tui_context = TuiContext::enter(&mut stdout)?;
|
||||||
|
let mut app = App::new(model, file_path);
|
||||||
|
|
||||||
|
if let Some(json) = import_value {
|
||||||
|
app.start_import_wizard(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tui_context.terminal.draw(|f| draw(f, &app))?;
|
||||||
|
|
||||||
|
if event::poll(Duration::from_millis(100))? {
|
||||||
|
if let Event::Key(key) = event::read()? {
|
||||||
|
app.handle_key(key)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.autosave_if_needed();
|
||||||
|
|
||||||
|
if matches!(app.mode, AppMode::Quit) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Drawing ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn fill_line(left: String, right: &str, width: u16) -> String {
|
||||||
|
let pad = " ".repeat((width as usize).saturating_sub(left.len() + right.len()));
|
||||||
|
format!("{left}{pad}{right}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn centered_popup(area: Rect, width: u16, height: u16) -> Rect {
|
||||||
|
let w = width.min(area.width);
|
||||||
|
let h = height.min(area.height);
|
||||||
|
let x = area.x + area.width.saturating_sub(w) / 2;
|
||||||
|
let y = area.y + area.height.saturating_sub(h) / 2;
|
||||||
|
Rect::new(x, y, w, h)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_popup_frame(f: &mut Frame, popup: Rect, title: &str, border_color: Color) -> Rect {
|
||||||
|
f.render_widget(Clear, popup);
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(border_color))
|
||||||
|
.title(title);
|
||||||
|
let inner = block.inner(popup);
|
||||||
|
f.render_widget(block, popup);
|
||||||
|
inner
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mode_name(mode: &AppMode) -> &'static str {
|
||||||
|
match mode {
|
||||||
|
AppMode::Normal => "NORMAL",
|
||||||
|
AppMode::Editing { .. } => "INSERT",
|
||||||
|
AppMode::FormulaEdit { .. } => "FORMULA",
|
||||||
|
AppMode::FormulaPanel => "FORMULAS",
|
||||||
|
AppMode::CategoryPanel => "CATEGORIES",
|
||||||
|
AppMode::CategoryAdd { .. } => "NEW CATEGORY",
|
||||||
|
AppMode::ItemAdd { .. } => "ADD ITEMS",
|
||||||
|
AppMode::ViewPanel => "VIEWS",
|
||||||
|
AppMode::TileSelect { .. } => "TILES",
|
||||||
|
AppMode::ImportWizard => "IMPORT",
|
||||||
|
AppMode::ExportPrompt { .. } => "EXPORT",
|
||||||
|
AppMode::CommandMode { .. } => "COMMAND",
|
||||||
|
AppMode::Help => "HELP",
|
||||||
|
AppMode::Quit => "QUIT",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mode_style(mode: &AppMode) -> Style {
|
||||||
|
match 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw(f: &mut Frame, app: &App) {
|
||||||
|
let size = f.area();
|
||||||
|
|
||||||
|
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 / command bar
|
||||||
|
])
|
||||||
|
.split(size);
|
||||||
|
|
||||||
|
draw_title(f, main_chunks[0], app);
|
||||||
|
draw_content(f, main_chunks[1], app);
|
||||||
|
draw_tile_bar(f, main_chunks[2], app);
|
||||||
|
draw_bottom_bar(f, main_chunks[3], app);
|
||||||
|
|
||||||
|
// Overlays (rendered last so they appear on top)
|
||||||
|
if matches!(app.mode, AppMode::Help) {
|
||||||
|
f.render_widget(HelpWidget, size);
|
||||||
|
}
|
||||||
|
if matches!(app.mode, AppMode::ImportWizard) {
|
||||||
|
if let Some(wizard) = &app.wizard {
|
||||||
|
f.render_widget(ImportWizardWidget::new(wizard), size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_title(f: &mut Frame, area: Rect, app: &App) {
|
||||||
|
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 line = fill_line(title, right, area.width);
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(line).style(
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Black)
|
||||||
|
.bg(Color::Blue)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_content(f: &mut Frame, area: Rect, app: &App) {
|
||||||
|
let side_open = app.formula_panel_open || app.category_panel_open || app.view_panel_open;
|
||||||
|
|
||||||
|
let grid_area;
|
||||||
|
if side_open {
|
||||||
|
let side_w = 32u16;
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([Constraint::Min(40), Constraint::Length(side_w)])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
grid_area = chunks[0];
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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 += ph;
|
||||||
|
}
|
||||||
|
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 += ph;
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
grid_area = area;
|
||||||
|
}
|
||||||
|
|
||||||
|
f.render_widget(
|
||||||
|
GridWidget::new(&app.model, &app.mode, &app.search_query),
|
||||||
|
grid_area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_tile_bar(f: &mut Frame, area: Rect, app: &App) {
|
||||||
|
f.render_widget(TileBar::new(&app.model, &app.mode), area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_bottom_bar(f: &mut Frame, area: Rect, app: &App) {
|
||||||
|
match app.mode {
|
||||||
|
AppMode::CommandMode { ref buffer } => draw_command_bar(f, area, buffer),
|
||||||
|
_ => draw_status(f, area, app),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_status(f: &mut Frame, area: Rect, app: &App) {
|
||||||
|
let search_part = if app.search_mode {
|
||||||
|
format!(" /{}▌", app.search_query)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
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!(" {}{search_part} {msg}", mode_name(&app.mode));
|
||||||
|
let line = fill_line(left, &view_badge, area.width);
|
||||||
|
|
||||||
|
f.render_widget(Paragraph::new(line).style(mode_style(&app.mode)), area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_command_bar(f: &mut Frame, area: Rect, buffer: &str) {
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(format!(":{buffer}▌"))
|
||||||
|
.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.as_str()
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
let popup = centered_popup(area, 64, 3);
|
||||||
|
let inner = draw_popup_frame(f, popup, " Export CSV — path (Esc cancel) ", Color::Yellow);
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(format!("{buf}▌")).style(Style::default().fg(Color::Green)),
|
||||||
|
inner,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_welcome(f: &mut Frame, area: Rect) {
|
||||||
|
let popup = centered_popup(area, 58, 20);
|
||||||
|
let inner = draw_popup_frame(f, popup, " Welcome to improvise ", Color::Blue);
|
||||||
|
|
||||||
|
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 <file> Import JSON or CSV file",
|
||||||
|
Style::default().fg(Color::Cyan),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
":add-cat <name> Add a category (dimension)",
|
||||||
|
Style::default().fg(Color::Cyan),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
":add-item <cat> <name> Add an item to a category",
|
||||||
|
Style::default().fg(Color::Cyan),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
":formula <cat> <expr> Add a formula, e.g.:",
|
||||||
|
Style::default().fg(Color::Cyan),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
" Profit = Revenue - Cost",
|
||||||
|
Style::default().fg(Color::Green),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
":w <file.improv> Save your model",
|
||||||
|
Style::default().fg(Color::Cyan),
|
||||||
|
),
|
||||||
|
("", Style::default()),
|
||||||
|
(
|
||||||
|
"Navigation",
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Blue)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
("", Style::default()),
|
||||||
|
(
|
||||||
|
"F C V Open panels (Formulas/Categories/Views)",
|
||||||
|
Style::default(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"T Tile-select: pivot rows ↔ cols ↔ page",
|
||||||
|
Style::default(),
|
||||||
|
),
|
||||||
|
("i Enter Edit a cell", Style::default()),
|
||||||
|
(
|
||||||
|
"[ ] Cycle the page-axis filter",
|
||||||
|
Style::default(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"? or :help Full key reference",
|
||||||
|
Style::default(),
|
||||||
|
),
|
||||||
|
(":q Quit", Style::default()),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (i, (text, style)) in lines.iter().enumerate() {
|
||||||
|
if i >= inner.height as usize {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(*text).style(*style),
|
||||||
|
Rect::new(
|
||||||
|
inner.x + 1,
|
||||||
|
inner.y + i as u16,
|
||||||
|
inner.width.saturating_sub(2),
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
388
src/main.rs
388
src/main.rs
@ -5,34 +5,14 @@ mod model;
|
|||||||
mod persistence;
|
mod persistence;
|
||||||
mod ui;
|
mod ui;
|
||||||
mod view;
|
mod view;
|
||||||
|
mod draw;
|
||||||
|
|
||||||
use std::io::{self, Stdout};
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use crossterm::{
|
|
||||||
event::{self, Event},
|
|
||||||
execute,
|
|
||||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
|
||||||
};
|
|
||||||
use ratatui::{
|
|
||||||
backend::CrosstermBackend,
|
|
||||||
layout::{Constraint, Direction, Layout, Rect},
|
|
||||||
style::{Color, Modifier, Style},
|
|
||||||
widgets::{Block, Borders, Clear, Paragraph},
|
|
||||||
Frame, Terminal,
|
|
||||||
};
|
|
||||||
|
|
||||||
use model::Model;
|
use model::Model;
|
||||||
use ui::app::{App, AppMode};
|
use draw::run_tui;
|
||||||
use ui::category_panel::CategoryPanel;
|
|
||||||
use ui::formula_panel::FormulaPanel;
|
|
||||||
use ui::grid::GridWidget;
|
|
||||||
use ui::help::HelpWidget;
|
|
||||||
use ui::import_wizard_ui::ImportWizardWidget;
|
|
||||||
use ui::tile_bar::TileBar;
|
|
||||||
use ui::view_panel::ViewPanel;
|
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
let args: Vec<String> = std::env::args().collect();
|
let args: Vec<String> = std::env::args().collect();
|
||||||
@ -234,367 +214,3 @@ fn get_initial_model(file_path: &Option<PathBuf>) -> Result<Model> {
|
|||||||
Ok(Model::new("New Model"))
|
Ok(Model::new("New Model"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TuiContext<'a> {
|
|
||||||
terminal: Terminal<CrosstermBackend<&'a mut Stdout>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> TuiContext<'a> {
|
|
||||||
fn enter(out: &'a mut Stdout) -> Result<Self> {
|
|
||||||
enable_raw_mode()?;
|
|
||||||
execute!(out, EnterAlternateScreen)?;
|
|
||||||
let backend = CrosstermBackend::new(out);
|
|
||||||
let terminal = Terminal::new(backend)?;
|
|
||||||
|
|
||||||
Ok(Self { terminal })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> Drop for TuiContext<'a> {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen);
|
|
||||||
let _ = disable_raw_mode();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_tui(
|
|
||||||
model: Model,
|
|
||||||
file_path: Option<PathBuf>,
|
|
||||||
import_value: Option<serde_json::Value>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let mut stdout = io::stdout();
|
|
||||||
let mut tui_context = TuiContext::enter(&mut stdout)?;
|
|
||||||
let mut app = App::new(model, file_path);
|
|
||||||
|
|
||||||
if let Some(json) = import_value {
|
|
||||||
app.start_import_wizard(json);
|
|
||||||
}
|
|
||||||
|
|
||||||
loop {
|
|
||||||
tui_context.terminal.draw(|f| draw(f, &app))?;
|
|
||||||
|
|
||||||
if event::poll(Duration::from_millis(100))? {
|
|
||||||
if let Event::Key(key) = event::read()? {
|
|
||||||
app.handle_key(key)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
app.autosave_if_needed();
|
|
||||||
|
|
||||||
if matches!(app.mode, AppMode::Quit) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Drawing ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
fn fill_line(left: String, right: &str, width: u16) -> String {
|
|
||||||
let pad = " ".repeat((width as usize).saturating_sub(left.len() + right.len()));
|
|
||||||
format!("{left}{pad}{right}")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn centered_popup(area: Rect, width: u16, height: u16) -> Rect {
|
|
||||||
let w = width.min(area.width);
|
|
||||||
let h = height.min(area.height);
|
|
||||||
let x = area.x + area.width.saturating_sub(w) / 2;
|
|
||||||
let y = area.y + area.height.saturating_sub(h) / 2;
|
|
||||||
Rect::new(x, y, w, h)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_popup_frame(f: &mut Frame, popup: Rect, title: &str, border_color: Color) -> Rect {
|
|
||||||
f.render_widget(Clear, popup);
|
|
||||||
let block = Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_style(Style::default().fg(border_color))
|
|
||||||
.title(title);
|
|
||||||
let inner = block.inner(popup);
|
|
||||||
f.render_widget(block, popup);
|
|
||||||
inner
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mode_name(mode: &AppMode) -> &'static str {
|
|
||||||
match mode {
|
|
||||||
AppMode::Normal => "NORMAL",
|
|
||||||
AppMode::Editing { .. } => "INSERT",
|
|
||||||
AppMode::FormulaEdit { .. } => "FORMULA",
|
|
||||||
AppMode::FormulaPanel => "FORMULAS",
|
|
||||||
AppMode::CategoryPanel => "CATEGORIES",
|
|
||||||
AppMode::CategoryAdd { .. } => "NEW CATEGORY",
|
|
||||||
AppMode::ItemAdd { .. } => "ADD ITEMS",
|
|
||||||
AppMode::ViewPanel => "VIEWS",
|
|
||||||
AppMode::TileSelect { .. } => "TILES",
|
|
||||||
AppMode::ImportWizard => "IMPORT",
|
|
||||||
AppMode::ExportPrompt { .. } => "EXPORT",
|
|
||||||
AppMode::CommandMode { .. } => "COMMAND",
|
|
||||||
AppMode::Help => "HELP",
|
|
||||||
AppMode::Quit => "QUIT",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mode_style(mode: &AppMode) -> Style {
|
|
||||||
match 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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw(f: &mut Frame, app: &App) {
|
|
||||||
let size = f.area();
|
|
||||||
|
|
||||||
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 / command bar
|
|
||||||
])
|
|
||||||
.split(size);
|
|
||||||
|
|
||||||
draw_title(f, main_chunks[0], app);
|
|
||||||
draw_content(f, main_chunks[1], app);
|
|
||||||
draw_tile_bar(f, main_chunks[2], app);
|
|
||||||
draw_bottom_bar(f, main_chunks[3], app);
|
|
||||||
|
|
||||||
// Overlays (rendered last so they appear on top)
|
|
||||||
if matches!(app.mode, AppMode::Help) {
|
|
||||||
f.render_widget(HelpWidget, size);
|
|
||||||
}
|
|
||||||
if matches!(app.mode, AppMode::ImportWizard) {
|
|
||||||
if let Some(wizard) = &app.wizard {
|
|
||||||
f.render_widget(ImportWizardWidget::new(wizard), size);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_title(f: &mut Frame, area: Rect, app: &App) {
|
|
||||||
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 line = fill_line(title, right, area.width);
|
|
||||||
f.render_widget(
|
|
||||||
Paragraph::new(line).style(
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::Black)
|
|
||||||
.bg(Color::Blue)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
),
|
|
||||||
area,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_content(f: &mut Frame, area: Rect, app: &App) {
|
|
||||||
let side_open = app.formula_panel_open || app.category_panel_open || app.view_panel_open;
|
|
||||||
|
|
||||||
let grid_area;
|
|
||||||
if side_open {
|
|
||||||
let side_w = 32u16;
|
|
||||||
let chunks = Layout::default()
|
|
||||||
.direction(Direction::Horizontal)
|
|
||||||
.constraints([Constraint::Min(40), Constraint::Length(side_w)])
|
|
||||||
.split(area);
|
|
||||||
|
|
||||||
grid_area = chunks[0];
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
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 += ph;
|
|
||||||
}
|
|
||||||
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 += ph;
|
|
||||||
}
|
|
||||||
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 {
|
|
||||||
grid_area = area;
|
|
||||||
}
|
|
||||||
|
|
||||||
f.render_widget(
|
|
||||||
GridWidget::new(&app.model, &app.mode, &app.search_query),
|
|
||||||
grid_area,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_tile_bar(f: &mut Frame, area: Rect, app: &App) {
|
|
||||||
f.render_widget(TileBar::new(&app.model, &app.mode), area);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_bottom_bar(f: &mut Frame, area: Rect, app: &App) {
|
|
||||||
match app.mode {
|
|
||||||
AppMode::CommandMode { ref buffer } => draw_command_bar(f, area, buffer),
|
|
||||||
_ => draw_status(f, area, app),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_status(f: &mut Frame, area: Rect, app: &App) {
|
|
||||||
let search_part = if app.search_mode {
|
|
||||||
format!(" /{}▌", app.search_query)
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
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!(" {}{search_part} {msg}", mode_name(&app.mode));
|
|
||||||
let line = fill_line(left, &view_badge, area.width);
|
|
||||||
|
|
||||||
f.render_widget(Paragraph::new(line).style(mode_style(&app.mode)), area);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_command_bar(f: &mut Frame, area: Rect, buffer: &str) {
|
|
||||||
f.render_widget(
|
|
||||||
Paragraph::new(format!(":{buffer}▌"))
|
|
||||||
.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.as_str()
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
};
|
|
||||||
let popup = centered_popup(area, 64, 3);
|
|
||||||
let inner = draw_popup_frame(f, popup, " Export CSV — path (Esc cancel) ", Color::Yellow);
|
|
||||||
f.render_widget(
|
|
||||||
Paragraph::new(format!("{buf}▌")).style(Style::default().fg(Color::Green)),
|
|
||||||
inner,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_welcome(f: &mut Frame, area: Rect) {
|
|
||||||
let popup = centered_popup(area, 58, 20);
|
|
||||||
let inner = draw_popup_frame(f, popup, " Welcome to improvise ", Color::Blue);
|
|
||||||
|
|
||||||
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 <file> Import JSON or CSV file",
|
|
||||||
Style::default().fg(Color::Cyan),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
":add-cat <name> Add a category (dimension)",
|
|
||||||
Style::default().fg(Color::Cyan),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
":add-item <cat> <name> Add an item to a category",
|
|
||||||
Style::default().fg(Color::Cyan),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
":formula <cat> <expr> Add a formula, e.g.:",
|
|
||||||
Style::default().fg(Color::Cyan),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
" Profit = Revenue - Cost",
|
|
||||||
Style::default().fg(Color::Green),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
":w <file.improv> Save your model",
|
|
||||||
Style::default().fg(Color::Cyan),
|
|
||||||
),
|
|
||||||
("", Style::default()),
|
|
||||||
(
|
|
||||||
"Navigation",
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::Blue)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
),
|
|
||||||
("", Style::default()),
|
|
||||||
(
|
|
||||||
"F C V Open panels (Formulas/Categories/Views)",
|
|
||||||
Style::default(),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"T Tile-select: pivot rows ↔ cols ↔ page",
|
|
||||||
Style::default(),
|
|
||||||
),
|
|
||||||
("i Enter Edit a cell", Style::default()),
|
|
||||||
(
|
|
||||||
"[ ] Cycle the page-axis filter",
|
|
||||||
Style::default(),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"? or :help Full key reference",
|
|
||||||
Style::default(),
|
|
||||||
),
|
|
||||||
(":q Quit", Style::default()),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (i, (text, style)) in lines.iter().enumerate() {
|
|
||||||
if i >= inner.height as usize {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
f.render_widget(
|
|
||||||
Paragraph::new(*text).style(*style),
|
|
||||||
Rect::new(
|
|
||||||
inner.x + 1,
|
|
||||||
inner.y + i as u16,
|
|
||||||
inner.width.saturating_sub(2),
|
|
||||||
1,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user