Merge #295 (65d6a3e1, improvise-7mro projected reads) into the editor integration branch

Resolutions against gh/main 8d870c15:
- client.rs: kept main's settle_status (1x9h) and #295's
  settle_declined_editor (5m9z) side by side.
- resolve.rs reopened_at: took #295's declined_at shape and moved #292's
  CellId::NonItem arm into declined_at, which reopened_at consults before
  any seed choice, so a NonItem landing still re-opens nothing.
- lockstep.rs: both sides appended at EOF; kept main's tail then #295's.
  Both defined sent_to_host with different semantics (main's panics on a
  non-local key, #295's lets an upstream key reach a silent host), so
  #295's copy is renamed sent_or_upstream with its three callers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
This commit is contained in:
Edward Langley
2026-09-24 12:10:53 -07:00
5 changed files with 936 additions and 58 deletions
+25 -3
View File
@@ -248,10 +248,11 @@ impl ClientState {
self.view.opened_editor = Some(crate::OpenedEditor::on(seed));
}
self.settle_awaited_seed();
self.settle_declined_editor();
}
/// An editor opened after a write takes its seed from the grid the host
/// answered with, never from the cache the write made stale (improvise-3uzg).
/// An editor opened after a write, or on a cell not projected yet, takes its
/// seed once both are answered, and never over typing (improvise-3uzg/7mro).
fn settle_awaited_seed(&mut self) {
let waiting = self.view.mode.is_editing()
&& self
@@ -260,7 +261,8 @@ impl ClientState {
.as_ref()
.is_some_and(|opened| opened.awaiting && !opened.touched);
let answered = !self.in_flight.iter().any(|sent| sent.awaited);
if let (true, true, Some(seed)) = (waiting, answered, crate::resolve::edit_seed(self)) {
if let (true, true, Some(seed)) = (waiting, answered, crate::resolve::projected_seed(self))
{
self.view.buffers.insert("edit".to_string(), seed.clone());
self.view.opened_editor = Some(crate::OpenedEditor::on(seed));
}
@@ -278,6 +280,22 @@ impl ClientState {
}
}
/// An untouched editor on a cell `i` declines closes once the host projects
/// that cell: an advance past the window opens before it can know (5m9z).
fn settle_declined_editor(&mut self) {
let (row, col) = self.view.cursor().selected;
let untouched = self.view.mode.is_editing()
&& self
.view
.opened_editor
.as_ref()
.is_some_and(|opened| !opened.touched);
if let (true, Some(refusal)) = (untouched, crate::resolve::declined_at(self, row, col)) {
self.view.mode = settled_mode(&crate::AppMode::Normal, self.grid.records);
self.view.status_msg = refusal;
}
}
/// The widget dispatch: a projection names the widget it is addressed to.
/// The `CategoryTreeCache`/`TileBarCache` overlap is deliberate (gotcha 32).
fn apply_projection(&mut self, projection: &Projection) {
@@ -577,6 +595,10 @@ mod tests {
fn answered_with(state: &mut ClientState, edit_text: &str) {
let mut cell = CellDisplay::number(edit_text);
cell.edit_text = Some(edit_text.into());
cell.id = Some(crate::CellId::Record(crate::RecordCell {
record: serde_json::from_str("\"fe4c8e5e9cbd578e0d388f4b2b5a7879\"").unwrap(),
measure: Some("Revenue".into()),
}));
for cmd in [
GridUpdate::RowFrame(AxisFrame::flat(["North"])),
GridUpdate::ColFrame(AxisFrame::flat(["Revenue"])),
+106 -45
View File
@@ -313,7 +313,7 @@ enum Step {
fn step(state: &ClientState, event: KeyEvent, name: &str, args: &[String]) -> Step {
match requested(name) {
Some(request) => Step::Request(request),
None => match declined(name, args) {
None => match declined(name, args).or_else(|| refused_here(state, name)) {
Some(said) => Step::Declined(vec![Command::SetStatus(said)]),
None => match composed(state, event, name, args) {
Some(commands) => Step::Local(commands),
@@ -323,6 +323,18 @@ fn step(state: &ClientState, event: KeyEvent, name: &str, args: &[String]) -> St
}
}
/// A commit onto a cell `i` declines is refused as `committable_cell_key` refuses
/// it, out loud with the binding (ik6l); a cell op on an unprojected cell waits (7mro).
fn refused_here(state: &ClientState, name: &str) -> Option<String> {
let (row, col) = state.view.cursor().selected;
let waiting = unprojected(state, row, col).then(|| NOT_PROJECTED.to_string());
match name {
"commit-cell-edit" | "commit-and-advance-right" => declined_at(state, row, col).or(waiting),
"clear-cell" | "yank" | "paste" => waiting,
_ => None,
}
}
/// The command names this client answers by **asking its host**, rather than
/// by composing anything. See `context/code/clients.md` (improvise-jz6w).
fn requested(name: &str) -> Option<Request> {
@@ -835,13 +847,11 @@ fn tile_cursor(state: &ClientState, delta: i32) -> Option<Vec<Command>> {
// ── editing a cell ──────────────────────────────────────────────────────
/// The cell under the cursor in this viewport-indexed cache. `None` when the
/// selection is outside the window — reachable only by a projection race.
/// The cell under the cursor, as the host projected it. `None` outside the
/// window the cache holds, which the cursor's own offsets may have left (7mro).
fn cell_at_cursor(state: &ClientState) -> Option<&crate::CellDisplay> {
let cursor = state.view.cursor();
let row = cursor.selected.0.checked_sub(cursor.row_offset)?;
let col = cursor.selected.1.checked_sub(cursor.col_offset)?;
state.grid.cell(row, col)
let (row, col) = state.view.cursor().selected;
projected_at(state, row, col)
}
/// `EditOrDrill::execute`, with ADR 002 answering the branch, and drill
@@ -852,6 +862,10 @@ fn edit_or_drill(state: &ClientState, mode: AppMode) -> Option<Vec<Command>> {
if state.grid.records {
return Some(vec![Command::SetStatus(not_yet("editing in records mode"))]);
}
let (row, col) = state.view.cursor().selected;
if unprojected(state, row, col) {
return Some(awaiting_seed(mode));
}
let cell = cell_at_cursor(state)?;
match cell.id.as_ref()? {
// `edit_text`, never `text`: the drawn text has been through
@@ -898,6 +912,19 @@ fn seeded(seed: String, mode: AppMode) -> Vec<Command> {
]
}
/// Open the cell editor with its seed still to come — after a write, or on a cell
/// not shown yet — settled by the fold once both are answered (3uzg/7mro).
fn awaiting_seed(mode: AppMode) -> Vec<Command> {
vec![
Command::SetBuffer {
name: EDIT_BUFFER.to_string(),
value: String::new(),
},
Command::SetOpenedEditor(Some(OpenedEditor::awaiting())),
Command::ChangeMode(mode),
]
}
/// One direction of the view history. An empty stack is answered out loud:
/// `reduce_view`'s own arm is silent there — `context/code/clients.md`.
fn step_history(stack: &[crate::ViewFrame], step: Command, empty: &str) -> Vec<Command> {
@@ -1104,19 +1131,6 @@ fn advanced(
Some(commands)
}
/// Open the cell editor with its seed still to come: the client settles it from
/// the host's answer, once nothing this client sent is unanswered (improvise-3uzg).
fn awaiting_seed(mode: AppMode) -> Vec<Command> {
vec![
Command::SetBuffer {
name: EDIT_BUFFER.to_string(),
value: String::new(),
},
Command::SetOpenedEditor(Some(OpenedEditor::awaiting())),
Command::ChangeMode(mode),
]
}
/// The surrogate naming the record under the cursor, or `None` when the
/// cursor is not on a writable cell.
fn record_at_cursor(state: &ClientState) -> Option<crate::RecordHash> {
@@ -1136,19 +1150,18 @@ pub(crate) fn edit_seed(state: &ClientState) -> Option<String> {
cell_at_cursor(state)?.edit_text.clone()
}
/// The display text at an absolute grid position, as the cache holds it.
fn seed_at(state: &ClientState, row: usize, col: usize) -> String {
cell_at(state, row, col)
.and_then(|cell| cell.edit_text.clone())
.unwrap_or_default()
/// What an editor awaiting its seed takes once the host projects the cell under
/// the cursor: `None` until then, a relabel's blank included (improvise-7mro).
pub(crate) fn projected_seed(state: &ClientState) -> Option<String> {
let cell = cell_at_cursor(state).filter(|cell| cell.id.is_some())?;
Some(cell.edit_text.clone().unwrap_or_default())
}
/// The projected cell at an absolute grid position, `None` outside the window.
fn cell_at(state: &ClientState, row: usize, col: usize) -> Option<&crate::CellDisplay> {
let cursor = state.view.cursor();
let r = row.checked_sub(cursor.row_offset)?;
let c = col.checked_sub(cursor.col_offset)?;
state.grid.cell(r, c)
/// The edit text the host projected at an absolute grid position.
fn seed_at(state: &ClientState, row: usize, col: usize) -> String {
projected_at(state, row, col)
.and_then(|cell| cell.edit_text.clone())
.unwrap_or_default()
}
/// `EnterEditAtCursor`: re-open the editor where the advance landed, unless `i`
@@ -1160,19 +1173,48 @@ fn reopened_at(
mode: AppMode,
writes: bool,
) -> Vec<Command> {
match cell_at(state, row, col).and_then(|cell| cell.id.as_ref()) {
Some(CellId::Aggregate(_)) => vec![Command::SetStatus(AGGREGATE_DECLINE.to_string())],
Some(CellId::Derived { target }) => vec![Command::SetStatus(derived_decline(target))],
Some(CellId::NonItem { category, item }) => {
vec![Command::SetStatus(crate::non_item_refusal(category, item))]
}
Some(CellId::Record(_) | CellId::Create(_) | CellId::Coordinate) | None => match writes {
true => awaiting_seed(mode),
false => opened_on(state, seed_at(state, row, col), mode),
},
match (declined_at(state, row, col), writes) {
(Some(refusal), _) => vec![Command::SetStatus(refusal)],
(None, true) => awaiting_seed(mode),
(None, false) if unprojected(state, row, col) => awaiting_seed(mode),
(None, false) => opened_on(state, seed_at(state, row, col), mode),
}
}
/// Why `i` declines the cell the host projected at `(row, col)`. `None` for an
/// editable cell, and for one not projected yet, which the fold settles later.
pub(crate) fn declined_at(state: &ClientState, row: usize, col: usize) -> Option<String> {
match projected_at(state, row, col)?.id.as_ref()? {
CellId::Aggregate(_) => Some(AGGREGATE_DECLINE.to_string()),
CellId::Derived { target } => Some(derived_decline(target)),
CellId::NonItem { category, item } => Some(crate::non_item_refusal(category, item)),
CellId::Record(_) | CellId::Create(_) | CellId::Coordinate => None,
}
}
/// The cell the host projected at an absolute position, read against the frame's
/// own origin: offsets the client has scrolled ahead of the cache cannot alias it.
fn projected_at(state: &ClientState, row: usize, col: usize) -> Option<&crate::CellDisplay> {
let r = row.checked_sub(state.grid.row_frame.first)?;
let c = col.checked_sub(state.grid.col_frame.first)?;
state.grid.cell(r, c)
}
/// Whether `(row, col)` is a cell of the grid that the window the cache holds
/// does not cover: unknown, never the cell at the same cache index (7mro).
pub(crate) fn unprojected(state: &ClientState, row: usize, col: usize) -> bool {
let GridExtent { rows, cols } = state.grid.extent;
let outside = |frame: &crate::AxisFrame, at: usize| {
!(frame.first..frame.first + frame.len()).contains(&at)
};
row < rows
&& col < cols
&& (outside(&state.grid.row_frame, row) || outside(&state.grid.col_frame, col))
}
/// What a cell op says while its cell is still unknown to this client.
const NOT_PROJECTED: &str = "waiting for the host to show this cell";
/// What the host says of an aggregate, the fold of a collapsed group included.
const AGGREGATE_DECLINE: &str =
"Cannot write to an aggregate — it combines several records, so there is no one cell to write";
@@ -1189,8 +1231,8 @@ pub struct OpenedEditor {
pub seed: String,
/// Whether the user has edited the buffer since it opened (improvise-2054).
pub touched: bool,
/// The seed is still to come from the host's answer (improvise-3uzg). Not
/// `serde(default)`: `false` is a claim — `protocol.md` §3, improvise-mgis.
/// The seed is still to come, from a write's answer or an unprojected cell
/// (3uzg/7mro). Not `serde(default)`: `false` is a claim (`protocol.md` §3, mgis).
pub awaiting: bool,
}
@@ -3583,7 +3625,7 @@ mod tests {
Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat(["Revenue"]))),
Projection::Grid(GridUpdate::Cells(vec![
((0, 0), first),
((1, 0), CellDisplay::number("900")),
((1, 0), CellDisplay::number("900").with_id(record_cell())),
])),
Projection::Grid(GridUpdate::Extent(GridExtent { rows: 2, cols: 1 })),
Projection::Basis(published()),
@@ -3894,7 +3936,7 @@ mod tests {
Projection::Grid(GridUpdate::Cells(vec![
((0, 0), CellDisplay::number("900")),
((1, 0), cell),
((2, 0), CellDisplay::number("800")),
((2, 0), CellDisplay::number("800").with_id(record_cell())),
])),
Projection::Grid(GridUpdate::Extent(GridExtent { rows: 3, cols: 1 })),
Projection::Basis(published()),
@@ -4524,6 +4566,25 @@ mod tests {
`KeyOutcome::Request` has nowhere to put one"
);
}
/// improvise-7mro: an empty grid has no cell to wait for, so nothing a cell op
/// says there is a wait. See `context/test-evidence/clients.md`.
#[test]
fn a_cursor_on_an_empty_grid_waits_for_nothing() {
let mut state = in_mode(AppMode::Normal);
state.window = (2, 2);
for key in ['i', 'x'] {
let said = match resolve(&state, ev(KeyCode::Char(key))) {
KeyOutcome::Local(cmds) => cmds,
_ => Vec::new(),
};
assert!(
!said.contains(&Command::SetStatus(NOT_PROJECTED.to_string()))
&& !said.contains(&Command::SetOpenedEditor(Some(OpenedEditor::awaiting()))),
"{key}: waits on a cell no grid has: {said:?}"
);
}
}
}
#[cfg(test)]
@@ -2277,6 +2277,262 @@ fn fold_host() -> App {
app
}
/// Measures on Row (`Profit` both stored and a formula's), months on Column,
/// so a `Down` from the bottom row wraps to the top of the next column.
fn wrapping_host() -> App {
let mut wb = Workbook::new("Test");
wb.add_category("Month").unwrap();
for month in ["Jan", "Feb"] {
wb.add_item("Month", month).unwrap();
}
for measure in ["Revenue", "Profit", "Cost"] {
wb.add_item("_Measure", measure).unwrap();
}
wb.model.add_formula(
improvise_command::formula::parse_formula("Profit = Revenue - Cost", "_Measure").unwrap(),
);
wb.active_view_mut().set_axis("_Measure", Axis::Row);
wb.active_view_mut().set_axis("Month", Axis::Column);
for month in ["Jan", "Feb"] {
for (measure, n) in [("Revenue", 10.0), ("Cost", 4.0)] {
let key = improvise_command::model::cell::CellKey::new(vec![
("Month".to_string(), month.to_string()),
("_Measure".to_string(), measure.to_string()),
]);
wb.model
.set_cell(key, improvise_protocol::CellValue::Number(n));
}
}
let mut app = App::new(wb, None);
app.rebuild_layout();
app
}
/// improvise-y5eb: the settle must not read the cache through offsets it has not
/// caught up with. See `context/test-evidence/clients.md`.
#[test]
fn a_wrapping_advance_settles_on_the_cell_it_lands_on_not_a_stale_one() {
let mut tui = wrapping_host();
tui.cursor_mut().selected = (2, 0);
tui.cursor_mut().row_offset = 1;
for k in ["i", "Enter"] {
reduce_key(&mut tui, BrowserKey::new(k).to_key_event().unwrap());
}
assert_eq!(
tui.view_state.cursor().selected,
(0, 1),
"setup: Enter wrapped"
);
assert!(
tui.view_state.mode.is_editing(),
"setup: Revenue/Feb is editable"
);
let mut host = improvise_command::ui::host::Host::new(wrapping_host());
let (session, welcome) = host.connect(2, 6);
let mut client = ClientState {
window: (2, 6),
..ClientState::default()
};
deliver(vec![welcome], session, &mut client);
let placed = vec![
improvise_protocol::Command::SetSelected { row: 2, col: 0 },
improvise_protocol::Command::SetRowOffset(1),
];
client.fold_outgoing(&placed);
deliver(
host.handle(session, ClientFrame::Commands(placed)),
session,
&mut client,
);
for k in ["i", "Enter"] {
let event = BrowserKey::new(k).to_key_event().unwrap();
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
panic!("{k}: the client must answer this key locally");
};
client.fold_outgoing(&cmds);
deliver(
host.handle(session, ClientFrame::Commands(cmds)),
session,
&mut client,
);
}
assert_eq!(client.view.cursor().selected, (0, 1));
assert_eq!(
format!("{:?}", client.view.mode),
format!("{:?}", tui.view_state.mode),
"the client settled on a cell not under its cursor: {:?}",
client.view.status_msg
);
}
/// improvise-ik6l: Enter on a typed-into editor over a cell `i` declines says
/// so on both ends. See `context/test-evidence/clients.md`.
#[test]
fn enter_on_an_editor_over_a_declined_cell_declines_out_loud_on_both_ends() {
type Case = (fn() -> App, (usize, usize), &'static str);
let cases: [Case; 4] = [
(fold_host, (2, 1), "Enter"),
(yank_host, (1, 3), "Enter"),
(fold_host, (2, 1), "Tab"),
(yank_host, (1, 3), "Tab"),
];
for (make, at, key) in cases {
let mut app = make();
app.cursor_mut().selected = at;
app.view_state.mode = AppMode::editing();
app.view_state.buffers.insert("edit".into(), "7".into());
app.view_state.opened_editor = Some(improvise_protocol::OpenedEditor {
seed: String::new(),
touched: true,
awaiting: false,
});
let before = document_text(&app);
let mut client = client_ready_to_edit(&app, (6, 6));
let event = BrowserKey::new(key).to_key_event().unwrap();
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
panic!("{at:?}: {key} went upstream, where nothing answers it");
};
for cmd in &cmds {
client.fold(cmd);
}
reduce_key(&mut app, event);
assert_eq!(document_text(&app), before, "setup: the TUI wrote nothing");
assert!(
cmds.iter()
.all(|c| !matches!(c, improvise_protocol::Command::Model { .. })),
"{at:?}: the browser composed a write"
);
assert_eq!(
format!("{:?}", client.view.mode),
format!("{:?}", app.view_state.mode),
"{at:?}: the two ends disagree about the mode"
);
assert_eq!(client.view.buffers, app.view_state.buffers, "{at:?}");
assert!(
!app.view_state.status_msg.is_empty() && !client.view.status_msg.is_empty(),
"{at:?}: a silent decline: TUI {:?}, browser {:?}",
app.view_state.status_msg,
client.view.status_msg
);
}
}
/// improvise-5m9z's other half: what the user typed before the host answered
/// is not thrown away. See `context/test-evidence/clients.md`.
#[test]
fn typing_before_the_host_answers_keeps_the_editor_past_the_window() {
let mut host = improvise_command::ui::host::Host::new(fold_host());
let (session, welcome) = host.connect(2, 6);
let mut client = ClientState {
window: (2, 6),
..ClientState::default()
};
deliver(vec![welcome], session, &mut client);
let placed = vec![improvise_protocol::Command::SetSelected { row: 1, col: 1 }];
client.fold_outgoing(&placed);
deliver(
host.handle(session, ClientFrame::Commands(placed)),
session,
&mut client,
);
let mut held = Vec::new();
for k in ["i", "Enter", "7"] {
let event = BrowserKey::new(k).to_key_event().unwrap();
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
panic!("{k}: the client must answer this key locally");
};
client.fold_outgoing(&cmds);
held.push(host.handle(session, ClientFrame::Commands(cmds)));
}
assert_eq!(client.view.cursor().selected, (2, 1), "setup: on the fold");
for reply in held {
deliver(reply, session, &mut client);
}
assert!(
client.view.mode.is_editing(),
"the typed 7 was thrown away: {:?}",
client.view.mode
);
assert_eq!(
client.view.buffers.get("edit").map(String::as_str),
Some("7")
);
}
/// improvise-5m9z: an advance past the client's window lands on a cell it holds
/// no id for, and must settle as the TUI does. See `context/test-evidence/clients.md`.
#[test]
fn advancing_the_editor_past_the_window_settles_where_the_tui_does() {
let expanded = || {
let mut app = fold_host();
let view = app.model_state.workbook.active_view_mut();
view.toggle_group_collapse("Month", "Q2");
app.rebuild_layout();
app
};
type Case = (
fn() -> App,
(usize, usize),
(usize, usize),
&'static str,
bool,
);
let cases: [Case; 3] = [
(fold_host, (2, 6), (1, 1), "Enter", false),
(yank_host, (6, 3), (1, 2), "Tab", false),
(expanded, (2, 6), (1, 1), "Enter", true),
];
for (make, window, at, key, editing) in cases {
let mut tui = make();
tui.cursor_mut().selected = at;
for k in ["i", key] {
reduce_key(&mut tui, BrowserKey::new(k).to_key_event().unwrap());
}
assert_eq!(tui.view_state.mode.is_editing(), editing, "setup: the TUI");
let mut host = improvise_command::ui::host::Host::new(make());
let (session, welcome) = host.connect(window.0, window.1);
let mut client = ClientState {
window,
..ClientState::default()
};
deliver(vec![welcome], session, &mut client);
let placed = vec![improvise_protocol::Command::SetSelected {
row: at.0,
col: at.1,
}];
client.fold_outgoing(&placed);
deliver(
host.handle(session, ClientFrame::Commands(placed)),
session,
&mut client,
);
for k in ["i", key] {
let event = BrowserKey::new(k).to_key_event().unwrap();
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
panic!("{k}: the client must answer this key locally");
};
client.fold_outgoing(&cmds);
deliver(
host.handle(session, ClientFrame::Commands(cmds)),
session,
&mut client,
);
}
assert_eq!(
client.view.cursor().selected,
tui.view_state.cursor().selected,
"{key}: the advance must land past the window on both ends"
);
assert_eq!(
format!("{:?}", client.view.mode),
format!("{:?}", tui.view_state.mode),
"{key} from {at:?} in a {window:?} window: the client and the TUI disagree"
);
}
}
/// improvise-enz6: a commit that advances onto a cell `i` declines re-opens no
/// editor there, on either end. See `context/test-evidence/clients.md`.
#[test]
@@ -3367,3 +3623,285 @@ fn a_move_sent_before_a_pruning_commit_is_answered_writes_where_the_tui_writes()
"the browser wrote a different record from the one the TUI wrote"
);
}
/// One scroll-then-edit case: the window, where the cursor starts, and the keys.
type Pipelined = (
&'static str,
(usize, usize),
((usize, usize), usize, usize),
&'static [&'static str],
);
/// When the host's replies reach the client.
#[derive(Debug, Clone, Copy, PartialEq)]
enum Replies {
/// Each before the next key.
Delivered,
/// All held to the end, so every key reads the first window.
Held,
/// Held until the last two keys, which then run delivered.
HeldThenTyped,
}
/// `wrapping_host`'s cells that differ from how it was built, as `Month/Measure`.
fn written_cells(app: &App) -> Vec<String> {
let model = &app.model_state.workbook.model;
let mut written = Vec::new();
for month in ["Jan", "Feb"] {
for (measure, built) in [
("Revenue", Some(10.0)),
("Profit", None),
("Cost", Some(4.0)),
] {
let key = improvise_command::model::cell::CellKey::new(vec![
("Month".to_string(), month.to_string()),
("_Measure".to_string(), measure.to_string()),
]);
let now = model.get_cell(&key).cloned();
if now != built.map(improvise_protocol::CellValue::Number) {
written.push(format!("{month}/{measure}"));
}
}
}
written
}
/// One key on the client, its frame sent to the host; the reply comes back for
/// the caller to deliver or hold. An upstream key reaches a host that answers nothing.
fn sent_or_upstream(
host: &mut improvise_command::ui::host::Host,
session: improvise_command::ui::project::SessionId,
client: &mut ClientState,
key: &str,
) -> Vec<improvise_command::ui::host::Addressed> {
match resolve(client, BrowserKey::new(key).to_key_event().unwrap()) {
KeyOutcome::Local(cmds) => {
client.fold_outgoing(&cmds);
host.handle(session, ClientFrame::Commands(cmds))
}
_ => Vec::new(),
}
}
/// A host over `make()` and a client on it, the cursor placed and answered.
fn placed_client(
make: fn() -> App,
window: (usize, usize),
(at, row_offset, col_offset): ((usize, usize), usize, usize),
) -> (
improvise_command::ui::host::Host,
improvise_command::ui::project::SessionId,
ClientState,
) {
let mut host = improvise_command::ui::host::Host::new(make());
let (session, welcome) = host.connect(window.0, window.1);
let mut client = ClientState {
window,
..ClientState::default()
};
deliver(vec![welcome], session, &mut client);
let placed = vec![
improvise_protocol::Command::SetSelected {
row: at.0,
col: at.1,
},
improvise_protocol::Command::SetRowOffset(row_offset),
improvise_protocol::Command::SetColOffset(col_offset),
];
client.fold_outgoing(&placed);
deliver(
host.handle(session, ClientFrame::Commands(placed)),
session,
&mut client,
);
(host, session, client)
}
/// improvise-7mro: the offset-aliasing matrix, with the host's replies delivered,
/// held, and held until the typing. See `context/test-evidence/clients.md`.
#[test]
fn keys_sent_before_a_scroll_is_answered_agree_with_the_tui() {
let cases: [Pipelined; 7] = [
(
"wrap",
(2, 6),
((2, 0), 1, 0),
&["i", "Enter", "8", "Enter"],
),
(
"horizontal wrap",
(2, 1),
((2, 0), 1, 0),
&["i", "Enter", "8", "Enter"],
),
("G", (2, 6), ((0, 0), 0, 0), &["G", "i", "7", "Enter"]),
("$", (2, 1), ((0, 0), 0, 0), &["$", "i", "7", "Enter"]),
("gg", (2, 6), ((2, 0), 1, 0), &["g", "g", "i", "7", "Enter"]),
("$ x", (2, 1), ((0, 0), 0, 0), &["$", "x"]),
("gg p", (2, 6), ((2, 0), 1, 0), &["y", "y", "g", "g", "p"]),
];
for (name, window, start, keys) in cases {
let mut tui = wrapping_host();
tui.cursor_mut().selected = start.0;
for &k in keys {
reduce_key(&mut tui, BrowserKey::new(k).to_key_event().unwrap());
}
assert_eq!(
written_cells(&tui).len(),
1,
"setup: {name}: the TUI wrote one cell"
);
for replies in [Replies::Delivered, Replies::Held, Replies::HeldThenTyped] {
let what = format!("{name} ({keys:?}, replies {replies:?})");
let (mut host, session, mut client) = placed_client(wrapping_host, window, start);
let typed_from = match replies {
Replies::Delivered => 0,
Replies::Held => keys.len(),
Replies::HeldThenTyped => keys.len().saturating_sub(2),
};
let mut held = Vec::new();
for (i, &k) in keys.iter().enumerate() {
if i == typed_from {
deliver(std::mem::take(&mut held), session, &mut client);
}
held.extend(sent_or_upstream(&mut host, session, &mut client, k));
if i >= typed_from {
deliver(std::mem::take(&mut held), session, &mut client);
}
}
if replies == Replies::Held {
assert_eq!(
written_cells(host.app()),
Vec::<String>::new(),
"{what}: a write was composed before its target was projected"
);
assert!(
client.view.mode.is_editing() == keys.contains(&"i")
&& !client.view.status_msg.is_empty(),
"{what}: the last key must wait out loud, any editor open: {:?}, {:?}",
client.view.mode,
client.view.status_msg
);
deliver(std::mem::take(&mut held), session, &mut client);
let retry = keys.last().expect("a key");
let reply = sent_or_upstream(&mut host, session, &mut client, retry);
deliver(reply, session, &mut client);
}
assert_eq!(
written_cells(host.app()),
written_cells(&tui),
"{what}: the browser wrote a different cell from the TUI"
);
if replies != Replies::Held {
assert_eq!(document_text(host.app()), document_text(&tui), "{what}");
assert_eq!(
client.view.buffers.get("edit"),
tui.view_state.buffers.get("edit"),
"{what}: the editor's buffer"
);
}
assert_eq!(
client.view.cursor().selected,
tui.view_state.cursor().selected,
"{what}: the cursor"
);
assert_eq!(
format!("{:?}", client.view.mode),
format!("{:?}", tui.view_state.mode),
"{what}: the mode; browser status {:?}",
client.view.status_msg
);
}
}
}
/// Four regions by two measures, every cell stored and no two alike, so a cell
/// read at the wrong row or column is a different value.
fn four_by_two_host() -> App {
let mut wb = Workbook::new("Test");
wb.add_category("Region").unwrap();
for region in ["R0", "R1", "R2", "R3"] {
wb.add_item("Region", region).unwrap();
}
for measure in ["A", "B"] {
wb.add_item("_Measure", measure).unwrap();
}
wb.active_view_mut().set_axis("Region", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column);
for (r, region) in ["R0", "R1", "R2", "R3"].into_iter().enumerate() {
for (base, measure) in [(10.0, "A"), (20.0, "B")] {
let key = improvise_command::model::cell::CellKey::new(vec![
("Region".to_string(), region.to_string()),
("_Measure".to_string(), measure.to_string()),
]);
wb.model
.set_cell(key, improvise_protocol::CellValue::Number(base + r as f64));
}
}
let mut app = App::new(wb, None);
app.rebuild_layout();
app
}
/// improvise-7mro: a cell inside the held window, read while the client's offsets
/// differ from the frame's origin. See `context/test-evidence/clients.md`.
#[test]
fn a_held_cell_is_read_at_its_position_and_not_its_cache_index() {
for keys in [
&["k", "k", "j", "i", "7", "Enter"][..],
&["k", "k", "j", "i", "Tab"][..],
] {
let mut tui = four_by_two_host();
tui.cursor_mut().selected = (3, 0);
for &k in keys {
reduce_key(&mut tui, BrowserKey::new(k).to_key_event().unwrap());
}
let (mut host, session, mut client) =
placed_client(four_by_two_host, (2, 6), ((3, 0), 2, 0));
let mut held = Vec::new();
for (i, &k) in keys.iter().enumerate() {
if i == 3 {
let cursor = client.view.cursor();
assert_eq!(
(
cursor.selected,
cursor.row_offset,
client.grid.row_frame.first
),
((2, 0), 1, 2),
"setup: inside the held window, the offsets not its origin"
);
}
held.extend(sent_or_upstream(&mut host, session, &mut client, k));
}
assert_eq!(document_text(host.app()), document_text(&tui), "{keys:?}");
assert_eq!(
client.view.cursor().selected,
tui.view_state.cursor().selected
);
// An editor reopened after a write awaits the host's seed (p6v6): read it
// only once the held replies arrive; an unawaited one is read now as well.
let awaiting = client
.view
.opened_editor
.as_ref()
.is_some_and(|o| o.awaiting);
if !awaiting {
assert_editor_agrees(&client, &tui, keys, "held");
}
deliver(held, session, &mut client);
assert_editor_agrees(&client, &tui, keys, "delivered");
}
}
fn assert_editor_agrees(client: &ClientState, tui: &App, keys: &[&str], when: &str) {
assert_eq!(
client.view.buffers.get("edit"),
tui.view_state.buffers.get("edit"),
"{keys:?}, {when}"
);
assert_eq!(
client.view.opened_editor, tui.view_state.opened_editor,
"{keys:?}, {when}: the editor opened on another cell's seed"
);
}