Fix desktop launch, app icons and keyboard parameter editing

This commit is contained in:
bherbruck committed 2026-10-11 19:09:47 -04:00
1 parent 8fa52c8e5e
commit 6dd202d277
45 files changed
+5993 -272

No files matched your search

+59 -5
View File
@@ -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));
+335
View File
@@ -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);
}
}
+2
View File
@@ -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;
+65 -74
View File
@@ -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)]
+29
View File
@@ -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());
+56 -5
View File
@@ -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");
}
}
}