Files
improvise/src/draw.rs
T
Edward Langley 5b7b53466f feat(active-view): split the document's default from each client's view
improvise-jytm, improvise-6c6q, improvise-k69y.

`Workbook::active_view` becomes the document's *default* — what a freshly
connected client opens and what `.improv` writes as `Initial View` — and
`ViewState::active_view` becomes authoritative per client. Two clients may sit
on different views of one document: the model is shared, the UI is not.

- `App::switch_to_view` writes only the client's slice; `App::view()`/
  `view_mut()`/`view_name()` resolve it against the workbook.
- `effect::SwitchView`/`ViewBack`/`ViewForward` are `ViewOnly`, which takes
  `ViewBack`/`ViewForward` off `ModelCommand` — their operand is one client's
  history stack (the `ApplyAndClearDrill` argument).
- `grid_in`/`category_tree_in`/`tile_bar_in` project against the session's view;
  `ClientSession` observes its own move through `projected_view`.
- `apply_switch` takes a `SessionId`, moves only the asker's mirror, and relays
  `Broadcast::ToAsker` only when the document refused. `adopt_active_view` and
  `Broadcast::ToOthers` are deleted: nothing produces them any more.
- `draw_status` reads the client's copy (improvise-k69y).

`the_two_active_view_names_never_diverge` is replaced, not deleted, by
`the_documents_default_view_outlives_this_clients_switches` and
`a_fresh_session_opens_the_documents_default_view`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:02:59 -07:00

582 lines
18 KiB
Rust

