Initial implementation of Improvise TUI

Multi-dimensional data modeling terminal application with:
- Core data model: categories, items, groups, sparse cell store
- Formula system: recursive-descent parser, named formulas, WHERE clauses
- View system: Row/Column/Page axes, tile-based pivot, page slicing
- JSON import wizard (interactive TUI + headless auto-mode)
- Command layer: all mutations via typed Command enum for headless replay
- TUI: Ratatui grid, tile bar, formula/category/view panels, help overlay
- Persistence: .improv (JSON), .improv.gz (gzip), CSV export, autosave
- Static binary via x86_64-unknown-linux-musl + nix flake devShell
- Headless mode: --cmd '{"op":"..."}' and --script file.jsonl

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ed L
2026-03-20 21:11:14 -07:00
parent 0ba39672d3
commit eae00522e2
35 changed files with 5413 additions and 0 deletions

657
src/ui/app.rs Normal file
View File

@ -0,0 +1,657 @@
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::model::Model;
use crate::model::cell::{CellKey, CellValue};
use crate::formula::parse_formula;
use crate::import::wizard::{ImportWizard, WizardState};
use crate::persistence;
use crate::view::Axis;
#[derive(Debug, Clone, PartialEq)]
pub enum AppMode {
Normal,
Editing { buffer: String },
FormulaEdit { buffer: String },
FormulaPanel,
CategoryPanel,
ViewPanel,
TileSelect { cat_idx: usize },
ImportWizard,
ExportPrompt { buffer: String },
Help,
Quit,
}
pub struct App {
pub model: Model,
pub file_path: Option<PathBuf>,
pub mode: AppMode,
pub status_msg: String,
pub wizard: Option<ImportWizard>,
pub last_autosave: Instant,
/// Input buffer for command-line style input
pub input_buffer: String,
/// Search query
pub search_query: String,
pub search_mode: bool,
pub formula_panel_open: bool,
pub category_panel_open: bool,
pub view_panel_open: bool,
/// Category panel cursor
pub cat_panel_cursor: usize,
/// View panel cursor
pub view_panel_cursor: usize,
/// Formula panel cursor
pub formula_cursor: usize,
pub dirty: bool,
}
impl App {
pub fn new(model: Model, file_path: Option<PathBuf>) -> Self {
Self {
model,
file_path,
mode: AppMode::Normal,
status_msg: String::new(),
wizard: None,
last_autosave: Instant::now(),
input_buffer: String::new(),
search_query: String::new(),
search_mode: false,
formula_panel_open: false,
category_panel_open: false,
view_panel_open: false,
cat_panel_cursor: 0,
view_panel_cursor: 0,
formula_cursor: 0,
dirty: false,
}
}
pub fn handle_key(&mut self, key: KeyEvent) -> Result<()> {
match &self.mode.clone() {
AppMode::Quit => {}
AppMode::Help => {
self.mode = AppMode::Normal;
}
AppMode::ImportWizard => {
self.handle_wizard_key(key)?;
}
AppMode::Editing { buffer } => {
self.handle_edit_key(key)?;
}
AppMode::FormulaEdit { buffer } => {
self.handle_formula_edit_key(key)?;
}
AppMode::FormulaPanel => {
self.handle_formula_panel_key(key)?;
}
AppMode::CategoryPanel => {
self.handle_category_panel_key(key)?;
}
AppMode::ViewPanel => {
self.handle_view_panel_key(key)?;
}
AppMode::TileSelect { cat_idx } => {
self.handle_tile_select_key(key)?;
}
AppMode::ExportPrompt { buffer } => {
self.handle_export_key(key)?;
}
AppMode::Normal => {
self.handle_normal_key(key)?;
}
}
Ok(())
}
fn handle_normal_key(&mut self, key: KeyEvent) -> Result<()> {
if self.search_mode {
return self.handle_search_key(key);
}
match (key.code, key.modifiers) {
(KeyCode::Char('q'), KeyModifiers::CONTROL) => {
self.mode = AppMode::Quit;
}
(KeyCode::F(1), _) => {
self.mode = AppMode::Help;
}
(KeyCode::Char('s'), KeyModifiers::CONTROL) => {
self.save()?;
}
(KeyCode::Char('f'), KeyModifiers::CONTROL) => {
self.formula_panel_open = !self.formula_panel_open;
}
(KeyCode::Char('c'), KeyModifiers::CONTROL) => {
self.category_panel_open = !self.category_panel_open;
}
(KeyCode::Char('v'), KeyModifiers::CONTROL) => {
self.view_panel_open = !self.view_panel_open;
}
(KeyCode::Char('e'), KeyModifiers::CONTROL) => {
self.mode = AppMode::ExportPrompt { buffer: String::new() };
}
// Navigation
(KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::NONE) => {
self.move_selection(-1, 0);
}
(KeyCode::Down, _) | (KeyCode::Char('j'), KeyModifiers::NONE) => {
self.move_selection(1, 0);
}
(KeyCode::Left, _) | (KeyCode::Char('h'), KeyModifiers::NONE) => {
self.move_selection(0, -1);
}
(KeyCode::Right, _) | (KeyCode::Char('l'), KeyModifiers::NONE) => {
self.move_selection(0, 1);
}
(KeyCode::Enter, _) => {
let cell_key = self.selected_cell_key();
let current = cell_key.as_ref().map(|k| {
self.model.get_cell(k).to_string()
}).unwrap_or_default();
self.mode = AppMode::Editing { buffer: current };
}
(KeyCode::Char('/'), _) => {
self.search_mode = true;
self.search_query.clear();
}
// Tab cycles panels
(KeyCode::Tab, _) => {
if self.formula_panel_open {
self.mode = AppMode::FormulaPanel;
} else if self.category_panel_open {
self.mode = AppMode::CategoryPanel;
} else if self.view_panel_open {
self.mode = AppMode::ViewPanel;
}
}
// Tile movement with Ctrl+Arrow
(KeyCode::Left, KeyModifiers::CONTROL) | (KeyCode::Right, KeyModifiers::CONTROL)
| (KeyCode::Up, KeyModifiers::CONTROL) | (KeyCode::Down, KeyModifiers::CONTROL) => {
let cat_names: Vec<String> = self.model.category_names()
.into_iter().map(String::from).collect();
if !cat_names.is_empty() {
self.mode = AppMode::TileSelect { cat_idx: 0 };
}
}
// Page axis navigation with [ ]
(KeyCode::Char('['), _) => {
self.page_prev();
}
(KeyCode::Char(']'), _) => {
self.page_next();
}
// Formula panel shortcut
(KeyCode::Char('F'), KeyModifiers::NONE) => {
self.formula_panel_open = true;
self.mode = AppMode::FormulaPanel;
}
_ => {}
}
Ok(())
}
fn handle_search_key(&mut self, key: KeyEvent) -> Result<()> {
match key.code {
KeyCode::Esc => { self.search_mode = false; }
KeyCode::Enter => { self.search_mode = false; }
KeyCode::Char(c) => { self.search_query.push(c); }
KeyCode::Backspace => { self.search_query.pop(); }
_ => {}
}
Ok(())
}
fn handle_edit_key(&mut self, key: KeyEvent) -> Result<()> {
let buf = if let AppMode::Editing { buffer } = &self.mode {
buffer.clone()
} else { return Ok(()); };
match key.code {
KeyCode::Esc => {
self.mode = AppMode::Normal;
}
KeyCode::Enter => {
// Commit value
if let Some(key) = self.selected_cell_key() {
let value = if buf.is_empty() {
CellValue::Empty
} else if let Ok(n) = buf.parse::<f64>() {
CellValue::Number(n)
} else {
CellValue::Text(buf.clone())
};
self.model.set_cell(key, value);
self.dirty = true;
}
self.mode = AppMode::Normal;
self.move_selection(1, 0);
}
KeyCode::Char(c) => {
if let AppMode::Editing { buffer } = &mut self.mode {
buffer.push(c);
}
}
KeyCode::Backspace => {
if let AppMode::Editing { buffer } = &mut self.mode {
buffer.pop();
}
}
_ => {}
}
Ok(())
}
fn handle_formula_edit_key(&mut self, key: KeyEvent) -> Result<()> {
let buf = if let AppMode::FormulaEdit { buffer } = &self.mode {
buffer.clone()
} else { return Ok(()); };
match key.code {
KeyCode::Esc => { self.mode = AppMode::FormulaPanel; }
KeyCode::Enter => {
// Try to parse and add formula
let first_cat = self.model.category_names().into_iter().next().map(String::from);
if let Some(cat) = first_cat {
match parse_formula(&buf, &cat) {
Ok(formula) => {
self.model.add_formula(formula);
self.status_msg = "Formula added".to_string();
self.dirty = true;
}
Err(e) => {
self.status_msg = format!("Formula error: {e}");
}
}
}
self.mode = AppMode::FormulaPanel;
}
KeyCode::Char(c) => {
if let AppMode::FormulaEdit { buffer } = &mut self.mode {
buffer.push(c);
}
}
KeyCode::Backspace => {
if let AppMode::FormulaEdit { buffer } = &mut self.mode {
buffer.pop();
}
}
_ => {}
}
Ok(())
}
fn handle_formula_panel_key(&mut self, key: KeyEvent) -> Result<()> {
match key.code {
KeyCode::Esc | KeyCode::Tab => { self.mode = AppMode::Normal; }
KeyCode::Char('a') | KeyCode::Char('n') => {
self.mode = AppMode::FormulaEdit { buffer: String::new() };
}
KeyCode::Char('d') | KeyCode::Delete => {
if self.formula_cursor < self.model.formulas.len() {
let target = self.model.formulas[self.formula_cursor].target.clone();
self.model.remove_formula(&target);
if self.formula_cursor > 0 { self.formula_cursor -= 1; }
self.dirty = true;
}
}
KeyCode::Up | KeyCode::Char('k') => {
if self.formula_cursor > 0 { self.formula_cursor -= 1; }
}
KeyCode::Down | KeyCode::Char('j') => {
if self.formula_cursor + 1 < self.model.formulas.len() {
self.formula_cursor += 1;
}
}
_ => {}
}
Ok(())
}
fn handle_category_panel_key(&mut self, key: KeyEvent) -> Result<()> {
let cat_names: Vec<String> = self.model.category_names().into_iter().map(String::from).collect();
match key.code {
KeyCode::Esc | KeyCode::Tab => { self.mode = AppMode::Normal; }
KeyCode::Up | KeyCode::Char('k') => {
if self.cat_panel_cursor > 0 { self.cat_panel_cursor -= 1; }
}
KeyCode::Down | KeyCode::Char('j') => {
if self.cat_panel_cursor + 1 < cat_names.len() {
self.cat_panel_cursor += 1;
}
}
KeyCode::Enter | KeyCode::Char(' ') => {
// Cycle axis for selected category
if let Some(cat_name) = cat_names.get(self.cat_panel_cursor) {
if let Some(view) = self.model.active_view_mut() {
view.cycle_axis(cat_name);
}
}
}
_ => {}
}
Ok(())
}
fn handle_view_panel_key(&mut self, key: KeyEvent) -> Result<()> {
let view_names: Vec<String> = self.model.views.keys().cloned().collect();
match key.code {
KeyCode::Esc | KeyCode::Tab => { self.mode = AppMode::Normal; }
KeyCode::Up | KeyCode::Char('k') => {
if self.view_panel_cursor > 0 { self.view_panel_cursor -= 1; }
}
KeyCode::Down | KeyCode::Char('j') => {
if self.view_panel_cursor + 1 < view_names.len() {
self.view_panel_cursor += 1;
}
}
KeyCode::Enter => {
if let Some(name) = view_names.get(self.view_panel_cursor) {
let _ = self.model.switch_view(name);
self.mode = AppMode::Normal;
}
}
KeyCode::Char('n') => {
let new_name = format!("View {}", self.model.views.len() + 1);
self.model.create_view(&new_name);
let _ = self.model.switch_view(&new_name);
self.dirty = true;
self.mode = AppMode::Normal;
}
KeyCode::Delete | KeyCode::Char('d') => {
if let Some(name) = view_names.get(self.view_panel_cursor) {
let _ = self.model.delete_view(name);
if self.view_panel_cursor > 0 { self.view_panel_cursor -= 1; }
self.dirty = true;
}
}
_ => {}
}
Ok(())
}
fn handle_tile_select_key(&mut self, key: KeyEvent) -> Result<()> {
let cat_names: Vec<String> = self.model.category_names().into_iter().map(String::from).collect();
let cat_idx = if let AppMode::TileSelect { cat_idx } = self.mode { cat_idx } else { 0 };
match key.code {
KeyCode::Esc => { self.mode = AppMode::Normal; }
KeyCode::Left | KeyCode::Char('h') => {
if let AppMode::TileSelect { ref mut cat_idx } = self.mode {
if *cat_idx > 0 { *cat_idx -= 1; }
}
}
KeyCode::Right | KeyCode::Char('l') => {
if let AppMode::TileSelect { ref mut cat_idx } = self.mode {
if *cat_idx + 1 < cat_names.len() { *cat_idx += 1; }
}
}
KeyCode::Enter | KeyCode::Char(' ') => {
if let Some(name) = cat_names.get(cat_idx) {
if let Some(view) = self.model.active_view_mut() {
view.cycle_axis(name);
}
self.dirty = true;
}
self.mode = AppMode::Normal;
}
KeyCode::Char('r') => {
if let Some(name) = cat_names.get(cat_idx) {
if let Some(view) = self.model.active_view_mut() {
view.set_axis(name, Axis::Row);
}
self.dirty = true;
}
self.mode = AppMode::Normal;
}
KeyCode::Char('c') => {
if let Some(name) = cat_names.get(cat_idx) {
if let Some(view) = self.model.active_view_mut() {
view.set_axis(name, Axis::Column);
}
self.dirty = true;
}
self.mode = AppMode::Normal;
}
KeyCode::Char('p') => {
if let Some(name) = cat_names.get(cat_idx) {
if let Some(view) = self.model.active_view_mut() {
view.set_axis(name, Axis::Page);
}
self.dirty = true;
}
self.mode = AppMode::Normal;
}
_ => {}
}
Ok(())
}
fn handle_export_key(&mut self, key: KeyEvent) -> Result<()> {
match key.code {
KeyCode::Esc => { self.mode = AppMode::Normal; }
KeyCode::Enter => {
let buf = if let AppMode::ExportPrompt { buffer } = &self.mode {
buffer.clone()
} else { return Ok(()); };
let path = PathBuf::from(buf);
let view_name = self.model.active_view.clone();
match persistence::export_csv(&self.model, &view_name, &path) {
Ok(_) => { self.status_msg = format!("Exported to {}", path.display()); }
Err(e) => { self.status_msg = format!("Export error: {e}"); }
}
self.mode = AppMode::Normal;
}
KeyCode::Char(c) => {
if let AppMode::ExportPrompt { buffer } = &mut self.mode {
buffer.push(c);
}
}
KeyCode::Backspace => {
if let AppMode::ExportPrompt { buffer } = &mut self.mode {
buffer.pop();
}
}
_ => {}
}
Ok(())
}
fn handle_wizard_key(&mut self, key: KeyEvent) -> Result<()> {
if let Some(wizard) = &mut self.wizard {
match &wizard.state.clone() {
WizardState::Preview => {
match key.code {
KeyCode::Enter | KeyCode::Char(' ') => wizard.advance(),
KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; }
_ => {}
}
}
WizardState::SelectArrayPath => {
match key.code {
KeyCode::Up | KeyCode::Char('k') => wizard.move_cursor(-1),
KeyCode::Down | KeyCode::Char('j') => wizard.move_cursor(1),
KeyCode::Enter => wizard.confirm_path(),
KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; }
_ => {}
}
}
WizardState::ReviewProposals => {
match key.code {
KeyCode::Up | KeyCode::Char('k') => wizard.move_cursor(-1),
KeyCode::Down | KeyCode::Char('j') => wizard.move_cursor(1),
KeyCode::Char(' ') => wizard.toggle_proposal(),
KeyCode::Char('c') => wizard.cycle_proposal_kind(),
KeyCode::Enter => wizard.advance(),
KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; }
_ => {}
}
}
WizardState::NameModel => {
match key.code {
KeyCode::Char(c) => wizard.push_name_char(c),
KeyCode::Backspace => wizard.pop_name_char(),
KeyCode::Enter => {
let result = wizard.build_model();
match result {
Ok(model) => {
self.model = model;
self.dirty = true;
self.status_msg = "Import successful!".to_string();
self.mode = AppMode::Normal;
self.wizard = None;
}
Err(e) => {
if let Some(w) = &mut self.wizard {
w.message = Some(format!("Error: {e}"));
}
}
}
}
KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; }
_ => {}
}
}
WizardState::Done => {
self.mode = AppMode::Normal;
self.wizard = None;
}
}
}
Ok(())
}
fn move_selection(&mut self, dr: i32, dc: i32) {
if let Some(view) = self.model.active_view_mut() {
let (r, c) = view.selected;
let new_r = (r as i32 + dr).max(0) as usize;
let new_c = (c as i32 + dc).max(0) as usize;
view.selected = (new_r, new_c);
}
}
fn page_next(&mut self) {
let page_cats: Vec<String> = self.model.active_view()
.map(|v| v.categories_on(crate::view::Axis::Page).into_iter().map(String::from).collect())
.unwrap_or_default();
for cat_name in &page_cats {
let items: Vec<String> = self.model.category(cat_name)
.map(|c| c.ordered_item_names().into_iter().map(String::from).collect())
.unwrap_or_default();
if items.is_empty() { continue; }
let current = self.model.active_view()
.and_then(|v| v.page_selection(cat_name))
.map(String::from)
.unwrap_or_else(|| items[0].clone());
let idx = items.iter().position(|i| i == &current).unwrap_or(0);
let next_idx = (idx + 1).min(items.len() - 1);
if let Some(view) = self.model.active_view_mut() {
view.set_page_selection(cat_name, &items[next_idx]);
}
break;
}
}
fn page_prev(&mut self) {
let page_cats: Vec<String> = self.model.active_view()
.map(|v| v.categories_on(crate::view::Axis::Page).into_iter().map(String::from).collect())
.unwrap_or_default();
for cat_name in &page_cats {
let items: Vec<String> = self.model.category(cat_name)
.map(|c| c.ordered_item_names().into_iter().map(String::from).collect())
.unwrap_or_default();
if items.is_empty() { continue; }
let current = self.model.active_view()
.and_then(|v| v.page_selection(cat_name))
.map(String::from)
.unwrap_or_else(|| items[0].clone());
let idx = items.iter().position(|i| i == &current).unwrap_or(0);
let prev_idx = idx.saturating_sub(1);
if let Some(view) = self.model.active_view_mut() {
view.set_page_selection(cat_name, &items[prev_idx]);
}
break;
}
}
pub fn selected_cell_key(&self) -> Option<CellKey> {
let view = self.model.active_view()?;
let row_cats: Vec<&str> = view.categories_on(Axis::Row);
let col_cats: Vec<&str> = view.categories_on(Axis::Column);
let page_cats: Vec<&str> = view.categories_on(Axis::Page);
let (sel_row, sel_col) = view.selected;
let mut coords = vec![];
// Page coords
for cat_name in &page_cats {
let items = self.model.category(cat_name)
.map(|c| c.ordered_item_names().into_iter().map(String::from).collect::<Vec<_>>())
.unwrap_or_default();
let sel = view.page_selection(cat_name)
.map(String::from)
.or_else(|| items.first().cloned())?;
coords.push((cat_name.to_string(), sel));
}
// Row coords
for (i, cat_name) in row_cats.iter().enumerate() {
let items: Vec<String> = self.model.category(cat_name)
.map(|c| c.ordered_item_names().into_iter()
.filter(|item| !view.is_hidden(cat_name, item))
.map(String::from).collect())
.unwrap_or_default();
let item = items.get(sel_row)?.clone();
coords.push((cat_name.to_string(), item));
}
// Col coords
for (i, cat_name) in col_cats.iter().enumerate() {
let items: Vec<String> = self.model.category(cat_name)
.map(|c| c.ordered_item_names().into_iter()
.filter(|item| !view.is_hidden(cat_name, item))
.map(String::from).collect())
.unwrap_or_default();
let item = items.get(sel_col)?.clone();
coords.push((cat_name.to_string(), item));
}
Some(CellKey::new(coords))
}
pub fn save(&mut self) -> Result<()> {
if let Some(path) = &self.file_path.clone() {
persistence::save(&self.model, path)?;
self.dirty = false;
self.status_msg = format!("Saved to {}", path.display());
} else {
self.status_msg = "No file path set. Use Ctrl+E to export.".to_string();
}
Ok(())
}
pub fn autosave_if_needed(&mut self) {
if self.dirty && self.last_autosave.elapsed() > Duration::from_secs(30) {
if let Some(path) = &self.file_path.clone() {
let autosave_path = persistence::autosave_path(path);
let _ = persistence::save(&self.model, &autosave_path);
self.last_autosave = Instant::now();
}
}
}
pub fn start_import_wizard(&mut self, json: serde_json::Value) {
self.wizard = Some(ImportWizard::new(json));
self.mode = AppMode::ImportWizard;
}
}

