diff --git a/crates/improvise-command/src/command/cmd/core.rs b/crates/improvise-command/src/command/cmd/core.rs index e3f9e876..c42a2453 100644 --- a/crates/improvise-command/src/command/cmd/core.rs +++ b/crates/improvise-command/src/command/cmd/core.rs @@ -11,7 +11,7 @@ use crate::ui::app::AppMode; use crate::ui::effect::{Effect, Panel}; use crate::view::{Axis, GridLayout, View}; use crate::workbook::Workbook; -use improvise_protocol::CellId; +use improvise_protocol::{CellId, non_item_refusal}; /// What a command says when the cursor sits on a collapsed group's row (§14). pub const FOLD_REFUSAL: &str = "Collapsed group — press z to expand it first"; @@ -102,6 +102,7 @@ impl<'a> CmdContext<'a> { match self.cursor_cell_id() { Some(CellId::Aggregate(_)) => Err(self.aggregate_refusal()), Some(CellId::Derived { target }) => Err(derived_refusal(&target)), + Some(CellId::NonItem { category, item }) => Err(non_item_refusal(&category, &item)), Some(CellId::Record(_) | CellId::Create(_) | CellId::Coordinate) | None => { self.cell_key().ok_or_else(|| NO_CELL.to_string()) } @@ -116,6 +117,7 @@ impl<'a> CmdContext<'a> { Some(CellId::Aggregate(_)) => Err(self.aggregate_refusal()), Some(CellId::Derived { target }) => Err(derived_refusal(&target)), Some(CellId::Create(_) | CellId::Coordinate) => Err(NO_VALUE_REFUSAL.to_string()), + Some(CellId::NonItem { category, item }) => Err(non_item_refusal(&category, &item)), None => Err(match self.cell_key() { Some(_) => NO_VALUE_REFUSAL, None => NO_CELL, diff --git a/crates/improvise-command/src/ui/app.rs b/crates/improvise-command/src/ui/app.rs index 66472d45..576187e3 100644 --- a/crates/improvise-command/src/ui/app.rs +++ b/crates/improvise-command/src/ui/app.rs @@ -595,10 +595,9 @@ impl App { /// (improvise-b114, improvise-nacd). See `context/code/command.md`. fn non_item_refusal(&self, key: &CellKey) -> Option { let model = &self.model_state.workbook.model; - key.0.iter().find_map(|(cat, name)| { - let item = model.effective_item_names(cat).contains(name); - (!item).then(|| format!("{name} is no item of {cat}, so there is no one cell to write")) - }) + model + .first_non_item(key) + .map(|(cat, name)| improvise_protocol::non_item_refusal(cat, name)) } /// A record the store does not hold is known only by its layout position, and diff --git a/crates/improvise-command/src/ui/project.rs b/crates/improvise-command/src/ui/project.rs index bf4fa3c6..0b0fc0e6 100644 --- a/crates/improvise-command/src/ui/project.rs +++ b/crates/improvise-command/src/ui/project.rs @@ -329,10 +329,16 @@ pub(crate) fn cell_id( // different intent from `SetCell` and still the host's (gotcha 1). RecordCol::Category(_) => return Some(CellId::Coordinate), }; - return Some(CellId::Record(RecordCell { - record: RecordHash::of(record), - measure, - })); + // The refusal's own question, asked here too, so no cell is offered + // for a write the host would then refuse (improvise-gk51). + let key = layout.record_cell_key(row, col)?; + return Some(match model.first_non_item(&key) { + Some((category, item)) => non_item(category, item), + None => CellId::Record(RecordCell { + record: RecordHash::of(record), + measure, + }), + }); } let key = layout.cell_key(row, col)?; // A folded group is an aggregate whatever is on None: no record carries @@ -348,17 +354,26 @@ pub(crate) fn cell_id( let derived = model.formula_for(&key).map(|f| CellId::Derived { target: f.target.clone(), }); + let stored = || match model.first_non_item(&key) { + Some((category, item)) => non_item(category, item), + None => CellId::Record(RecordCell { + measure: key.get(MEASURE_CATEGORY).map(String::from), + record: RecordHash::of(&key), + }), + }; Some(match aggregated { true => CellId::Aggregate(AggregateHash::of(&key)), - false => derived.unwrap_or_else(|| { - CellId::Record(RecordCell { - measure: key.get(MEASURE_CATEGORY).map(String::from), - record: RecordHash::of(&key), - }) - }), + false => derived.unwrap_or_else(stored), }) } +fn non_item(category: &str, item: &str) -> CellId { + CellId::NonItem { + category: category.to_string(), + item: item.to_string(), + } +} + /// The categories the category panel draws: **every** category, virtual ones /// included, with their items and their axis (improvise-vjms, gotcha 7). pub fn category_rows(model: &Model, view: &View) -> Vec { @@ -505,7 +520,8 @@ pub fn published_basis(app: &App, grid: &GridCache) -> Basis { CellId::Aggregate(_) | CellId::Derived { .. } | CellId::Create(_) - | CellId::Coordinate => None, + | CellId::Coordinate + | CellId::NonItem { .. } => None, }) .collect(); basis::published(app.document_generation(), app.layout_version(), records) diff --git a/crates/improvise-core/src/model/types.rs b/crates/improvise-core/src/model/types.rs index eb1ac3a4..4f476d66 100644 --- a/crates/improvise-core/src/model/types.rs +++ b/crates/improvise-core/src/model/types.rs @@ -589,6 +589,15 @@ impl Model { } } + /// The first coordinate of `key` naming no item of its category: the one + /// question a write's refusal and a cell's projection share (improvise-gk51). + pub fn first_non_item<'k>(&self, key: &'k CellKey) -> Option<(&'k str, &'k str)> { + key.0 + .iter() + .find(|(cat, name)| !self.effective_item_names(cat).contains(name)) + .map(|(cat, name)| (cat.as_str(), name.as_str())) + } + /// Category names excluding virtual categories (_Index, _Dim). pub fn regular_category_names(&self) -> Vec<&str> { self.categories diff --git a/crates/improvise-protocol/src/lib.rs b/crates/improvise-protocol/src/lib.rs index 84b55876..f79fa800 100644 --- a/crates/improvise-protocol/src/lib.rs +++ b/crates/improvise-protocol/src/lib.rs @@ -33,7 +33,8 @@ pub use render::{ DocumentCache, FormulaCache, FormulaEntry, FrameEntry, FrameGroup, GridCache, GridExtent, GridUpdate, MAX_ROW_HEADER_W, MIN_ROW_HEADER_W, Projection, RecordCell, Snapshot, TileBarCache, TileEntry, ViewListCache, ViewSummary, WidgetCache, WizardCache, WizardDateField, - WizardFieldKind, WizardProposal, WizardStepKind, header_level_widths, opening_tile, + WizardFieldKind, WizardProposal, WizardStepKind, header_level_widths, non_item_refusal, + opening_tile, }; pub use resolve::{ Click, KeyOutcome, OpenedEditor, Request, click, edited, read_buffer, resolve, typed, diff --git a/crates/improvise-protocol/src/render.rs b/crates/improvise-protocol/src/render.rs index 88e043db..f3db9ff3 100644 --- a/crates/improvise-protocol/src/render.rs +++ b/crates/improvise-protocol/src/render.rs @@ -59,6 +59,15 @@ pub enum CellId { /// A records-mode coordinate. Renaming it moves every measure cell in the /// row (gotcha 1), which no client composes: `protocol.md` §3 (improvise-exdg). Coordinate, + /// A stored cell whose `category` coordinate names no item of it, so no write + /// can address it: `context/code/command.md` (improvise-gk51). + NonItem { category: String, item: String }, +} + +/// Why a cell whose coordinate names no item takes no write. One wording for the +/// projection's decline and the host's refusal (improvise-gk51). +pub fn non_item_refusal(category: &str, item: &str) -> String { + format!("{item} is no item of {category}, so there is no one cell to write") } /// What a creating column creates. The name typed into it is the whole diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index 981b5227..53083517 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -793,6 +793,9 @@ fn edit_or_drill(state: &ClientState, mode: AppMode) -> Option> { // them; declining rather than `None` keeps that a door, not a hole. CellId::Create(_) => Some(vec![Command::SetStatus(not_yet("creating a column"))]), CellId::Coordinate => Some(vec![Command::SetStatus(not_yet("renaming a coordinate"))]), + CellId::NonItem { category, item } => Some(vec![Command::SetStatus( + crate::non_item_refusal(category, item), + )]), } } @@ -1004,9 +1007,11 @@ fn advanced(state: &ClientState, edit_mode: AppMode, advance: Advance) -> Option fn record_at_cursor(state: &ClientState) -> Option { match cell_at_cursor(state)?.id.as_ref()? { CellId::Record(cell) => Some(cell.record), - CellId::Aggregate(_) | CellId::Derived { .. } | CellId::Create(_) | CellId::Coordinate => { - None - } + CellId::Aggregate(_) + | CellId::Derived { .. } + | CellId::Create(_) + | CellId::Coordinate + | CellId::NonItem { .. } => None, } } @@ -1112,9 +1117,11 @@ fn model(state: &ClientState, cmd: ModelCommand) -> Option { fn record_cell_at_cursor(state: &ClientState) -> Option { match cell_at_cursor(state)?.id.as_ref()? { CellId::Record(cell) => Some(cell.clone()), - CellId::Aggregate(_) | CellId::Derived { .. } | CellId::Create(_) | CellId::Coordinate => { - None - } + CellId::Aggregate(_) + | CellId::Derived { .. } + | CellId::Create(_) + | CellId::Coordinate + | CellId::NonItem { .. } => None, } } diff --git a/crates/improvise-wasm-client/src/lib.rs b/crates/improvise-wasm-client/src/lib.rs index 8d214b40..9a6b84ee 100644 --- a/crates/improvise-wasm-client/src/lib.rs +++ b/crates/improvise-wasm-client/src/lib.rs @@ -201,7 +201,8 @@ impl Client { CellId::Aggregate(_) | CellId::Derived { .. } | CellId::Create(_) - | CellId::Coordinate => None, + | CellId::Coordinate + | CellId::NonItem { .. } => None, } } diff --git a/crates/improvise-wasm-client/src/render.rs b/crates/improvise-wasm-client/src/render.rs index 8535f78b..b0edfebc 100644 --- a/crates/improvise-wasm-client/src/render.rs +++ b/crates/improvise-wasm-client/src/render.rs @@ -147,7 +147,8 @@ impl RenderCell { CellId::Aggregate(_) | CellId::Derived { .. } | CellId::Create(_) - | CellId::Coordinate, + | CellId::Coordinate + | CellId::NonItem { .. }, ) | None => false, },