From ce437747eda192e41a861469c17bf68f8e3cb632 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 05:28:14 +0200 Subject: [PATCH 1/3] Expressions: accepting a function completion inserts the call parentheses (fixes #1393) --- crates/ui-egui/src/panels/expr_editor.rs | 77 +++++++++++++++++++++--- 1 file changed, 69 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/panels/expr_editor.rs b/crates/ui-egui/src/panels/expr_editor.rs index f800b1a4..f342d563 100644 --- a/crates/ui-egui/src/panels/expr_editor.rs +++ b/crates/ui-egui/src/panels/expr_editor.rs @@ -2,6 +2,7 @@ //! Font Size, Syntax Highlighting, Line Numbers, Auto-complete (Tab accepts), Bracket Matching //! and Word Wrap. The Composition viewer uses the shared diagnostics in `expr_bar`. +use effectcraft_engine::commands::expr_tools::language_menu; use effectcraft_engine::prefs::Scripting; use egui::text::{LayoutJob, TextFormat}; use egui::{Color32, FontId, Rect, Stroke, pos2, vec2}; @@ -211,6 +212,37 @@ pub fn completions(text: &str, cursor: usize) -> (String, Vec<&'static str>) { (word, c) } +/// How `name` is called, from the Expression Language menu's signatures: `None` for a +/// property, `Some(true)` for a function that takes arguments, `Some(false)` for one that never +/// does. +fn call_shape(name: &str) -> Option { + let calls: Vec<&str> = + language_menu().into_iter().flat_map(|(_, items)| items).filter_map(|(_, text)| text.strip_prefix(name)?.strip_prefix('(')).collect(); + (!calls.is_empty()).then(|| calls.iter().any(|rest| !rest.starts_with(')'))) +} + +/// Accepts the first auto-complete candidate at char index `cursor`: inserts the rest of the +/// name, plus `()` for a function (#1393), and returns the new cursor: between the parentheses, +/// or after them when the function takes no arguments. +pub fn accept_completion(buf: &mut String, cursor: usize) -> Option { + let (word, cands) = completions(buf, cursor); + let first = cands.first()?; + let at = byte_of(buf, cursor); + let rest = &first[word.len()..]; + let end = cursor + first.chars().count() - word.chars().count(); + let already_called = buf.get(at..).is_some_and(|after| after.starts_with('(')); + match call_shape(first).filter(|_| !already_called) { + Some(takes_args) => { + buf.insert_str(at, &format!("{rest}()")); + Some(end + if takes_args { 1 } else { 2 }) + } + None => { + buf.insert_str(at, rest); + Some(end) + } + } +} + /// Colour runs for JavaScript: (byte start, byte end, colour). pub fn highlight(text: &str, base: Color32, sx: &Syntax) -> Vec<(usize, usize, Color32)> { let b = text.as_bytes(); @@ -320,15 +352,10 @@ pub fn editor(ui: &mut egui::Ui, id: egui::Id, buf: &mut String, rect: Rect, p: if p.auto_complete && child.memory(|m| m.has_focus(id)) && let Some(c) = cursor + && !completions(buf, c).1.is_empty() + && child.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Tab)) { - let (word, cands) = completions(buf, c); - if let Some(first) = cands.first() - && child.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Tab)) - { - let at = byte_of(buf, c); - buf.insert_str(at, &first[word.len()..]); - accepted = Some(c + first.chars().count() - word.chars().count()); - } + accepted = accept_completion(buf, c); } let out = egui::TextEdit::multiline(buf) .id(id) @@ -446,3 +473,37 @@ mod contrast_tests { assert_eq!(col("// note"), Some(sx.comment)); } } + +#[cfg(test)] +mod completion_tests { + use super::*; + + /// #1393: accepting a function inserts the call, with the cursor between the parentheses + /// (after them when the function takes no arguments); a property stays a bare name. + #[test] + fn accepting_a_function_completion_inserts_the_call() { + let mut buf = String::from("x = loopO"); + assert_eq!(accept_completion(&mut buf, 9), Some(12)); + assert_eq!(buf, "x = loopOut()"); + + let mut buf = String::from("wigg"); + assert_eq!(accept_completion(&mut buf, 4), Some(7)); + assert_eq!(buf, "wiggle()"); + + let mut buf = String::from("isClo"); + assert_eq!(accept_completion(&mut buf, 5), Some(10)); + assert_eq!(buf, "isClosed()"); + + let mut buf = String::from("tim"); + assert_eq!(accept_completion(&mut buf, 3), Some(4)); + assert_eq!(buf, "time"); + } + + /// Completing a name already followed by its call does not add a second pair. + #[test] + fn accepting_before_an_existing_call_keeps_one_pair_of_parentheses() { + let mut buf = String::from("loopO(\"cycle\")"); + assert_eq!(accept_completion(&mut buf, 5), Some(7)); + assert_eq!(buf, "loopOut(\"cycle\")"); + } +} From 0f0fef2b150dcbddd6f8dcc7246a472cdb2c80b2 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 05:28:41 +0200 Subject: [PATCH 2/3] Contributors: add my real name --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 705feb25..20e4736f 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -78,3 +78,7 @@ sync_github_name = true [people.moecr7] real_name = "Moein Saharkhiz" display_name = "onlymoein" + +[people.marcoarnulfo] +real_name = "Marco Arnulfo" +sync_github_name = false From 1bcf1a9f61deccbe0d6ad191fc7198dc89680f35 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 16 Oct 2026 18:39:16 +0700 Subject: [PATCH 3/3] Contributors: keep the people.toml entry that landed with #1608 Co-Authored-By: Claude Opus 5.5 --- contributors/people.toml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 20e4736f..705feb25 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -78,7 +78,3 @@ sync_github_name = true [people.moecr7] real_name = "Moein Saharkhiz" display_name = "onlymoein" - -[people.marcoarnulfo] -real_name = "Marco Arnulfo" -sync_github_name = false