98
src/ui/category_panel.rs Normal file
View File

@ -0,0 +1,98 @@
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
widgets::{Block, Borders, Widget},
};
use crate::model::Model;
use crate::view::Axis;
use crate::ui::app::AppMode;
pub struct CategoryPanel<'a> {
pub model: &'a Model,
pub mode: &'a AppMode,
pub cursor: usize,
}
impl<'a> CategoryPanel<'a> {
pub fn new(model: &'a Model, mode: &'a AppMode, cursor: usize) -> Self {
Self { model, mode, cursor }
}
}
impl<'a> Widget for CategoryPanel<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
let is_active = matches!(self.mode, AppMode::CategoryPanel);
let border_style = if is_active {
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(Color::DarkGray)
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(" Categories [Enter] cycle axis ");
let inner = block.inner(area);
block.render(area, buf);
let view = match self.model.active_view() {
Some(v) => v,
None => return,
};
let cat_names: Vec<&str> = self.model.category_names();
if cat_names.is_empty() {
buf.set_string(inner.x, inner.y,
"(no categories)",
Style::default().fg(Color::DarkGray));
return;
}
for (i, cat_name) in cat_names.iter().enumerate() {
if inner.y + i as u16 >= inner.y + inner.height { break; }
let axis = view.axis_of(cat_name);
let axis_str = match axis {
Axis::Row => "Row ↕",
Axis::Column => "Col ↔",
Axis::Page => "Page ☰",
Axis::Unassigned => "none",
};
let axis_color = match axis {
Axis::Row => Color::Green,
Axis::Column => Color::Blue,
Axis::Page => Color::Magenta,
Axis::Unassigned => Color::DarkGray,
};
let cat = self.model.category(cat_name);
let item_count = cat.map(|c| c.items.len()).unwrap_or(0);
let is_selected = i == self.cursor && is_active;
let base_style = if is_selected {
Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
// Background fill for selected row
if is_selected {
let fill = " ".repeat(inner.width as usize);
buf.set_string(inner.x, inner.y + i as u16, &fill, base_style);
}
let name_part = format!(" {cat_name} ({item_count})");
let axis_part = format!(" [{axis_str}]");
let available = inner.width as usize;
buf.set_string(inner.x, inner.y + i as u16, &name_part, base_style);
if name_part.len() + axis_part.len() < available {
let axis_x = inner.x + name_part.len() as u16;
buf.set_string(axis_x, inner.y + i as u16, &axis_part,
if is_selected { base_style } else { Style::default().fg(axis_color) });
}
}
}
}

