diff --git a/crates/ui-egui/src/dimension_create_ui_tests.rs b/crates/ui-egui/src/dimension_create_ui_tests.rs index 7cfd1910..fc013b22 100644 --- a/crates/ui-egui/src/dimension_create_ui_tests.rs +++ b/crates/ui-egui/src/dimension_create_ui_tests.rs @@ -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 [ diff --git a/crates/ui-egui/src/expression_complete.rs b/crates/ui-egui/src/expression_complete.rs index 26df7cd5..67388312 100644 --- a/crates/ui-egui/src/expression_complete.rs +++ b/crates/ui-egui/src/expression_complete.rs @@ -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_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_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_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"); diff --git a/crates/ui-egui/src/params_dialog.rs b/crates/ui-egui/src/params_dialog.rs index 8752588d..23bdb450 100644 --- a/crates/ui-egui/src/params_dialog.rs +++ b/crates/ui-egui/src/params_dialog.rs @@ -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 { - 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 { + 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_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. diff --git a/docs/book/src/guide/parameters.md b/docs/book/src/guide/parameters.md index 5aa1f18a..c3edf3ea 100644 --- a/docs/book/src/guide/parameters.md +++ b/docs/book/src/guide/parameters.md @@ -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.