use std::io::{self, Stdout};
use std::path::PathBuf;
use std::time::Duration;
use anyhow::Result;
use crossterm::{
event::{self, Event},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{
Frame, Terminal,
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
widgets::{Block, Borders, Clear, Paragraph},
};
use crate::command::key;
use crate::ui::app::{App, AppMode, MinibufColor};
use crate::ui::category_panel::CategoryContent;
use crate::ui::formula_panel::FormulaContent;
use crate::ui::grid::GridWidget;
use crate::ui::help::HelpWidget;
use crate::ui::import_wizard_ui::ImportWizardWidget;
use crate::ui::panel::Panel;
use crate::ui::tile_bar::TileBar;
use crate::ui::view_panel::ViewContent;
use crate::ui::which_key::WhichKeyWidget;
use crate::view::AxisColor;
use improvise_command::ui::project;
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(
workbook: crate::workbook::Workbook,
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(workbook, file_path);
if let Ok((w, h)) = crossterm::terminal::size() {
app.term_width = w;
app.term_height = h;
}
if let Some(json) = import_value {
app.start_import_wizard(json);
} else if app.is_empty_model() {
app.view_state.mode = AppMode::Help;
}
loop {
tui_context.terminal.draw(|f| draw(f, &app))?;
if event::poll(Duration::from_millis(100))? {
match event::read()? {
Event::Key(key) => {
app.handle_key(convert_key_event(key))?;
}
Event::Resize(w, h) => {
app.term_width = w;
app.term_height = h;
}
_ => {}
}
}
app.autosave_if_needed();
if matches!(app.view_state.mode, AppMode::Quit) {
break;
}
}
Ok(())
}
// ── Key event conversion (TUI boundary) ──────────────────────────────────────
// The only place crossterm's key types and `command::key` may both appear.
// Its three rules are in `context/code/tui.md`.
/// Convert a crossterm key event into the crate's own `KeyEvent`.
fn convert_key_event(ev: event::KeyEvent) -> key::KeyEvent {
key::KeyEvent::new(convert_key_code(ev.code), convert_modifiers(ev.modifiers))
}
/// Map a crossterm key code onto the crate's `KeyCode`. Codes the command
/// layer does not model become `KeyCode::Null`, which no keymap binds.
fn convert_key_code(code: event::KeyCode) -> key::KeyCode {
match code {
event::KeyCode::Char(c) => key::KeyCode::Char(c),
event::KeyCode::Esc => key::KeyCode::Esc,
event::KeyCode::Enter => key::KeyCode::Enter,
event::KeyCode::Up => key::KeyCode::Up,
event::KeyCode::Down => key::KeyCode::Down,
event::KeyCode::Left => key::KeyCode::Left,
event::KeyCode::Right => key::KeyCode::Right,
event::KeyCode::Tab => key::KeyCode::Tab,
event::KeyCode::BackTab => key::KeyCode::BackTab,
event::KeyCode::Backspace => key::KeyCode::Backspace,
event::KeyCode::Delete => key::KeyCode::Delete,
event::KeyCode::Home => key::KeyCode::Home,
event::KeyCode::End => key::KeyCode::End,
event::KeyCode::PageUp => key::KeyCode::PageUp,
event::KeyCode::PageDown => key::KeyCode::PageDown,
event::KeyCode::F(n) => key::KeyCode::F(n),
_ => key::KeyCode::Null,
}
}
/// Keep SHIFT/CONTROL/ALT; drop any other modifier bits (SUPER, HYPER, META).
fn convert_modifiers(mods: event::KeyModifiers) -> key::KeyModifiers {
let mut out = key::KeyModifiers::NONE;
if mods.contains(event::KeyModifiers::SHIFT) {
out = out | key::KeyModifiers::SHIFT;
}
if mods.contains(event::KeyModifiers::CONTROL) {
out = out | key::KeyModifiers::CONTROL;
}
if mods.contains(event::KeyModifiers::ALT) {
out = out | key::KeyModifiers::ALT;
}
out
}
// ── Drawing ──────────────────────────────────────────────────────────────────
/// The one place `AxisColor` becomes a terminal color (improvise-rml); the
/// accent itself is decided by `Axis::color`. See `context/code/tui.md`.
pub(crate) fn axis_fg(color: AxisColor) -> Color {
match color {
AxisColor::Row => Color::Green,
AxisColor::Column => Color::Blue,
AxisColor::Page => Color::Magenta,
AxisColor::Filter => Color::Yellow,
AxisColor::Inactive => Color::DarkGray,
}
}
/// Map the semantic minibuffer accent to a concrete terminal color.
fn minibuf_fg(color: MinibufColor) -> Color {
match color {
MinibufColor::Green => Color::Green,
MinibufColor::Cyan => Color::Cyan,
MinibufColor::Yellow => Color::Yellow,
}
}
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
}
/// Terminal styling for the mode indicator. Its wildcard arm is a known
/// defect (improvise-zx8t) — see `context/code/tui.md`.
fn mode_style(mode: &AppMode) -> Style {
match mode {
AppMode::Editing { .. } | AppMode::RecordsEditing { .. } => {
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.view_state.mode, AppMode::Help) {
f.render_widget(HelpWidget::new(app.view_state.help_page), size);
}
if matches!(app.view_state.mode, AppMode::ImportWizard)
&& let Some(wizard) = project::wizard_of(app)
{
// Built rather than sent: session-local, no `Projection`
// (improvise-764). Still through `project`, so the shape is shared.
f.render_widget(ImportWizardWidget::new(&wizard), size);
}
// ExportPrompt now uses the minibuffer at the bottom bar.
if app.is_empty_model()
&& matches!(
app.view_state.mode,
AppMode::Normal | AppMode::CommandMode { .. }
)
{
draw_welcome(f, main_chunks[1]);
}
// Which-key popup. The armed prefix is stored as the key that opened it;
// the keymap set resolves it (improvise-jc8).
if let Some(km) = app.armed_prefix_keymap() {
let hints = km.binding_hints();
f.render_widget(WhichKeyWidget::new(&hints), size);
}
}
fn draw_title(f: &mut Frame, area: Rect, app: &App) {
let dirty = if app.model_state.dirty { " [+]" } else { "" };
let file = app
.model_state
.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_state.workbook.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.view_state.formula_panel_open
|| app.view_state.category_panel_open
|| app.view_state.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.view_state.formula_panel_open,
app.view_state.category_panel_open,
app.view_state.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.view_state.formula_panel_open {
let a = Rect::new(side.x, y, side.width, ph);
// Built per frame from the authoritative `App` — the temporary
// half of improvise-35e (`context/code/tui.md`).
let cache = project::formulas_of(app);
let content = FormulaContent::new(&cache, &app.view_state.mode);
f.render_widget(
Panel::new(content, &app.view_state.mode, app.view_state.formula_cursor),
a,
);
y += ph;
}
if app.view_state.category_panel_open {
let a = Rect::new(side.x, y, side.width, ph);
let cache = project::category_tree_of(app);
let content = CategoryContent::new(&cache, &app.view_state.expanded_cats);
f.render_widget(
Panel::new(
content,
&app.view_state.mode,
app.view_state.cat_panel_cursor,
),
a,
);
y += ph;
}
if app.view_state.view_panel_open {
let a = Rect::new(side.x, y, side.width, ph);
let cache = project::view_list_of(app);
let content = ViewContent::new(&cache, &app.view_state.active_view);
f.render_widget(
Panel::new(
content,
&app.view_state.mode,
app.view_state.view_panel_cursor,
),
a,
);
}
} else {
grid_area = area;
}
// The viewport covers the WHOLE grid, so a cache position is an absolute
// position (gotcha 44). Only the viewport narrows for a client.
let grid_cache = project::grid_of(
app,
project::Viewport {
row_offset: 0,
col_offset: 0,
rows: app.layout.row_count(),
cols: app.layout.col_count(),
},
);
f.render_widget(
GridWidget::new(
&grid_cache,
app.cursor(),
&app.view_state.active_view,
&app.layout,
&app.view_state.mode,
&app.view_state.search_query,
&app.view_state.buffers,
app.drill_state.as_ref(),
),
grid_area,
);
}
fn draw_tile_bar(f: &mut Frame, area: Rect, app: &App) {
let cache = project::tile_bar_of(app);
f.render_widget(
TileBar::new(&cache, &app.view_state.mode, app.view_state.tile_cat_idx),
area,
);
}
fn draw_bottom_bar(f: &mut Frame, area: Rect, app: &App) {
if let Some(mb) = app.view_state.mode.minibuffer() {
let buf = app
.view_state
.buffers
.get(&mb.buffer_key)
.map(|s| s.as_str())
.unwrap_or("");
let text = format!("{}{}▌", mb.prompt, buf);
f.render_widget(
Paragraph::new(text).style(
Style::default()
.fg(minibuf_fg(mb.color))
.bg(Color::Indexed(235))
.add_modifier(Modifier::BOLD),
),
area,
);
} else {
draw_status(f, area, app);
}
}
fn draw_status(f: &mut Frame, area: Rect, app: &App) {
let search_part = if app.view_state.search_mode {
format!(" /{}▌", app.view_state.search_query)
} else {
String::new()
};
let msg = if !app.view_state.status_msg.is_empty() {
app.view_state.status_msg.as_str()
} else {
app.hint_text()
};
let yank_indicator = if app.view_state.yanked.is_some() {
" [yank]"
} else {
""
};
let view_badge = format!(" {}{} ", app.view_state.active_view, yank_indicator);
let left = format!(" {}{search_part} {msg}", app.view_state.mode.label());
let line = fill_line(left, &view_badge, area.width);
f.render_widget(
Paragraph::new(line).style(mode_style(&app.view_state.mode)),
area,
);
}
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,
),
);
}
}
#[cfg(test)]
mod key_conversion_tests {
use super::*;
use crossterm::event::{KeyEventKind, KeyEventState};
#[test]
fn char_with_control_maps_code_and_modifier() {
let ev = event::KeyEvent::new(event::KeyCode::Char('d'), event::KeyModifiers::CONTROL);
let got = convert_key_event(ev);
assert_eq!(got.code, key::KeyCode::Char('d'));
assert_eq!(got.modifiers, key::KeyModifiers::CONTROL);
}
#[test]
fn backtab_maps() {
let ev = event::KeyEvent::new(event::KeyCode::BackTab, event::KeyModifiers::SHIFT);
let got = convert_key_event(ev);
assert_eq!(got.code, key::KeyCode::BackTab);
assert_eq!(got.modifiers, key::KeyModifiers::SHIFT);
}
#[test]
fn unknown_code_maps_to_null() {
for code in [
event::KeyCode::Insert,
event::KeyCode::CapsLock,
event::KeyCode::Menu,
] {
let got = convert_key_event(event::KeyEvent::new(code, event::KeyModifiers::NONE));
assert_eq!(got.code, key::KeyCode::Null);
}
}
/// All `KeyEventKind`s convert alike, as the pre-conversion loop
/// dispatched them — `context/test-evidence/tui.md`.
#[test]
fn release_kind_converts_like_press() {
let ev = event::KeyEvent {
code: event::KeyCode::Enter,
modifiers: event::KeyModifiers::NONE,
kind: KeyEventKind::Release,
state: KeyEventState::NONE,
};
let got = convert_key_event(ev);
assert_eq!(
got,
key::KeyEvent::new(key::KeyCode::Enter, key::KeyModifiers::NONE)
);
}
#[test]
fn unknown_modifier_bits_are_dropped() {
let mods =
event::KeyModifiers::SUPER | event::KeyModifiers::META | event::KeyModifiers::ALT;
let got = convert_modifiers(mods);
assert_eq!(got, key::KeyModifiers::ALT);
}
}