Files
gridcraft/crates/ui-egui/src/dialogs/insert_function.rs
T
prxnvt 474472f999 Engine: Recently Used follows the formulas entered; Insert Function category list
formulas.recentlyUsed scanned the session journal for cell.set inputs.
It missed formulas entered by range.* commands, lost them when the
journal dropped its oldest entries (a large file open or a long script
is enough), and started empty in every session. The Function Library's
Recently Used menu and the Insert Function dialog list what it returns.

- Prefs::recent_functions (a new engine module, recent.rs) holds the
  ten functions most recently entered in a formula, newest first.
  Session::execute updates it for every cell.* and range.* command it
  journals, from any source (the interface, the control channel, MCP or
  a script). It reads at most 2,000 of the formulas a command enters,
  starting from the last one; formulas longer than Excel's limit are
  skipped. formulas.recentlyUsed
  returns it. It never reads the journal back, so the journal's caps do
  not affect it.
- The list is kept with the other engine preferences (prefs.json, the
  browser's storage on the web), so it survives a restart as Excel's
  does. It is read leniently: a malformed value loses only this list. A
  fresh install starts from common functions.

The Insert Function dialog is titled "Insert Function", as in Excel (it
said "Formula Builder"), and gains a working category list: Most
Recently Used, All, then each category in Excel's order. A category may
be given by label or by engine id, so a Function Library menu or the
control channel (ui.dialog.set) opens it on one, and a new search
switches to All. The dialog keeps its translated labels and its search
in the interface language. Each row shows the function's name and its
category in two columns; the category column starts after the widest
name shown, so a long name in any font or formula language stays clear
of it. A function counts as recently used once its formula is entered,
not when OK is pressed. The unused "Formula Builder" translations are
removed.

Tests: engine unit tests for reading functions from formulas (strings,
unknown names, over-long and malformed formulas), the formulas a
command enters (newest first, within the budget), the ten-name list
and the lenient read of prefs.json; tests/it/recently_used.rs for
cell.set and range.setValues from the session, a failed command, a
cleared journal and a save and reload of the preferences. UI tests for
the menu's order and the dialog's title, categories, engine-id
category, search and OK, and for the category column with a wide font.
2026-10-31 04:00:56 -04:00

119 lines
6.0 KiB
Rust

use super::show::ok_cancel;
use super::{Dialog, Json, SheetApp, json, vec2};
/// Insert Function: search names (as the formula language spells them) and descriptions, or pick
/// a category (Most Recently Used, All or one category), then a function; each hit shows its
/// category and description. OK starts editing `=NAME(` (or appends to the formula).
pub(super) fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) {
use crate::function_library::{self as lib, ALL, MOST_RECENTLY_USED};
let language = app.ui.language;
ui.horizontal(|ui| {
ui.label(language.tr("Search:"));
let r = ui.add(egui::TextEdit::singleline(&mut d.search).desired_width(300.0).hint_text(language.tr("e.g. lookup, average, date")));
r.request_focus();
});
// A new search (typed, or set through the control channel) looks through every function, as
// Excel's does.
if d.values.get("searched").and_then(Json::as_str) != Some(d.search.as_str()) {
d.list_index = 0;
if !d.search.trim().is_empty() {
d.values.insert("category".into(), json!(ALL));
}
d.values.insert("searched".into(), json!(d.search));
}
// The category may be given by label or by engine id (a Function Library menu, `ui.dialog.set`).
let saved = d.values.get("category").and_then(Json::as_str).unwrap_or(MOST_RECENTLY_USED).to_string();
let mut category = if saved.eq_ignore_ascii_case(ALL) {
ALL.to_string()
} else {
lib::category_id(&saved).map(lib::category_label).unwrap_or(MOST_RECENTLY_USED).to_string()
};
ui.horizontal(|ui| {
let label = ui.label(language.tr("Or select a category:"));
craft_ui::ComboBox::from_id_salt("insertFunctionCategory")
.width(220.0)
.selected_text(language.tr(&category))
.show_ui(ui, |ui| {
for c in [MOST_RECENTLY_USED, ALL].into_iter().chain(lib::CATEGORIES.iter().map(|(_, l)| *l)) {
ui.selectable_value(&mut category, c.to_string(), language.tr(c));
}
})
.response
.labelled_by(label.id);
});
if category != saved {
d.list_index = 0;
}
d.values.insert("category".into(), json!(category));
let locale = language.formula_locale();
// The names and descriptions searched are the ones shown, so in the interface language.
let q = d.search.trim().to_lowercase();
let matches = |f: &lib::FnEntry| {
q.is_empty()
|| locale.function_name(&f.name).to_lowercase().contains(&q)
|| language.function_description(&f.description).unwrap_or(&f.description).to_lowercase().contains(&q)
};
let hits: Vec<&lib::FnEntry> = if category == MOST_RECENTLY_USED {
lib::recent(app).iter().filter_map(|n| lib::find(n)).filter(|f| matches(f)).collect()
} else {
let id = lib::category_id(&category);
// The catalog is fixed (some 500 functions), so All lists every one.
lib::all().iter().filter(|f| id.is_none_or(|id| f.category == id) && matches(f)).collect()
};
let t = crate::theme::Tokens::get(ui.ctx());
let font = egui::TextStyle::Body.resolve(ui.style());
let rows: Vec<(&str, &str)> = hits.iter().map(|f| (locale.function_name(&f.name), language.tr(lib::category_label(&f.category)))).collect();
// The category column starts after the widest name shown, so a long name in any formula
// language or font never runs into it.
let name_w = rows.iter().map(|(n, _)| ui.painter().layout_no_wrap(n.to_string(), font.clone(), t.text).size().x).fold(0.0_f32, f32::max);
egui::ScrollArea::vertical().max_height(260.0).auto_shrink([false, false]).show(ui, |ui| {
if rows.is_empty() {
ui.label(egui::RichText::new(language.tr("No functions match.")).italics());
}
let row_h = ui.text_style_height(&egui::TextStyle::Body) + 4.0;
for (i, (name, category)) in rows.iter().enumerate() {
let (rect, r) = ui.allocate_exact_size(vec2(ui.available_width().max(name_w + 120.0), row_h), egui::Sense::click());
if i == d.list_index {
ui.painter().rect_filled(rect, 3.0, ui.visuals().selection.bg_fill);
} else if r.hovered() {
ui.painter().rect_filled(rect, 3.0, t.hover);
}
let color = if i == d.list_index { ui.visuals().selection.stroke.color } else { t.text };
ui.painter().text(rect.left_center() + vec2(6.0, 0.0), egui::Align2::LEFT_CENTER, *name, font.clone(), color);
ui.painter().text(rect.left_center() + vec2(6.0 + name_w + 16.0, 0.0), egui::Align2::LEFT_CENTER, *category, font.clone(), t.text_dim);
r.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, i == d.list_index, format!("{name} ({category})")));
if r.clicked() {
d.list_index = i;
}
if r.double_clicked() {
d.list_index = i;
*confirm = true;
}
}
});
if let Some(f) = hits.get(d.list_index) {
ui.separator();
ui.label(egui::RichText::new(crate::formula_locale::signature(&f.name, locale, language).unwrap_or_default()).strong());
ui.label(crate::formula_locale::description(&f.name, locale, language).unwrap_or_default());
}
let mut open = true;
ok_cancel(ui, confirm, &mut open);
// The function counts as recently used once the formula is entered (the engine notes it).
if *confirm && let Some(f) = hits.get(d.list_index) {
let n = app
.session
.active()
.map(|doc| crate::formula_locale::completion_name(&f.name, locale, &doc.wb, doc.wb.active_sheet()))
.unwrap_or(&f.name);
let cur = app.editor.as_ref().map(|e| e.text.clone());
let text = match cur {
Some(t) if t.starts_with('=') => format!("{t}{n}("),
_ => format!("={n}("),
};
app.begin_edit(Some(text), false);
}
if !open {
*confirm = true;
}
}