76
src/ui/formula_panel.rs Normal file
View File

@ -0,0 +1,76 @@
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
widgets::{Block, Borders, Widget},
};
use crate::model::Model;
use crate::ui::app::AppMode;
pub struct FormulaPanel<'a> {
pub model: &'a Model,
pub mode: &'a AppMode,
pub cursor: usize,
}
impl<'a> FormulaPanel<'a> {
pub fn new(model: &'a Model, mode: &'a AppMode, cursor: usize) -> Self {
Self { model, mode, cursor }
}
}
impl<'a> Widget for FormulaPanel<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
let is_active = matches!(self.mode, AppMode::FormulaPanel | AppMode::FormulaEdit { .. });
let border_style = if is_active {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::DarkGray)
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(" Formulas [n]ew [d]elete ");
let inner = block.inner(area);
block.render(area, buf);
let formulas = &self.model.formulas;
if formulas.is_empty() {
buf.set_string(inner.x, inner.y,
"(no formulas — press 'n' to add)",
Style::default().fg(Color::DarkGray));
return;
}
for (i, formula) in formulas.iter().enumerate() {
if inner.y + i as u16 >= inner.y + inner.height { break; }
let is_selected = i == self.cursor && is_active;
let style = if is_selected {
Style::default().fg(Color::Black).bg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Green)
};
let text = format!(" {} = {:?}", formula.target, formula.raw);
let truncated = if text.len() > inner.width as usize {
format!("{}", &text[..inner.width as usize - 1])
} else {
text
};
buf.set_string(inner.x, inner.y + i as u16, &truncated, style);
}
// Formula edit mode
if let AppMode::FormulaEdit { buffer } = self.mode {
let y = inner.y + inner.height.saturating_sub(2);
buf.set_string(inner.x, y,
"┄ Enter formula (Name = expr): ",
Style::default().fg(Color::Yellow));
let y = y + 1;
let prompt = format!("> {buffer}");
buf.set_string(inner.x, y, &prompt, Style::default().fg(Color::Green));
}
}
}

