refactor(workspace): extract improvise-command crate (improvise-3mm)
Command layer + semantic App state move below the TUI: command/ (cmd,
keymap, parse, key), ui/{app,effect,cat_tree}.rs, and the pure viewport
helpers now live in crates/improvise-command, which depends on core +
formula + io and provably not on ratatui/crossterm (cargo tree clean).
The root crate remains the TUI binary and re-exports improvise_command
under the old crate::command / crate::ui::{app,effect,cat_tree} paths,
so widgets, draw.rs, and main.rs are unchanged. App moves whole (per
the improvise-ltq design note) rather than splitting an AppState out —
its fields are all command-safe after the key/color decoupling.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Generated
+14
@@ -793,6 +793,7 @@ dependencies = [
|
||||
"dirs",
|
||||
"enum_dispatch",
|
||||
"flate2",
|
||||
"improvise-command",
|
||||
"improvise-core",
|
||||
"improvise-formula",
|
||||
"improvise-io",
|
||||
@@ -809,6 +810,19 @@ dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "improvise-command"
|
||||
version = "0.1.0-rc2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"improvise-core",
|
||||
"improvise-formula",
|
||||
"improvise-io",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "improvise-core"
|
||||
version = "0.1.0-rc2"
|
||||
|
||||
+2
-1
@@ -1,5 +1,5 @@
|
||||
[workspace]
|
||||
members = [".", "crates/improvise-formula", "crates/improvise-core", "crates/improvise-io"]
|
||||
members = [".", "crates/improvise-formula", "crates/improvise-core", "crates/improvise-io", "crates/improvise-command"]
|
||||
|
||||
[package]
|
||||
name = "improvise"
|
||||
@@ -21,6 +21,7 @@ path = "src/main.rs"
|
||||
improvise-core = { path = "crates/improvise-core" }
|
||||
improvise-formula = { path = "crates/improvise-formula" }
|
||||
improvise-io = { path = "crates/improvise-io" }
|
||||
improvise-command = { path = "crates/improvise-command" }
|
||||
ratatui = "0.30"
|
||||
crossterm = "0.29"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
+12
-6
@@ -6,8 +6,9 @@ Apache-2.0. Root binary+lib `improvise`; sub-crates under `crates/`:
|
||||
- `improvise-core` — `Model`, `View`, `Workbook`, number formatting. No UI/IO.
|
||||
- `improvise-formula` — formula parser, AST, `parse_formula`.
|
||||
- `improvise-io` — `.improv` save/load, CSV/JSON import. No UI/commands.
|
||||
- `improvise-command` — command/effect layer + semantic `App` state (app.rs, effect.rs, cat_tree.rs, viewport.rs). No ratatui/crossterm.
|
||||
|
||||
The main crate re-exports each as `crate::{model, view, workbook, format, formula, persistence, import}` so consumer paths stay stable when crates shuffle.
|
||||
The main crate (and `improvise-command`) re-export the sub-crates as `crate::{model, view, workbook, format, formula, persistence, import}` so consumer paths stay stable when crates shuffle. The main crate also re-exports `improvise_command::command` as `crate::command` and `improvise_command::ui::{app, cat_tree, effect, viewport}` under `crate::ui`.
|
||||
|
||||
Architectural intent lives in `context/design-principles.md` — read that for the "why". This doc is the "where".
|
||||
|
||||
@@ -177,7 +178,7 @@ import/analyzer.rs 292 / 9t Field kind detection (Category/Measure/Time/
|
||||
import/csv_parser.rs 300 / 8t CSV parsing, multi-file merge
|
||||
```
|
||||
|
||||
### Command layer (`src/command/`)
|
||||
### Command layer (`crates/improvise-command/src/command/`)
|
||||
```
|
||||
cmd/core.rs 319 / 2t Cmd trait, CmdContext, CmdRegistry, parse helpers
|
||||
cmd/registry.rs 601 / 0t default_registry() — all registrations (no tests yet)
|
||||
@@ -195,14 +196,19 @@ keymap.rs 1248 / 22t KeyPattern, Binding, Keymap, ModeKey, 16 mod
|
||||
parse.rs 236 / 19t Script/command-line parser (prefix syntax)
|
||||
```
|
||||
|
||||
### UI, draw, main (`src/ui/`, `src/draw.rs`, `src/main.rs`)
|
||||
### Semantic UI state (`crates/improvise-command/src/ui/`)
|
||||
```
|
||||
ui/effect.rs 1860 / 41t Effect trait, 50+ effect types
|
||||
ui/app.rs 1475 / 30t App, ModelState, ViewState, AppMode (16), handle_key, autosave
|
||||
ui/grid.rs 1063 / 13t GridWidget (ratatui), column widths
|
||||
ui/app.rs 1489 / 30t App, ModelState, ViewState, AppMode (16), handle_key, autosave, HELP_PAGE_COUNT
|
||||
ui/cat_tree.rs 156 / 6t Category tree flattener for panel
|
||||
ui/viewport.rs 128 compute_col_widths / row_header_width / visible_cols (pure geometry)
|
||||
```
|
||||
|
||||
### TUI rendering, draw, main (`src/ui/`, `src/draw.rs`, `src/main.rs`)
|
||||
```
|
||||
ui/grid.rs 998 / 13t GridWidget (ratatui); geometry helpers live in improvise-command viewport.rs
|
||||
ui/help.rs 603 5-page help overlay (render only)
|
||||
ui/import_wizard_ui.rs 347 Import wizard rendering
|
||||
ui/cat_tree.rs 156 / 6t Category tree flattener for panel
|
||||
draw.rs 422 TUI event loop, frame composition
|
||||
main.rs 388 CLI entry (clap): open, import, cmd, script
|
||||
# other ui/*.rs are small panel renderers — skip unless changing layout/style
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "improvise-command"
|
||||
version = "0.1.0-rc2"
|
||||
edition = "2024"
|
||||
description = "Command/effect layer and semantic app state for improvise"
|
||||
license = "Apache-2.0"
|
||||
repository = "https://github.com/fiddlerwoaroof/improvise"
|
||||
|
||||
[dependencies]
|
||||
improvise-core = { path = "../improvise-core" }
|
||||
improvise-formula = { path = "../improvise-formula" }
|
||||
improvise-io = { path = "../improvise-io" }
|
||||
anyhow = "1"
|
||||
serde_json = "1"
|
||||
unicode-width = "^0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,13 @@
|
||||
//! Command layer and semantic app state for improvise — no terminal deps.
|
||||
//!
|
||||
//! Holds the `Cmd`/`Effect` pipeline, keymaps, and the `App` state machine.
|
||||
//! Rendering lives in the root `improvise` crate.
|
||||
|
||||
pub mod command;
|
||||
pub mod ui;
|
||||
|
||||
// Path aliases mirroring the root crate so `crate::model::...` etc. keep
|
||||
// resolving unchanged inside the moved modules.
|
||||
pub use improvise_core::{format, model, view, workbook};
|
||||
pub use improvise_formula as formula;
|
||||
pub use improvise_io::{import, persistence};
|
||||
@@ -11,13 +11,17 @@ use crate::command::cmd::CmdContext;
|
||||
use crate::command::keymap::{Keymap, KeymapSet};
|
||||
use crate::import::wizard::ImportWizard;
|
||||
use crate::model::cell::CellValue;
|
||||
use crate::format::parse_number_format;
|
||||
use crate::persistence;
|
||||
use crate::ui::grid::{
|
||||
compute_col_widths, compute_row_header_width, compute_visible_cols, parse_number_format,
|
||||
};
|
||||
use crate::ui::viewport::{compute_col_widths, compute_row_header_width, compute_visible_cols};
|
||||
use crate::view::GridLayout;
|
||||
use crate::workbook::Workbook;
|
||||
|
||||
/// Number of pages in the Help screen. Lives here (with the `help_page`
|
||||
/// state it bounds) so help-page effects stay terminal-independent; the
|
||||
/// help renderer in the root crate imports it.
|
||||
pub const HELP_PAGE_COUNT: usize = 5;
|
||||
|
||||
/// A saved view+mode pair for the navigation stack.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ViewFrame {
|
||||
@@ -1326,7 +1330,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn help_page_clamps_at_max() {
|
||||
use crate::ui::help::HELP_PAGE_COUNT;
|
||||
use crate::ui::app::HELP_PAGE_COUNT;
|
||||
|
||||
let mut app = App::new(Workbook::new("T"), None);
|
||||
app.view_state.mode = AppMode::Help;
|
||||
@@ -1002,7 +1002,7 @@ pub fn set_selected(row: usize, col: usize) -> Box<dyn Effect> {
|
||||
pub struct HelpPageNext;
|
||||
impl Effect for HelpPageNext {
|
||||
fn apply(&self, app: &mut App) {
|
||||
let max = crate::ui::help::HELP_PAGE_COUNT.saturating_sub(1);
|
||||
let max = crate::ui::app::HELP_PAGE_COUNT.saturating_sub(1);
|
||||
app.view_state.help_page = app.view_state.help_page.saturating_add(1).min(max);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod app;
|
||||
pub mod cat_tree;
|
||||
pub mod effect;
|
||||
pub mod viewport;
|
||||
@@ -0,0 +1,128 @@
|
||||
//! Pure viewport-geometry helpers shared by the semantic app state (cursor
|
||||
//! tracking, scroll clamping) and the root crate's grid widget.
|
||||
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::format::format_f64;
|
||||
use crate::model::Model;
|
||||
use crate::view::{AxisEntry, GridLayout};
|
||||
|
||||
/// Minimum column width — enough for short numbers/labels + 1 char gap.
|
||||
pub const MIN_COL_WIDTH: u16 = 5;
|
||||
pub const MAX_COL_WIDTH: u16 = 32;
|
||||
pub const MIN_ROW_HEADER_W: u16 = 4;
|
||||
pub const MAX_ROW_HEADER_W: u16 = 24;
|
||||
|
||||
/// Compute adaptive column widths for pivot mode (header labels + cell values).
|
||||
/// Header widths use the widest *individual* level label (not the joined
|
||||
/// multi-level string), matching how the grid renderer draws each level on
|
||||
/// its own row with repeat-suppression.
|
||||
pub fn compute_col_widths(
|
||||
model: &Model,
|
||||
layout: &GridLayout,
|
||||
fmt_comma: bool,
|
||||
fmt_decimals: u8,
|
||||
) -> Vec<u16> {
|
||||
let n = layout.col_count();
|
||||
let mut widths = vec![0u16; n];
|
||||
// Measure individual header level labels
|
||||
let data_col_items: Vec<&Vec<String>> = layout
|
||||
.col_items
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
if let AxisEntry::DataItem(v) = e {
|
||||
Some(v)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
for (ci, wref) in widths.iter_mut().enumerate().take(n) {
|
||||
if let Some(levels) = data_col_items.get(ci) {
|
||||
let max_level_w = levels.iter().map(|s| s.width() as u16).max().unwrap_or(0);
|
||||
if max_level_w > *wref {
|
||||
*wref = max_level_w;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Measure cell content widths (works for both pivot and records modes)
|
||||
for ri in 0..layout.row_count() {
|
||||
for (ci, wref) in widths.iter_mut().enumerate().take(n) {
|
||||
let s = layout.display_text(model, ri, ci, fmt_comma, fmt_decimals);
|
||||
let w = s.width() as u16;
|
||||
if w > *wref {
|
||||
*wref = w;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Measure total row (column sums) — pivot mode only
|
||||
if !layout.is_records_mode() && layout.row_count() > 0 {
|
||||
for (ci, wref) in widths.iter_mut().enumerate().take(n) {
|
||||
let total: f64 = (0..layout.row_count())
|
||||
.filter_map(|ri| layout.cell_key(ri, ci))
|
||||
.map(|key| model.evaluate_aggregated_f64(&key, &layout.none_cats))
|
||||
.sum();
|
||||
let s = format_f64(total, fmt_comma, fmt_decimals);
|
||||
let w = s.width() as u16;
|
||||
if w > *wref {
|
||||
*wref = w;
|
||||
}
|
||||
}
|
||||
}
|
||||
widths
|
||||
.into_iter()
|
||||
.map(|w| (w + 1).clamp(MIN_COL_WIDTH, MAX_COL_WIDTH))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Compute the total row header width from the layout's row items.
|
||||
pub fn compute_row_header_width(layout: &GridLayout) -> u16 {
|
||||
let n_row_levels = layout.row_cats.len().max(1);
|
||||
let data_row_items: Vec<&Vec<String>> = layout
|
||||
.row_items
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
if let AxisEntry::DataItem(v) = e {
|
||||
Some(v)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let sub_widths: Vec<u16> = (0..n_row_levels)
|
||||
.map(|d| {
|
||||
let max_label = data_row_items
|
||||
.iter()
|
||||
.filter_map(|v| v.get(d))
|
||||
.map(|s| s.width() as u16)
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
(max_label + 1).clamp(MIN_ROW_HEADER_W, MAX_ROW_HEADER_W)
|
||||
})
|
||||
.collect();
|
||||
sub_widths.iter().sum()
|
||||
}
|
||||
|
||||
/// Count how many columns fit starting from `col_offset` given the available width.
|
||||
pub fn compute_visible_cols(
|
||||
col_widths: &[u16],
|
||||
row_header_width: u16,
|
||||
term_width: u16,
|
||||
col_offset: usize,
|
||||
) -> usize {
|
||||
// Account for grid border (2 chars)
|
||||
let data_area_width = term_width
|
||||
.saturating_sub(2)
|
||||
.saturating_sub(row_header_width);
|
||||
let mut acc = 0u16;
|
||||
let mut count = 0usize;
|
||||
for w in &col_widths[col_offset..] {
|
||||
let w = *w;
|
||||
if acc + w > data_area_width {
|
||||
break;
|
||||
}
|
||||
acc += w;
|
||||
count += 1;
|
||||
}
|
||||
count.max(1)
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
pub mod command;
|
||||
pub use improvise_command::command;
|
||||
pub mod draw;
|
||||
pub use improvise_core::format;
|
||||
pub use improvise_core::model;
|
||||
|
||||
+3
-119
@@ -8,13 +8,11 @@ use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::model::Model;
|
||||
use crate::ui::app::AppMode;
|
||||
use crate::ui::viewport::{
|
||||
compute_col_widths, MAX_ROW_HEADER_W, MIN_COL_WIDTH, MIN_ROW_HEADER_W,
|
||||
};
|
||||
use crate::view::{AxisEntry, GridLayout, View};
|
||||
|
||||
/// Minimum column width — enough for short numbers/labels + 1 char gap.
|
||||
const MIN_COL_WIDTH: u16 = 5;
|
||||
const MAX_COL_WIDTH: u16 = 32;
|
||||
const MIN_ROW_HEADER_W: u16 = 4;
|
||||
const MAX_ROW_HEADER_W: u16 = 24;
|
||||
/// Subtle dark-gray background used to highlight the row containing the cursor.
|
||||
const ROW_HIGHLIGHT_BG: Color = Color::Indexed(237);
|
||||
const GROUP_EXPANDED: &str = "▼";
|
||||
@@ -532,120 +530,6 @@ impl<'a> Widget for GridWidget<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute adaptive column widths for pivot mode (header labels + cell values).
|
||||
/// Header widths use the widest *individual* level label (not the joined
|
||||
/// multi-level string), matching how the grid renderer draws each level on
|
||||
/// its own row with repeat-suppression.
|
||||
pub fn compute_col_widths(
|
||||
model: &Model,
|
||||
layout: &GridLayout,
|
||||
fmt_comma: bool,
|
||||
fmt_decimals: u8,
|
||||
) -> Vec<u16> {
|
||||
let n = layout.col_count();
|
||||
let mut widths = vec![0u16; n];
|
||||
// Measure individual header level labels
|
||||
let data_col_items: Vec<&Vec<String>> = layout
|
||||
.col_items
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
if let AxisEntry::DataItem(v) = e {
|
||||
Some(v)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
for (ci, wref) in widths.iter_mut().enumerate().take(n) {
|
||||
if let Some(levels) = data_col_items.get(ci) {
|
||||
let max_level_w = levels.iter().map(|s| s.width() as u16).max().unwrap_or(0);
|
||||
if max_level_w > *wref {
|
||||
*wref = max_level_w;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Measure cell content widths (works for both pivot and records modes)
|
||||
for ri in 0..layout.row_count() {
|
||||
for (ci, wref) in widths.iter_mut().enumerate().take(n) {
|
||||
let s = layout.display_text(model, ri, ci, fmt_comma, fmt_decimals);
|
||||
let w = s.width() as u16;
|
||||
if w > *wref {
|
||||
*wref = w;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Measure total row (column sums) — pivot mode only
|
||||
if !layout.is_records_mode() && layout.row_count() > 0 {
|
||||
for (ci, wref) in widths.iter_mut().enumerate().take(n) {
|
||||
let total: f64 = (0..layout.row_count())
|
||||
.filter_map(|ri| layout.cell_key(ri, ci))
|
||||
.map(|key| model.evaluate_aggregated_f64(&key, &layout.none_cats))
|
||||
.sum();
|
||||
let s = format_f64(total, fmt_comma, fmt_decimals);
|
||||
let w = s.width() as u16;
|
||||
if w > *wref {
|
||||
*wref = w;
|
||||
}
|
||||
}
|
||||
}
|
||||
widths
|
||||
.into_iter()
|
||||
.map(|w| (w + 1).clamp(MIN_COL_WIDTH, MAX_COL_WIDTH))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Compute the total row header width from the layout's row items.
|
||||
pub fn compute_row_header_width(layout: &GridLayout) -> u16 {
|
||||
let n_row_levels = layout.row_cats.len().max(1);
|
||||
let data_row_items: Vec<&Vec<String>> = layout
|
||||
.row_items
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
if let AxisEntry::DataItem(v) = e {
|
||||
Some(v)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let sub_widths: Vec<u16> = (0..n_row_levels)
|
||||
.map(|d| {
|
||||
let max_label = data_row_items
|
||||
.iter()
|
||||
.filter_map(|v| v.get(d))
|
||||
.map(|s| s.width() as u16)
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
(max_label + 1).clamp(MIN_ROW_HEADER_W, MAX_ROW_HEADER_W)
|
||||
})
|
||||
.collect();
|
||||
sub_widths.iter().sum()
|
||||
}
|
||||
|
||||
/// Count how many columns fit starting from `col_offset` given the available width.
|
||||
pub fn compute_visible_cols(
|
||||
col_widths: &[u16],
|
||||
row_header_width: u16,
|
||||
term_width: u16,
|
||||
col_offset: usize,
|
||||
) -> usize {
|
||||
// Account for grid border (2 chars)
|
||||
let data_area_width = term_width
|
||||
.saturating_sub(2)
|
||||
.saturating_sub(row_header_width);
|
||||
let mut acc = 0u16;
|
||||
let mut count = 0usize;
|
||||
for w in &col_widths[col_offset..] {
|
||||
let w = *w;
|
||||
if acc + w > data_area_width {
|
||||
break;
|
||||
}
|
||||
acc += w;
|
||||
count += 1;
|
||||
}
|
||||
count.max(1)
|
||||
}
|
||||
|
||||
/// Decide whether the multi-level header sub-label at depth `d` for the data
|
||||
/// entry `idx` should be rendered, or blanked because it repeats the previous
|
||||
/// visible entry's prefix. `first_rendered` is the index of the first entry
|
||||
|
||||
+2
-2
@@ -5,8 +5,8 @@ use ratatui::{
|
||||
widgets::{Block, Borders, Clear, Widget},
|
||||
};
|
||||
|
||||
/// Number of help pages available.
|
||||
pub const HELP_PAGE_COUNT: usize = 5;
|
||||
/// Number of help pages available (defined alongside the help-page state).
|
||||
pub use crate::ui::app::HELP_PAGE_COUNT;
|
||||
|
||||
/// Style presets used throughout help pages.
|
||||
struct HelpStyles {
|
||||
|
||||
+2
-3
@@ -1,7 +1,6 @@
|
||||
pub mod app;
|
||||
pub mod cat_tree;
|
||||
pub use improvise_command::ui::{app, cat_tree, effect, viewport};
|
||||
|
||||
pub mod category_panel;
|
||||
pub mod effect;
|
||||
pub mod formula_panel;
|
||||
pub mod grid;
|
||||
pub mod help;
|
||||
|
||||
Reference in New Issue
Block a user