Files
gridcraft/crates/ui-egui/src/editor.rs
T
prxnvt 9ac06dd8cc UI: Python mode in the formula bar
Python cells get an editing mode of their own in the formula bar, in the
code editor (monospace, Python colours). It starts with Ctrl+Alt+Shift+P
(Cmd+Option+Shift+P on macOS), with =PY then Tab as in Excel, or by
editing a Python cell (F2, a double-click, a click on the formula bar);
typing over a Python cell still replaces it. The shortcut again leaves
Python mode and keeps the text as an ordinary entry.

In Python mode Enter starts a new line with the indentation, Tab and
Shift+Tab indent, Ctrl+Enter (Cmd+Enter on macOS) commits the code
through python.set and Esc cancels. Every commit path of a Python edit
goes through python.set, the formula bar's Enter button and a click on
the sheet included. When the command refuses the code (longer than a
cell holds), its message shows and the edit stays open with the code as
typed. A locked cell on a protected sheet is refused before a draft
exists, and a draft whose cell protection locks while it is open is
dropped with the warning, as an ordinary edit is (#2945). A Python cell's code committed unchanged writes
nothing (no undo step), as F2 then Enter on any other cell does. With
all the code deleted, the commit clears a Python cell through cell.set,
as an empty entry clears any other cell (code of spaces is still code);
on another cell it writes nothing. The formula bar shows while a Python
edit is open, also when View > Formula Bar hides it, since Python mode
edits only there. A Python draft records the window it was typed in,
as an ordinary draft does, so it is committed in that window when
another comes forward.

Python mode replaces the route that sent a changed edit of a Python
cell's code through python.set (EditState.code_edit and
commit_code_edit): every such edit now starts in Python mode, so that
route is removed, and leaving Python mode commits the text as an
ordinary entry through cell.set. The UI tests of that route commit with
Ctrl+Enter, since Enter starts a new line in Python mode; the guard that
keeps a column completion out of a code edit now reads Python mode.

The keys are applied in input order with the typing, on main's
apply_in_order: the code editor takes a set of stop keys from its caller
(apply_in_order_until), so Ctrl+Enter, Esc and the shortcut act on the
text as it was when they were pressed. After a key the text field acts
on (select all), a stop key waits for the next pass with the editor's
keys before it, so select all, Tab and Ctrl+Enter in one frame commits
the indented code. Keyboard events after a stop key in the same pass are
dropped. code_field is the editor without its frame, for the bar.

The PY badge beside fx shows in Python mode and on a selected Python
cell; clicking it switches Python mode as the shortcut does.
ui.pythonMode {on?} does the same from the control channel, without a
dialog, and engine.commands lists it. ui.inspect reports editor.python,
and leaves a Python draft's code out (text null, hidden true) while its
cell's code is hidden on a protected sheet, checked when it is called:
a protection command earlier in the same batch runs before the next
frame. Theme tokens badge_py,
on_badge, sheet_py_tag and on_sheet_py_tag join the contrast test.

A draft, Python or not, records its sheet's identity (Sheet::uid) when
it starts and follows that sheet, since commands that run while it is
open (sheet.move, home.insertSheet, home.deleteSheet, their undo, a
macro, a tool that adds a report sheet) change which sheet sits at its
index. A commit goes to the draft's own sheet; before, a draft committed
after a sheet was inserted before its own went into the inserted sheet,
an ordinary draft as well. A draft whose sheet is gone is dropped with a
message, as one whose cell protection locks is. ui.inspect checks for
hidden code on the draft's own sheet, and once that sheet is gone it
leaves out a Python draft, or one that began on a Python cell.
2026-11-01 21:02:03 -05:00