321
src/ui/grid.rs Normal file
View File

@ -0,0 +1,321 @@
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Widget},
};
use unicode_width::UnicodeWidthStr;
use crate::model::Model;
use crate::model::cell::{CellKey, CellValue};
use crate::view::Axis;
use crate::ui::app::AppMode;
const ROW_HEADER_WIDTH: u16 = 16;
const COL_WIDTH: u16 = 10;
const MIN_COL_WIDTH: u16 = 6;
pub struct GridWidget<'a> {
pub model: &'a Model,
pub mode: &'a AppMode,
pub search_query: &'a str,
}
impl<'a> GridWidget<'a> {
pub fn new(model: &'a Model, mode: &'a AppMode, search_query: &'a str) -> Self {
Self { model, mode, search_query }
}
fn render_grid(&self, area: Rect, buf: &mut Buffer) {
let view = match self.model.active_view() {
Some(v) => v,
None => return,
};
let row_cats: Vec<&str> = view.categories_on(Axis::Row);
let col_cats: Vec<&str> = view.categories_on(Axis::Column);
let page_cats: Vec<&str> = view.categories_on(Axis::Page);
// Gather row items
let row_items: Vec<Vec<String>> = if row_cats.is_empty() {
vec![vec![]]
} else {
let cat_name = row_cats[0];
let cat = match self.model.category(cat_name) {
Some(c) => c,
None => return,
};
cat.ordered_item_names().into_iter()
.filter(|item| !view.is_hidden(cat_name, item))
.map(|item| vec![item.to_string()])
.collect()
};
// Gather col items
let col_items: Vec<Vec<String>> = if col_cats.is_empty() {
vec![vec![]]
} else {
let cat_name = col_cats[0];
let cat = match self.model.category(cat_name) {
Some(c) => c,
None => return,
};
cat.ordered_item_names().into_iter()
.filter(|item| !view.is_hidden(cat_name, item))
.map(|item| vec![item.to_string()])
.collect()
};
// Page filter coords
let page_coords: Vec<(String, String)> = page_cats.iter().map(|cat_name| {
let items: Vec<String> = self.model.category(cat_name)
.map(|c| c.ordered_item_names().into_iter().map(String::from).collect())
.unwrap_or_default();
let sel = view.page_selection(cat_name)
.map(String::from)
.or_else(|| items.first().cloned())
.unwrap_or_default();
(cat_name.to_string(), sel)
}).collect();
let (sel_row, sel_col) = view.selected;
let row_offset = view.row_offset;
let col_offset = view.col_offset;
// Available cols
let available_cols = ((area.width.saturating_sub(ROW_HEADER_WIDTH)) / COL_WIDTH) as usize;
let visible_col_items: Vec<_> = col_items.iter()
.skip(col_offset)
.take(available_cols.max(1))
.collect();
let available_rows = area.height.saturating_sub(2) as usize; // header + border
let visible_row_items: Vec<_> = row_items.iter()
.skip(row_offset)
.take(available_rows.max(1))
.collect();
let mut y = area.y;
// Column headers
let header_style = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD);
let row_header_col = format!("{:<width$}", "", width = ROW_HEADER_WIDTH as usize);
buf.set_string(area.x, y, &row_header_col, Style::default());
let mut x = area.x + ROW_HEADER_WIDTH;
for (ci, col_item) in visible_col_items.iter().enumerate() {
let abs_ci = ci + col_offset;
let label = col_item.join("/");
let styled = if abs_ci == sel_col {
header_style.add_modifier(Modifier::UNDERLINED)
} else {
header_style
};
let truncated = truncate(&label, COL_WIDTH as usize);
buf.set_string(x, y, format!("{:>width$}", truncated, width = COL_WIDTH as usize), styled);
x += COL_WIDTH;
if x >= area.x + area.width { break; }
}
y += 1;
// Separator
let sep = "".repeat(area.width as usize);
buf.set_string(area.x, y, &sep, Style::default().fg(Color::DarkGray));
y += 1;
// Data rows
for (ri, row_item) in visible_row_items.iter().enumerate() {
let abs_ri = ri + row_offset;
if y >= area.y + area.height { break; }
let row_label = row_item.join("/");
let row_style = if abs_ri == sel_row {
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let row_header_str = truncate(&row_label, ROW_HEADER_WIDTH as usize - 1);
buf.set_string(area.x, y,
format!("{:<width$}", row_header_str, width = ROW_HEADER_WIDTH as usize),
row_style);
let mut x = area.x + ROW_HEADER_WIDTH;
for (ci, col_item) in visible_col_items.iter().enumerate() {
let abs_ci = ci + col_offset;
if x >= area.x + area.width { break; }
let mut coords = page_coords.clone();
for (cat, item) in row_cats.iter().zip(row_item.iter()) {
coords.push((cat.to_string(), item.clone()));
}
for (cat, item) in col_cats.iter().zip(col_item.iter()) {
coords.push((cat.to_string(), item.clone()));
}
let key = CellKey::new(coords);
let value = self.model.evaluate(&key);
let cell_str = format_value(&value);
let is_selected = abs_ri == sel_row && abs_ci == sel_col;
let is_search_match = !self.search_query.is_empty()
&& cell_str.to_lowercase().contains(&self.search_query.to_lowercase());
let cell_style = if is_selected {
Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD)
} else if is_search_match {
Style::default().fg(Color::Black).bg(Color::Yellow)
} else if matches!(value, CellValue::Empty) {
Style::default().fg(Color::DarkGray)
} else {
Style::default()
};
let formatted = format!("{:>width$}", truncate(&cell_str, COL_WIDTH as usize), width = COL_WIDTH as usize);
buf.set_string(x, y, formatted, cell_style);
x += COL_WIDTH;
}
// Edit indicator
if matches!(self.mode, AppMode::Editing { .. }) && abs_ri == sel_row {
if let AppMode::Editing { buffer } = self.mode {
let edit_x = area.x + ROW_HEADER_WIDTH + (sel_col.saturating_sub(col_offset)) as u16 * COL_WIDTH;
let edit_str = format!("{:<width$}", buffer, width = COL_WIDTH as usize);
buf.set_string(edit_x, y,
truncate(&edit_str, COL_WIDTH as usize),
Style::default().fg(Color::Green).add_modifier(Modifier::UNDERLINED));
}
}
y += 1;
}
// Total row
if !col_items.is_empty() && !row_items.is_empty() {
if y < area.y + area.height {
let sep = "".repeat(area.width as usize);
buf.set_string(area.x, y, &sep, Style::default().fg(Color::DarkGray));
y += 1;
}
if y < area.y + area.height {
buf.set_string(area.x, y,
format!("{:<width$}", "Total", width = ROW_HEADER_WIDTH as usize),
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
let mut x = area.x + ROW_HEADER_WIDTH;
for (ci, col_item) in visible_col_items.iter().enumerate() {
if x >= area.x + area.width { break; }
let mut coords = page_coords.clone();
for (cat, item) in col_cats.iter().zip(col_item.iter()) {
coords.push((cat.to_string(), item.clone()));
}
let total: f64 = row_items.iter().map(|ri| {
let mut c = coords.clone();
for (cat, item) in row_cats.iter().zip(ri.iter()) {
c.push((cat.to_string(), item.clone()));
}
let key = CellKey::new(c);
self.model.evaluate(&key).as_f64().unwrap_or(0.0)
}).sum();
let total_str = format_f64(total);
buf.set_string(x, y,
format!("{:>width$}", truncate(&total_str, COL_WIDTH as usize), width = COL_WIDTH as usize),
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
x += COL_WIDTH;
}
}
}
}
}
impl<'a> Widget for GridWidget<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
let view_name = self.model.active_view
.clone();
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" View: {} ", view_name));
let inner = block.inner(area);
block.render(area, buf);
// Page axis bar
let view = self.model.active_view();
if let Some(view) = view {
let page_cats: Vec<&str> = view.categories_on(Axis::Page);
if !page_cats.is_empty() && inner.height > 0 {
let page_info: Vec<String> = page_cats.iter().map(|cat_name| {
let items: Vec<String> = self.model.category(cat_name)
.map(|c| c.ordered_item_names().into_iter().map(String::from).collect())
.unwrap_or_default();
let sel = view.page_selection(cat_name)
.map(String::from)
.or_else(|| items.first().cloned())
.unwrap_or_else(|| "(none)".to_string());
format!("{cat_name} = {sel}")
}).collect();
let page_str = format!(" [{}] ", page_info.join(" | "));
buf.set_string(inner.x, inner.y,
&page_str,
Style::default().fg(Color::Magenta));
let grid_area = Rect {
y: inner.y + 1,
height: inner.height.saturating_sub(1),
..inner
};
self.render_grid(grid_area, buf);
} else {
self.render_grid(inner, buf);
}
}
}
}
fn format_value(v: &CellValue) -> String {
match v {
CellValue::Number(n) => format_f64(*n),
CellValue::Text(s) => s.clone(),
CellValue::Empty => String::new(),
}
}
fn format_f64(n: f64) -> String {
if n == 0.0 {
return "0".to_string();
}
if n.fract() == 0.0 && n.abs() < 1e12 {
// Integer with comma formatting
let i = n as i64;
let s = i.to_string();
let is_neg = s.starts_with('-');
let digits = if is_neg { &s[1..] } else { &s[..] };
let mut result = String::new();
for (idx, c) in digits.chars().rev().enumerate() {
if idx > 0 && idx % 3 == 0 { result.push(','); }
result.push(c);
}
if is_neg { result.push('-'); }
result.chars().rev().collect()
} else {
format!("{n:.2}")
}
}
fn truncate(s: &str, max_width: usize) -> String {
let w = s.width();
if w <= max_width {
s.to_string()
} else if max_width > 1 {
let mut result = String::new();
let mut width = 0;
for c in s.chars() {
let cw = unicode_width::UnicodeWidthChar::width(c).unwrap_or(1);
if width + cw + 1 > max_width { break; }
result.push(c);
width += cw;
}
result.push('…');
result
} else {
s.chars().take(max_width).collect()
}
}

