ui(feat): open expression suggestions with Ctrl+Space (#1852)

Thanks @Karl-VonRichter.
This commit is contained in:
Karl Von Richter authored and GitHub committed 2026-10-17 04:07:56 -04:00
1 parent ddc6aa6301
commit 3689f0d6e5
4 files changed
+80 -8

No files matched your search

@@ -26,8 +26,39 @@ fn place(h: &mut Harness, picks: &[[f64; 2]], at: [f64; 2]) {
}
fn type_value(h: &mut Harness, text: &str) {
h.step(&json!({"key":"A","cmd":true})).unwrap();
h.step(&json!({"text":text})).unwrap();
if text.is_empty() {
h.step(&json!({"key":"Backspace"})).unwrap();
} else {
h.step(&json!({"text":text})).unwrap();
}
}
#[test]
fn dimension_parameters_use_typing_and_ctrl_space_completion() {
for (prefix, expected) in [("wid", "width"), ("", "width"), ("d", "d1")] {
let mut h = line([20., 0.]);
h.app.run("parameters.add", json!({"name":"width","expression":"25 mm"})).unwrap();
if prefix == "d" {
h.app.run("sketch.line", json!({"points":[[0,-10],[12,-10]],"infer":false})).unwrap();
h.app.run("sketch.dimension", json!({"entities":["l2"],"value":12,"text_at":[6,-16]})).unwrap();
h.settle();
}
place(&mut h, &[[10., 0.]], [10., 6.]);
let baseline = h.app.session.doc.to_json();
type_value(&mut h, prefix);
if prefix.is_empty() {
h.step(&json!({"key":"Space","cmd":true})).unwrap();
}
assert!(h.widget(expected).is_some(), "parameter completion is available for {prefix:?}: {}", h.call("ui.inspect", json!({})));
h.step(&json!({"key":"Tab"})).unwrap();
assert!(crate::dim_view::editing(), "accepting completion does not apply the dimension");
assert_eq!(h.app.session.doc.to_json(), baseline);
h.step(&json!({"key":"Enter"})).unwrap();
assert!(!crate::dim_view::editing());
assert_eq!(h.app.session.doc.params.iter().rev().find(|p| p.model).unwrap().expr, expected);
crate::dim_view::cancel();
}
}
#[test]
fn edge_and_endpoint_creation_commit_once_and_undo_restores_exact_design() {
for (end, picks, at) in [
+34 -3
View File
@@ -2,7 +2,7 @@
use egui::{Key, RichText};
use crate::params_dialog::{suggestions, word_before};
use crate::params_dialog::{suggestions_for, word_before};
use crate::theme::Tokens;
#[derive(Clone)]
@@ -15,6 +15,7 @@ struct Popup {
count: usize,
accept: bool,
dismissed: bool,
explicit: bool,
}
fn popup_id() -> egui::Id {
@@ -25,6 +26,17 @@ fn popup_id() -> egui::Id {
/// owns these keys. Raw events are removed too: several expression editors inspect raw Tab.
pub(crate) fn begin_frame(ctx: &egui::Context) {
let Some(mut popup) = ctx.data(|d| d.get_temp::<Popup>(popup_id())) else { return };
if ctx.memory(|m| m.has_focus(popup.editor))
&& popup.frame.saturating_add(1) >= ctx.cumulative_frame_nr()
&& ctx.input_mut(|i| i.consume_key(egui::Modifiers::CTRL, Key::Space))
{
ctx.input_mut(|i| {
i.raw.events.retain(|e| !matches!(e, egui::Event::Key { key: Key::Space, modifiers, .. } if modifiers.ctrl));
});
popup.explicit = true;
popup.dismissed = false;
ctx.data_mut(|d| d.insert_temp(popup_id(), popup.clone()));
}
let escape_blurred = ctx.input(|i| i.key_pressed(Key::Escape)) && ctx.memory(|m| m.focused().is_none() && m.had_focus_last_frame(popup.editor));
let focused = ctx.memory(|m| m.has_focus(popup.editor)) || escape_blurred;
if popup.frame.saturating_add(1) < ctx.cumulative_frame_nr() || !focused || popup.dismissed || popup.count == 0 {
@@ -88,7 +100,6 @@ pub(crate) fn complete(ui: &mut egui::Ui, resp: &egui::Response, text: &mut Stri
let state = egui::TextEdit::load_state(&ctx, resp.id);
let cursor = state.as_ref().and_then(|s| s.cursor.char_range()).map(|r| r.primary.index.0).unwrap_or_else(|| text.chars().count());
let (start, word) = word_before(text, cursor);
let list = suggestions(&ctx, &word);
let previous = ctx.data(|d| d.get_temp::<Popup>(popup_id()));
let mut popup = previous.filter(|p| p.editor == resp.id && p.word == word && p.cursor == cursor).unwrap_or(Popup {
editor: resp.id,
@@ -96,10 +107,12 @@ pub(crate) fn complete(ui: &mut egui::Ui, resp: &egui::Response, text: &mut Stri
word,
cursor,
selected: 0,
count: list.len(),
count: 0,
accept: false,
dismissed: false,
explicit: false,
});
let list = suggestions_for(&ctx, &popup.word, popup.explicit);
popup.frame = ctx.cumulative_frame_nr();
popup.count = list.len();
popup.selected = popup.selected.min(list.len().saturating_sub(1));
@@ -153,6 +166,7 @@ pub(crate) fn complete(ui: &mut egui::Ui, resp: &egui::Response, text: &mut Stri
#[cfg(test)]
mod tests {
use super::*;
use crate::params_dialog::suggestions;
use crate::{SolveApp, params_dialog};
use serde_json::json;
@@ -308,6 +322,23 @@ mod tests {
assert!(e.leaked.contains(&Key::Enter));
}
#[test]
fn ctrl_space_opens_empty_expression_suggestions_and_reopens_dismissed_ones() {
for prefix in ["", "wi"] {
let mut e = Editor::new(prefix);
if !prefix.is_empty() {
e.key(Key::Escape);
}
e.frame(vec![egui::Event::Key { key: Key::Space, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::CTRL }]);
let popup = e.ctx.data(|d| d.get_temp::<Popup>(popup_id())).unwrap();
assert!(popup.explicit && !popup.dismissed && popup.count > 0);
assert_eq!(e.text, prefix, "Ctrl+Space does not add a space");
e.key(Key::Tab);
assert!(e.accepted);
assert!(!e.text.is_empty());
}
}
#[test]
fn modified_navigation_remains_available_to_the_editor() {
let mut e = Editor::new("wi");
+11 -3
View File
@@ -498,16 +498,24 @@ pub(crate) fn word_before(text: &str, cursor: usize) -> (usize, String) {
/// Suggestions for the word being typed.
pub fn suggestions(ctx: &egui::Context, word: &str) -> Vec<Suggestion> {
if word.is_empty() || word.chars().next().is_some_and(|c| c.is_ascii_digit()) {
suggestions_for(ctx, word, false)
}
pub(crate) fn suggestions_for(ctx: &egui::Context, word: &str, explicit: bool) -> Vec<Suggestion> {
if (!explicit && word.is_empty()) || word.chars().next().is_some_and(|c| c.is_ascii_digit()) {
return Vec::new();
}
let all = ctx.data(|d| d.get_temp::<Cache>(cache_id())).map(|c| c.all).unwrap_or_default();
// A complete token is ready to submit, even when longer names share its prefix.
if all.iter().any(|s| s.text == word) {
if !explicit && all.iter().any(|s| s.text == word) {
return Vec::new();
}
let w = word.to_ascii_lowercase();
all.iter().filter(|s| s.text.to_ascii_lowercase().starts_with(&w) && s.text.trim_end_matches('(') != word).take(8).cloned().collect()
all.iter()
.filter(|s| s.text.to_ascii_lowercase().starts_with(&w) && (explicit || s.text.trim_end_matches('(') != word))
.take(8)
.cloned()
.collect()
}
/// Offer keyboard and pointer completions under a focused expression box.
+3 -1
View File
@@ -11,7 +11,9 @@ parameter editor. The separate **Configurations** button opens the configuration
Press **Enter** in the new parameter's name, expression or comment field to add it.
Valid entries return focus to the name field for the next parameter; invalid entries remain
editable. In expression fields, **Up/Down** selects an autocomplete suggestion,
editable. In expression fields, type a parameter or dimension name for autocomplete,
or press **Ctrl+Space** to open suggestions, including in an empty field.
**Up/Down** selects an autocomplete suggestion,
**Enter/Tab** accepts it, and **Escape** dismisses the suggestions. Accepting a suggestion
does not also submit the parameter.