Fix desktop launch, app icons and keyboard parameter editing
This commit is contained in:
1 parent
8fa52c8e5e
commit
6dd202d277
45 files changed
+5993
-272
No files matched your search
@@ -0,0 +1,335 @@
|
||||
//! Focus-scoped completion keyboard handling before editors and CAD shortcuts see input.
|
||||
|
||||
use egui::{Key, RichText};
|
||||
|
||||
use crate::params_dialog::{suggestions, word_before};
|
||||
use crate::theme::Tokens;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Popup {
|
||||
editor: egui::Id,
|
||||
frame: u64,
|
||||
word: String,
|
||||
cursor: usize,
|
||||
selected: usize,
|
||||
count: usize,
|
||||
accept: bool,
|
||||
dismissed: bool,
|
||||
}
|
||||
|
||||
fn popup_id() -> egui::Id {
|
||||
egui::Id::new("expression_completion_keyboard")
|
||||
}
|
||||
|
||||
/// Run before any TextEdit or viewport shortcuts. Only the focused, recently drawn popup
|
||||
/// 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 };
|
||||
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 {
|
||||
if !focused || popup.frame.saturating_add(1) < ctx.cumulative_frame_nr() {
|
||||
ctx.data_mut(|d| d.remove::<Popup>(popup_id()));
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Let the editor process a newly typed/deleted prefix before deciding what Enter means.
|
||||
// A prior frame's completion must never replace text changed in this input batch.
|
||||
if ctx.input(|i| {
|
||||
i.raw.events.iter().any(|e| {
|
||||
matches!(e, egui::Event::Text(_) | egui::Event::Paste(_) | egui::Event::Key { key: Key::Backspace | Key::Delete, pressed: true, .. })
|
||||
})
|
||||
}) {
|
||||
return;
|
||||
}
|
||||
let mut consumed = false;
|
||||
ctx.input_mut(|input| {
|
||||
let mut owned = Vec::new();
|
||||
for event in &input.raw.events {
|
||||
if let egui::Event::Key { key, pressed: true, modifiers, .. } = event
|
||||
&& *modifiers == egui::Modifiers::NONE
|
||||
{
|
||||
match key {
|
||||
Key::ArrowDown => popup.selected = (popup.selected + 1) % popup.count,
|
||||
Key::ArrowUp => popup.selected = (popup.selected + popup.count - 1) % popup.count,
|
||||
Key::Enter | Key::Tab => popup.accept = true,
|
||||
Key::Escape => {
|
||||
popup.dismissed = true;
|
||||
popup.accept = false;
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
owned.push(*key);
|
||||
consumed = true;
|
||||
}
|
||||
}
|
||||
let keep = |event: &egui::Event| {
|
||||
!matches!(event, egui::Event::Key { key, modifiers, .. }
|
||||
if owned.contains(key) && *modifiers == egui::Modifiers::NONE)
|
||||
};
|
||||
input.events.retain(keep);
|
||||
input.raw.events.retain(keep);
|
||||
});
|
||||
if consumed {
|
||||
// egui schedules navigation (and Escape blur) at begin_pass, before this hook.
|
||||
ctx.memory_mut(|m| {
|
||||
m.move_focus(egui::FocusDirection::None);
|
||||
m.request_focus(popup.editor);
|
||||
});
|
||||
}
|
||||
ctx.data_mut(|d| d.insert_temp(popup_id(), popup));
|
||||
}
|
||||
|
||||
pub(crate) fn complete(ui: &mut egui::Ui, resp: &egui::Response, text: &mut String) -> bool {
|
||||
let ctx = ui.ctx().clone();
|
||||
if !ctx.memory(|m| m.has_focus(resp.id)) {
|
||||
return false;
|
||||
}
|
||||
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,
|
||||
frame: 0,
|
||||
word,
|
||||
cursor,
|
||||
selected: 0,
|
||||
count: list.len(),
|
||||
accept: false,
|
||||
dismissed: false,
|
||||
});
|
||||
popup.frame = ctx.cumulative_frame_nr();
|
||||
popup.count = list.len();
|
||||
popup.selected = popup.selected.min(list.len().saturating_sub(1));
|
||||
if list.is_empty() || popup.dismissed {
|
||||
ctx.data_mut(|d| d.insert_temp(popup_id(), popup));
|
||||
return false;
|
||||
}
|
||||
let mut pick = if popup.accept { list.get(popup.selected).map(|s| s.text.clone()) } else { None };
|
||||
popup.accept = false;
|
||||
let t = Tokens::get();
|
||||
crate::frame::area(resp.id.with("completions")).order(egui::Order::Tooltip).fixed_pos(resp.rect.left_bottom() + egui::vec2(0.0, 2.0)).show(
|
||||
&ctx,
|
||||
|ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.set_min_width(resp.rect.width().max(180.0));
|
||||
for (i, suggestion) in list.iter().enumerate() {
|
||||
let colour = match suggestion.kind.as_str() {
|
||||
"parameter" => t.text,
|
||||
"function" => t.accent,
|
||||
_ => t.text_dim,
|
||||
};
|
||||
let r = ui
|
||||
.add(egui::Button::new(RichText::new(&suggestion.text).color(colour)).selected(i == popup.selected))
|
||||
.on_hover_text(&suggestion.detail);
|
||||
if i == popup.selected {
|
||||
ui.label(RichText::new(&suggestion.detail).small().color(t.text_dim));
|
||||
}
|
||||
if r.clicked() {
|
||||
pick = Some(suggestion.text.clone());
|
||||
}
|
||||
}
|
||||
ui.label(RichText::new("↑ ↓ choose · Enter / Tab complete · Esc dismiss").small().color(t.text_dim));
|
||||
});
|
||||
},
|
||||
);
|
||||
ctx.data_mut(|d| d.insert_temp(popup_id(), popup));
|
||||
let Some(pick) = pick else { return false };
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let before: String = chars.get(..start).map(|s| s.iter().collect()).unwrap_or_default();
|
||||
let after: String = chars.get(cursor.min(chars.len())..).map(|s| s.iter().collect()).unwrap_or_default();
|
||||
*text = format!("{before}{pick}{after}");
|
||||
let mut state = state.unwrap_or_default();
|
||||
state.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(start + pick.chars().count()))));
|
||||
state.store(&ctx, resp.id);
|
||||
resp.request_focus();
|
||||
// Do not leave the accepted popup owning Enter on the next frame.
|
||||
ctx.data_mut(|d| d.remove::<Popup>(popup_id()));
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{SolveApp, params_dialog};
|
||||
use serde_json::json;
|
||||
|
||||
struct Editor {
|
||||
ctx: egui::Context,
|
||||
app: SolveApp,
|
||||
text: String,
|
||||
id: egui::Id,
|
||||
accepted: bool,
|
||||
leaked: Vec<Key>,
|
||||
}
|
||||
|
||||
impl Editor {
|
||||
fn new(text: &str) -> Self {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
|
||||
for (name, value) in [("width", 40), ("width_extra", 50), ("wing", 20), ("wire", 10)] {
|
||||
app.run("parameters.add", json!({"name":name,"expression":format!("{value} mm")})).unwrap();
|
||||
}
|
||||
params_dialog::publish(&mut app, &ctx);
|
||||
let id = egui::Id::new("completion_test_editor");
|
||||
ctx.memory_mut(|m| m.request_focus(id));
|
||||
let mut editor = Self { ctx, app, text: text.into(), id, accepted: false, leaked: Vec::new() };
|
||||
editor.frame(Vec::new());
|
||||
let mut state = egui::TextEdit::load_state(&editor.ctx, id).unwrap();
|
||||
state.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(text.chars().count()))));
|
||||
state.store(&editor.ctx, id);
|
||||
editor.frame(Vec::new());
|
||||
editor
|
||||
}
|
||||
|
||||
fn frame(&mut self, events: Vec<egui::Event>) {
|
||||
self.accepted = false;
|
||||
self.leaked.clear();
|
||||
let _ = self.ctx.run_ui(
|
||||
egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 400.0))),
|
||||
events,
|
||||
..Default::default()
|
||||
},
|
||||
|ui| {
|
||||
begin_frame(ui.ctx());
|
||||
params_dialog::publish(&mut self.app, ui.ctx());
|
||||
egui::CentralPanel::default().show(ui, |ui| {
|
||||
let response = ui.add(egui::TextEdit::singleline(&mut self.text).id(self.id));
|
||||
self.accepted = complete(ui, &response, &mut self.text);
|
||||
for key in [Key::ArrowUp, Key::ArrowDown, Key::Enter, Key::Tab, Key::Escape] {
|
||||
if ui.input(|i| {
|
||||
i.key_pressed(key)
|
||||
|| i.raw.events.iter().any(|e| matches!(e, egui::Event::Key { key:k, pressed:true, .. } if *k == key))
|
||||
}) {
|
||||
self.leaked.push(key);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn key(&mut self, key: Key) {
|
||||
self.frame(vec![egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }]);
|
||||
}
|
||||
|
||||
fn cursor(&self) -> usize {
|
||||
egui::TextEdit::load_state(&self.ctx, self.id).unwrap().cursor.char_range().unwrap().primary.index.0
|
||||
}
|
||||
|
||||
fn selected(&self) -> usize {
|
||||
self.ctx.data(|d| d.get_temp::<Popup>(popup_id())).unwrap().selected
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrows_cycle_without_moving_caret_enter_accepts_selected_without_leaking() {
|
||||
let mut e = Editor::new("2 * wi");
|
||||
let list = suggestions(&e.ctx, "wi");
|
||||
assert!(list.len() >= 3);
|
||||
let cursor = e.cursor();
|
||||
e.key(Key::ArrowDown);
|
||||
assert_eq!(e.selected(), 1);
|
||||
assert_eq!(e.cursor(), cursor);
|
||||
assert!(e.leaked.is_empty());
|
||||
e.key(Key::ArrowUp);
|
||||
assert_eq!(e.selected(), 0);
|
||||
e.key(Key::ArrowUp);
|
||||
assert_eq!(e.selected(), list.len() - 1);
|
||||
e.key(Key::Enter);
|
||||
assert_eq!(e.text, format!("2 * {}", list.last().unwrap().text));
|
||||
assert!(e.accepted);
|
||||
assert!(e.leaked.is_empty());
|
||||
assert_eq!(e.cursor(), e.text.chars().count());
|
||||
assert!(e.ctx.memory(|m| m.has_focus(e.id)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_accepts_selection_filtering_resets_it_and_escape_dismisses() {
|
||||
let mut e = Editor::new("wi");
|
||||
e.key(Key::ArrowDown);
|
||||
let expected = suggestions(&e.ctx, "wi").get(1).unwrap().text.clone();
|
||||
e.key(Key::Tab);
|
||||
assert_eq!(e.text, expected);
|
||||
assert!(e.accepted && e.leaked.is_empty());
|
||||
let mut e = Editor::new("wi");
|
||||
e.key(Key::ArrowDown);
|
||||
e.frame(vec![egui::Event::Text("d".into())]);
|
||||
assert_eq!(e.text, "wid");
|
||||
assert_eq!(e.selected(), 0);
|
||||
e.key(Key::Escape);
|
||||
assert!(e.leaked.is_empty());
|
||||
assert_eq!(e.text, "wid");
|
||||
e.key(Key::Enter);
|
||||
assert!(!e.accepted);
|
||||
assert!(e.leaked.contains(&Key::Enter));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_popup_preserves_editor_and_application_keys() {
|
||||
let mut e = Editor::new("123");
|
||||
e.key(Key::ArrowDown);
|
||||
assert!(e.leaked.contains(&Key::ArrowDown));
|
||||
e.key(Key::Escape);
|
||||
assert!(e.leaked.contains(&Key::Escape));
|
||||
assert!(!e.ctx.memory(|m| m.has_focus(e.id)));
|
||||
e.ctx.memory_mut(|m| m.request_focus(e.id));
|
||||
e.frame(Vec::new());
|
||||
let mut state = egui::TextEdit::load_state(&e.ctx, e.id).unwrap();
|
||||
state.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(e.text.chars().count()))));
|
||||
state.store(&e.ctx, e.id);
|
||||
e.frame(vec![egui::Event::Text("4".into())]);
|
||||
assert_eq!(e.text, "1234");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_tokens_submit_without_completing_longer_names() {
|
||||
for text in ["width", "2 mm"] {
|
||||
let mut e = Editor::new(text);
|
||||
e.key(Key::Enter);
|
||||
assert_eq!(e.text, text);
|
||||
assert!(!e.accepted);
|
||||
assert!(e.leaked.contains(&Key::Enter));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_and_enter_in_one_batch_does_not_accept_stale_prefix() {
|
||||
let mut e = Editor::new("wi");
|
||||
e.frame(vec![
|
||||
egui::Event::Text("dth".into()),
|
||||
egui::Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE },
|
||||
]);
|
||||
assert_eq!(e.text, "width");
|
||||
assert!(!e.accepted);
|
||||
assert!(e.leaked.contains(&Key::Enter));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modified_navigation_remains_available_to_the_editor() {
|
||||
let mut e = Editor::new("wi");
|
||||
e.frame(vec![egui::Event::Key {
|
||||
key: Key::ArrowUp,
|
||||
physical_key: None,
|
||||
pressed: true,
|
||||
repeat: false,
|
||||
modifiers: egui::Modifiers { ctrl: true, ..Default::default() },
|
||||
}]);
|
||||
assert!(e.leaked.contains(&Key::ArrowUp));
|
||||
assert!(!e.accepted);
|
||||
let mut e = Editor::new("wi");
|
||||
e.frame(vec![egui::Event::Key {
|
||||
key: Key::Tab,
|
||||
physical_key: None,
|
||||
pressed: true,
|
||||
repeat: false,
|
||||
modifiers: egui::Modifiers { shift: true, ..Default::default() },
|
||||
}]);
|
||||
assert!(e.leaked.contains(&Key::Tab));
|
||||
assert_eq!(e.text, "wi");
|
||||
assert!(!e.accepted);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user