77
src/ui/help.rs Normal file
View File

@ -0,0 +1,77 @@
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
widgets::{Block, Borders, Clear, Widget},
};
pub struct HelpWidget;
impl Widget for HelpWidget {
fn render(self, area: Rect, buf: &mut Buffer) {
// Center popup
let popup_w = 60u16.min(area.width);
let popup_h = 30u16.min(area.height);
let x = area.x + area.width.saturating_sub(popup_w) / 2;
let y = area.y + area.height.saturating_sub(popup_h) / 2;
let popup_area = Rect::new(x, y, popup_w, popup_h);
Clear.render(popup_area, buf);
let block = Block::default()
.borders(Borders::ALL)
.title(" Help — Improvise ")
.border_style(Style::default().fg(Color::Yellow));
let inner = block.inner(popup_area);
block.render(popup_area, buf);
let help_text = [
("Navigation", ""),
(" ↑/↓/←/→ or hjkl", "Move cursor"),
(" Enter", "Edit selected cell"),
(" /", "Search in grid"),
(" [ / ]", "Prev/next page item"),
("", ""),
("Panels", ""),
(" Ctrl+F", "Toggle formula panel"),
(" Ctrl+C", "Toggle category panel"),
(" Ctrl+V", "Toggle view panel"),
(" Tab", "Focus next open panel"),
("", ""),
("Tiles / Pivot", ""),
(" Ctrl+Arrow", "Enter tile select mode"),
(" Enter/Space", "Cycle axis (Row→Col→Page)"),
(" r / c / p", "Set axis to Row/Col/Page"),
("", ""),
("File", ""),
(" Ctrl+S", "Save model"),
(" Ctrl+E", "Export CSV"),
("", ""),
("Headless / Batch", ""),
(" --cmd '{...}'", "Run a single JSON command"),
(" --script file", "Run commands from file"),
("", ""),
(" F1", "This help"),
(" Ctrl+Q", "Quit"),
("", ""),
(" Any key to close", ""),
];
for (i, (key, desc)) in help_text.iter().enumerate() {
if i >= inner.height as usize { break; }
let y = inner.y + i as u16;
if key.is_empty() {
continue;
}
if desc.is_empty() {
buf.set_string(inner.x, y, key, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
} else {
buf.set_string(inner.x, y, key, Style::default().fg(Color::Cyan));
let desc_x = inner.x + 26;
if desc_x < inner.x + inner.width {
buf.set_string(desc_x, y, desc, Style::default());
}
}
}
}
}

147
src/ui/import_wizard_ui.rs Normal file
View File

@ -0,0 +1,147 @@
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
widgets::{Block, Borders, Clear, Widget},
};
use crate::import::wizard::{ImportWizard, WizardState};
use crate::import::analyzer::FieldKind;
pub struct ImportWizardWidget<'a> {
pub wizard: &'a ImportWizard,
}
impl<'a> ImportWizardWidget<'a> {
pub fn new(wizard: &'a ImportWizard) -> Self {
Self { wizard }
}
}
impl<'a> Widget for ImportWizardWidget<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
let popup_w = area.width.min(80);
let popup_h = area.height.min(30);
let x = area.x + area.width.saturating_sub(popup_w) / 2;
let y = area.y + area.height.saturating_sub(popup_h) / 2;
let popup_area = Rect::new(x, y, popup_w, popup_h);
Clear.render(popup_area, buf);
let title = match self.wizard.state {
WizardState::Preview => " Import Wizard — Step 1: Preview ",
WizardState::SelectArrayPath => " Import Wizard — Step 2: Select Array ",
WizardState::ReviewProposals => " Import Wizard — Step 3: Review Fields ",
WizardState::NameModel => " Import Wizard — Step 4: Name Model ",
WizardState::Done => " Import Wizard — Done ",
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta))
.title(title);
let inner = block.inner(popup_area);
block.render(popup_area, buf);
let mut y = inner.y;
let x = inner.x;
let w = inner.width as usize;
match &self.wizard.state {
WizardState::Preview => {
let summary = self.wizard.preview_summary();
buf.set_string(x, y, truncate(&summary, w), Style::default());
y += 2;
buf.set_string(x, y,
"Press Enter to continue…",
Style::default().fg(Color::Yellow));
}
WizardState::SelectArrayPath => {
buf.set_string(x, y,
"Select the path containing records:",
Style::default().fg(Color::Yellow));
y += 1;
for (i, path) in self.wizard.array_paths.iter().enumerate() {
if y >= inner.y + inner.height { break; }
let is_sel = i == self.wizard.cursor;
let style = if is_sel {
Style::default().fg(Color::Black).bg(Color::Magenta).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let label = format!(" {}", if path.is_empty() { "(root)" } else { path });
buf.set_string(x, y, truncate(&label, w), style);
y += 1;
}
y += 1;
buf.set_string(x, y, "↑↓ select Enter confirm", Style::default().fg(Color::DarkGray));
}
WizardState::ReviewProposals => {
buf.set_string(x, y,
"Review field proposals (Space toggle, c cycle kind):",
Style::default().fg(Color::Yellow));
y += 1;
let header = format!(" {:<20} {:<22} {:<6}", "Field", "Kind", "Accept");
buf.set_string(x, y, truncate(&header, w), Style::default().fg(Color::Gray).add_modifier(Modifier::UNDERLINED));
y += 1;
for (i, proposal) in self.wizard.proposals.iter().enumerate() {
if y >= inner.y + inner.height - 2 { break; }
let is_sel = i == self.wizard.cursor;
let kind_color = match proposal.kind {
FieldKind::Category => Color::Green,
FieldKind::Measure => Color::Cyan,
FieldKind::TimeCategory => Color::Magenta,
FieldKind::Label => Color::DarkGray,
};
let accept_str = if proposal.accepted { "[✓]" } else { "[ ]" };
let row = format!(" {:<20} {:<22} {}",
truncate(&proposal.field, 20),
truncate(proposal.kind_label(), 22),
accept_str);
let style = if is_sel {
Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD)
} else if proposal.accepted {
Style::default().fg(kind_color)
} else {
Style::default().fg(Color::DarkGray)
};
buf.set_string(x, y, truncate(&row, w), style);
y += 1;
}
let hint_y = inner.y + inner.height - 1;
buf.set_string(x, hint_y, "Enter: next Space: toggle c: cycle kind Esc: cancel",
Style::default().fg(Color::DarkGray));
}
WizardState::NameModel => {
buf.set_string(x, y, "Model name:", Style::default().fg(Color::Yellow));
y += 1;
let name_str = format!("> {}", self.wizard.model_name);
buf.set_string(x, y, truncate(&name_str, w),
Style::default().fg(Color::Green));
y += 2;
buf.set_string(x, y, "Enter to import, Esc to cancel",
Style::default().fg(Color::DarkGray));
if let Some(msg) = &self.wizard.message {
let msg_y = inner.y + inner.height - 1;
buf.set_string(x, msg_y, truncate(msg, w),
Style::default().fg(Color::Red));
}
}
WizardState::Done => {
buf.set_string(x, y, "Import complete!", Style::default().fg(Color::Green));
}
}
}
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max { s.to_string() }
else if max > 1 { format!("{}", &s[..max-1]) }
else { s[..max].to_string() }
}

