mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-24 02:36:48 -06:00
Merge PR #2388
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
use egui::{Color32, Key, Sense, Stroke, StrokeKind, vec2};
|
||||
|
||||
use crate::icons::{self, Icon};
|
||||
use gridcraft_engine::core::{CellRef, RangeRef};
|
||||
use serde_json::{Map, Value as Json, json};
|
||||
|
||||
use crate::SheetApp;
|
||||
@@ -44,6 +45,28 @@ pub struct Dialog {
|
||||
/// The dialog this one was opened from (Name Manager › New…): it comes back when this closes.
|
||||
back: Option<Box<Dialog>>,
|
||||
focus_initialized: bool,
|
||||
/// The formula box that takes references clicked or dragged on the grid (New Formatting Rule).
|
||||
pick: Option<RefPick>,
|
||||
}
|
||||
|
||||
/// A formula box in point mode, as in Excel: a click on the grid writes the cell's absolute
|
||||
/// reference at the caret instead of selecting it, and a drag grows it into a range.
|
||||
#[derive(Clone, Debug)]
|
||||
struct RefPick {
|
||||
key: &'static str,
|
||||
/// The sheet the dialog was opened on, by name: its index changes when sheets are inserted or
|
||||
/// moved while the dialog is open. References to another sheet carry that sheet's name.
|
||||
sheet: String,
|
||||
/// Caret (char index), mirrored from the text box while it has the keyboard.
|
||||
caret: usize,
|
||||
/// The reference last inserted (byte span), replaced by the next click or the drag.
|
||||
point: Option<(usize, usize)>,
|
||||
anchor: CellRef,
|
||||
/// Give the box the keyboard back, caret after the reference, on the next frame.
|
||||
refocus: bool,
|
||||
/// The selection the rule applies to, fixed when the dialog opened as Excel's "Applies to":
|
||||
/// pointing on another sheet must not move the rule there.
|
||||
applies_to: String,
|
||||
}
|
||||
|
||||
impl Dialog {
|
||||
@@ -77,6 +100,46 @@ impl Dialog {
|
||||
self.values.insert(key.into(), value);
|
||||
}
|
||||
|
||||
/// Whether a click on the grid goes to a formula box of this dialog rather than the selection.
|
||||
pub fn picks_references(&self) -> bool {
|
||||
self.pick.is_some()
|
||||
}
|
||||
|
||||
/// A click (`extend` false) or a drag reaching `cell`: writes the absolute reference at the
|
||||
/// formula box's caret, replacing the reference the press inserted, as in the formula bar's
|
||||
/// point mode. An empty box starts with `=`. After an operand there is no place for a
|
||||
/// reference, so nothing changes. `active_sheet` names the sheet shown when it is not the
|
||||
/// dialog's own.
|
||||
pub fn pick_reference(&mut self, cell: CellRef, active_sheet: &str, extend: bool) {
|
||||
let Some(pick) = self.pick.as_mut() else { return };
|
||||
let text = self.values.get(pick.key).and_then(Json::as_str).unwrap_or("").to_string();
|
||||
let mut ed = crate::editor::EditState::new(0, cell, text, true, false);
|
||||
ed.caret = pick.caret.min(ed.text.chars().count());
|
||||
ed.point = pick.point;
|
||||
if ed.text.trim().is_empty() {
|
||||
ed.text = "=".into();
|
||||
ed.caret = 1;
|
||||
ed.point = None;
|
||||
}
|
||||
if !ed.can_point() {
|
||||
return;
|
||||
}
|
||||
if !extend {
|
||||
pick.anchor = cell;
|
||||
}
|
||||
let range = abs(&RangeRef::new(pick.anchor, cell));
|
||||
let reference = if active_sheet.eq_ignore_ascii_case(&pick.sheet) {
|
||||
range
|
||||
} else {
|
||||
format!("{}!{range}", gridcraft_engine::formula::quote_sheet(active_sheet))
|
||||
};
|
||||
ed.insert_ref(&reference);
|
||||
pick.caret = ed.caret;
|
||||
pick.point = ed.point;
|
||||
pick.refocus = true;
|
||||
self.values.insert(pick.key.into(), json!(ed.text));
|
||||
}
|
||||
|
||||
fn form(name: &str, title: &str, command: &'static str, fields: Vec<Field>, defaults: Json) -> Dialog {
|
||||
Dialog {
|
||||
name: name.into(),
|
||||
@@ -94,6 +157,7 @@ impl Dialog {
|
||||
picture_target: None,
|
||||
back: None,
|
||||
focus_initialized: false,
|
||||
pick: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +178,7 @@ impl Dialog {
|
||||
picture_target: None,
|
||||
back: None,
|
||||
focus_initialized: false,
|
||||
pick: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -690,6 +755,19 @@ impl Dialog {
|
||||
fields,
|
||||
json!({"preset": "lightRedFillDarkRedText", "period": "today"}),
|
||||
);
|
||||
if ty == "expression" {
|
||||
let (sheet, applies_to) = app
|
||||
.session
|
||||
.active()
|
||||
.map(|doc| {
|
||||
(
|
||||
doc.wb.active().map(|sh| sh.name.clone()).unwrap_or_default(),
|
||||
doc.selection.ranges.iter().map(RangeRef::a1).collect::<Vec<_>>().join(","),
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
d.pick = Some(RefPick { key: "formula", sheet, caret: 0, point: None, anchor: CellRef::new(0, 0), refocus: false, applies_to });
|
||||
}
|
||||
d.values.insert("type".into(), json!(ty));
|
||||
d.values.insert("operator".into(), p("operator"));
|
||||
d
|
||||
@@ -1115,6 +1193,10 @@ pub(crate) fn build_params(d: &Dialog) -> Json {
|
||||
rule.insert(k.clone(), val.clone());
|
||||
}
|
||||
v = json!({"rule": Json::Object(rule)});
|
||||
if let Some(pick) = d.pick.as_ref().filter(|pick| !pick.applies_to.is_empty() && !pick.sheet.is_empty()) {
|
||||
v["sheet"] = json!(pick.sheet.as_str());
|
||||
v["range"] = json!(pick.applies_to);
|
||||
}
|
||||
}
|
||||
"series" => {
|
||||
if let Some(s) = d.values.get("stop").and_then(Json::as_str).and_then(|s| s.parse::<f64>().ok()) {
|
||||
@@ -1634,6 +1716,25 @@ fn take_first_focus(d: &mut Dialog, r: &egui::Response) {
|
||||
d.focus_first_field = false;
|
||||
}
|
||||
|
||||
/// Keeps a picking formula box's caret, and gives it the keyboard back after a grid click put a
|
||||
/// reference in it (the click took the focus), its caret after the reference.
|
||||
fn track_pick(pick: &mut RefPick, r: &egui::Response, typed: bool) {
|
||||
if typed {
|
||||
pick.point = None;
|
||||
}
|
||||
if pick.refocus {
|
||||
let mut state = egui::text_edit::TextEditState::load(&r.ctx, r.id).unwrap_or_default();
|
||||
state.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(pick.caret))));
|
||||
state.store(&r.ctx, r.id);
|
||||
r.request_focus();
|
||||
pick.refocus = false;
|
||||
} else if r.has_focus()
|
||||
&& let Some(range) = egui::text_edit::TextEditState::load(&r.ctx, r.id).and_then(|state| state.cursor.char_range())
|
||||
{
|
||||
pick.caret = range.primary.index.into();
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws a dialog's `fields`, editing its values.
|
||||
fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
// Field labels, choices and notes are English source strings, translated as they are drawn.
|
||||
@@ -1659,6 +1760,9 @@ fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
if r.changed() {
|
||||
d.values.insert(key.into(), json!(s));
|
||||
}
|
||||
if let Some(pick) = d.pick.as_mut().filter(|pick| pick.key == key) {
|
||||
track_pick(pick, &r, r.changed());
|
||||
}
|
||||
});
|
||||
}
|
||||
Field::Password { key, label } => {
|
||||
|
||||
@@ -2336,6 +2336,19 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
||||
app.grid.filter_menu = Some((c.col, c.row, geo.mirror_pos(p)));
|
||||
return;
|
||||
}
|
||||
// A dialog's formula box in point mode (New Formatting Rule) takes the reference; the
|
||||
// selection, which the dialog acts on, stays.
|
||||
if let Some(d) = app.dialog.as_mut().filter(|d| d.picks_references()) {
|
||||
d.pick_reference(c, &sh.name, false);
|
||||
app.grid.drag = Drag::Select;
|
||||
app.grid.drag_select = true;
|
||||
if resp.drag_started()
|
||||
&& let Some(p) = pos
|
||||
{
|
||||
select_to_pointer(app, geo, sh, wb, p);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Point mode while editing a formula: clicking inserts a reference.
|
||||
if let Some(ed) = app.editor.as_mut()
|
||||
&& ed.can_point()
|
||||
@@ -2658,7 +2671,10 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
||||
/// An armed Format Painter pastes onto the cells just clicked or dragged over (not while
|
||||
/// pointing a formula reference, which also drags a Select).
|
||||
fn format_painter_apply(app: &mut SheetApp) {
|
||||
if app.session.format_painter.is_some() && !app.editor.as_ref().is_some_and(|ed| ed.can_point()) {
|
||||
if app.session.format_painter.is_some()
|
||||
&& !app.editor.as_ref().is_some_and(|ed| ed.can_point())
|
||||
&& !app.dialog.as_ref().is_some_and(|d| d.picks_references())
|
||||
{
|
||||
let dest = app.session.active().map(|d| d.selection.current().a1());
|
||||
if let Some(dest) = dest
|
||||
&& let Err(e) = app.run("edit.formatPainter", json!({"apply": dest}))
|
||||
@@ -2711,7 +2727,9 @@ fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p
|
||||
geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)),
|
||||
geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)),
|
||||
);
|
||||
if let Some(ed) = app.editor.as_mut()
|
||||
if let Some(d) = app.dialog.as_mut().filter(|d| d.picks_references()) {
|
||||
d.pick_reference(cell, &sh.name, true);
|
||||
} else if let Some(ed) = app.editor.as_mut()
|
||||
&& ed.point.is_some()
|
||||
{
|
||||
if let Some(start) = ed.point_anchor.filter(|(sheet, _)| *sheet == wb.active_sheet).map(|(_, cell)| cell).or(ed.point_cell) {
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
//! New Formatting Rule ▸ formula: clicking or dragging on the grid writes the absolute reference
|
||||
//! into the formula box at its caret, as in Excel, and leaves the selection the rule applies to alone.
|
||||
|
||||
use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2, vec2};
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::{SheetApp, grid::Geo};
|
||||
use serde_json::json;
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
fn harness() -> Harness {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
session.execute("home.insertSheet", json!({})).unwrap();
|
||||
session.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
session.execute("selection.set", json!({"range": "F8:G9"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h.state_mut().open_dialog("cfQuick", json!({"type": "expression", "title": "New Formatting Rule"}));
|
||||
h.run_steps(6);
|
||||
h
|
||||
}
|
||||
|
||||
fn formula(h: &Harness) -> String {
|
||||
h.state().dialog.as_ref().and_then(|d| d.values.get("formula")).and_then(|v| v.as_str()).unwrap_or("").to_string()
|
||||
}
|
||||
|
||||
fn selection(h: &Harness) -> String {
|
||||
h.state().session.active().unwrap().selection.current().a1()
|
||||
}
|
||||
|
||||
fn type_text(h: &mut Harness, text: &str) {
|
||||
for c in text.chars() {
|
||||
h.input_mut().events.push(Event::Text(c.to_string()));
|
||||
h.run_steps(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn cell_center(h: &Harness, a1: &str) -> Pos2 {
|
||||
let app = h.state();
|
||||
let sheet = app.session.active().unwrap().wb.active().unwrap();
|
||||
let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll);
|
||||
let c = gridcraft_engine::core::CellRef::parse(a1).unwrap();
|
||||
pos2(geo.x(sheet, c.col) + sheet.col_width(c.col) * geo.z * 0.5, geo.y(sheet, c.row) + sheet.row_height(c.row) * geo.z * 0.5)
|
||||
}
|
||||
|
||||
fn click(h: &mut Harness, a1: &str) {
|
||||
let pos = cell_center(h, a1);
|
||||
for pressed in [true, false] {
|
||||
h.input_mut()
|
||||
.events
|
||||
.extend([Event::PointerMoved(pos), Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]);
|
||||
h.step();
|
||||
}
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn drag(h: &mut Harness, from: &str, to: &str) {
|
||||
let (a, b) = (cell_center(h, from), cell_center(h, to));
|
||||
h.input_mut()
|
||||
.events
|
||||
.extend([Event::PointerMoved(a), Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }]);
|
||||
h.step();
|
||||
for i in 1..=6 {
|
||||
let p = a + (b - a) * (i as f32 / 6.0);
|
||||
h.input_mut().events.push(Event::PointerMoved(p));
|
||||
h.step();
|
||||
}
|
||||
h.input_mut().events.push(Event::PointerButton { pos: b, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_a_cell_after_equals_inserts_its_absolute_reference() {
|
||||
let mut h = harness();
|
||||
type_text(&mut h, "=");
|
||||
assert_eq!(formula(&h), "=", "the formula box had the keyboard");
|
||||
click(&mut h, "B2");
|
||||
assert_eq!(formula(&h), "=$B$2");
|
||||
assert_eq!(selection(&h), "F8:G9", "the range the rule applies to is still selected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_a_cell_in_an_empty_box_starts_the_formula() {
|
||||
let mut h = harness();
|
||||
click(&mut h, "C3");
|
||||
assert_eq!(formula(&h), "=$C$3");
|
||||
assert_eq!(selection(&h), "F8:G9");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_click_replaces_the_reference_just_inserted() {
|
||||
let mut h = harness();
|
||||
type_text(&mut h, "=");
|
||||
click(&mut h, "B2");
|
||||
click(&mut h, "D5");
|
||||
assert_eq!(formula(&h), "=$D$5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_inserts_a_range() {
|
||||
let mut h = harness();
|
||||
type_text(&mut h, "=");
|
||||
drag(&mut h, "B2", "C4");
|
||||
assert_eq!(formula(&h), "=$B$2:$C$4");
|
||||
assert_eq!(selection(&h), "F8:G9");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_reference_goes_at_the_caret_and_typing_continues_after_it() {
|
||||
let mut h = harness();
|
||||
type_text(&mut h, "=SUM()");
|
||||
h.key_press(Key::ArrowLeft);
|
||||
h.run_steps(2);
|
||||
click(&mut h, "B2");
|
||||
assert_eq!(formula(&h), "=SUM($B$2)");
|
||||
type_text(&mut h, "+1");
|
||||
assert_eq!(formula(&h), "=SUM($B$2+1)", "the box has the keyboard back, its caret after the reference");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_after_an_operand_changes_nothing() {
|
||||
let mut h = harness();
|
||||
type_text(&mut h, "=A1>5");
|
||||
click(&mut h, "B2");
|
||||
assert_eq!(formula(&h), "=A1>5");
|
||||
assert_eq!(selection(&h), "F8:G9");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ok_applies_the_picked_formula_to_the_selection() {
|
||||
let mut h = harness();
|
||||
type_text(&mut h, "=");
|
||||
click(&mut h, "B2");
|
||||
type_text(&mut h, ">0");
|
||||
h.key_press(Key::Enter);
|
||||
h.run_steps(4);
|
||||
assert!(h.state().dialog.is_none(), "Enter confirmed the rule");
|
||||
let sheet = h.state().session.active().unwrap().wb.active().unwrap();
|
||||
let cf = sheet.cond_formats.last().expect("a rule was added");
|
||||
assert_eq!(cf.ranges.iter().map(|r| r.a1()).collect::<Vec<_>>(), ["F8:G9"]);
|
||||
assert!(format!("{:?}", cf.rule).contains("$B$2>0"), "rule: {:?}", cf.rule);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_dialogs_leave_grid_clicks_alone() {
|
||||
let mut h = harness();
|
||||
h.state_mut().dialog = None;
|
||||
h.state_mut().open_dialog("cfQuick", json!({"type": "containsText", "title": "Text that Contains"}));
|
||||
h.run_steps(6);
|
||||
click(&mut h, "B2");
|
||||
assert_eq!(selection(&h), "B2", "a box that is not a formula does not take references");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pointing_on_another_sheet_keeps_the_rule_on_the_sheet_it_was_opened_for() {
|
||||
let mut h = harness();
|
||||
type_text(&mut h, "=");
|
||||
h.state_mut().session.execute("sheet.activate", json!({"sheet": 1})).unwrap();
|
||||
h.run_steps(3);
|
||||
click(&mut h, "A1");
|
||||
assert_eq!(formula(&h), "=Sheet2!$A$1");
|
||||
h.key_press(Key::Enter);
|
||||
h.run_steps(4);
|
||||
assert!(h.state().dialog.is_none(), "Enter confirmed the rule");
|
||||
let wb = &h.state().session.active().unwrap().wb;
|
||||
assert!(wb.sheet(1).unwrap().cond_formats.is_empty(), "nothing lands on the sheet pointed at");
|
||||
let cf = wb.sheet(0).unwrap().cond_formats.last().expect("the rule is on the sheet the dialog was opened on");
|
||||
assert_eq!(cf.ranges.iter().map(|r| r.a1()).collect::<Vec<_>>(), ["F8:G9"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_rule_follows_its_sheet_when_sheets_move_while_the_dialog_is_open() {
|
||||
let mut h = harness();
|
||||
type_text(&mut h, "=");
|
||||
click(&mut h, "B2");
|
||||
h.state_mut().session.execute("sheet.move", json!({"sheet": 0, "to": 1})).unwrap();
|
||||
h.run_steps(3);
|
||||
let names = |h: &Harness| h.state().session.active().unwrap().wb.sheets.iter().map(|s| s.name.clone()).collect::<Vec<_>>();
|
||||
assert_eq!(names(&h), ["Sheet2", "Sheet1"], "Sheet1 now sits at index 1");
|
||||
h.key_press(Key::Enter);
|
||||
h.run_steps(4);
|
||||
assert!(h.state().dialog.is_none(), "Enter confirmed the rule");
|
||||
let wb = &h.state().session.active().unwrap().wb;
|
||||
let sheet1 = wb.sheets.iter().find(|s| s.name == "Sheet1").unwrap();
|
||||
let sheet2 = wb.sheets.iter().find(|s| s.name == "Sheet2").unwrap();
|
||||
assert!(sheet2.cond_formats.is_empty(), "nothing lands on the sheet that took index 0");
|
||||
let cf = sheet1.cond_formats.last().expect("the rule stays on Sheet1");
|
||||
assert_eq!(cf.ranges.iter().map(|r| r.a1()).collect::<Vec<_>>(), ["F8:G9"]);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ mod autosave_persist;
|
||||
mod borders;
|
||||
mod cell_editor_caret;
|
||||
mod cell_pictures;
|
||||
mod cf_rule_pick;
|
||||
mod chart_resize;
|
||||
mod clipboard;
|
||||
mod comments_pane;
|
||||
|
||||
Reference in New Issue
Block a user