12d976b432
axis_fg, minibuf_fg, field_kind_fg and mode_style are gone, and AxisColor leaves improvise-core. The category-add prompt turns green, ADR 004 clause 8's one declared exception. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
1307 lines
61 KiB
Rust
1307 lines
61 KiB
Rust
use ratatui::{
|
|
buffer::Buffer,
|
|
layout::Rect,
|
|
style::{Color, Modifier, Style},
|
|
widgets::{Block, Borders, Clear, Widget},
|
|
};
|
|
|
|
use unicode_width::UnicodeWidthStr;
|
|
|
|
use crate::protocol::face::Face;
|
|
use crate::protocol::{WizardCache, WizardStepKind};
|
|
use crate::ui::text::wrap;
|
|
|
|
/// The import wizard, drawn from a [`WizardCache`] (improvise-764) and from
|
|
/// no `improvise-io` or model type. See `context/code/tui.md`.
|
|
pub struct ImportWizardWidget<'a> {
|
|
pub wizard: &'a WizardCache,
|
|
}
|
|
|
|
impl<'a> ImportWizardWidget<'a> {
|
|
pub fn new(wizard: &'a WizardCache) -> 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.step {
|
|
WizardStepKind::Preview => " Import Wizard — Preview ",
|
|
WizardStepKind::SelectArrayPath => " Import Wizard — Select Array ",
|
|
WizardStepKind::ReviewProposals => " Import Wizard — Review Fields ",
|
|
WizardStepKind::ConfigureDates => " Import Wizard — Date Components ",
|
|
WizardStepKind::ResolveDuplicates => " Import Wizard — Duplicate Records ",
|
|
WizardStepKind::NameDuplicateCategory => " Import Wizard — Name the New Category ",
|
|
WizardStepKind::DefineFormulas => " Import Wizard — Formulas ",
|
|
WizardStepKind::NameModel => " Import Wizard — Name Model ",
|
|
WizardStepKind::Done => " Import Wizard — Confirm ",
|
|
};
|
|
|
|
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 panel = Panel { buf, clip: inner };
|
|
|
|
let mut y = inner.y;
|
|
let x = inner.x;
|
|
let w = inner.width as usize;
|
|
|
|
match &self.wizard.step {
|
|
WizardStepKind::Preview => {
|
|
let summary = &self.wizard.preview_summary;
|
|
panel.put(x, y, truncate(summary, w), Style::default());
|
|
y += 2;
|
|
panel.put(
|
|
x,
|
|
y,
|
|
"Press Enter to continue\u{2026}",
|
|
Style::default().fg(Color::Yellow),
|
|
);
|
|
}
|
|
WizardStepKind::SelectArrayPath => {
|
|
panel.put(
|
|
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.bottom() {
|
|
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()
|
|
};
|
|
// The root path is already spelled "(root)" by the
|
|
// projection, so both ends say it the same way.
|
|
let label = format!(" {path}");
|
|
panel.put(x, y, truncate(&label, w), style);
|
|
y += 1;
|
|
}
|
|
y += 1;
|
|
panel.put(
|
|
x,
|
|
y,
|
|
"\u{2191}\u{2193} select Enter confirm \u{2190} back",
|
|
Style::default().fg(Color::DarkGray),
|
|
);
|
|
}
|
|
WizardStepKind::ReviewProposals => {
|
|
panel.put(
|
|
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");
|
|
panel.put(
|
|
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.bottom().saturating_sub(2) {
|
|
break;
|
|
}
|
|
let is_sel = i == self.wizard.cursor;
|
|
|
|
let kind_style = crate::draw::face_style(Face::WizardField(proposal.kind));
|
|
|
|
let accept_str = if proposal.accepted {
|
|
"[\u{2713}]"
|
|
} 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 {
|
|
kind_style
|
|
} else {
|
|
Style::default().fg(Color::DarkGray)
|
|
};
|
|
|
|
panel.put(x, y, truncate(&row, w), style);
|
|
y += 1;
|
|
}
|
|
let hint_y = inner.bottom().saturating_sub(1);
|
|
panel.bottom_line(
|
|
x,
|
|
hint_y,
|
|
w,
|
|
"Enter: next Space: toggle c: cycle kind \u{2190} back Esc: cancel",
|
|
Style::default().fg(Color::DarkGray),
|
|
);
|
|
}
|
|
WizardStepKind::ConfigureDates => {
|
|
panel.put(
|
|
x,
|
|
y,
|
|
"Select date components to extract (Space toggle):",
|
|
Style::default().fg(Color::Yellow),
|
|
);
|
|
y += 1;
|
|
|
|
let mut item_idx = 0;
|
|
for proposal in &self.wizard.date_fields {
|
|
if y >= inner.bottom().saturating_sub(2) {
|
|
break;
|
|
}
|
|
let header = format!(" {} (format: {})", proposal.field, proposal.format);
|
|
panel.put(
|
|
x,
|
|
y,
|
|
truncate(&header, w),
|
|
Style::default()
|
|
.fg(Color::Magenta)
|
|
.add_modifier(Modifier::BOLD),
|
|
);
|
|
y += 1;
|
|
|
|
// Components arrive in the wizard's own order, already
|
|
// paired with their state, so the cursor counts these rows.
|
|
for (label, enabled) in &proposal.components {
|
|
if y >= inner.bottom().saturating_sub(2) {
|
|
break;
|
|
}
|
|
let enabled = *enabled;
|
|
let check = if enabled { "[\u{2713}]" } else { "[ ]" };
|
|
let row = format!(" {} {}", check, label);
|
|
let is_sel = item_idx == self.wizard.cursor;
|
|
let style = if is_sel {
|
|
Style::default()
|
|
.fg(Color::Black)
|
|
.bg(Color::Cyan)
|
|
.add_modifier(Modifier::BOLD)
|
|
} else if enabled {
|
|
Style::default().fg(Color::Green)
|
|
} else {
|
|
Style::default().fg(Color::DarkGray)
|
|
};
|
|
panel.put(x, y, truncate(&row, w), style);
|
|
y += 1;
|
|
item_idx += 1;
|
|
}
|
|
}
|
|
let hint_y = inner.bottom().saturating_sub(1);
|
|
panel.bottom_line(
|
|
x,
|
|
hint_y,
|
|
w,
|
|
"Space: toggle Enter: next \u{2190} back Esc: cancel",
|
|
Style::default().fg(Color::DarkGray),
|
|
);
|
|
}
|
|
WizardStepKind::ResolveDuplicates => {
|
|
let wiz = self.wizard;
|
|
let records: usize = wiz.collisions.iter().map(|c| c.records.len()).sum();
|
|
let intro = format!(
|
|
"{records} records share every coordinate with another, in {} group(s). \
|
|
What should the import do with them?",
|
|
wiz.collisions.len()
|
|
);
|
|
y = panel.wrapped(x, y, w, &intro, Style::default().fg(Color::Yellow));
|
|
y += 1;
|
|
for (i, choice) in wiz.duplicate_choices.iter().enumerate() {
|
|
let is_sel = i == wiz.cursor;
|
|
let marker = if is_sel { "> " } else { " " };
|
|
let label = match &choice.disabled {
|
|
Some(_) => format!("{marker}{} (unavailable)", choice.label),
|
|
None => format!("{marker}{}", choice.label),
|
|
};
|
|
let style = match (is_sel, &choice.disabled) {
|
|
(true, _) => Style::default()
|
|
.fg(Color::Black)
|
|
.bg(Color::Cyan)
|
|
.add_modifier(Modifier::BOLD),
|
|
(false, Some(_)) => Style::default().fg(Color::DarkGray),
|
|
(false, None) => Style::default(),
|
|
};
|
|
y = panel.wrapped(x, y, w, &label, style);
|
|
if let Some(reason) = &choice.disabled {
|
|
let dim = Style::default().fg(Color::DarkGray);
|
|
y = panel.wrapped(x + 4, y, w.saturating_sub(4), reason, dim);
|
|
}
|
|
}
|
|
y += 1;
|
|
let rows: Vec<String> = wiz.collisions.iter().map(|c| c.describe()).collect();
|
|
let bottom = inner.bottom().saturating_sub(1);
|
|
panel.scrolling(
|
|
x,
|
|
(y, "Colliding records"),
|
|
y.saturating_add(1)..bottom,
|
|
w,
|
|
&rows,
|
|
wiz.scroll,
|
|
" ",
|
|
Style::default(),
|
|
);
|
|
panel.bottom_line(x,
|
|
bottom,
|
|
w,
|
|
"\u{2191}\u{2193} choose Enter: select PgUp/PgDn: scroll \u{2190} back Esc: cancel",
|
|
Style::default().fg(Color::DarkGray),
|
|
);
|
|
}
|
|
WizardStepKind::NameDuplicateCategory => {
|
|
panel.put(
|
|
x,
|
|
y,
|
|
"Name the category that tells duplicate records apart:",
|
|
Style::default().fg(Color::Yellow),
|
|
);
|
|
y += 1;
|
|
let input = format!("> {}\u{2588}", self.wizard.duplicate_category);
|
|
panel.put(x, y, truncate(&input, w), Style::default().fg(Color::Green));
|
|
y += 2;
|
|
panel.wrapped(
|
|
x,
|
|
y,
|
|
w,
|
|
"Records that share every coordinate are numbered 1, 2, 3\u{2026} in \
|
|
source order; every other record gets 1. The category starts off the \
|
|
grid, so totals look as they would summed.",
|
|
Style::default().fg(Color::DarkGray),
|
|
);
|
|
panel.bottom_line(
|
|
x,
|
|
inner.bottom().saturating_sub(1),
|
|
w,
|
|
"Enter: next \u{2190} back Esc: cancel",
|
|
Style::default().fg(Color::DarkGray),
|
|
);
|
|
}
|
|
WizardStepKind::DefineFormulas => {
|
|
panel.put(
|
|
x,
|
|
y,
|
|
"Define formulas (optional):",
|
|
Style::default().fg(Color::Yellow),
|
|
);
|
|
y += 1;
|
|
|
|
// Show existing formulas
|
|
if self.wizard.formulas.is_empty() && !self.wizard.formula_editing {
|
|
panel.put(
|
|
x,
|
|
y,
|
|
" (no formulas yet)",
|
|
Style::default().fg(Color::DarkGray),
|
|
);
|
|
y += 1;
|
|
}
|
|
for (i, formula) in self.wizard.formulas.iter().enumerate() {
|
|
if y >= inner.bottom().saturating_sub(5) {
|
|
break;
|
|
}
|
|
let is_sel = i == self.wizard.cursor && !self.wizard.formula_editing;
|
|
let style = if is_sel {
|
|
Style::default()
|
|
.fg(Color::Black)
|
|
.bg(Color::Cyan)
|
|
.add_modifier(Modifier::BOLD)
|
|
} else {
|
|
Style::default().fg(Color::Green)
|
|
};
|
|
panel.put(x, y, truncate(&format!(" {}", formula), w), style);
|
|
y += 1;
|
|
}
|
|
|
|
// Formula input area
|
|
if self.wizard.formula_editing {
|
|
y += 1;
|
|
panel.put(
|
|
x,
|
|
y,
|
|
"Formula (e.g., Profit = Revenue - Cost):",
|
|
Style::default().fg(Color::Yellow),
|
|
);
|
|
y += 1;
|
|
let input = format!("> {}\u{2588}", self.wizard.formula_buffer);
|
|
panel.put(x, y, truncate(&input, w), Style::default().fg(Color::Green));
|
|
y += 1;
|
|
}
|
|
|
|
// Sample formulas
|
|
let samples = &self.wizard.sample_formulas;
|
|
if !samples.is_empty() {
|
|
y += 1;
|
|
panel.put(x, y, "Examples:", Style::default().fg(Color::DarkGray));
|
|
y += 1;
|
|
for sample in samples {
|
|
if y >= inner.bottom().saturating_sub(1) {
|
|
break;
|
|
}
|
|
panel.put(
|
|
x,
|
|
y,
|
|
truncate(&format!(" {}", sample), w),
|
|
Style::default().fg(Color::DarkGray),
|
|
);
|
|
y += 1;
|
|
}
|
|
}
|
|
|
|
let hint_y = inner.bottom().saturating_sub(1);
|
|
let hint = if self.wizard.formula_editing {
|
|
"Enter: add Esc: cancel"
|
|
} else {
|
|
"n: new formula d: delete Enter: next \u{2190} back Esc: cancel"
|
|
};
|
|
panel.bottom_line(x, hint_y, w, hint, Style::default().fg(Color::DarkGray));
|
|
}
|
|
WizardStepKind::NameModel => {
|
|
panel.put(x, y, "Model name:", Style::default().fg(Color::Yellow));
|
|
y += 1;
|
|
let name_str = format!("> {}\u{2588}", self.wizard.model_name);
|
|
panel.put(
|
|
x,
|
|
y,
|
|
truncate(&name_str, w),
|
|
Style::default().fg(Color::Green),
|
|
);
|
|
y += 2;
|
|
panel.put(
|
|
x,
|
|
y,
|
|
"Enter: build \u{2190} back Esc: cancel",
|
|
Style::default().fg(Color::DarkGray),
|
|
);
|
|
}
|
|
WizardStepKind::Done => {
|
|
// The workbook is built but not yet installed — show what the
|
|
// user is about to accept, warnings included (improvise-907).
|
|
match &self.wizard.built_summary {
|
|
Some(summary) => {
|
|
panel.put(
|
|
x,
|
|
y,
|
|
truncate(summary, w),
|
|
Style::default().fg(Color::Green),
|
|
);
|
|
y += 2;
|
|
let bottom = inner.bottom().saturating_sub(1);
|
|
let warnings = &self.wizard.built_warnings;
|
|
if !warnings.is_empty() {
|
|
panel.scrolling(
|
|
x,
|
|
(y, "Warnings"),
|
|
y.saturating_add(1)..bottom,
|
|
w,
|
|
warnings,
|
|
self.wizard.scroll,
|
|
"! ",
|
|
Style::default().fg(Color::Yellow),
|
|
);
|
|
}
|
|
}
|
|
None => panel.put(x, y, "Nothing to confirm.", Style::default().fg(Color::Red)),
|
|
}
|
|
let hint_y = inner.bottom().saturating_sub(1);
|
|
panel.bottom_line(
|
|
x,
|
|
hint_y,
|
|
w,
|
|
"Enter: accept \u{2190}/Esc: back",
|
|
Style::default().fg(Color::DarkGray),
|
|
);
|
|
}
|
|
}
|
|
|
|
// The wizard message takes the bottom line over the key hint, wrapping
|
|
// upward from there rather than being cut (improvise-cgqt).
|
|
if let Some(msg) = &self.wizard.message {
|
|
let lines = wrap(msg, w);
|
|
let bottom = inner.bottom().saturating_sub(1);
|
|
let first = bottom.saturating_sub(lines.len() as u16 - 1).max(inner.y);
|
|
for (row, line) in (first..=bottom).zip(&lines) {
|
|
panel.bottom_line(x, row, w, line, Style::default().fg(Color::Red));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The wizard's drawing surface. Every write is clipped to the panel's rows
|
|
/// and width, so no step can reach past the buffer (review B2 on #304).
|
|
struct Panel<'b> {
|
|
buf: &'b mut Buffer,
|
|
clip: Rect,
|
|
}
|
|
|
|
impl Panel<'_> {
|
|
fn put(&mut self, x: u16, y: u16, text: impl AsRef<str>, style: Style) {
|
|
let inside = (self.clip.y..self.clip.bottom()).contains(&y) && x < self.clip.right();
|
|
if inside {
|
|
let room = usize::from(self.clip.right() - x);
|
|
self.buf.set_stringn(x, y, text, room, style);
|
|
}
|
|
}
|
|
|
|
fn bottom_line(&mut self, x: u16, y: u16, w: usize, text: &str, style: Style) {
|
|
if (self.clip.y..self.clip.bottom()).contains(&y) {
|
|
set_bottom_line(self.buf, x, y, w, text, style);
|
|
}
|
|
}
|
|
|
|
/// Draw `text` wrapped to `w` cells from row `y`; returns the row after it.
|
|
fn wrapped(&mut self, x: u16, y: u16, w: usize, text: &str, style: Style) -> u16 {
|
|
let lines = wrap(text, w);
|
|
for (i, line) in lines.iter().enumerate() {
|
|
self.put(x, y.saturating_add(i as u16), line, style);
|
|
}
|
|
y.saturating_add(lines.len() as u16)
|
|
}
|
|
|
|
/// Draw `items` from `scroll` on, each wrapped whole, in `rows`. Which of
|
|
/// them are shown is said on the `header` row, where no item can cover it.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn scrolling(
|
|
&mut self,
|
|
x: u16,
|
|
header: (u16, &str),
|
|
rows: std::ops::Range<u16>,
|
|
w: usize,
|
|
items: &[String],
|
|
scroll: usize,
|
|
prefix: &str,
|
|
style: Style,
|
|
) {
|
|
let mut y = rows.start;
|
|
let mut shown = scroll;
|
|
for item in items.iter().skip(scroll) {
|
|
let lines = wrap(&format!("{prefix}{item}"), w);
|
|
let room = usize::from(rows.end.saturating_sub(y));
|
|
if lines.len() > room && shown > scroll {
|
|
break;
|
|
}
|
|
for line in lines.iter().take(room) {
|
|
self.put(x, y, line, style);
|
|
y += 1;
|
|
}
|
|
shown += 1;
|
|
}
|
|
let (row, title) = header;
|
|
let label = match (scroll, shown) {
|
|
(0, n) if n == items.len() => format!("{title}:"),
|
|
(first, last) => format!(
|
|
"{title} {}\u{2013}{last} of {} (PgUp/PgDn):",
|
|
first + 1,
|
|
items.len()
|
|
),
|
|
};
|
|
self.put(x, row, label, Style::default().fg(Color::Gray));
|
|
}
|
|
}
|
|
|
|
/// The ONE writer for the wizard's bottom row, padding in terminal cells
|
|
/// (improvise-q0vb). Both halves are argued in `context/code/tui.md`.
|
|
fn set_bottom_line(buf: &mut Buffer, x: u16, y: u16, w: usize, text: &str, style: Style) {
|
|
let text = crate::ui::text::truncate(text, w);
|
|
let pad = " ".repeat(w.saturating_sub(UnicodeWidthStr::width(text.as_str())));
|
|
buf.set_string(x, y, format!("{text}{pad}"), style);
|
|
}
|
|
|
|
fn truncate(s: &str, max: usize) -> String {
|
|
if s.len() <= max {
|
|
s.to_string()
|
|
} else if max > 1 {
|
|
format!("{}\u{2026}", prefix(s, max - 1))
|
|
} else {
|
|
prefix(s, max).to_string()
|
|
}
|
|
}
|
|
|
|
/// The longest prefix of `s` at most `max` **bytes** ending on a char
|
|
/// boundary. Bytes and not columns, deliberately — `context/code/tui.md`.
|
|
fn prefix(s: &str, max: usize) -> &str {
|
|
let mut end = max.min(s.len());
|
|
while !s.is_char_boundary(end) {
|
|
end -= 1;
|
|
}
|
|
&s[..end]
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod truncate_tests {
|
|
use super::truncate;
|
|
|
|
/// `"Régions"` is 8 bytes for 7 characters, so `max = 3` cuts inside the
|
|
/// é. The bug is in `context/test-evidence/tui.md`.
|
|
#[test]
|
|
fn a_cut_inside_a_multibyte_character_does_not_panic() {
|
|
assert_eq!(truncate("Régions", 3), "R\u{2026}");
|
|
}
|
|
|
|
/// Not one cut point, every cut point; and each result must be a PREFIX,
|
|
/// which is what says the search walked down. `context/test-evidence/tui.md`.
|
|
#[test]
|
|
fn no_cut_point_of_a_multibyte_string_panics() {
|
|
let s = "Ünïcödé";
|
|
for max in 0..=s.len() + 2 {
|
|
let out = truncate(s, max);
|
|
let body = out.strip_suffix('\u{2026}').unwrap_or(&out);
|
|
assert!(
|
|
s.starts_with(body),
|
|
"truncate({s:?}, {max}) = {out:?}, which is not a prefix of the input"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// ASCII is unaffected, which is what lets the characterization fixtures
|
|
/// below keep asserting frames written before this change.
|
|
#[test]
|
|
fn ascii_truncation_is_unchanged() {
|
|
assert_eq!(truncate("cancel", 10), "cancel");
|
|
assert_eq!(truncate("cancel", 4), "can\u{2026}");
|
|
assert_eq!(truncate("cancel", 1), "c");
|
|
assert_eq!(truncate("cancel", 0), "");
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::import::wizard::{ImportWizard, WizardStep};
|
|
use improvise_command::ui::project;
|
|
use ratatui::widgets::Widget;
|
|
use serde_json::json;
|
|
|
|
/// A category, a measure and a date column, so the analyzer proposes one
|
|
/// of each kind and the date step has something to configure.
|
|
fn wizard() -> ImportWizard {
|
|
ImportWizard::new(json!([
|
|
{"Region": "East", "Revenue": 100, "Cost": 60, "When": "2024-01-15"},
|
|
{"Region": "West", "Revenue": 50, "Cost": 20, "When": "2024-04-02"},
|
|
]))
|
|
}
|
|
|
|
fn buf_text(buf: &Buffer) -> String {
|
|
let w = buf.area().width as usize;
|
|
buf.content()
|
|
.chunks(w)
|
|
.map(|row| {
|
|
// A double-width character's second cell is padding, not text.
|
|
let mut skip = 0;
|
|
let mut text = String::new();
|
|
for cell in row {
|
|
match skip {
|
|
0 => {
|
|
text.push_str(cell.symbol());
|
|
skip = UnicodeWidthStr::width(cell.symbol()).saturating_sub(1);
|
|
}
|
|
_ => skip -= 1,
|
|
}
|
|
}
|
|
text.trim_end().to_string()
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
.trim_end()
|
|
.to_string()
|
|
}
|
|
|
|
/// Drive a real `ImportWizard` through the projection — the whole path
|
|
/// the TUI takes, never a handcrafted cache (`context/test-evidence/tui.md`).
|
|
fn render(w: &ImportWizard) -> String {
|
|
let area = Rect::new(0, 0, 80, 22);
|
|
let mut buf = Buffer::empty(area);
|
|
let cache = project::wizard_cache(w);
|
|
ImportWizardWidget::new(&cache).render(area, &mut buf);
|
|
buf_text(&buf)
|
|
}
|
|
|
|
/// Every step the wizard can draw, byte for byte, against the
|
|
/// pre-improvise-764 path. `context/test-evidence/tui.md`.
|
|
#[test]
|
|
fn every_step_draws_what_it_drew_before() {
|
|
let mut w = wizard();
|
|
let mut seen = Vec::new();
|
|
// Bounded, and stops when `advance` no longer moves: the transitions
|
|
// are conditional (improvise-907) and a refusal must end the walk.
|
|
for _ in 0..16 {
|
|
seen.push(format!("{:?}\n{}", w.step, render(&w)));
|
|
let before = format!("{:?}", w.step);
|
|
match w.step {
|
|
// `NameModel` is terminal for `advance` (gotcha 22): building
|
|
// is what parks the workbook on `Done`.
|
|
WizardStep::NameModel => w.build(),
|
|
_ => w.advance(),
|
|
}
|
|
if format!("{:?}", w.step) == before {
|
|
break;
|
|
}
|
|
}
|
|
assert_eq!(seen.join("\n\n====\n\n"), EVERY_STEP);
|
|
}
|
|
|
|
/// The transient bits of `DefineFormulas`: a formula in the list, one
|
|
/// being typed, and a message overriding the hint line.
|
|
#[test]
|
|
fn the_formula_step_draws_its_transient_state() {
|
|
let mut w = wizard();
|
|
while !matches!(w.step, WizardStep::DefineFormulas) {
|
|
w.advance();
|
|
}
|
|
w.start_formula_edit();
|
|
for c in "Profit = Revenue - Cost".chars() {
|
|
w.push_formula_char(c);
|
|
}
|
|
w.confirm_formula();
|
|
w.start_formula_edit();
|
|
for c in "Marg".chars() {
|
|
w.push_formula_char(c);
|
|
}
|
|
w.message = Some("something went wrong".to_string());
|
|
assert_eq!(render(&w), FORMULA_STEP);
|
|
}
|
|
|
|
/// improvise-q0vb, on `ReviewProposals` because its hint is the longest
|
|
/// in the wizard. `context/test-evidence/tui.md`.
|
|
#[test]
|
|
fn a_message_shorter_than_the_hint_leaves_none_of_it() {
|
|
const HINT: &str = "Enter: next Space: toggle c: cycle kind ← back Esc: cancel";
|
|
let mut w = wizard();
|
|
assert!(matches!(w.step, WizardStep::ReviewProposals));
|
|
assert!(
|
|
render(&w).lines().any(|l| l.contains(HINT)),
|
|
"precondition: this step draws the long hint"
|
|
);
|
|
|
|
w.message = Some("short".to_string());
|
|
let drawn = render(&w);
|
|
let bottom = drawn.lines().nth_back(1).expect("the row above the border");
|
|
assert_eq!(
|
|
bottom.trim_end_matches([' ', '│']).trim_start_matches('│'),
|
|
"short"
|
|
);
|
|
assert!(
|
|
"short".len() < HINT.len(),
|
|
"precondition: the message is shorter than what it replaces"
|
|
);
|
|
}
|
|
|
|
/// Cells, not characters (Copilot, PR #22). Asserted against an `X`
|
|
/// sentinel, and why that is required: `context/test-evidence/tui.md`.
|
|
#[test]
|
|
fn set_bottom_line_clears_every_cell_it_covers_and_no_more() {
|
|
const W: usize = 18;
|
|
for text in [
|
|
"\u{5e7f}\u{5ea6}\u{5e7f}\u{5ea6}\u{5e7f}\u{5ea6}", // 6 chars, 12 cells
|
|
"e\u{0301}e\u{0301}e\u{0301}", // 6 chars, 3 cells
|
|
"short", // 5 chars, 5 cells
|
|
] {
|
|
let mut buf = Buffer::empty(Rect::new(0, 0, W as u16 + 2, 1));
|
|
buf.set_string(0, 0, "X".repeat(W + 2), Style::default());
|
|
|
|
set_bottom_line(&mut buf, 1, 0, W, text, Style::default());
|
|
|
|
let row: Vec<&str> = (0..W as u16 + 2).map(|i| buf[(i, 0)].symbol()).collect();
|
|
assert_eq!(row[0], "X", "{text:?} wrote left of the panel: {row:?}");
|
|
assert_eq!(
|
|
row[W + 1],
|
|
"X",
|
|
"{text:?} overran the panel's right border: {row:?}"
|
|
);
|
|
assert!(
|
|
!row[1..=W].contains(&"X"),
|
|
"{text:?} left a cell it should have cleared: {row:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// One of the two steps the flat-array fixture skips, and the only step
|
|
/// that draws `pipeline.array_paths`.
|
|
#[test]
|
|
fn the_array_path_step_draws_its_candidates() {
|
|
let w = ImportWizard::new(json!({
|
|
"sales": [{"Region": "East", "Revenue": 1}],
|
|
"costs": [{"Region": "West", "Cost": 2}],
|
|
}));
|
|
assert!(matches!(w.step, WizardStep::SelectArrayPath));
|
|
assert_eq!(render(&w), ARRAY_PATH_STEP);
|
|
}
|
|
|
|
/// The one step `every_step_draws_what_it_drew_before` never reaches
|
|
/// (improvise-g6s1) — and a wart it pins: `context/test-evidence/tui.md`.
|
|
#[test]
|
|
fn the_preview_step_draws_an_empty_document() {
|
|
let w = ImportWizard::new(json!([]));
|
|
assert!(
|
|
matches!(w.step, WizardStep::Preview),
|
|
"precondition: an empty array must enter at Preview, got {:?}",
|
|
w.step
|
|
);
|
|
assert_eq!(render(&w), PREVIEW_STEP);
|
|
}
|
|
|
|
/// Every whitespace-separated word of `text` is on screen, in order —
|
|
/// the property "wrapped, not truncated" (improvise-cgqt).
|
|
fn assert_drawn_whole(drawn: &str, text: &str) {
|
|
let flat: String = drawn
|
|
.lines()
|
|
.map(|l| l.trim_matches(|c| c == '│' || c == ' '))
|
|
.collect::<Vec<_>>()
|
|
.join(" ");
|
|
let mut rest = flat.as_str();
|
|
for word in text.split_whitespace() {
|
|
let at = rest
|
|
.find(word)
|
|
.unwrap_or_else(|| panic!("{word:?} of {text:?} is not drawn:\n{drawn}"));
|
|
rest = &rest[at + word.len()..];
|
|
}
|
|
}
|
|
|
|
fn records(pairs: &[(&str, f64)]) -> ImportWizard {
|
|
ImportWizard::new(serde_json::Value::Array(
|
|
pairs
|
|
.iter()
|
|
.map(|(r, v)| json!({"Region": r, "Revenue": v}))
|
|
.collect(),
|
|
))
|
|
}
|
|
|
|
/// Walk a colliding import to `Done` by summing, so it carries the long
|
|
/// collision warning. `context/test-evidence/tui.md`.
|
|
#[test]
|
|
fn a_warning_longer_than_the_panel_is_drawn_whole() {
|
|
let mut w = records(&[
|
|
("Quokka-Territory-North-Alpha", 1.0),
|
|
("Quokka-Territory-North-Alpha", 2.0),
|
|
("Wombat-Province-Southern-Beta", 3.0),
|
|
("Wombat-Province-Southern-Beta", 4.0),
|
|
]);
|
|
w.advance();
|
|
assert!(matches!(w.step, WizardStep::ResolveDuplicates), "setup");
|
|
w.choose_duplicate();
|
|
w.advance();
|
|
w.build();
|
|
assert!(matches!(w.step, WizardStep::Done), "setup: {:?}", w.message);
|
|
let warning = project::wizard_cache(&w).built_warnings[0].clone();
|
|
assert!(
|
|
warning.len() > 80,
|
|
"precondition: wider than the panel: {warning}"
|
|
);
|
|
assert_drawn_whole(&render(&w), &warning);
|
|
}
|
|
|
|
#[test]
|
|
fn a_message_longer_than_the_panel_is_drawn_whole() {
|
|
let mut w = wizard();
|
|
let msg = "Too many categories: thirteen proposed, and the model allows twelve. \
|
|
Mark fields as Measure (c) or toggle them off (Space) before going on.";
|
|
w.message = Some(msg.to_string());
|
|
assert_drawn_whole(&render(&w), msg);
|
|
}
|
|
|
|
#[test]
|
|
fn the_duplicates_step_lists_every_colliding_record() {
|
|
let mut w = records(&[
|
|
("East", 1.0),
|
|
("West", 2.0),
|
|
("East", 3.0),
|
|
("North", 4.0),
|
|
("West", 5.0),
|
|
("East", 6.0),
|
|
]);
|
|
w.advance();
|
|
let drawn = render(&w);
|
|
let cache = project::wizard_cache(&w);
|
|
assert_eq!(cache.collisions.len(), 2, "setup");
|
|
for c in &cache.collisions {
|
|
assert_drawn_whole(&drawn, &c.describe());
|
|
}
|
|
assert!(
|
|
drawn.contains("3 records (#1, #3, #6): Region=East"),
|
|
"{drawn}"
|
|
);
|
|
assert!(
|
|
!drawn.contains("North"),
|
|
"a record that collides with nothing: {drawn}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn scrolling_reaches_the_last_collision() {
|
|
let pairs: Vec<(String, f64)> = (0..14)
|
|
.flat_map(|i| {
|
|
let r = format!("Region-{i:02}");
|
|
[(r.clone(), 1.0), (r, 2.0)]
|
|
})
|
|
.collect();
|
|
let borrowed: Vec<(&str, f64)> = pairs.iter().map(|(r, v)| (r.as_str(), *v)).collect();
|
|
let mut w = records(&borrowed);
|
|
w.advance();
|
|
let first = render(&w);
|
|
assert!(first.contains("Region=Region-00"), "{first}");
|
|
assert!(
|
|
!first.contains("Region=Region-13"),
|
|
"precondition: it overflows\n{first}"
|
|
);
|
|
assert!(first.contains("Colliding records 1\u{2013}"), "{first}");
|
|
assert!(first.contains(" of 14 (PgUp/PgDn):"), "{first}");
|
|
|
|
w.scroll(100);
|
|
let last = render(&w);
|
|
assert!(last.contains("Region=Region-13"), "{last}");
|
|
assert!(
|
|
last.contains("Colliding records 14\u{2013}14 of 14"),
|
|
"{last}"
|
|
);
|
|
}
|
|
|
|
/// Codex P2 on PR #304: an item that fills the list exactly left the "more
|
|
/// below" marker on the hint row, drawn over. `context/test-evidence/tui.md`.
|
|
#[test]
|
|
fn a_first_item_that_fills_the_list_still_says_more_follow() {
|
|
let area = Rect::new(0, 0, 10, 6);
|
|
let mut buf = Buffer::empty(area);
|
|
let mut panel = Panel {
|
|
buf: &mut buf,
|
|
clip: area,
|
|
};
|
|
let items = ["aaaa bbbb cccc".to_string(), "dddd".to_string()];
|
|
panel.scrolling(0, (0, "L"), 1..4, 4, &items, 0, "", Style::default());
|
|
set_bottom_line(&mut buf, 0, 4, 10, "hint", Style::default());
|
|
let drawn = buf_text(&buf);
|
|
let rows: Vec<&str> = drawn.lines().collect();
|
|
assert_eq!(
|
|
rows[1..4],
|
|
["aaaa", "bbbb", "cccc"],
|
|
"the first item whole:\n{drawn}"
|
|
);
|
|
assert!(rows[0].starts_with("L 1\u{2013}1 of 2"), "{drawn}");
|
|
}
|
|
|
|
#[test]
|
|
fn keeping_separate_at_the_cap_is_drawn_unavailable_with_its_reason() {
|
|
let recs: Vec<serde_json::Value> = (0..3)
|
|
.chain([0])
|
|
.map(|r| {
|
|
let mut m = serde_json::Map::new();
|
|
for i in 0..crate::model::MAX_CATEGORIES {
|
|
m.insert(format!("c{i:02}"), json!(format!("v{r}")));
|
|
}
|
|
m.insert("amount".into(), json!(1.0));
|
|
serde_json::Value::Object(m)
|
|
})
|
|
.collect();
|
|
let mut w = ImportWizard::new(serde_json::Value::Array(recs));
|
|
w.advance();
|
|
assert!(matches!(w.step, WizardStep::ResolveDuplicates), "setup");
|
|
let drawn = render(&w);
|
|
let reason = project::wizard_cache(&w).duplicate_choices[1]
|
|
.disabled
|
|
.clone()
|
|
.expect("setup: disabled at the cap");
|
|
assert!(drawn.contains("(unavailable)"), "{drawn}");
|
|
assert_drawn_whole(&drawn, &reason);
|
|
}
|
|
|
|
fn render_at(w: &ImportWizard, width: u16, height: u16) -> String {
|
|
let area = Rect::new(0, 0, width, height);
|
|
let mut buf = Buffer::empty(area);
|
|
let cache = project::wizard_cache(w);
|
|
ImportWizardWidget::new(&cache).render(area, &mut buf);
|
|
buf_text(&buf)
|
|
}
|
|
|
|
/// The reported import: a full set of categories and a collision.
|
|
fn wizard_at_the_cap() -> ImportWizard {
|
|
let recs: Vec<serde_json::Value> = (0..3)
|
|
.chain([0])
|
|
.map(|r| {
|
|
let mut m = serde_json::Map::new();
|
|
for i in 0..crate::model::MAX_CATEGORIES {
|
|
m.insert(format!("c{i:02}"), json!(format!("v{r}")));
|
|
}
|
|
m.insert("amount".into(), json!(1.0));
|
|
serde_json::Value::Object(m)
|
|
})
|
|
.collect();
|
|
let mut w = ImportWizard::new(serde_json::Value::Array(recs));
|
|
w.advance();
|
|
assert!(matches!(w.step, WizardStep::ResolveDuplicates), "setup");
|
|
w
|
|
}
|
|
|
|
/// Review B1 on PR #304: the cap reason carries a `←`, three bytes wide,
|
|
/// and a byte-measured bottom row cut it. `context/test-evidence/tui.md`.
|
|
#[test]
|
|
fn a_multibyte_message_is_drawn_whole_at_every_width() {
|
|
let mut w = wizard_at_the_cap();
|
|
w.move_cursor(1);
|
|
w.choose_duplicate();
|
|
let reason = w
|
|
.message
|
|
.clone()
|
|
.expect("setup: choosing Separate at the cap");
|
|
assert!(reason.contains('\u{2190}'), "setup: {reason}");
|
|
let messages = [
|
|
reason,
|
|
"Région 日本語 🎉 naïve café — ← → Skip, Measure, Sum".repeat(3),
|
|
];
|
|
for msg in messages {
|
|
w.message = Some(msg.clone());
|
|
for width in 30..=100u16 {
|
|
let drawn = render_at(&w, width, 30);
|
|
let flat: String = drawn.lines().collect();
|
|
assert!(
|
|
!flat.contains('\u{2026}'),
|
|
"width {width} truncated:\n{drawn}"
|
|
);
|
|
assert_drawn_whole(&drawn, &msg);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Every step the wizard can reach, as the TUI would draw it: each a fresh
|
|
/// wizard walked by `advance`, `choose` or `build` in turn.
|
|
fn every_step() -> Vec<ImportWizard> {
|
|
#[derive(Clone, Copy)]
|
|
enum Op {
|
|
Advance,
|
|
Separate,
|
|
Sum,
|
|
Build,
|
|
}
|
|
let colliding = || {
|
|
ImportWizard::new(json!([
|
|
{"Region": "East", "Revenue": 1.0}, {"Region": "East", "Revenue": 2.0}
|
|
]))
|
|
};
|
|
let walk = |mut w: ImportWizard, ops: &[Op]| {
|
|
for op in ops {
|
|
match op {
|
|
Op::Advance => w.advance(),
|
|
Op::Separate => {
|
|
w.move_cursor(1);
|
|
w.choose_duplicate();
|
|
}
|
|
Op::Sum => w.choose_duplicate(),
|
|
Op::Build => w.build(),
|
|
}
|
|
}
|
|
w
|
|
};
|
|
use Op::*;
|
|
vec![
|
|
ImportWizard::new(json!([])),
|
|
ImportWizard::new(json!({
|
|
"sales": [{"Region": "East", "Revenue": 1}],
|
|
"costs": [{"Region": "West", "Cost": 2}],
|
|
})),
|
|
wizard(),
|
|
walk(wizard(), &[Advance]),
|
|
walk(wizard(), &[Advance, Advance]),
|
|
walk(wizard(), &[Advance, Advance, Advance]),
|
|
walk(wizard(), &[Advance, Advance, Advance, Build]),
|
|
walk(colliding(), &[Advance]),
|
|
walk(colliding(), &[Advance, Separate]),
|
|
walk(colliding(), &[Advance, Sum, Advance, Build]),
|
|
wizard_at_the_cap(),
|
|
walk(wizard_at_the_cap(), &[Separate]),
|
|
{
|
|
let mut w = walk(wizard(), &[Advance, Advance]);
|
|
for formula in ["Profit = Revenue - Cost", "Margin = Profit / Revenue"] {
|
|
w.start_formula_edit();
|
|
formula.chars().for_each(|c| w.push_formula_char(c));
|
|
w.confirm_formula();
|
|
}
|
|
w.start_formula_edit();
|
|
w.push_formula_char('M');
|
|
w
|
|
},
|
|
]
|
|
}
|
|
|
|
/// Review B2 on PR #304: the duplicates step wrote past the panel and
|
|
/// ratatui panicked. Every step, every small size. `context/test-evidence/tui.md`.
|
|
#[test]
|
|
fn no_step_panics_on_a_small_terminal() {
|
|
let steps = every_step();
|
|
let kinds: Vec<String> = steps.iter().map(|w| format!("{:?}", w.step)).collect();
|
|
for kind in [
|
|
"Preview",
|
|
"SelectArrayPath",
|
|
"ReviewProposals",
|
|
"ConfigureDates",
|
|
"ResolveDuplicates",
|
|
"NameDuplicateCategory",
|
|
"DefineFormulas",
|
|
"NameModel",
|
|
"Done",
|
|
] {
|
|
assert!(
|
|
kinds.iter().any(|k| k == kind),
|
|
"setup: no {kind} in {kinds:?}"
|
|
);
|
|
}
|
|
let sweep = |cache: &crate::protocol::WizardCache, kind: &str| {
|
|
let mut panics = vec![];
|
|
for width in 20..=100u16 {
|
|
for height in 3..=20u16 {
|
|
let drawn = std::panic::catch_unwind(|| {
|
|
let area = Rect::new(0, 0, width, height);
|
|
ImportWizardWidget::new(cache).render(area, &mut Buffer::empty(area));
|
|
});
|
|
if drawn.is_err() {
|
|
panics.push(format!("{kind} {width}x{height}"));
|
|
}
|
|
}
|
|
}
|
|
panics
|
|
};
|
|
let caches: Vec<_> = steps.iter().map(project::wizard_cache).collect();
|
|
let panics: Vec<String> = std::thread::scope(|scope| {
|
|
let running: Vec<_> = caches
|
|
.iter()
|
|
.zip(&kinds)
|
|
.map(|(cache, kind)| scope.spawn(move || sweep(cache, kind)))
|
|
.collect();
|
|
running
|
|
.into_iter()
|
|
.flat_map(|t| t.join().expect("the sweep itself does not panic"))
|
|
.collect()
|
|
});
|
|
assert!(panics.is_empty(), "{} panics: {:?}", panics.len(), panics);
|
|
}
|
|
|
|
const PREVIEW_STEP: &str = concat!(
|
|
"┌ Import Wizard — Preview ─────────────────────────────────────────────────────┐\n",
|
|
"│Array of 0 records. Sample keys: │\n",
|
|
"│ │\n",
|
|
"│Press Enter to continue… │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"└──────────────────────────────────────────────────────────────────────────────┘",
|
|
);
|
|
|
|
const ARRAY_PATH_STEP: &str = concat!(
|
|
"┌ Import Wizard — Select Array ────────────────────────────────────────────────┐\n",
|
|
"│Select the path containing records: │\n",
|
|
"│ costs │\n",
|
|
"│ sales │\n",
|
|
"│ │\n",
|
|
"│↑↓ select Enter confirm ← back │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"└──────────────────────────────────────────────────────────────────────────────┘",
|
|
);
|
|
const EVERY_STEP: &str = concat!(
|
|
"ReviewProposals\n",
|
|
"┌ Import Wizard — Review Fields ───────────────────────────────────────────────┐\n",
|
|
"│Review field proposals (Space toggle, c cycle kind): │\n",
|
|
"│ Field Kind Accept │\n",
|
|
"│ Cost Measure (numeric) [✓] │\n",
|
|
"│ Region Category (dimension) [✓] │\n",
|
|
"│ Revenue Measure (numeric) [✓] │\n",
|
|
"│ When Time Category [✓] │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│Enter: next Space: toggle c: cycle kind ← back Esc: cancel │\n",
|
|
"└──────────────────────────────────────────────────────────────────────────────┘\n",
|
|
"\n",
|
|
"====\n",
|
|
"\n",
|
|
"ConfigureDates\n",
|
|
"┌ Import Wizard — Date Components ─────────────────────────────────────────────┐\n",
|
|
"│Select date components to extract (Space toggle): │\n",
|
|
"│ When (format: %Y-%m-%d) │\n",
|
|
"│ [ ] Year │\n",
|
|
"│ [ ] Month │\n",
|
|
"│ [ ] Quarter │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│Space: toggle Enter: next ← back Esc: cancel │\n",
|
|
"└──────────────────────────────────────────────────────────────────────────────┘\n",
|
|
"\n",
|
|
"====\n",
|
|
"\n",
|
|
"DefineFormulas\n",
|
|
"┌ Import Wizard — Formulas ────────────────────────────────────────────────────┐\n",
|
|
"│Define formulas (optional): │\n",
|
|
"│ (no formulas yet) │\n",
|
|
"│ │\n",
|
|
"│Examples: │\n",
|
|
"│ Diff = Cost - Revenue │\n",
|
|
"│ Total = SUM(Cost) │\n",
|
|
"│ Ratio = Cost / Revenue │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│n: new formula d: delete Enter: next ← back Esc: cancel │\n",
|
|
"└──────────────────────────────────────────────────────────────────────────────┘\n",
|
|
"\n",
|
|
"====\n",
|
|
"\n",
|
|
"NameModel\n",
|
|
"┌ Import Wizard — Name Model ──────────────────────────────────────────────────┐\n",
|
|
"│Model name: │\n",
|
|
"│> Imported Model█ │\n",
|
|
"│ │\n",
|
|
"│Enter: build ← back Esc: cancel │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"└──────────────────────────────────────────────────────────────────────────────┘\n",
|
|
"\n",
|
|
"====\n",
|
|
"\n",
|
|
"Done\n",
|
|
"┌ Import Wizard — Confirm ─────────────────────────────────────────────────────┐\n",
|
|
"│Imported Model — 2 categories, 2 measures │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│Enter: accept ←/Esc: back │\n",
|
|
"└──────────────────────────────────────────────────────────────────────────────┘",
|
|
);
|
|
const FORMULA_STEP: &str = concat!(
|
|
"┌ Import Wizard — Formulas ────────────────────────────────────────────────────┐\n",
|
|
"│Define formulas (optional): │\n",
|
|
"│ Profit = Revenue - Cost │\n",
|
|
"│ │\n",
|
|
"│Formula (e.g., Profit = Revenue - Cost): │\n",
|
|
"│> Marg█ │\n",
|
|
"│ │\n",
|
|
"│Examples: │\n",
|
|
"│ Diff = Cost - Revenue │\n",
|
|
"│ Total = SUM(Cost) │\n",
|
|
"│ Ratio = Cost / Revenue │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
"│ │\n",
|
|
// improvise-q0vb: this line used to read `something went wrongcel`.
|
|
// The only one of the eight pinned frames that commit moved.
|
|
"│something went wrong │\n",
|
|
"└──────────────────────────────────────────────────────────────────────────────┘",
|
|
);
|
|
}
|