10
src/ui/mod.rs Normal file
View File

@ -0,0 +1,10 @@
pub mod app;
pub mod grid;
pub mod formula_panel;
pub mod category_panel;
pub mod view_panel;
pub mod tile_bar;
pub mod import_wizard_ui;
pub mod help;
pub use app::{App, AppMode};

82
src/ui/tile_bar.rs Normal file
View File

@ -0,0 +1,82 @@
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
widgets::Widget,
};
use crate::model::Model;
use crate::view::Axis;
use crate::ui::app::AppMode;
pub struct TileBar<'a> {
pub model: &'a Model,
pub mode: &'a AppMode,
}
impl<'a> TileBar<'a> {
pub fn new(model: &'a Model, mode: &'a AppMode) -> Self {
Self { model, mode }
}
}
impl<'a> Widget for TileBar<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
let view = match self.model.active_view() {
Some(v) => v,
None => return,
};
let selected_cat_idx = if let AppMode::TileSelect { cat_idx } = self.mode {
Some(*cat_idx)
} else {
None
};
let mut x = area.x + 1;
buf.set_string(area.x, area.y, " Tiles: ", Style::default().fg(Color::Gray));
x += 8;
let cat_names: Vec<&str> = self.model.category_names();
for (i, cat_name) in cat_names.iter().enumerate() {
let axis = view.axis_of(cat_name);
let axis_symbol = match axis {
Axis::Row => "",
Axis::Column => "",
Axis::Page => "",
Axis::Unassigned => "",
};
let label = format!(" [{cat_name} {axis_symbol}] ");
let is_selected = selected_cat_idx == Some(i);
let style = if is_selected {
Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD)
} else {
match axis {
Axis::Row => Style::default().fg(Color::Green),
Axis::Column => Style::default().fg(Color::Blue),
Axis::Page => Style::default().fg(Color::Magenta),
Axis::Unassigned => Style::default().fg(Color::DarkGray),
}
};
if x + label.len() as u16 > area.x + area.width { break; }
buf.set_string(x, area.y, &label, style);
x += label.len() as u16;
}
// Hint
if matches!(self.mode, AppMode::TileSelect { .. }) {
let hint = " [Enter] cycle axis [r/c/p] set axis [←→] select [Esc] cancel";
if x + hint.len() as u16 <= area.x + area.width {
buf.set_string(x, area.y, hint, Style::default().fg(Color::DarkGray));
}
} else {
let hint = " Ctrl+↑↓←→ to move tiles";
if x + hint.len() as u16 <= area.x + area.width {
buf.set_string(x, area.y, hint, Style::default().fg(Color::DarkGray));
}
}
}
}