571 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! In-cell / formula-bar editing state: point mode, reference colouring, function
//! autocomplete and argument hints.
use gridcraft_engine::core::{CellRef, RangeRef};
/// How many distinct reference colours a formula cycles through (`Tokens::ref_colors`).
pub const REF_COLOR_COUNT: usize = 8;
#[derive(Clone, Debug)]
pub struct EditState {
/// The workbook (`DocState::uid`) the draft belongs to: it is drawn and committed only there.
pub doc: u64,
/// The window it was typed in: switching to another window, even on the same workbook,
/// commits it there first (`None` until the app sets it).
pub window: Option<u64>,
/// The sheet's index when the draft was last resolved; [`EditState::sheet_in`] finds it again
/// by `sheet_uid` after sheets are inserted, deleted or moved.
pub sheet: usize,
/// The sheet's identity while the app runs (`model::SheetUid`), recorded when the draft is
/// created ([`EditState::on_sheet`]); 0 (never given out) when none was.
pub sheet_uid: u64,
/// The draft began on a Python cell, with its code as the text. Kept so the code stays out of
/// view once the cell can't be looked at, its sheet gone.
pub py_cell: bool,
/// The order of the workbook's sheets ([`sheet_order`]) when the draft was last resolved: a
/// change after a command points the draft at its sheet again and lets go of
/// `point_anchor`, which holds a sheet index.
pub sheet_order: u64,
/// Syntax of this edit, captured when it starts (prevents a language switch reinterpreting it).
pub locale: gridcraft_engine::formula::FormulaLocale,
pub cell: CellRef,
pub text: String,
/// Started by typing (Enter mode: arrows commit) rather than F2 (Edit mode: arrows move the caret).
pub enter_mode: bool,
pub from_formula_bar: bool,
/// Caret position (char index), mirrored from the text widget.
pub caret: usize,
/// The other end of the selected text (char index), mirrored like `caret`; equal to `caret`
/// when nothing is selected.
pub anchor: usize,
/// The reference most recently inserted by point mode (byte range) — arrows/clicks replace it.
pub point: Option<(usize, usize)>,
/// Where point mode's keyboard cursor is.
pub point_cell: Option<CellRef>,
/// The fixed range anchor and its sheet while pointing with the mouse or keyboard.
pub point_anchor: Option<(usize, CellRef)>,
pub autocomplete: Vec<String>,
pub ac_index: usize,
pub request_focus: bool,
/// The text changed programmatically: push `caret` into the widget on the next frame.
pub sync_caret: bool,
/// A dialog opened over the edit has the keyboard: when the editor takes it back without a
/// change to the caret, the selected text (`anchor`..`caret`) stays selected.
pub keep_selection: bool,
/// Column AutoComplete: the full entry the typed text completes to.
pub completion: Option<String>,
/// Move the caret to the end on the next frame.
pub caret_to_end: bool,
/// The cell's text when an F2-style edit began. Committing it unchanged leaves the cell alone
/// (no undo step, the workbook stays clean). `None` for an edit started by typing.
pub original: Option<String>,
/// Python mode: the text is a Python cell's code, edited in the formula bar's code editor and
/// committed with Ctrl+Enter (Cmd+Enter on macOS) through `python.set`.
pub python: bool,
}
/// A digest of the order of `wb`'s sheets by identity: it changes when a sheet is inserted,
/// deleted or moved, not when one is renamed or edited.
pub fn sheet_order(wb: &gridcraft_engine::model::Workbook) -> u64 {
wb.sheets.iter().fold(0xcbf2_9ce4_8422_2325_u64, |h, sh| (h ^ sh.uid.get()).wrapping_mul(0x0000_0100_0000_01b3))
}
impl EditState {
pub fn new(doc: u64, sheet: usize, cell: CellRef, text: String, enter_mode: bool, from_formula_bar: bool) -> EditState {
let caret = text.chars().count();
let original = (!enter_mode).then(|| text.clone());
EditState {
doc,
window: None,
sheet,
sheet_uid: 0,
py_cell: false,
sheet_order: 0,
locale: Default::default(),
cell,
text,
enter_mode,
from_formula_bar,
caret,
anchor: caret,
point: None,
point_cell: None,
point_anchor: None,
autocomplete: vec![],
ac_index: 0,
request_focus: true,
sync_caret: true,
keep_selection: false,
caret_to_end: true,
completion: None,
original,
python: false,
}
}
/// An edit of a cell's Python code, in the formula bar.
pub fn python(doc: u64, sheet: usize, cell: CellRef, code: String) -> EditState {
let mut ed = EditState::new(doc, sheet, cell, code, false, true);
ed.python = true;
ed
}
/// Records the identity of the draft's sheet, from the workbook it is typed in.
pub fn on_sheet(mut self, wb: &gridcraft_engine::model::Workbook) -> EditState {
self.sheet_uid = wb.sheet(self.sheet).map_or(0, |sh| sh.uid.get());
self.sheet_order = sheet_order(wb);
self
}
/// The draft's sheet's index now, found by its identity, since commands insert, delete and
/// move sheets under an open draft; `None` once the sheet is gone (or none was recorded).
pub fn sheet_in(&self, wb: &gridcraft_engine::model::Workbook) -> Option<usize> {
if self.sheet_uid == 0 {
return None;
}
if wb.sheet(self.sheet).is_some_and(|sh| sh.uid.get() == self.sheet_uid) {
return Some(self.sheet);
}
wb.sheets.iter().position(|sh| sh.uid.get() == self.sheet_uid)
}
/// Whether the text is the `=PY` that Tab turns into Python mode, as in Excel (`=PY(` too,
/// in any letter case).
pub fn is_py_trigger(&self) -> bool {
let t = self.text.trim();
!self.python && (t.eq_ignore_ascii_case("=PY") || t.eq_ignore_ascii_case("=PY("))
}
/// Switches the edit to Python mode, in the formula bar. The `=PY` trigger becomes empty code;
/// other text is kept as the code, so the shortcut pressed mid-edit loses nothing.
pub fn enter_python(&mut self) {
if self.is_py_trigger() {
self.text.clear();
}
self.python = true;
self.from_formula_bar = true;
self.enter_mode = false;
self.point = None;
self.point_cell = None;
self.point_anchor = None;
self.autocomplete.clear();
self.completion = None;
self.caret = self.text.chars().count();
self.anchor = self.caret;
self.caret_to_end = false;
self.request_focus = true;
self.sync_caret = true;
}
/// Leaves Python mode: the edit goes on as an ordinary entry of the same text.
pub fn leave_python(&mut self) {
self.python = false;
self.caret = self.text.chars().count();
self.anchor = self.caret;
self.request_focus = true;
self.sync_caret = true;
}
/// Whether the text is a formula (never in Python mode: there it is code).
pub fn is_formula(&self) -> bool {
!self.python
&& (self.text.starts_with('=')
|| (self.text.starts_with('+') && self.text.len() > 1)
|| (self.text.starts_with('-') && self.text.len() > 1 && !self.text[1..].starts_with(|c: char| c.is_ascii_digit())))
}
fn byte_caret(&self) -> usize {
self.text.char_indices().nth(self.caret).map(|(i, _)| i).unwrap_or(self.text.len())
}
/// Point mode: the caret follows an operator, `(`, `,` or `=` in a formula, so clicking a
/// cell or pressing an arrow inserts a reference.
pub fn can_point(&self) -> bool {
if !self.is_formula() {
return false;
}
if self.point_at_caret() {
return true;
}
let before = self.text.get(..self.byte_caret()).unwrap_or("");
let last = before.trim_end().chars().last();
matches!(last, Some('=' | '(' | ',' | '+' | '-' | '*' | '/' | '^' | '&' | '<' | '>' | ':' | ';' | '{'))
}
pub fn point_at_caret(&self) -> bool {
self.point.is_some_and(|(start, end)| end == self.byte_caret() && self.text.get(start..end).is_some())
}
/// Inserts (or replaces the last pointed) reference text at the caret.
pub fn insert_ref(&mut self, r: &str) {
let caret_b = self.byte_caret();
let (start, end) = match self.point {
Some((s, e)) if e == caret_b => (s, e),
_ => (caret_b, caret_b),
};
let (Some(a), Some(b)) = (self.text.get(..start), self.text.get(end..)) else { return };
self.text = format!("{a}{r}{b}");
let new_end = start + r.len();
self.point = Some((start, new_end));
self.caret = self.text.get(..new_end).map(|s| s.chars().count()).unwrap_or(0);
self.caret_to_end = false;
self.sync_caret = true;
}
/// Inserts `t` at the caret as typed text (Paste Names), in place of the selected text: the
/// caret ends after it, and point mode starts afresh.
pub fn insert_at_caret(&mut self, t: &str) {
// When code moved the caret this frame (`sync_caret`), the widget has not yet stored it, so `anchor` is out of
// date and the name goes in at the caret.
let (from, to) = if self.sync_caret { (self.caret, self.caret) } else { (self.anchor.min(self.caret), self.anchor.max(self.caret)) };
let byte = |c: usize| self.text.char_indices().nth(c).map(|(i, _)| i).unwrap_or(self.text.len());
let (Some(a), Some(b)) = (self.text.get(..byte(from)), self.text.get(byte(to)..)) else { return };
self.text = format!("{a}{t}{b}");
self.point = None;
self.caret = from + t.chars().count();
self.anchor = self.caret;
// A function suggestion shown before the name went in no longer fits the text.
self.autocomplete.clear();
self.ac_index = 0;
self.completion = None;
self.caret_to_end = false;
self.sync_caret = true;
self.request_focus = true;
}
/// Add another argument after the current pointed reference. Keep only the new reference
/// replaceable, so a subsequent plain click does not remove earlier arguments or the separator.
pub fn append_ref(&mut self, r: &str) {
let separator = if self.point_at_caret() { self.locale.list_separator().to_string() } else { String::new() };
self.point = None;
self.insert_ref(&format!("{separator}{r}"));
if let Some((start, end)) = self.point {
self.point = Some((start + separator.len(), end));
}
}
/// Updates function autocomplete for the word before the caret.
pub fn update_autocomplete(&mut self, names: &[String]) {
self.autocomplete.clear();
if self.python || !self.is_formula() {
return;
}
let before = self.text.get(..self.byte_caret()).unwrap_or("");
let word: String =
before.chars().rev().take_while(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '_').collect::<String>().chars().rev().collect();
if word.is_empty() || word.chars().next().is_some_and(|c| c.is_ascii_digit()) {
return;
}
let u = word.to_ascii_uppercase();
// Not after a column letter of a reference like A1 (digits follow) – fine either way.
self.autocomplete = names.iter().filter(|n| n.starts_with(&u)).take(12).cloned().collect();
if self.autocomplete.len() == 1 && self.autocomplete[0] == u {
self.autocomplete.clear();
}
self.ac_index = self.ac_index.min(self.autocomplete.len().saturating_sub(1));
}
/// Accepts the highlighted autocomplete entry: replaces the word and adds `(`.
pub fn accept_autocomplete(&mut self) -> bool {
let Some(name) = self.autocomplete.get(self.ac_index).cloned() else { return false };
let caret_b = self.byte_caret();
let before = self.text.get(..caret_b).unwrap_or("");
let wlen: usize = before.chars().rev().take_while(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '_').map(char::len_utf8).sum();
let start = caret_b - wlen;
let (Some(a), Some(b)) = (self.text.get(..start), self.text.get(caret_b..)) else { return false };
self.text = format!("{a}{name}({b}");
self.caret = self.text.get(..start + name.len() + 1).map(|s| s.chars().count()).unwrap_or(0);
self.autocomplete.clear();
self.caret_to_end = false;
self.sync_caret = true;
true
}
/// Ctrl+Shift+A, as in Excel: right after a function name, or after its `(`, inserts the
/// argument names (optional ones without their brackets) and the closing parenthesis, and
/// the `(` too when missing. Anywhere else nothing changes and it returns false.
pub fn insert_argument_names(&mut self, language: crate::i18n::Language) -> bool {
if !self.is_formula() {
return false;
}
let caret_b = self.byte_caret();
let (Some(before), Some(after)) = (self.text.get(..caret_b), self.text.get(caret_b..)) else { return false };
// An odd number of quotes before the caret: it is inside a text literal.
if before.matches('"').count() % 2 == 1 {
return false;
}
let open = before.ends_with('(');
if !open && after.starts_with('(') {
return false;
}
let head = before.strip_suffix('(').unwrap_or(before);
let name: String =
head.chars().rev().take_while(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '_').collect::<String>().chars().rev().collect();
if name.is_empty() || name.starts_with(|c: char| c.is_ascii_digit()) {
return false;
}
let Some(signature) = crate::formula_locale::signature(&name, self.locale, language) else { return false };
let sep = self.locale.list_separator();
let inner = signature.split_once('(').map(|(_, rest)| rest.trim_end().trim_end_matches(')')).unwrap_or("");
let args: Vec<&str> = inner.split(sep).map(|a| a.trim().trim_start_matches('[').trim_end_matches(']')).filter(|a| !a.is_empty()).collect();
let close = if open && after.starts_with(')') { "" } else { ")" };
let inserted = format!("{}{}{close}", if open { "" } else { "(" }, args.join(&sep.to_string()));
self.text = format!("{before}{inserted}{after}");
self.caret += inserted.chars().count();
self.autocomplete.clear();
self.completion = None;
self.caret_to_end = false;
self.sync_caret = true;
true
}
/// The function call the caret is inside and the argument index (for the hint tooltip).
pub fn current_function(&self) -> Option<(String, usize)> {
if self.python || !self.is_formula() {
return None;
}
let before = self.text.get(..self.byte_caret())?;
let mut depth = 0i32;
let mut arg = 0usize;
let chars: Vec<char> = before.chars().collect();
let mut in_str = false;
let mut i = chars.len();
while i > 0 {
i -= 1;
let c = chars[i];
if c == '"' {
in_str = !in_str;
continue;
}
if in_str {
continue;
}
match c {
')' | '}' | ']' => depth += 1,
'{' | '[' => depth -= 1,
'(' => {
if depth == 0 {
let name: String = chars[..i]
.iter()
.rev()
.take_while(|c| c.is_ascii_alphanumeric() || **c == '.' || **c == '_')
.collect::<String>()
.chars()
.rev()
.collect();
if name.is_empty() {
return None;
}
return Some((name.to_ascii_uppercase(), arg));
}
depth -= 1;
}
c if c == self.locale.list_separator() && depth == 0 => arg += 1,
_ => {}
}
}
None
}
}
/// References in formula text with their byte spans and colour index (same reference text ⇒
/// same colour), for colouring the editor and outlining ranges on the grid.
pub fn formula_refs(text: &str) -> Vec<(usize, usize, RangeRef, Option<String>, usize)> {
let body = text.strip_prefix('=').or_else(|| text.strip_prefix('+')).unwrap_or(text);
let offset = text.len() - body.len();
let Ok(toks) = gridcraft_engine::formula::lexer::tokenize(body) else { return vec![] };
let mut out: Vec<(usize, usize, RangeRef, Option<String>, usize)> = Vec::new();
let mut seen: Vec<String> = Vec::new();
let mut i = 0;
while i < toks.len() {
use gridcraft_engine::formula::lexer::Tok;
let (sheet, start, j) = match &toks[i].tok {
Tok::Sheet(a, _) => (Some(a.clone()), toks[i].start, i + 1),
_ => (None, toks[i].start, i),
};
if let Some(Tok::Word(w)) = toks.get(j).map(|t| &t.tok) {
let range_end = if matches!(toks.get(j + 1).map(|t| &t.tok), Some(Tok::Colon | Tok::Trim(_))) {
toks.get(j + 2).and_then(|token| {
let end = match &token.tok {
Tok::Word(w) => Some(w.clone()),
Tok::Number(n) if n.fract() == 0.0 && *n >= 1.0 => Some(format!("{n}")),
_ => None,
}?;
RangeRef::parse(&format!("{w}:{end}")).map(|range| (range, token.end))
})
} else {
None
};
let (range, end) = if let Some(range_end) = range_end {
i = j + 3;
range_end
} else if let Some(c) = CellRef::parse(w) {
i = j + 1;
(RangeRef::cell(c), toks[j].end)
} else {
i += 1;
continue;
};
let key = body.get(start..end).unwrap_or("").replace('$', "").to_ascii_uppercase();
let idx = match seen.iter().position(|k| *k == key) {
Some(k) => k,
None => {
seen.push(key);
seen.len() - 1
}
};
out.push((start + offset, end + offset, range, sheet, idx % REF_COLOR_COUNT));
continue;
}
i += 1;
}
out
}
/// Whether a reference of the formula being edited belongs on the sheet shown: a reference with a
/// sheet name is on that sheet, one without is on the sheet the edit started on, even while
/// pointing at another sheet.
pub fn ref_is_on_sheet(ref_sheet: Option<&str>, origin: &str, shown: &str) -> bool {
ref_sheet.unwrap_or(origin).eq_ignore_ascii_case(shown)
}
/// Excel-style AutoComplete: the one distinct text entry in the same column (contiguous data
/// above and below) that starts with `typed` (case-insensitive). `None` when ambiguous.
pub fn column_completion(sheet: &gridcraft_engine::model::Sheet, at: CellRef, typed: &str) -> Option<String> {
if typed.is_empty() || typed.starts_with('=') || typed.chars().all(|c| c.is_ascii_digit() || c == '.' || c == '-') {
return None;
}
let lower = typed.to_lowercase();
let mut found: Option<String> = None;
let mut ambiguous = false;
let mut scan = |row: u32| -> bool {
match sheet.value(CellRef::new(row, at.col)) {
gridcraft_engine::core::Value::Empty => false,
gridcraft_engine::core::Value::Text(t) => {
let folded = t.to_lowercase();
if folded.starts_with(&lower) && folded.len() > lower.len() {
match &found {
Some(f) if f.to_lowercase() != folded => ambiguous = true,
None => found = Some(t.to_string()),
_ => {}
}
}
true
}
_ => true,
}
};
let mut r = at.row;
let mut n = 0;
while r > 0 && n < 5000 {
r -= 1;
n += 1;
if !scan(r) {
break;
}
}
let mut r = at.row + 1;
n = 0;
while n < 5000 && scan(r) {
r += 1;
n += 1;
}
if ambiguous { None } else { found }
}
/// The part of `full` after the `typed` prefix, counted in characters: case folding can change
/// a character's byte length ("ß" is two bytes, "ẞ" three).
pub fn completion_rest<'a>(full: &'a str, typed: &str) -> Option<&'a str> {
let start = full.char_indices().nth(typed.chars().count())?.0;
full.get(start..)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn point_mode() {
let mut e = EditState::new(1, 0, CellRef::new(0, 0), "=SUM(".into(), true, false);
assert!(e.can_point());
e.insert_ref("A1");
assert_eq!(e.text, "=SUM(A1");
e.insert_ref("A2");
assert_eq!(e.text, "=SUM(A2");
e.text.push(':');
e.caret += 1;
e.point = None;
assert!(e.can_point());
let e2 = EditState::new(1, 0, CellRef::new(0, 0), "hello".into(), true, false);
assert!(!e2.can_point());
}
#[test]
fn a_pasted_name_replaces_the_selected_text() {
let mut e = EditState::new(1, 0, CellRef::new(0, 0), "=OldName+1".into(), false, false);
e.sync_caret = false;
(e.anchor, e.caret) = (8, 1);
e.autocomplete = vec!["SUM".into()];
e.insert_at_caret("Sales");
assert_eq!((e.text.as_str(), e.caret, e.anchor), ("=Sales+1", 6, 6));
assert!(e.autocomplete.is_empty(), "the earlier function suggestion is gone");
// Nothing selected: the name goes in at the caret.
e.sync_caret = false;
e.insert_at_caret("Rate");
assert_eq!((e.text.as_str(), e.caret), ("=SalesRate+1", 10));
}
#[test]
fn autocomplete_and_hints() {
let names: Vec<String> = ["SUM", "SUMIF", "SUMPRODUCT", "AVERAGE"].iter().map(|s| s.to_string()).collect();
let mut e = EditState::new(1, 0, CellRef::new(0, 0), "=su".into(), true, false);
e.update_autocomplete(&names);
assert_eq!(e.autocomplete.len(), 3);
assert!(e.accept_autocomplete());
assert_eq!(e.text, "=SUM(");
let e = EditState::new(1, 0, CellRef::new(0, 0), "=IF(A1>0,SUM(B1,".into(), true, false);
assert_eq!(e.current_function(), Some(("SUM".into(), 1)));
}
#[test]
fn ref_spans() {
let r = formula_refs("=A1+B2:C3*a1+Sheet2!D4");
assert_eq!(r.len(), 4);
assert_eq!(r[0].4, r[2].4); // A1 and a1 share a colour
assert_eq!(r[1].2.a1(), "B2:C3");
assert_eq!(r[3].3.as_deref(), Some("Sheet2"));
}
#[test]
fn refs_are_outlined_on_their_own_sheet_only() {
// Pointing at Sheet2 while the edit started on Sheet1: `A1` stays on Sheet1, `Sheet2!B2` shows on Sheet2.
assert!(ref_is_on_sheet(None, "Sheet1", "Sheet1"));
assert!(!ref_is_on_sheet(None, "Sheet1", "Sheet2"));
assert!(ref_is_on_sheet(Some("Sheet2"), "Sheet1", "Sheet2"));
assert!(!ref_is_on_sheet(Some("Sheet2"), "Sheet1", "Sheet1"));
assert!(ref_is_on_sheet(Some("sheet2"), "Sheet1", "Sheet2"), "sheet names compare ignoring case");
}
#[test]
fn whole_row_and_column_ref_spans() {
let refs = formula_refs("=SUM(C:C,4:6,$D:$E,Sheet2!7:8)");
assert_eq!(refs.len(), 4);
assert_eq!(refs[0].2.a1(), "C:C");
assert_eq!(refs[1].2.a1(), "4:6");
assert_eq!(refs[2].2.a1(), "D:E");
assert_eq!(refs[3].2.a1(), "7:8");
assert_eq!(refs[3].3.as_deref(), Some("Sheet2"));
}
#[test]
fn inserting_a_union_keeps_each_reference_separately_pointed() {
let mut edit = EditState::new(1, 0, CellRef::new(0, 0), "=SUM(".into(), true, false);
edit.insert_ref("C:C");
edit.append_ref("E:E");
assert_eq!(edit.text, "=SUM(C:C,E:E");
assert_eq!(formula_refs(&edit.text).iter().map(|r| r.2.a1()).collect::<Vec<_>>(), ["C:C", "E:E"]);
assert_eq!(edit.point, Some((9, 12)));
assert!(edit.can_point());
}
}