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
@@ -1,9 +1,10 @@
|
||||
//! Context menus, one look everywhere.
|
||||
//!
|
||||
//! The viewport's right-click opens a radial marking menu (eight commands around the cursor,
|
||||
//! Repeat at the top) with a list underneath of what fits the selection or the thing under the
|
||||
//! cursor: faces, edges, bodies, sketch entities or empty space. Holding the right button opens
|
||||
//! it too; releasing over a direction picks that command (a flick). The browser's right-click
|
||||
//! The viewport's right-click opens a list of commands for the selection or the thing under the
|
||||
//! cursor: faces, edges, bodies, sketch entities or empty space. The optional radial marking
|
||||
//! menu adds eight commands around the cursor, with Repeat at the top. With that preference on,
|
||||
//! holding the right button opens it too; releasing over a direction picks a command (a flick).
|
||||
//! The browser's right-click
|
||||
//! opens the same list styling for a body, sketch or component.
|
||||
//!
|
||||
//! Items are commands (by id, with parameters filled from the target) or `ui.*` actions on view
|
||||
@@ -1172,7 +1173,7 @@ pub fn open(app: &mut SolveApp, at: Pos2) {
|
||||
/// Open a menu for a target (the radial ring only for the viewport).
|
||||
pub fn open_for(app: &mut SolveApp, at: Pos2, target: Target) {
|
||||
app.menu.key_row = None;
|
||||
app.menu.radial = target == Target::Viewport;
|
||||
app.menu.radial = target == Target::Viewport && app.preferences.radial_context_menu;
|
||||
app.menu.target = target;
|
||||
app.menu.flick = false;
|
||||
app.viewport.context_menu = Some(at);
|
||||
@@ -1301,6 +1302,12 @@ fn slot_rect(c: Pos2, k: usize, size: Vec2) -> Rect {
|
||||
/// Press-and-hold of the right button in the viewport opens the menu; releasing over a
|
||||
/// direction picks it.
|
||||
fn track_hold(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
if !app.preferences.radial_context_menu {
|
||||
app.menu.press = None;
|
||||
app.menu.flick = false;
|
||||
app.menu.radial = false;
|
||||
return;
|
||||
}
|
||||
let (pressed, down, released, pos, time) = ctx.input(|i| {
|
||||
(
|
||||
i.pointer.button_pressed(egui::PointerButton::Secondary),
|
||||
@@ -1648,6 +1655,53 @@ fn show_props(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn viewport_right_click_keeps_list_menu_and_radial_is_opt_in() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.settle();
|
||||
let point = h.at(&json!({"world":[0,0,0]})).unwrap();
|
||||
h.step(&json!({"click":point,"button":"right"})).unwrap();
|
||||
assert!(h.app.viewport.context_menu.is_some(), "ordinary right click must still open a menu");
|
||||
let list = describe(&h.app);
|
||||
assert!(list["radial"].as_array().unwrap().is_empty());
|
||||
assert!(list["items"].as_array().unwrap().iter().any(|item| item["id"] == "ui.origin" && item["enabled"] == true));
|
||||
h.step(&json!({"click":{"handle":"menu:ui.origin"}})).unwrap();
|
||||
assert!(!h.app.ui.show_origin && h.app.viewport.context_menu.is_none(), "the ordinary menu must execute Hide Origin and close");
|
||||
h.call("ui.prefs", json!({"radial_context_menu":true}));
|
||||
h.step(&json!({"click":point,"button":"right"})).unwrap();
|
||||
assert_eq!(describe(&h.app)["radial"].as_array().unwrap().len(), DIRS);
|
||||
open_for(&mut h.app, pos2(point[0] as f32, point[1] as f32), Target::Origin);
|
||||
assert!(!h.app.menu.radial, "browser menus remain lists when radial menus are enabled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_radial_menu_does_not_capture_right_button_hold() {
|
||||
let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
|
||||
let ctx = egui::Context::default();
|
||||
let point = pos2(600.0, 400.0);
|
||||
app.viewport.mouse = Some(point);
|
||||
for (time, events) in [
|
||||
(
|
||||
0.0,
|
||||
vec![
|
||||
egui::Event::PointerMoved(point),
|
||||
egui::Event::PointerButton {
|
||||
pos: point,
|
||||
button: egui::PointerButton::Secondary,
|
||||
pressed: true,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
},
|
||||
],
|
||||
),
|
||||
(1.0, vec![]),
|
||||
] {
|
||||
let mut output = ctx.run_ui(egui::RawInput { time: Some(time), events, ..Default::default() }, |_| track_hold(&mut app, &ctx));
|
||||
output.textures_delta.clear();
|
||||
}
|
||||
assert!(app.viewport.context_menu.is_none() && app.menu.press.is_none() && !app.menu.flick);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sectors_go_clockwise_from_the_top() {
|
||||
assert_eq!(sector(vec2(0.0, -50.0)), Some(0));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,7 @@ mod status_tests {
|
||||
assert!(app.session.log.last().is_some_and(|l| l.starts_with("Redo: ") && !l.contains("edit.redo")), "{:?}", app.session.log.last());
|
||||
}
|
||||
}
|
||||
mod expression_complete;
|
||||
#[cfg(test)]
|
||||
mod extrude_edit_tests;
|
||||
mod file_load;
|
||||
@@ -953,6 +954,7 @@ impl SolveApp {
|
||||
|
||||
/// Lay out the whole window.
|
||||
pub fn ui(&mut self, ui: &mut egui::Ui) {
|
||||
expression_complete::begin_frame(ui.ctx());
|
||||
let t0 = now_ms();
|
||||
let _language = crate::i18n::scope(&self.preferences.language);
|
||||
let mut report_input = None;
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::{Color32, RichText};
|
||||
use egui::RichText;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::SolveApp;
|
||||
@@ -275,7 +275,9 @@ fn table(app: &mut SolveApp, ui: &mut egui::Ui, d: &mut ParamsDialog, rows: &[&R
|
||||
if bad {
|
||||
te = te.text_color(t.error);
|
||||
}
|
||||
let resp = ui.add_sized([170.0, 20.0], te);
|
||||
let id = ui.make_persistent_id(("pexpr", &r.name));
|
||||
let focused = ui.memory(|m| m.has_focus(id));
|
||||
let resp = ui.add_sized([170.0, 20.0], te.id(id));
|
||||
crate::scenario::publish_handle(&format!("param:{}", r.name), resp.rect.center());
|
||||
let completed = complete(ui, &resp, &mut e);
|
||||
if resp.changed() || completed {
|
||||
@@ -284,10 +286,10 @@ fn table(app: &mut SolveApp, ui: &mut egui::Ui, d: &mut ParamsDialog, rows: &[&R
|
||||
if resp.has_focus() && matches!(live, Some(Ok(_))) {
|
||||
d.preview = Some((r.name.clone(), e.clone()));
|
||||
}
|
||||
if resp.lost_focus() {
|
||||
if !completed && (resp.lost_focus() || enter_for(ui, focused)) {
|
||||
d.preview = None;
|
||||
if e != r.expression {
|
||||
let res = app.run("parameters.change", json!({"name": r.name, "expression": e}));
|
||||
let res = check(app, &e, &r.unit).and_then(|_| app.run("parameters.change", json!({"name": r.name, "expression": e})));
|
||||
d.error = res.err();
|
||||
if d.error.is_none() {
|
||||
d.editing.remove(&r.name);
|
||||
@@ -336,41 +338,79 @@ fn unit_label(u: &str) -> &str {
|
||||
if u.is_empty() { "none" } else { u }
|
||||
}
|
||||
|
||||
/// Only a fresh Enter in this field submits; held keys and unrelated focus do not.
|
||||
fn enter_for(ui: &mut egui::Ui, focused: bool) -> bool {
|
||||
focused
|
||||
&& ui.input_mut(|i| {
|
||||
let fresh = i.raw.events.iter().any(|event| {
|
||||
matches!(event,
|
||||
egui::Event::Key { key: egui::Key::Enter, pressed: true, repeat: false, modifiers, .. } if *modifiers == egui::Modifiers::NONE)
|
||||
});
|
||||
if fresh {
|
||||
i.consume_key(egui::Modifiers::NONE, egui::Key::Enter);
|
||||
}
|
||||
fresh
|
||||
})
|
||||
}
|
||||
|
||||
fn add_row(app: &mut SolveApp, ui: &mut egui::Ui, d: &mut ParamsDialog) {
|
||||
let t = Tokens::get();
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(egui::TextEdit::singleline(&mut d.new_name).hint_text("new parameter").desired_width(120.0));
|
||||
egui::ComboBox::from_id_salt("pnew_unit").width(56.0).selected_text(unit_label(UNITS.get(d.new_unit).copied().unwrap_or("mm"))).show_ui(
|
||||
ui,
|
||||
|ui| {
|
||||
let name_id = ui.make_persistent_id("pnew_name");
|
||||
let expr_id = ui.make_persistent_id("pnew_expr");
|
||||
let comment_id = ui.make_persistent_id("pnew_comment");
|
||||
let focused_id = ui.memory(|m| [name_id, expr_id, comment_id].into_iter().find(|id| m.has_focus(*id)));
|
||||
let name = ui.add(egui::TextEdit::singleline(&mut d.new_name).id(name_id).hint_text("new parameter").desired_width(120.0));
|
||||
crate::scenario::publish_handle("param:new:name", name.rect.center());
|
||||
let unit_response = egui::ComboBox::from_id_salt("pnew_unit")
|
||||
.width(56.0)
|
||||
.selected_text(unit_label(UNITS.get(d.new_unit).copied().unwrap_or("mm")))
|
||||
.show_ui(ui, |ui| {
|
||||
for (i, u) in UNITS.iter().enumerate() {
|
||||
ui.selectable_value(&mut d.new_unit, i, unit_label(u));
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
crate::scenario::publish_handle("param:new:unit", unit_response.response.rect.center());
|
||||
let unit = UNITS.get(d.new_unit).copied().unwrap_or("mm");
|
||||
let live = check(app, &d.new_expr, unit);
|
||||
let mut te = egui::TextEdit::singleline(&mut d.new_expr).desired_width(170.0);
|
||||
if live.is_err() {
|
||||
let bad = check(app, &d.new_expr, unit).is_err();
|
||||
let mut te = egui::TextEdit::singleline(&mut d.new_expr).id(expr_id).desired_width(170.0);
|
||||
if bad {
|
||||
te = te.text_color(t.error);
|
||||
}
|
||||
let resp = ui.add(te);
|
||||
complete(ui, &resp, &mut d.new_expr);
|
||||
crate::scenario::publish_handle("param:new:expression", resp.rect.center());
|
||||
let completed = complete(ui, &resp, &mut d.new_expr);
|
||||
let live = check(app, &d.new_expr, unit);
|
||||
match &live {
|
||||
Ok(v) => ui.label(RichText::new(v).color(t.text_dim)),
|
||||
Err(m) => ui.label(RichText::new(m).color(t.error)),
|
||||
};
|
||||
ui.add(egui::TextEdit::singleline(&mut d.new_comment).hint_text("comment").desired_width(120.0));
|
||||
let comment = ui.add(egui::TextEdit::singleline(&mut d.new_comment).id(comment_id).hint_text("comment").desired_width(120.0));
|
||||
crate::scenario::publish_handle("param:new:comment", comment.rect.center());
|
||||
let submit = !completed && enter_for(ui, focused_id.is_some());
|
||||
let ok = !d.new_name.trim().is_empty() && live.is_ok();
|
||||
if ui.add_enabled(ok, egui::Button::new("Add")).clicked() {
|
||||
if ui.add_enabled(ok, egui::Button::new("Add")).clicked() || submit {
|
||||
if let Err(error) = &live {
|
||||
d.error = Some(error.clone());
|
||||
if let Some(id) = focused_id {
|
||||
ui.memory_mut(|m| m.request_focus(id));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let p = json!({"name": d.new_name.trim(), "expression": d.new_expr, "unit": unit, "comment": d.new_comment});
|
||||
match app.run("parameters.add", p) {
|
||||
Ok(_) => {
|
||||
d.new_name.clear();
|
||||
d.new_comment.clear();
|
||||
d.error = None;
|
||||
name.request_focus();
|
||||
}
|
||||
Err(e) => {
|
||||
d.error = Some(e);
|
||||
if let Some(id) = focused_id {
|
||||
ui.memory_mut(|m| m.request_focus(id));
|
||||
}
|
||||
}
|
||||
Err(e) => d.error = Some(e),
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -421,7 +461,7 @@ pub fn publish(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
|
||||
/// The identifier being typed before `cursor` (chars) and where it starts.
|
||||
fn word_before(text: &str, cursor: usize) -> (usize, String) {
|
||||
pub(crate) fn word_before(text: &str, cursor: usize) -> (usize, String) {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let end = cursor.min(chars.len());
|
||||
let mut start = end;
|
||||
@@ -437,67 +477,18 @@ pub fn suggestions(ctx: &egui::Context, word: &str) -> Vec<Suggestion> {
|
||||
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) {
|
||||
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()
|
||||
}
|
||||
|
||||
/// Offer completions under an expression box while it has focus; Tab or a click takes the first
|
||||
/// (or the clicked) one. Returns true when the text changed.
|
||||
/// Offer keyboard and pointer completions under a focused expression box.
|
||||
/// Returns true when a suggestion was accepted; callers must not submit that frame.
|
||||
pub fn complete(ui: &mut egui::Ui, resp: &egui::Response, text: &mut String) -> bool {
|
||||
let ctx = ui.ctx().clone();
|
||||
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);
|
||||
// Raw events: egui's focus navigation consumes Tab before widgets see it.
|
||||
let tab = ui.input(|i| i.raw.events.iter().any(|e| matches!(e, egui::Event::Key { key: egui::Key::Tab, pressed: true, .. })));
|
||||
// Tab moves focus away before we see it: accept on that frame and take focus back.
|
||||
// Keyboard focus in egui's memory (not `has_focus`, which also needs the OS window focused).
|
||||
let focused = ctx.memory(|m| m.has_focus(resp.id));
|
||||
let active = focused || (resp.lost_focus() && tab);
|
||||
if !active {
|
||||
return false;
|
||||
}
|
||||
let list = suggestions(&ctx, &word);
|
||||
if list.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut pick: Option<String> = if tab { list.first().map(|s| s.text.clone()) } else { None };
|
||||
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, s) in list.iter().enumerate() {
|
||||
let colour = match s.kind.as_str() {
|
||||
"parameter" => t.text,
|
||||
"function" => t.accent,
|
||||
_ => t.text_dim,
|
||||
};
|
||||
let label = RichText::new(&s.text).color(colour);
|
||||
let r = ui.add(egui::Button::new(label).frame(false).fill(Color32::TRANSPARENT)).on_hover_text(&s.detail);
|
||||
if i == 0 {
|
||||
ui.label(RichText::new(&s.detail).small().color(t.text_dim));
|
||||
}
|
||||
if r.clicked() {
|
||||
pick = Some(s.text.clone());
|
||||
}
|
||||
}
|
||||
ui.label(RichText::new("Tab to complete").small().color(t.text_dim));
|
||||
});
|
||||
},
|
||||
);
|
||||
let Some(p) = 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}{p}{after}");
|
||||
let at = start + p.chars().count();
|
||||
let mut st = state.unwrap_or_default();
|
||||
st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(at))));
|
||||
st.store(&ctx, resp.id);
|
||||
resp.request_focus();
|
||||
true
|
||||
crate::expression_complete::complete(ui, resp, text)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -35,6 +35,8 @@ pub struct Prefs {
|
||||
pub default_units: String,
|
||||
/// Mouse mapping: `fusion`, `solidworks` or `inventor`.
|
||||
pub nav: String,
|
||||
/// Show the radial ring and enable hold/flick gestures for viewport context menus.
|
||||
pub radial_context_menu: bool,
|
||||
/// The wheel zooms out when turned forward.
|
||||
pub zoom_reverse: bool,
|
||||
/// Orbit around the point under the cursor (else around the view's centre).
|
||||
@@ -70,6 +72,7 @@ impl Default for Prefs {
|
||||
Prefs {
|
||||
default_units: "mm".into(),
|
||||
nav: "fusion".into(),
|
||||
radial_context_menu: false,
|
||||
zoom_reverse: false,
|
||||
orbit_cursor: false,
|
||||
space_mouse_enabled: true,
|
||||
@@ -178,6 +181,7 @@ fn defaults(app: &mut SolveApp, page: usize) {
|
||||
}
|
||||
1 => {
|
||||
p.nav = d.nav;
|
||||
p.radial_context_menu = d.radial_context_menu;
|
||||
p.zoom_reverse = d.zoom_reverse;
|
||||
p.zoom_to_cursor = d.zoom_to_cursor;
|
||||
p.orbit_cursor = d.orbit_cursor;
|
||||
@@ -563,6 +567,9 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
});
|
||||
ui.end_row();
|
||||
note(ui, &format!("{}; hold Shift during orbit to snap; the wheel zooms", nav_help(&app.preferences.nav)));
|
||||
ui.label("Context menu");
|
||||
ui.checkbox(&mut app.preferences.radial_context_menu, "Radial context menu");
|
||||
ui.end_row();
|
||||
ui.label("Reverse zoom direction");
|
||||
ui.checkbox(&mut app.preferences.zoom_reverse, "");
|
||||
ui.end_row();
|
||||
@@ -753,6 +760,28 @@ pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result<serde_json::Valu
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn radial_context_menu_defaults_off_and_follows_preferences_transactions() {
|
||||
assert!(!Prefs::default().radial_context_menu);
|
||||
let old: Prefs = serde_json::from_value(json!({"nav":"solidworks","zoom_reverse":true})).unwrap();
|
||||
assert!(!old.radial_context_menu && old.zoom_reverse && old.nav == "solidworks");
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.call("ui.prefs", json!({"open":"Navigation"}));
|
||||
h.step(&json!({"widget":"Radial context menu"})).unwrap();
|
||||
assert!(h.app.preferences.radial_context_menu, "the actual checkbox enables radial menus");
|
||||
let mut restored = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
|
||||
restored.load_prefs(&h.app.prefs());
|
||||
assert!(restored.preferences.radial_context_menu, "enabled preference survives save/load");
|
||||
h.call("ui.prefs", json!({"button":"cancel"}));
|
||||
assert!(!h.app.preferences.radial_context_menu, "Cancel restores the default-off setting");
|
||||
h.call("ui.prefs", json!({"open":"Navigation","radial_context_menu":true}));
|
||||
h.call("ui.prefs", json!({"button":"apply"}));
|
||||
h.call("ui.prefs", json!({"button":"defaults"}));
|
||||
assert!(!h.app.preferences.radial_context_menu, "Navigation defaults disable the ring");
|
||||
h.call("ui.prefs", json!({"button":"cancel"}));
|
||||
assert!(h.app.preferences.radial_context_menu, "Cancel restores the last Apply");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_map_the_mouse_and_prefs_round_trip() {
|
||||
let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
|
||||
|
||||
@@ -263,9 +263,9 @@ pub fn set_dark(dark: bool) {
|
||||
DARK.store(dark, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn apply(ctx: &egui::Context) {
|
||||
let t = Tokens::get();
|
||||
let mut v = if is_dark() { egui::Visuals::dark() } else { egui::Visuals::light() };
|
||||
fn visuals(dark: bool) -> egui::Visuals {
|
||||
let t = if dark { Tokens::dark() } else { Tokens::light() };
|
||||
let mut v = if dark { egui::Visuals::dark() } else { egui::Visuals::light() };
|
||||
v.panel_fill = t.panel;
|
||||
v.window_fill = t.panel;
|
||||
v.window_stroke = Stroke::new(1.0, t.border);
|
||||
@@ -282,11 +282,62 @@ pub fn apply(ctx: &egui::Context) {
|
||||
v.widgets.inactive.fg_stroke = Stroke::new(1.0, t.text);
|
||||
v.widgets.hovered.weak_bg_fill = t.hover;
|
||||
v.widgets.hovered.bg_fill = t.hover;
|
||||
// egui uses the open-state fill for a movable window's active title bar.
|
||||
v.widgets.open.weak_bg_fill = t.panel_header;
|
||||
v.window_corner_radius = egui::CornerRadius::same(6);
|
||||
ctx.set_theme(if is_dark() { egui::Theme::Dark } else { egui::Theme::Light });
|
||||
ctx.set_visuals(v);
|
||||
v
|
||||
}
|
||||
|
||||
pub fn apply(ctx: &egui::Context) {
|
||||
let dark = is_dark();
|
||||
ctx.set_theme(if dark { egui::Theme::Dark } else { egui::Theme::Light });
|
||||
ctx.set_visuals(visuals(dark));
|
||||
ctx.global_style_mut(|s| {
|
||||
s.spacing.item_spacing = egui::vec2(6.0, 4.0);
|
||||
s.spacing.button_padding = egui::vec2(6.0, 3.0);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn active_movable_window_header_uses_theme_panel_header() {
|
||||
for dark in [false, true] {
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_theme(if dark { egui::Theme::Dark } else { egui::Theme::Light });
|
||||
ctx.set_visuals(super::visuals(dark));
|
||||
let mut output = egui::FullOutput::default();
|
||||
for _ in 0..3 {
|
||||
let raw = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))),
|
||||
..Default::default()
|
||||
};
|
||||
output = ctx.run_ui(raw, |ui| {
|
||||
crate::frame::window(ui.ctx(), "Parameters", crate::frame::Width::Normal).fade_in(false).show(ui.ctx(), |ui| {
|
||||
ui.label("Parameter values");
|
||||
});
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
}
|
||||
let title = output
|
||||
.shapes
|
||||
.iter()
|
||||
.find_map(|shape| match &shape.shape {
|
||||
egui::Shape::Text(text) if text.galley.text() == "Parameters" => Some(text.pos + text.galley.rect.center().to_vec2()),
|
||||
_ => None,
|
||||
})
|
||||
.expect("window title must be drawn");
|
||||
let header = output
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|shape| match &shape.shape {
|
||||
egui::Shape::Rect(rect) if rect.fill.a() > 0 && rect.rect.contains(title) => Some(rect),
|
||||
_ => None,
|
||||
})
|
||||
.min_by(|a, b| a.rect.area().total_cmp(&b.rect.area()))
|
||||
.expect("window header must be drawn behind its title");
|
||||
let expected = if dark { super::Tokens::dark() } else { super::Tokens::light() };
|
||||
assert_eq!(header.fill, expected.panel_header, "active title bar should follow the {dark} palette");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user