62
src/ui/view_panel.rs Normal file
View File

@ -0,0 +1,62 @@
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
widgets::{Block, Borders, Widget},
};
use crate::model::Model;
use crate::ui::app::AppMode;
pub struct ViewPanel<'a> {
pub model: &'a Model,
pub mode: &'a AppMode,
pub cursor: usize,
}
impl<'a> ViewPanel<'a> {
pub fn new(model: &'a Model, mode: &'a AppMode, cursor: usize) -> Self {
Self { model, mode, cursor }
}
}
impl<'a> Widget for ViewPanel<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
let is_active = matches!(self.mode, AppMode::ViewPanel);
let border_style = if is_active {
Style::default().fg(Color::Blue)
} else {
Style::default().fg(Color::DarkGray)
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(" Views [Enter] switch [n]ew [d]elete ");
let inner = block.inner(area);
block.render(area, buf);
let view_names: Vec<&str> = self.model.views.keys().map(|s| s.as_str()).collect();
let active = &self.model.active_view;
for (i, view_name) in view_names.iter().enumerate() {
if inner.y + i as u16 >= inner.y + inner.height { break; }
let is_selected = i == self.cursor && is_active;
let is_active_view = *view_name == active.as_str();
let style = if is_selected {
Style::default().fg(Color::Black).bg(Color::Blue).add_modifier(Modifier::BOLD)
} else if is_active_view {
Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let prefix = if is_active_view { "" } else { " " };
buf.set_string(inner.x, inner.y + i as u16,
format!("{prefix}{view_name}"),
style);
}
}
}