mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 02:25:55 +00:00
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"en": {
|
||||
"prefs.viewport_analysis_status": "Show viewport analysis status",
|
||||
"prefs.viewport_analysis_status_help": "Show active analyses above the viewport, open their legends from each item, and use Esc to turn off the newest analysis. Turn off to restore corner legends and the previous Esc behaviour.",
|
||||
"ui.viewport_status_edges": "{count} edges",
|
||||
"ui.viewport_status_from": "from {direction}"
|
||||
},
|
||||
"ar": {
|
||||
"prefs.viewport_analysis_status": "إظهار حالة التحليل في منفذ العرض",
|
||||
"prefs.viewport_analysis_status_help": "عرض التحليلات النشطة أعلى منفذ العرض وفتح مفتاح كل تحليل من عنصره. ينهي Esc أحدث تحليل. أوقف الخيار لاستعادة المفاتيح في الزاوية وسلوك Esc السابق.",
|
||||
"ui.viewport_status_edges": "{count} حواف",
|
||||
"ui.viewport_status_from": "من {direction}"
|
||||
},
|
||||
"de": {
|
||||
"prefs.viewport_analysis_status": "Analysestatus im Ansichtsfenster anzeigen",
|
||||
"prefs.viewport_analysis_status_help": "Zeigt aktive Analysen über dem Ansichtsfenster und ihre Legenden am jeweiligen Eintrag. Esc beendet die neueste Analyse. Ausschalten stellt Ecklegenden und das bisherige Esc-Verhalten wieder her.",
|
||||
"ui.viewport_status_edges": "{count} Kanten",
|
||||
"ui.viewport_status_from": "aus {direction}"
|
||||
},
|
||||
"he": {
|
||||
"prefs.viewport_analysis_status": "הצגת מצב ניתוח בחלון התצוגה",
|
||||
"prefs.viewport_analysis_status_help": "הצגת ניתוחים פעילים מעל חלון התצוגה ופתיחת המקרא מכל פריט. Esc מכבה את הניתוח האחרון. כיבוי האפשרות משחזר את המקרא בפינה ואת פעולת Esc הקודמת.",
|
||||
"ui.viewport_status_edges": "{count} קצוות",
|
||||
"ui.viewport_status_from": "מכיוון {direction}"
|
||||
},
|
||||
"ja": {
|
||||
"prefs.viewport_analysis_status": "ビューポートの解析状態を表示",
|
||||
"prefs.viewport_analysis_status_help": "有効な解析をビューポートの上部に表示し、各項目から凡例を開きます。Esc で最新の解析を解除します。無効にすると隅の凡例と従来の Esc 動作に戻ります。",
|
||||
"ui.viewport_status_edges": "{count} エッジ",
|
||||
"ui.viewport_status_from": "方向 {direction}"
|
||||
},
|
||||
"ko": {
|
||||
"prefs.viewport_analysis_status": "뷰포트 해석 상태 표시",
|
||||
"prefs.viewport_analysis_status_help": "활성 해석을 뷰포트 위에 표시하고 각 항목에서 범례를 엽니다. Esc로 최신 해석을 끕니다. 끄면 모서리 범례와 이전 Esc 동작으로 돌아갑니다.",
|
||||
"ui.viewport_status_edges": "모서리 {count}개",
|
||||
"ui.viewport_status_from": "{direction} 방향에서"
|
||||
},
|
||||
"vi": {
|
||||
"prefs.viewport_analysis_status": "Hiện trạng thái phân tích trong khung nhìn",
|
||||
"prefs.viewport_analysis_status_help": "Hiện các phân tích đang bật phía trên khung nhìn và mở chú giải từ từng mục. Esc tắt phân tích mới nhất. Tắt tùy chọn để khôi phục chú giải ở góc và hành vi Esc trước đây.",
|
||||
"ui.viewport_status_edges": "{count} cạnh",
|
||||
"ui.viewport_status_from": "từ {direction}"
|
||||
},
|
||||
"zh": {
|
||||
"prefs.viewport_analysis_status": "显示视口分析状态",
|
||||
"prefs.viewport_analysis_status_help": "在视口上方显示启用的分析,从各项目打开图例,并按 Esc 关闭最近启用的分析。关闭此选项可恢复角落图例和原来的 Esc 行为。",
|
||||
"ui.viewport_status_edges": "{count} 条边",
|
||||
"ui.viewport_status_from": "从 {direction}"
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,7 @@ fn section(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.section = Some((origin, normal));
|
||||
s.section_hatch = bool_(p, "hatch").unwrap_or(true);
|
||||
s.status_slot(crate::status_items::StatusId::Section, true);
|
||||
s.status_activated(crate::status_items::StatusId::Section);
|
||||
s.revision += 1;
|
||||
let mut section = json!({"origin": origin, "normal": normal, "hatch": s.section_hatch});
|
||||
if let Some(i) = axis {
|
||||
|
||||
@@ -320,6 +320,51 @@ mod tests {
|
||||
serde_json::from_value::<crate::status_items::StatusItems>(s.execute("view.status_items", &json!({})).unwrap()).unwrap().items
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newest_status_uses_activation_not_row_order_and_filters_unpresented_effects() {
|
||||
use crate::status_items::StatusId::{Continuity, Section, SurfaceAnalysis};
|
||||
let mut s = Session::default();
|
||||
s.execute("inspect.zebra", &json!({})).unwrap();
|
||||
s.execute("inspect.section", &json!({"plane":"XY"})).unwrap();
|
||||
s.execute("inspect.draft", &json!({"angle":7})).unwrap();
|
||||
assert_eq!(status(&mut s).iter().map(|item| item.id).collect::<Vec<_>>(), [SurfaceAnalysis, Section]);
|
||||
assert_eq!(s.newest_status_item(&[SurfaceAnalysis, Continuity, Section]).unwrap().command, "inspect.draft");
|
||||
s.execute("inspect.section", &json!({"plane":"XZ"})).unwrap();
|
||||
assert_eq!(s.newest_status_item(&[SurfaceAnalysis, Continuity, Section]).unwrap().id, Section);
|
||||
let before = (s.revision, s.undo.len(), s.doc.to_json(), s.status_activation.clone());
|
||||
assert_eq!(s.newest_status_item(&[SurfaceAnalysis, Continuity]).unwrap().command, "inspect.draft");
|
||||
assert_eq!((s.revision, s.undo.len(), s.doc.to_json(), s.status_activation.clone()), before);
|
||||
assert!(s.execute("inspect.zebra", &json!({"stripes":-1})).is_err());
|
||||
assert_eq!(s.status_activation, before.3);
|
||||
s.execute("inspect.draft", &json!({"clear":true})).unwrap();
|
||||
assert!(s.newest_status_item(&[SurfaceAnalysis, Continuity]).is_none());
|
||||
s.execute("inspect.zebra", &json!({})).unwrap();
|
||||
assert_eq!(s.newest_status_item(&[SurfaceAnalysis, Continuity, Section]).unwrap().command, "inspect.zebra");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newest_status_tracks_independent_continuity_and_policy_normalisation() {
|
||||
use crate::status_items::StatusId::{Continuity, SurfaceAnalysis};
|
||||
for continuity_first in [false, true] {
|
||||
let mut s = Session::default();
|
||||
boxed(&mut s, 10.0);
|
||||
s.set_single_surface_analysis(false);
|
||||
let shading = ("inspect.zebra", json!({}));
|
||||
let seams = ("inspect.continuity", json!({"edges":[[5,0,0]],"samples":5}));
|
||||
for (cmd, params) in if continuity_first { [seams, shading] } else { [shading, seams] } {
|
||||
s.execute(cmd, ¶ms).unwrap();
|
||||
}
|
||||
let latest = if continuity_first { "inspect.zebra" } else { "inspect.continuity" };
|
||||
assert_eq!(s.newest_status_item(&[SurfaceAnalysis, Continuity]).unwrap().command, latest);
|
||||
s.set_single_surface_analysis(true);
|
||||
assert_eq!(s.newest_status_item(&[SurfaceAnalysis]).unwrap().command, latest);
|
||||
s.set_single_surface_analysis(false);
|
||||
let clear = s.newest_status_item(&[SurfaceAnalysis, Continuity]).unwrap().clear;
|
||||
s.execute(&clear.command, &json!({"clear":true})).unwrap();
|
||||
assert!(s.newest_status_item(&[SurfaceAnalysis, Continuity]).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_items_zero_seam_continuity_never_leaves_a_phantom_winner() {
|
||||
use crate::status_items::StatusId;
|
||||
|
||||
@@ -472,8 +472,10 @@ pub struct Session {
|
||||
/// The seams `inspect.continuity` last measured, for the viewport to shade. View state: not
|
||||
/// part of the design and not an undo step.
|
||||
pub continuity: Vec<ContinuitySeam>,
|
||||
/// Transient activation order only; effect settings remain in their existing fields.
|
||||
/// Oldest active slot first; replacing an effect retains its row position.
|
||||
status_order: Vec<status_items::StatusId>,
|
||||
/// Successful source activations, independently of row order and sharing policy.
|
||||
status_activation: Vec<status_items::StatusId>,
|
||||
single_surface_analysis: bool,
|
||||
latest_analysis: Option<status_items::StatusId>,
|
||||
/// What is hidden or shown in the view (`browser.visibility`): saved with the design and an
|
||||
@@ -656,6 +658,7 @@ impl Session {
|
||||
analysis: None,
|
||||
continuity: Vec::new(),
|
||||
status_order: Vec::new(),
|
||||
status_activation: Vec::new(),
|
||||
single_surface_analysis: true,
|
||||
latest_analysis: None,
|
||||
checkpoints: Vec::new(),
|
||||
@@ -1506,6 +1509,7 @@ impl Session {
|
||||
analysis: None,
|
||||
continuity: Vec::new(),
|
||||
status_order: Vec::new(),
|
||||
status_activation: Vec::new(),
|
||||
single_surface_analysis: self.single_surface_analysis,
|
||||
latest_analysis: None,
|
||||
checkpoints: Vec::new(),
|
||||
|
||||
@@ -113,6 +113,14 @@ impl Session {
|
||||
}
|
||||
}
|
||||
self.single_surface_analysis = single;
|
||||
let shading = self.analysis.is_some();
|
||||
let continuity = !self.continuity.is_empty();
|
||||
let section = self.section.is_some();
|
||||
self.status_activation.retain(|source| match source {
|
||||
StatusId::SurfaceAnalysis => shading,
|
||||
StatusId::Continuity => continuity,
|
||||
StatusId::Section => section,
|
||||
});
|
||||
self.status_order.clear();
|
||||
for item in &before {
|
||||
let id = match item.id {
|
||||
@@ -160,6 +168,10 @@ impl Session {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.status_activation.retain(|id| *id == StatusId::Section || active(id));
|
||||
if let Some(id) = activated.filter(active) {
|
||||
self.status_activated(id);
|
||||
}
|
||||
self.latest_analysis = activated.filter(active).or(self.latest_analysis.filter(active)).or(remaining);
|
||||
self.status_slot(StatusId::SurfaceAnalysis, self.analysis.is_some() || (self.single_surface_analysis && !self.continuity.is_empty()));
|
||||
self.status_slot(StatusId::Continuity, !self.single_surface_analysis && !self.continuity.is_empty());
|
||||
@@ -212,9 +224,39 @@ impl Session {
|
||||
items
|
||||
}
|
||||
|
||||
/// Most recently activated effect among the caller's currently presented slots.
|
||||
/// Row position is deliberately independent: replacing an analysis retains its slot.
|
||||
/// Effects assigned directly by embedders have no activation record; their declared
|
||||
/// order provides a deterministic fallback without inventing session timestamps.
|
||||
pub fn newest_status_item(&self, eligible: &[StatusId]) -> Option<StatusItem> {
|
||||
let items = self.status_items();
|
||||
let find = |source: StatusId| {
|
||||
items
|
||||
.iter()
|
||||
.find(|item| {
|
||||
eligible.contains(&item.id)
|
||||
&& match source {
|
||||
StatusId::SurfaceAnalysis => item.id == StatusId::SurfaceAnalysis && item.command != "inspect.continuity",
|
||||
StatusId::Continuity => item.command == "inspect.continuity",
|
||||
StatusId::Section => item.id == StatusId::Section,
|
||||
}
|
||||
})
|
||||
.cloned()
|
||||
};
|
||||
self.status_activation.iter().rev().find_map(|id| find(*id)).or_else(|| items.into_iter().rev().find(|item| eligible.contains(&item.id)))
|
||||
}
|
||||
|
||||
pub(crate) fn status_activated(&mut self, id: StatusId) {
|
||||
self.status_activation.retain(|source| *source != id);
|
||||
self.status_activation.push(id);
|
||||
}
|
||||
|
||||
pub(crate) fn status_slot(&mut self, id: StatusId, active: bool) {
|
||||
if !active {
|
||||
self.status_order.retain(|slot| *slot != id);
|
||||
if id == StatusId::Section {
|
||||
self.status_activation.retain(|source| *source != id);
|
||||
}
|
||||
} else if !self.status_order.contains(&id) {
|
||||
self.status_order.push(id);
|
||||
}
|
||||
|
||||
@@ -3054,6 +3054,7 @@ pub(crate) fn corner_confirms(d: &Dialog) -> bool {
|
||||
/// Sketch Palette keeps the right there, and the dialog takes the left so both stay on screen.
|
||||
/// With Dialogs › In the left column it holds the left column instead (`dialog_column`).
|
||||
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let escape_routed_here = !app.preferences.viewport_analysis_status || app.escape_owner.is_none_or(|owner| owner == crate::EscapeOwner::Dialog);
|
||||
// A click in the view's corner is for the dialog open this frame, or for none.
|
||||
let corner = app.viewport.corner.take();
|
||||
app.viewport.corner_ok = false;
|
||||
@@ -3248,7 +3249,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
apply_again = false;
|
||||
}
|
||||
crate::ui_kit::Action::Cancel => {
|
||||
if !app.esc_handled || !ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
if (!app.esc_handled && escape_routed_here) || !ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
cancel = true;
|
||||
}
|
||||
}
|
||||
@@ -3321,7 +3322,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
if !preset_prompt && !report_active && enter_applies && app.applying.is_none() && (enter || canvas_enter || (enter_free && nothing_focused)) {
|
||||
ok = true;
|
||||
}
|
||||
if !preset_prompt && !report_active && esc && !app.esc_handled {
|
||||
if !preset_prompt && !report_active && esc && !app.esc_handled && escape_routed_here {
|
||||
app.esc_handled = true;
|
||||
cancel = true;
|
||||
}
|
||||
|
||||
@@ -299,6 +299,7 @@ mod view_confirm;
|
||||
pub mod view_pick;
|
||||
pub mod viewport;
|
||||
mod viewport_layout;
|
||||
mod viewport_status;
|
||||
mod viewport_status_fixture;
|
||||
pub mod workspace;
|
||||
|
||||
@@ -452,7 +453,16 @@ pub struct PreSketchComponent {
|
||||
pub opened_in: u64,
|
||||
}
|
||||
|
||||
/// Which late UI handler has first refusal of this frame's Escape. Reserving input
|
||||
/// is distinct from having performed cancellation (`esc_handled`).
|
||||
#[derive(Clone, Copy, Eq, PartialEq)]
|
||||
pub(crate) enum EscapeOwner {
|
||||
Dialog,
|
||||
Overlay,
|
||||
}
|
||||
|
||||
pub struct SolveApp {
|
||||
pub(crate) viewport_status: ui_kit::StatusPresentation,
|
||||
pub(crate) viewport_status_fixture: viewport_status_fixture::Fixture,
|
||||
/// Compact browser embed: retain model panels and joint controls without application chrome.
|
||||
pub embedded: bool,
|
||||
@@ -574,6 +584,7 @@ pub struct SolveApp {
|
||||
pub foreign_copy: Option<std::sync::Arc<solidcraft_engine::ForeignCopy>>,
|
||||
/// Esc was already acted on this frame (by the shortcuts), so later handlers skip it.
|
||||
pub esc_handled: bool,
|
||||
pub(crate) escape_owner: Option<EscapeOwner>,
|
||||
/// When Esc was last pressed (input time): a second press soon after finishes the sketch.
|
||||
pub last_escape: Option<f64>,
|
||||
pub status: Option<(String, f64, bool)>,
|
||||
@@ -709,6 +720,7 @@ impl SolveApp {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
solidcraft_engine::slicer_files::clean_old();
|
||||
let mut app = SolveApp {
|
||||
viewport_status: Default::default(),
|
||||
viewport_status_fixture: Default::default(),
|
||||
session,
|
||||
ui: UiState::default(),
|
||||
@@ -772,6 +784,7 @@ impl SolveApp {
|
||||
own_clipboard_stamp: 0,
|
||||
foreign_copy: None,
|
||||
esc_handled: false,
|
||||
escape_owner: None,
|
||||
last_escape: None,
|
||||
status: None,
|
||||
status_undo: false,
|
||||
@@ -2475,7 +2488,15 @@ impl SolveApp {
|
||||
}
|
||||
open_meshing::tick(self, ctx);
|
||||
open_meshing::refresh(self, ctx.input(|i| i.time));
|
||||
self.esc_handled = false;
|
||||
// Routing survives a layout re-pass, but never the next input frame. The legacy
|
||||
// policy retains its original per-pass cancellation flag behavior.
|
||||
let first_pass = ctx.current_pass_index() == 0;
|
||||
if first_pass {
|
||||
self.escape_owner = None;
|
||||
}
|
||||
if first_pass || !self.preferences.viewport_analysis_status {
|
||||
self.esc_handled = false;
|
||||
}
|
||||
prefs::apply(self, ctx);
|
||||
// The view's own dark or light only changes tokens, not egui's visuals: no restyle.
|
||||
theme::set_viewport_dark(self.preferences.viewport_theme.dark());
|
||||
|
||||
@@ -1062,6 +1062,7 @@ preference_settings! {
|
||||
ribbon_labels: bool = true, PAGE_INTERFACE;
|
||||
/// Show engine-derived persistent selection in the ribbon; off retains neutral buttons.
|
||||
persistent_ribbon_pressed: bool = true, PAGE_INTERFACE => check_id("prefs.persistent_ribbon_pressed");
|
||||
viewport_analysis_status: bool = true, PAGE_INTERFACE => check_id("prefs.viewport_analysis_status");
|
||||
/// Keep the SKETCH tab in the ribbon, right after MODEL, while no sketch is open; its drawing
|
||||
/// tools then start a sketch first (#7904).
|
||||
sketch_tab_always: bool = false, PAGE_INTERFACE => check_id("prefs.sketch_tab_always");
|
||||
@@ -4963,6 +4964,7 @@ mod tests {
|
||||
("layout", json!("compact"), PAGE_INTERFACE),
|
||||
("ribbon_labels", json!(false), PAGE_INTERFACE),
|
||||
("persistent_ribbon_pressed", json!(false), PAGE_INTERFACE),
|
||||
("viewport_analysis_status", json!(false), PAGE_INTERFACE),
|
||||
("sketch_tab_always", json!(true), PAGE_INTERFACE),
|
||||
("ribbon_reveal", json!(true), PAGE_INTERFACE),
|
||||
("dialog_buttons", json!("top"), PAGE_INTERFACE),
|
||||
@@ -6344,6 +6346,59 @@ mod tests {
|
||||
assert_eq!(h.app.session.doc.to_json(), design);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewport_analysis_status_preference_checkbox_control_persists_cancels_and_resets() {
|
||||
assert!(Prefs::default().viewport_analysis_status);
|
||||
assert!(lenient(&json!({"theme":"light"})).viewport_analysis_status);
|
||||
assert!(lenient(&json!({"viewport_analysis_status":"no"})).viewport_analysis_status);
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
let design = h.app.session.doc.to_json();
|
||||
let revision = h.app.session.revision;
|
||||
h.call("ui.prefs", json!({"open":"Interface"}));
|
||||
super::reveal_preference(&mut h, "prefs:viewport_analysis_status");
|
||||
let checkbox = crate::scenario::handle_at("prefs:viewport_analysis_status").unwrap();
|
||||
h.call("ui.click", json!({"x":checkbox.x,"y":checkbox.y}));
|
||||
assert!(!h.app.preferences.viewport_analysis_status);
|
||||
assert_eq!(h.call("ui.prefs", json!({}))["result"]["viewport_analysis_status"], false);
|
||||
let mut restored = SolveApp::new(Default::default(), Default::default());
|
||||
restored.load_prefs(&h.app.prefs());
|
||||
assert!(!restored.preferences.viewport_analysis_status);
|
||||
h.call("ui.prefs", json!({"button":"cancel"}));
|
||||
assert!(h.app.preferences.viewport_analysis_status);
|
||||
h.call("ui.prefs", json!({"open":"Interface","viewport_analysis_status":false,"button":"apply"}));
|
||||
h.call("ui.prefs", json!({"button":"defaults"}));
|
||||
assert!(h.app.preferences.viewport_analysis_status);
|
||||
h.call("ui.prefs", json!({"button":"cancel"}));
|
||||
assert!(!h.app.preferences.viewport_analysis_status);
|
||||
for malformed in [json!("false"), json!(null), json!(0), json!([])] {
|
||||
assert!(super::set(&mut h.app, &json!({"viewport_analysis_status":malformed})).is_err());
|
||||
assert!(!h.app.preferences.viewport_analysis_status);
|
||||
}
|
||||
assert_eq!(h.app.session.revision, revision);
|
||||
assert_eq!(h.app.session.doc.to_json(), design);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewport_analysis_status_is_independent_of_session_policy_in_background_documents() {
|
||||
let mut app = SolveApp::new(Default::default(), Default::default());
|
||||
super::set(&mut app, &json!({"single_surface_analysis":false,"persistent_ribbon_pressed":false})).unwrap();
|
||||
app.run("inspect.zebra", json!({})).unwrap();
|
||||
let mut other = solidcraft_engine::Session::default();
|
||||
other.execute("inspect.draft", &json!({})).unwrap();
|
||||
crate::documents::open_tab(&mut app, other).unwrap();
|
||||
let before: Vec<_> =
|
||||
std::iter::once(&app.session).chain(app.docs.background()).map(|s| (s.status_items(), s.revision, s.undo.len())).collect();
|
||||
super::control(&mut app, &json!({"open":"Interface","viewport_analysis_status":false})).unwrap();
|
||||
assert!(!app.preferences.viewport_analysis_status);
|
||||
assert!(!app.preferences.persistent_ribbon_pressed);
|
||||
assert!(std::iter::once(&app.session).chain(app.docs.background()).all(|s| !s.single_surface_analysis()));
|
||||
super::control(&mut app, &json!({"button":"cancel"})).unwrap();
|
||||
assert!(app.preferences.viewport_analysis_status);
|
||||
let after: Vec<_> =
|
||||
std::iter::once(&app.session).chain(app.docs.background()).map(|s| (s.status_items(), s.revision, s.undo.len())).collect();
|
||||
assert_eq!(before, after, "a presentation preference never changes effects in any document");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_surface_analysis_preference_checkbox_persists_cancels_and_resets() {
|
||||
assert!(Prefs::default().single_surface_analysis);
|
||||
|
||||
@@ -98,6 +98,10 @@ pub(super) fn interface(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
let check = setting::persistent_ribbon_pressed(app, ui).on_hover_text(app.tr("prefs.persistent_ribbon_pressed_help"));
|
||||
crate::scenario::publish_target("prefs:persistent_ribbon_pressed", check.rect);
|
||||
});
|
||||
ui_kit::row(ui, "", |ui| {
|
||||
let check = setting::viewport_analysis_status(app, ui).on_hover_text(app.tr("prefs.viewport_analysis_status_help"));
|
||||
crate::scenario::publish_target("prefs:viewport_analysis_status", check.rect);
|
||||
});
|
||||
// The SKETCH tab outside a sketch too (#7904).
|
||||
ui_kit::row(ui, "", |ui| {
|
||||
let check = setting::sketch_tab_always(app, ui).on_hover_text(app.tr("prefs.sketch_tab_always_note"));
|
||||
|
||||
@@ -998,7 +998,7 @@ fn persistent_pressed(app: &SolveApp, id: &str) -> bool {
|
||||
app.preferences.persistent_ribbon_pressed && persistent_command(id) && app.session.status_items().iter().any(|item| item.command == id)
|
||||
}
|
||||
|
||||
fn persistent_button_name(app: &SolveApp, command: &solidcraft_engine::CommandSpec) -> String {
|
||||
pub(crate) fn persistent_button_name(app: &SolveApp, command: &solidcraft_engine::CommandSpec) -> String {
|
||||
let info = crate::render_workspace::interactive_command_info(app, command);
|
||||
let label = crate::workspace::ribbon_button_label(command);
|
||||
app.tr_label(if label == command.label { info.label } else { label }).to_string()
|
||||
@@ -2866,6 +2866,12 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
if app.file_load.busy() {
|
||||
return;
|
||||
}
|
||||
if app.preferences.viewport_analysis_status
|
||||
&& (app.esc_handled || app.escape_owner.is_some())
|
||||
&& ctx.input(|input| input.key_pressed(egui::Key::Escape))
|
||||
{
|
||||
return;
|
||||
}
|
||||
// File is a custom Area, so it must claim Escape before the underlying editors and
|
||||
// sketch shortcuts. Clear raw events too: egui may replay them on a layout pass.
|
||||
let file_menu_id = egui::Id::new("sc_file_menu");
|
||||
@@ -2891,17 +2897,39 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// These owners render after global shortcuts. Leave their Escape available to the
|
||||
// normal window/palette handler, but retain ownership if egui repeats the layout.
|
||||
if ctx.input(|input| input.key_pressed(egui::Key::Escape))
|
||||
&& (app.prefs_window.open || app.keymap.open || app.ui.palette_open || ctx.memory(|memory| memory.top_modal_layer().is_some()))
|
||||
{
|
||||
app.escape_owner = Some(crate::EscapeOwner::Overlay);
|
||||
if app.preferences.viewport_analysis_status {
|
||||
app.last_escape = None;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if crate::params_dialog::cancel_on_escape(app, ctx) {
|
||||
return;
|
||||
}
|
||||
// A dimension value that is taking the keyboard: letters belong to it (a parameter name
|
||||
// starting with L or D), even on the frame before its field holds the focus.
|
||||
if egui::Popup::is_any_open(ctx)
|
||||
|| ctx.egui_wants_keyboard_input()
|
||||
|| crate::dim_view::editing()
|
||||
|| crate::dialogs_save_mesh::popup_open(ctx)
|
||||
|| crate::hole_presets::name_prompt_open(ctx, app.dialog.as_ref())
|
||||
let popup_owner =
|
||||
egui::Popup::is_any_open(ctx) || crate::dialogs_save_mesh::popup_open(ctx) || crate::hole_presets::name_prompt_open(ctx, app.dialog.as_ref());
|
||||
let inline_editor = crate::dim_view::editing();
|
||||
if popup_owner
|
||||
|| inline_editor
|
||||
|| (app.preferences.viewport_analysis_status && app.ui.palette_open)
|
||||
|| (ctx.egui_wants_keyboard_input() && !(app.preferences.viewport_analysis_status && app.viewport_status.owns_focus(ctx)))
|
||||
{
|
||||
if ctx.input(|input| input.key_pressed(egui::Key::Escape)) {
|
||||
let owner = if app.dialog.is_some() && !popup_owner && !inline_editor { crate::EscapeOwner::Dialog } else { crate::EscapeOwner::Overlay };
|
||||
// Reserve first refusal without claiming that cancellation already occurred.
|
||||
// A dialog's own late handler must still be able to cancel its focused field.
|
||||
app.escape_owner.get_or_insert(owner);
|
||||
if app.preferences.viewport_analysis_status {
|
||||
app.last_escape = None;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let (keys, mods) = ctx.input(|i| {
|
||||
@@ -2989,6 +3017,8 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
// Not while a tool is still out (its first Esc only ended a chain): this press
|
||||
// puts it down, and the next quick one finishes.
|
||||
if again
|
||||
&& !(app.preferences.viewport_analysis_status
|
||||
&& (app.viewport_status.open_key.is_some() || crate::viewport_status::has_effect(app)))
|
||||
&& app.tool.is_none()
|
||||
&& app.dialog.is_none()
|
||||
&& let Some(sketch) = app.session.active_sketch
|
||||
@@ -3013,6 +3043,8 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
crate::tools::finish(app);
|
||||
} else if app.dialog.is_some() || app.pending_feature_edit.is_some() {
|
||||
crate::dialogs::cancel(app);
|
||||
} else if app.preferences.viewport_analysis_status && crate::viewport_status::escape(app, repeat) {
|
||||
app.last_escape = None;
|
||||
} else if !app.session.selection.is_empty() || app.session.active_sketch.is_none() {
|
||||
crate::edit_clipboard::drop_cut(app);
|
||||
app.tree.picked_joints.clear();
|
||||
|
||||
@@ -298,9 +298,9 @@ impl Container {
|
||||
}
|
||||
|
||||
pub fn area(self, id: Id) -> egui::Area {
|
||||
// Persistent workspace cards stay below tool/system windows even when clicked.
|
||||
// Other areas keep egui's normal window layer; popups choose their own order.
|
||||
let order = if self.surface == Surface::Panel { egui::Order::Background } else { egui::Order::Middle };
|
||||
// Workspace cards and viewport status stay below tool/system windows even when
|
||||
// clicked. Other areas keep the normal window layer; popups choose their order.
|
||||
let order = if matches!(self.surface, Surface::Panel | Surface::ViewportStatus) { egui::Order::Background } else { egui::Order::Middle };
|
||||
egui::Area::new(id).enabled(self.input_enabled()).order(order)
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,13 @@ pub(crate) struct StatusItem {
|
||||
#[derive(Default)]
|
||||
pub(crate) struct StatusPresentation {
|
||||
pub open_key: Option<String>,
|
||||
focus_ids: Vec<Id>,
|
||||
}
|
||||
|
||||
impl StatusPresentation {
|
||||
pub(crate) fn owns_focus(&self, ctx: &egui::Context) -> bool {
|
||||
ctx.memory(|memory| memory.focused().is_some_and(|id| self.focus_ids.contains(&id)))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
@@ -116,19 +123,24 @@ pub(crate) fn viewport_status_item(ui: &mut Ui, item: &StatusItem, state: &mut S
|
||||
if item.has_key {
|
||||
let open = state.open_key.as_deref() == Some(&item.id);
|
||||
let label = crate::i18n::format("ui.viewport_status_key", &[("name", &item.name)]);
|
||||
if icon_action(ui, item, "key", "chevron", &label, &label, open).clicked() {
|
||||
let response = icon_action(ui, item, "key", "chevron", &label, &label, open);
|
||||
state.focus_ids.push(response.id);
|
||||
if response.clicked() {
|
||||
state.open_key = if open { None } else { Some(item.id.clone()) };
|
||||
}
|
||||
}
|
||||
let label = crate::i18n::format("ui.viewport_status_clear", &[("name", &item.name)]);
|
||||
let hint = crate::i18n::format("ui.viewport_status_clear_hint", &[("name", &item.name)]);
|
||||
if icon_action(ui, item, "close", "close", &label, &hint, false).clicked() {
|
||||
let response = icon_action(ui, item, "close", "close", &label, &hint, false);
|
||||
state.focus_ids.push(response.id);
|
||||
if response.clicked() {
|
||||
action = Some(StatusAction::Clear(item.id.clone()));
|
||||
}
|
||||
});
|
||||
})
|
||||
.response;
|
||||
let focus = ui.interact(response.rect, Id::new(target(item, "item")), egui::Sense::focusable_noninteractive());
|
||||
state.focus_ids.push(focus.id);
|
||||
focus.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, ui.is_enabled(), format!("{} {}", item.name, item.setting)));
|
||||
if focus.has_focus() {
|
||||
ui.painter().rect_stroke(response.rect.expand(t.space_3), frame.corner_radius, ui.visuals().selection.stroke, egui::StrokeKind::Outside);
|
||||
@@ -145,6 +157,7 @@ pub(crate) fn viewport_status_row(
|
||||
state: &mut StatusPresentation,
|
||||
mut key_contents: impl FnMut(&mut Ui, &str),
|
||||
) -> Vec<StatusAction> {
|
||||
state.focus_ids.clear();
|
||||
if items.is_empty() {
|
||||
state.open_key = None;
|
||||
return Vec::new();
|
||||
@@ -179,11 +192,40 @@ pub(crate) fn viewport_status_row(
|
||||
scenario::publish_count("viewport_status:count", items.len() as f64);
|
||||
if let Some((anchor, id)) = key_anchor {
|
||||
let card = Container::new(ctx, Surface::Popup);
|
||||
let result = card.area(Id::new("viewport_status_key")).movable(false).fixed_pos(anchor + vec2(0.0, t.space_6)).show(ctx, |ui| {
|
||||
let frame = card.frame(ctx, t.overlay, crate::frame::Side::Left).inner_margin(t.space_12);
|
||||
card.show_frame(ui, frame, free, |ui| key_contents(ui, id));
|
||||
});
|
||||
// The key belongs to viewport chrome, not to the system-window/popup layer.
|
||||
let result =
|
||||
card.area(Id::new("viewport_status_key")).order(egui::Order::Background).movable(false).fixed_pos(anchor + vec2(0.0, t.space_6)).show(
|
||||
ctx,
|
||||
|ui| {
|
||||
let frame = card.frame(ctx, t.overlay, crate::frame::Side::Left).inner_margin(t.space_12);
|
||||
card.show_frame(ui, frame, free, |ui| key_contents(ui, id));
|
||||
},
|
||||
);
|
||||
scenario::publish_target("viewport_status:card", result.response.rect);
|
||||
}
|
||||
actions
|
||||
}
|
||||
|
||||
/// Shared legend content; the caller supplies the existing analysis-token swatches and text.
|
||||
pub(crate) fn analysis_key(ui: &mut Ui, title: &str, along: Option<&str>, rows: &[(egui::Color32, String, bool)]) {
|
||||
let t = Tokens::get();
|
||||
ui.label(RichText::new(title).semibold().size(t.font_label).color(t.text));
|
||||
if let Some(along) = along {
|
||||
ui.label(RichText::new(along).monospace().size(t.font_small).color(t.text_dim));
|
||||
}
|
||||
for (colour, label, hatched) in rows {
|
||||
ui.horizontal(|ui| {
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(t.space_12, t.space_12), egui::Sense::hover());
|
||||
ui.painter().rect_filled(rect, t.radius_sm, *colour);
|
||||
if *hatched {
|
||||
let painter = ui.painter().with_clip_rect(rect);
|
||||
for offset in [-t.space_8, -t.space_4, 0.0, t.space_4, t.space_8] {
|
||||
let from = rect.left_bottom() + vec2(offset, 0.0);
|
||||
painter
|
||||
.line_segment([from, from + vec2(rect.height(), -rect.height())], egui::Stroke::new(t.primary_border(t.text).width, t.text));
|
||||
}
|
||||
}
|
||||
ui.label(RichText::new(label).size(t.font_label).color(t.text));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2904,7 +2904,9 @@ fn analysis_uniform(app: &SolveApp) -> [f32; 8] {
|
||||
/// While Draft, Accessibility or Curvature Map analysis is on, what each of its colours means and
|
||||
/// the values it stands for, at the bottom left of the viewport (#973). The colours alone don't
|
||||
/// say, and with colour vision loss they can be hard to tell apart.
|
||||
fn analysis_legend(app: &SolveApp, painter: &egui::Painter, rect: Rect) {
|
||||
pub(crate) type AnalysisLegend = (&'static str, Option<String>, Vec<(Color32, String, bool)>);
|
||||
|
||||
pub(crate) fn analysis_legend_data(app: &SolveApp) -> Option<AnalysisLegend> {
|
||||
use solidcraft_engine::SurfaceAnalysis as A;
|
||||
let t = Tokens::get();
|
||||
let tr = crate::i18n::tr_label;
|
||||
@@ -2915,7 +2917,7 @@ fn analysis_legend(app: &SolveApp, painter: &egui::Painter, rect: Rect) {
|
||||
let dir = |v: Vec3| format!("({:.2}, {:.2}, {:.2})", v.x, v.y, v.z);
|
||||
let a = &t.analysis;
|
||||
// (title, the direction it measures along, rows of colour, text, hatched).
|
||||
let (title, along, rows): (&str, Option<String>, Vec<(Color32, String, bool)>) = match app.session.analysis {
|
||||
let (title, along, rows) = match app.session.analysis {
|
||||
Some(A::Draft { pull, angle }) => (
|
||||
tr("Draft Analysis"),
|
||||
Some(format!("{} {}", tr("Pull direction"), dir(pull))),
|
||||
@@ -2940,8 +2942,14 @@ fn analysis_legend(app: &SolveApp, painter: &egui::Painter, rect: Rect) {
|
||||
(col(a.curvature[2]), format!("{} ≤ {}", tr("Radius"), crate::prefs::show_mm(app, radius, 1)), false),
|
||||
],
|
||||
),
|
||||
_ => return,
|
||||
_ => return None,
|
||||
};
|
||||
Some((title, along, rows))
|
||||
}
|
||||
|
||||
fn analysis_legend(app: &SolveApp, painter: &egui::Painter, rect: Rect) {
|
||||
let Some((title, along, rows)) = analysis_legend_data(app) else { return };
|
||||
let t = Tokens::get();
|
||||
let (pad, row) = (8.0, 18.0);
|
||||
let h = pad * 2.0 + 18.0 + along.as_ref().map_or(0.0, |_| row) + row * rows.len() as f32;
|
||||
// Clear Browser and a left History card so the legend is not painted under them (#4515).
|
||||
@@ -4309,7 +4317,9 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
let overlay_rect = crate::shell::bottom_overlay_rect(app, rect);
|
||||
analysis_legend(app, &painter, overlay_rect);
|
||||
if !app.preferences.viewport_analysis_status {
|
||||
analysis_legend(app, &painter, overlay_rect);
|
||||
}
|
||||
crate::frame_rate::paint(app, ui, &painter, overlay_rect);
|
||||
|
||||
// ---- interaction ----
|
||||
@@ -4875,6 +4885,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
axis_view(app, ui, rect);
|
||||
crate::view_angles::show(app, ui, rect);
|
||||
crate::navigation_bar_view::show(app, ui, rect);
|
||||
crate::viewport_status::show(app, ui.ctx(), rect);
|
||||
crate::viewport_status_fixture::show(app, ui.ctx(), rect);
|
||||
// The notices and the agent chip first, so the 3D mouse picture can keep clear of them.
|
||||
let toasts = transient_messages(app, ui, rect);
|
||||
@@ -9249,6 +9260,8 @@ mod tests {
|
||||
// #973: Draft, Accessibility and Curvature Map say what their colours mean; Zebra and no
|
||||
// analysis draw no legend.
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
// Keep the existing corner legend available through the HUD compatibility setting.
|
||||
h.app.preferences.viewport_analysis_status = false;
|
||||
h.app.home.open = false;
|
||||
h.app.run("solid.box", json!({"length":20,"width":20,"height":20})).unwrap();
|
||||
let legend = |h: &mut crate::scenario::Harness| {
|
||||
|
||||
@@ -680,6 +680,8 @@ fn toast_buttons_group_by_purpose_and_wrap_as_groups() {
|
||||
#[test]
|
||||
fn bottom_readouts_clear_floating_history_on_the_first_frame() {
|
||||
let mut h = Harness::new();
|
||||
// The corner legend remains available through the analysis HUD compatibility setting.
|
||||
h.app.preferences.viewport_analysis_status = false;
|
||||
h.app.home.open = false;
|
||||
h.app.ui.layout = LayoutMode::Floating;
|
||||
h.app.preferences.timeline_left = false;
|
||||
@@ -738,6 +740,8 @@ fn continuity_legend_clears_floating_history() {
|
||||
#[test]
|
||||
fn left_history_sidebar_leaves_bottom_readouts_at_the_bottom() {
|
||||
let mut h = Harness::new();
|
||||
// The corner legend remains available through the analysis HUD compatibility setting.
|
||||
h.app.preferences.viewport_analysis_status = false;
|
||||
h.app.home.open = false;
|
||||
h.app.ui.layout = LayoutMode::Floating;
|
||||
h.app.preferences.timeline_left = true;
|
||||
@@ -1141,3 +1145,393 @@ fn selected_sketch_point_aliases_respect_owner_and_visibility() {
|
||||
"selection does not reveal hidden points; the origin stays visible"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_analysis_status_uses_session_settings_and_declared_clear_without_history() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
|
||||
h.app.session.mark_saved();
|
||||
let before = (h.app.session.doc.to_json(), h.app.session.undo.len());
|
||||
for (command, params, expected, key) in [
|
||||
("inspect.zebra", json!({"stripes":14.5}), "14.5 stripes", false),
|
||||
("inspect.draft", json!({"pull":[0,0,1],"angle":7.5}), "Z · 7.5°", true),
|
||||
("inspect.environment_map", json!({}), "", false),
|
||||
("inspect.accessibility", json!({"direction":[0,0,1]}), "from Z", true),
|
||||
("inspect.curvature_map", json!({"radius":12}), "R 12.00 mm", true),
|
||||
("inspect.continuity", json!({"edges":[[5,0,0]],"samples":5}), "1 edges", false),
|
||||
] {
|
||||
h.app.run(command, params).unwrap();
|
||||
let effect = h.app.session.status_items().remove(0);
|
||||
let item = crate::viewport_status::presentation(&h.app, &effect).unwrap();
|
||||
assert_eq!(item.setting, expected);
|
||||
assert_eq!(item.has_key, key);
|
||||
assert_eq!(item.name, crate::toolbar::persistent_button_name(&h.app, solidcraft_engine::find_command(command).unwrap()));
|
||||
h.frames(4);
|
||||
assert!(crate::scenario::rect_of(&format!("viewport_status:{}:item", item.id)).is_some());
|
||||
if key {
|
||||
assert!(crate::scenario::rect_of("analysis:legend").is_none());
|
||||
h.step(&json!({"click":{"handle":format!("viewport_status:{}:key",item.id)}})).unwrap();
|
||||
h.frames(3);
|
||||
let pill = crate::scenario::rect_of(&format!("viewport_status:{}:item", item.id)).unwrap();
|
||||
let card = crate::scenario::rect_of("viewport_status:card").unwrap();
|
||||
assert!(card.top() > pill.bottom());
|
||||
assert!((card.left() - pill.left()).abs() < 1.0);
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert!(h.app.viewport_status.open_key.is_none());
|
||||
assert_eq!(h.app.session.status_items()[0].command, command);
|
||||
}
|
||||
h.step(&json!({"click":{"handle":format!("viewport_status:{}:close",item.id)}})).unwrap();
|
||||
assert!(h.app.session.status_items().is_empty());
|
||||
assert_eq!((h.app.session.doc.to_json(), h.app.session.undo.len()), before);
|
||||
assert!(!h.app.session.is_dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analysis_status_preference_restores_corner_legend_and_old_escape() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("inspect.draft", json!({})).unwrap();
|
||||
h.call("ui.prefs", json!({"viewport_analysis_status":false}));
|
||||
h.frames(4);
|
||||
assert!(crate::scenario::rect_of("analysis:legend").is_some());
|
||||
assert!(crate::scenario::rect_of("viewport_status:row").is_none());
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert!(h.app.session.analysis.is_some());
|
||||
assert!(h.app.preferences.persistent_ribbon_pressed);
|
||||
assert!(h.app.session.single_surface_analysis());
|
||||
h.call("ui.prefs", json!({"viewport_analysis_status":true}));
|
||||
h.frames(4);
|
||||
assert!(crate::scenario::rect_of("analysis:legend").is_none());
|
||||
assert!(crate::scenario::rect_of("viewport_status:row").is_some());
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert!(h.app.session.analysis.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analysis_status_formats_units_and_truthful_non_axis_directions() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("inspect.draft", json!({"pull":[1,1,0],"angle":2})).unwrap();
|
||||
let effect = h.app.session.status_items().remove(0);
|
||||
assert_eq!(crate::viewport_status::presentation(&h.app, &effect).unwrap().setting, "(0.71, 0.71, 0.00) · 2°");
|
||||
h.app.run("inspect.accessibility", json!({"direction":[0,0,-1]})).unwrap();
|
||||
let effect = h.app.session.status_items().remove(0);
|
||||
assert_eq!(crate::viewport_status::presentation(&h.app, &effect).unwrap().setting, "from −Z");
|
||||
h.app.run("inspect.curvature_map", json!({"radius":25.4})).unwrap();
|
||||
let effect = h.app.session.status_items().remove(0);
|
||||
for (units, expected) in [("mm", "R 25.40 mm"), ("in", "R 1.000 in")] {
|
||||
h.app.session.doc_mut().units = units.into();
|
||||
assert_eq!(crate::viewport_status::presentation(&h.app, &effect).unwrap().setting, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analysis_status_escape_ends_live_tool_before_effect_and_never_hidden_section() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
|
||||
h.app.start("inspect.continuity");
|
||||
assert!(h.app.tool.is_some());
|
||||
assert!(!h.app.session.continuity.is_empty());
|
||||
h.frames(3);
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert!(h.app.tool.is_none());
|
||||
assert!(!h.app.session.continuity.is_empty(), "first Escape only puts down the live tool");
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert!(h.app.session.continuity.is_empty());
|
||||
h.app.run("inspect.zebra", json!({})).unwrap();
|
||||
h.app.run("inspect.section", json!({"plane":"XY"})).unwrap();
|
||||
h.app.run("inspect.draft", json!({})).unwrap();
|
||||
h.app.run("inspect.section", json!({"plane":"XZ"})).unwrap();
|
||||
h.frames(3);
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert!(h.app.session.analysis.is_none());
|
||||
assert!(h.app.session.section.is_some(), "a later-issue effect with no presented item is ineligible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analysis_status_escape_yields_to_search_even_without_text_field_focus() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("inspect.draft", json!({})).unwrap();
|
||||
h.frames(3);
|
||||
let effect = h.app.session.status_items().remove(0);
|
||||
let item = crate::viewport_status::presentation(&h.app, &effect).unwrap();
|
||||
h.step(&json!({"click":{"handle":format!("viewport_status:{}:key",item.id)}})).unwrap();
|
||||
assert!(h.app.viewport_status.open_key.is_some());
|
||||
h.app.start("ui.palette");
|
||||
h.frames(3);
|
||||
h.ctx.memory_mut(|memory| {
|
||||
if let Some(id) = memory.focused() {
|
||||
memory.surrender_focus(id);
|
||||
}
|
||||
});
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert!(!h.app.ui.palette_open, "Search commands owns the first Escape");
|
||||
assert!(h.app.viewport_status.open_key.is_some());
|
||||
assert!(h.app.session.analysis.is_some());
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert!(h.app.viewport_status.open_key.is_none());
|
||||
assert!(h.app.session.analysis.is_some(), "the following Escape closes only the analysis key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analysis_status_ignores_invalid_layout_bounds_without_ending_the_effect() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("inspect.draft", json!({})).unwrap();
|
||||
let before = (h.app.session.status_items(), h.app.session.revision);
|
||||
for bounds in [Rect::NOTHING, Rect::from_min_max(pos2(165.0, 165.0), pos2(1.0, 1.0)), Rect::from_min_max(pos2(f32::NAN, 0.0), pos2(10.0, 10.0))] {
|
||||
crate::viewport_status::show(&mut h.app, &h.ctx, bounds);
|
||||
assert_eq!((h.app.session.status_items(), h.app.session.revision), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analysis_status_preferences_take_first_escape_and_release_the_next_frame() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("inspect.zebra", json!({})).unwrap();
|
||||
h.call("ui.prefs", json!({"open":"Interface"}));
|
||||
h.ctx.memory_mut(|memory| {
|
||||
if let Some(id) = memory.focused() {
|
||||
memory.surrender_focus(id);
|
||||
}
|
||||
});
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert!(!h.app.prefs_window.open);
|
||||
assert!(h.app.session.analysis.is_some(), "Preferences owns the first Escape even without text focus");
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert!(h.app.session.analysis.is_none(), "the next idle frame may end the effect");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analysis_status_escape_owner_survives_egui_layout_repasses() {
|
||||
for owner in ["preferences", "command", "tool", "card", "search"] {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
|
||||
h.app.run("inspect.draft", json!({})).unwrap();
|
||||
match owner {
|
||||
"preferences" => {
|
||||
h.call("ui.prefs", json!({"open":"Interface"}));
|
||||
}
|
||||
"command" => {
|
||||
h.app.start("solid.fillet");
|
||||
assert!(h.app.dialog.is_some());
|
||||
}
|
||||
"tool" => {
|
||||
h.app.start("inspect.continuity");
|
||||
assert!(h.app.tool.is_some());
|
||||
}
|
||||
"search" => {
|
||||
h.app.start("ui.palette");
|
||||
}
|
||||
_ => {
|
||||
h.frames(3);
|
||||
let effect = h.app.session.status_items().remove(0);
|
||||
let item = crate::viewport_status::presentation(&h.app, &effect).unwrap();
|
||||
h.step(&json!({"click":{"handle":format!("viewport_status:{}:key", item.id)}})).unwrap();
|
||||
assert!(h.app.viewport_status.open_key.is_some());
|
||||
}
|
||||
}
|
||||
h.frames(3);
|
||||
h.ctx.memory_mut(|memory| {
|
||||
if let Some(id) = memory.focused() {
|
||||
memory.surrender_focus(id);
|
||||
}
|
||||
});
|
||||
let before = h.app.session.status_items();
|
||||
let mut passes = 0;
|
||||
// One tap, like ui.key, rather than a held Escape that makes the next press a repeat.
|
||||
let mut escape = crate::test_support::key(egui::Key::Escape);
|
||||
escape.push(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: false, repeat: false, modifiers: Default::default() });
|
||||
let _output = h.ctx.run_ui(
|
||||
egui::RawInput {
|
||||
screen_rect: Some(h.ctx.content_rect()),
|
||||
time: Some(h.ctx.input(|input| input.time) + 1.0 / 60.0),
|
||||
focused: true,
|
||||
events: escape,
|
||||
..Default::default()
|
||||
},
|
||||
|ui| {
|
||||
passes += 1;
|
||||
h.app.logic(ui.ctx());
|
||||
h.app.ui(ui);
|
||||
if ui.ctx().current_pass_index() == 0 {
|
||||
ui.ctx().request_discard("check Escape ownership over layout passes");
|
||||
}
|
||||
},
|
||||
);
|
||||
assert!(passes >= 2, "the frame must actually repeat its layout");
|
||||
assert_eq!(h.app.session.status_items(), before, "{owner} owns one Escape over every pass");
|
||||
assert!(!h.app.prefs_window.open && h.app.dialog.is_none() && h.app.tool.is_none() && !h.app.ui.palette_open);
|
||||
assert!(h.app.viewport_status.open_key.is_none());
|
||||
h.frame();
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert!(h.app.session.status_items().is_empty(), "{owner} releases the following idle Escape");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analysis_status_row_and_key_stay_below_preferences_and_cannot_steal_its_click() {
|
||||
for window_first in [false, true] {
|
||||
let mut h = Harness::new();
|
||||
if window_first {
|
||||
h.call("ui.prefs", json!({"open":"Interface"}));
|
||||
}
|
||||
h.app.run("inspect.draft", json!({})).unwrap();
|
||||
h.frames(3);
|
||||
let effect = h.app.session.status_items().remove(0);
|
||||
let item = crate::viewport_status::presentation(&h.app, &effect).unwrap();
|
||||
if !window_first {
|
||||
h.step(&json!({"click":{"handle":format!("viewport_status:{}:key", item.id)}})).unwrap();
|
||||
h.call("ui.prefs", json!({"open":"Interface"}));
|
||||
}
|
||||
h.frames(3);
|
||||
let window = crate::scenario::rect_of("prefs:window").unwrap();
|
||||
let mut targets = vec![format!("viewport_status:{}:close", item.id)];
|
||||
if !window_first {
|
||||
targets.push("viewport_status:card".into());
|
||||
}
|
||||
for target in targets {
|
||||
let at = crate::scenario::rect_of(&target).unwrap().center();
|
||||
assert!(window.contains(at), "the real Preferences window must overlap {target}");
|
||||
let layer = h.ctx.layer_id_at(at).unwrap();
|
||||
assert_eq!(layer.order, egui::Order::Middle, "Preferences stays above {target}");
|
||||
assert_ne!(layer.id, egui::Id::new("viewport_status_row"));
|
||||
assert_ne!(layer.id, egui::Id::new("viewport_status_key"));
|
||||
}
|
||||
let at = crate::scenario::handle_at(&format!("viewport_status:{}:close", item.id)).unwrap();
|
||||
h.call("ui.click", json!({"x":at.x,"y":at.y}));
|
||||
assert!(h.app.prefs_window.open);
|
||||
assert!(h.app.session.analysis.is_some(), "an occluded HUD close never receives the window's click");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analysis_status_focused_dialog_and_real_text_popup_preserve_owner_priority_in_both_policies() {
|
||||
for enabled in [false, true] {
|
||||
for popup in [false, true] {
|
||||
let mut h = Harness::new();
|
||||
h.call("ui.prefs", json!({"viewport_analysis_status":enabled}));
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
|
||||
h.app.run("inspect.draft", json!({})).unwrap();
|
||||
h.app.start("solid.fillet");
|
||||
h.frames(3);
|
||||
let at = crate::scenario::handle_at("dialog:Radius").unwrap();
|
||||
h.call("ui.click", json!({"x":at.x,"y":at.y}));
|
||||
assert!(h.ctx.memory(|memory| memory.focused().is_some()), "the real radius editor has focus");
|
||||
if popup {
|
||||
h.call("ui.click", json!({"x":at.x,"y":at.y,"button":"right"}));
|
||||
assert!(egui::Popup::is_any_open(&h.ctx), "a real editor context menu is open");
|
||||
let mut reached_guard = false;
|
||||
let mut tap = crate::test_support::key(egui::Key::Escape);
|
||||
tap.push(egui::Event::Key {
|
||||
key: egui::Key::Escape,
|
||||
physical_key: None,
|
||||
pressed: false,
|
||||
repeat: false,
|
||||
modifiers: Default::default(),
|
||||
});
|
||||
let _output = h.ctx.run_ui(
|
||||
egui::RawInput {
|
||||
screen_rect: Some(h.ctx.content_rect()),
|
||||
time: Some(h.ctx.input(|input| input.time) + 1.0 / 60.0),
|
||||
focused: true,
|
||||
events: tap,
|
||||
..Default::default()
|
||||
},
|
||||
|ui| {
|
||||
let first = ui.ctx().current_pass_index() == 0;
|
||||
if first {
|
||||
assert!(egui::Popup::is_any_open(ui.ctx()), "the surviving popup predicate reaches the global guard");
|
||||
reached_guard = true;
|
||||
}
|
||||
h.app.logic(ui.ctx());
|
||||
if first {
|
||||
assert!(h.app.dialog.is_some());
|
||||
assert!(!h.app.esc_handled, "routing first refusal is not completed cancellation");
|
||||
assert!(ui.ctx().input(|input| input.key_pressed(egui::Key::Escape)), "the real popup still receives its Escape");
|
||||
}
|
||||
h.app.ui(ui);
|
||||
},
|
||||
);
|
||||
assert!(reached_guard);
|
||||
assert!(!egui::Popup::is_any_open(&h.ctx));
|
||||
assert!(h.app.dialog.is_some(), "the first Escape closes only the editor's menu");
|
||||
assert!(h.app.session.analysis.is_some());
|
||||
}
|
||||
// Plain TextEdit may blur at begin-pass: this asserts behavior, not guard capture.
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert!(h.app.dialog.is_none(), "the dialog's own Escape still cancels in policy {enabled}");
|
||||
assert!(h.app.session.analysis.is_some());
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert_eq!(h.app.session.analysis.is_none(), enabled, "the idle Escape follows the selected policy");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn analysis_escape_relayout_frame(h: &mut Harness, mut first_pass: impl FnMut(&crate::SolveApp)) {
|
||||
let ctx = h.ctx.clone();
|
||||
let mut tap = crate::test_support::key(egui::Key::Escape);
|
||||
tap.push(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: false, repeat: false, modifiers: Default::default() });
|
||||
let mut passes = 0;
|
||||
let _output = ctx.run_ui(
|
||||
egui::RawInput {
|
||||
screen_rect: Some(ctx.content_rect()),
|
||||
time: Some(ctx.input(|input| input.time) + 1.0 / 60.0),
|
||||
focused: true,
|
||||
events: tap,
|
||||
..Default::default()
|
||||
},
|
||||
|ui| {
|
||||
passes += 1;
|
||||
h.app.logic(ui.ctx());
|
||||
h.app.ui(ui);
|
||||
if ui.ctx().current_pass_index() == 0 {
|
||||
first_pass(&h.app);
|
||||
ui.ctx().request_discard("check restored policy during the same Escape frame");
|
||||
}
|
||||
},
|
||||
);
|
||||
assert!(passes >= 2, "the policy transition must actually cross a layout re-pass");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analysis_status_preferences_cancel_can_restore_either_policy_without_reusing_escape() {
|
||||
for saved in [false, true] {
|
||||
let mut h = Harness::new();
|
||||
h.call("ui.prefs", json!({"viewport_analysis_status":saved}));
|
||||
h.app.run("inspect.zebra", json!({})).unwrap();
|
||||
h.call("ui.prefs", json!({"open":"Interface"}));
|
||||
h.call("ui.prefs", json!({"viewport_analysis_status":!saved}));
|
||||
analysis_escape_relayout_frame(&mut h, |app| {
|
||||
assert!(!app.prefs_window.open);
|
||||
assert_eq!(app.preferences.viewport_analysis_status, saved, "Cancel restores policy in pass one");
|
||||
assert!(app.session.analysis.is_some());
|
||||
});
|
||||
assert_eq!(h.app.preferences.viewport_analysis_status, saved);
|
||||
assert!(h.app.session.analysis.is_some(), "Cancel owns this Escape through pass two while restoring policy {saved}");
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert_eq!(h.app.session.analysis.is_none(), saved);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analysis_status_preferences_restore_false_keeps_underlying_dialog_through_the_owned_frame() {
|
||||
let mut h = Harness::new();
|
||||
h.call("ui.prefs", json!({"viewport_analysis_status":false}));
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
|
||||
h.app.run("inspect.draft", json!({})).unwrap();
|
||||
h.app.start("solid.fillet");
|
||||
assert!(h.app.dialog.is_some());
|
||||
h.call("ui.prefs", json!({"open":"Interface"}));
|
||||
h.call("ui.prefs", json!({"viewport_analysis_status":true}));
|
||||
analysis_escape_relayout_frame(&mut h, |app| {
|
||||
assert!(!app.prefs_window.open);
|
||||
assert!(!app.preferences.viewport_analysis_status, "Cancel restores false before pass two");
|
||||
assert!(app.dialog.is_some(), "Preferences shields the existing command dialog in pass one");
|
||||
});
|
||||
assert!(h.app.dialog.is_some(), "the same Escape must not cancel the underlying dialog in pass two");
|
||||
assert!(h.app.session.analysis.is_some());
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert!(h.app.dialog.is_none(), "the next physical Escape belongs to the command dialog");
|
||||
assert!(h.app.session.analysis.is_some());
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert!(h.app.session.analysis.is_some(), "the restored legacy policy keeps shading on idle Escape");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
//! Thin presentation adapter: persistent effects and activation order belong to the session.
|
||||
use crate::{
|
||||
SolveApp,
|
||||
ui_kit::{self, StatusAction, StatusItem, StatusKind},
|
||||
};
|
||||
use solidcraft_engine::status_items::{StatusId, StatusItem as Effect, StatusSetting};
|
||||
|
||||
const PRESENTED: &[StatusId] = &[StatusId::SurfaceAnalysis, StatusId::Continuity];
|
||||
|
||||
fn identity(effect: &Effect) -> String {
|
||||
format!("{:?}:{}", effect.id, effect.command)
|
||||
}
|
||||
|
||||
fn direction(value: [f64; 3]) -> String {
|
||||
for (axis, vector) in [("X", [1.0, 0.0, 0.0]), ("Y", [0.0, 1.0, 0.0]), ("Z", [0.0, 0.0, 1.0])] {
|
||||
if value.iter().zip(vector).all(|(a, b)| (*a - b).abs() < 1e-8) {
|
||||
return axis.into();
|
||||
}
|
||||
if value.iter().zip(vector).all(|(a, b)| (*a + b).abs() < 1e-8) {
|
||||
return format!("−{axis}");
|
||||
}
|
||||
}
|
||||
let [x, y, z] = value;
|
||||
format!("({x:.2}, {y:.2}, {z:.2})")
|
||||
}
|
||||
|
||||
pub(crate) fn presentation(app: &SolveApp, effect: &Effect) -> Option<StatusItem> {
|
||||
if !PRESENTED.contains(&effect.id) {
|
||||
return None;
|
||||
}
|
||||
let command = solidcraft_engine::find_command(&effect.command)?;
|
||||
let setting = match effect.setting {
|
||||
StatusSetting::Zebra { stripes } => crate::i18n::format("ui.viewport_status_stripes", &[("count", &stripes.to_string())]),
|
||||
StatusSetting::Draft { pull, angle_deg } => format!("{} · {angle_deg}°", direction(pull)),
|
||||
StatusSetting::Environment => String::new(),
|
||||
StatusSetting::Accessibility { direction: from } => crate::i18n::format("ui.viewport_status_from", &[("direction", &direction(from))]),
|
||||
StatusSetting::Curvature { radius_mm } => format!("R {}", crate::prefs::show_mm(app, radius_mm, 1)),
|
||||
StatusSetting::Continuity { edge_count } => crate::i18n::format("ui.viewport_status_edges", &[("count", &edge_count.to_string())]),
|
||||
StatusSetting::Section { .. } => return None,
|
||||
};
|
||||
Some(StatusItem {
|
||||
id: identity(effect),
|
||||
kind: StatusKind::Mode,
|
||||
name: crate::toolbar::persistent_button_name(app, command),
|
||||
setting,
|
||||
has_key: effect.has_key,
|
||||
editable: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn clear(app: &mut SolveApp, effect: &Effect) {
|
||||
let _ = app.run(&effect.clear.command, serde_json::json!({"clear": effect.clear.params.clear}));
|
||||
}
|
||||
|
||||
pub(crate) fn has_effect(app: &SolveApp) -> bool {
|
||||
app.session.newest_status_item(PRESENTED).is_some()
|
||||
}
|
||||
|
||||
/// Called only after existing Escape owners have yielded. A repeat cannot cascade clears.
|
||||
pub(crate) fn escape(app: &mut SolveApp, repeat: bool) -> bool {
|
||||
if repeat {
|
||||
return app.viewport_status.open_key.is_some() || has_effect(app);
|
||||
}
|
||||
if let Some(open) = app.viewport_status.open_key.take()
|
||||
&& app.session.status_items().iter().any(|effect| effect.has_key && identity(effect) == open)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if let Some(effect) = app.session.newest_status_item(PRESENTED) {
|
||||
clear(app, &effect);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) fn show(app: &mut SolveApp, ctx: &egui::Context, viewport: egui::Rect) {
|
||||
if !app.preferences.viewport_analysis_status {
|
||||
app.viewport_status.open_key = None;
|
||||
return;
|
||||
}
|
||||
let effects = app.session.status_items();
|
||||
let items: Vec<_> = effects.iter().filter_map(|effect| presentation(app, effect)).collect();
|
||||
if items.is_empty() {
|
||||
app.viewport_status = Default::default();
|
||||
return;
|
||||
}
|
||||
let content = crate::shell::content_rect(app, viewport);
|
||||
if !content.is_finite() || !content.is_positive() {
|
||||
return;
|
||||
}
|
||||
let mut free = crate::shell::workspace_content(app, content);
|
||||
for card in &app.shell.cards {
|
||||
free.min.y = free.top().max(card.bottom()).min(free.bottom());
|
||||
}
|
||||
if let Some(layout) = &app.viewport.layout {
|
||||
free.max.x = free.right().min(layout.axis_view.left()).max(free.left());
|
||||
}
|
||||
if !free.is_finite() || !free.is_positive() {
|
||||
return;
|
||||
}
|
||||
let legend = crate::viewport::analysis_legend_data(app);
|
||||
let actions = ui_kit::viewport_status_row(ctx, free, &items, &mut app.viewport_status, |ui, _| {
|
||||
if let Some((title, along, rows)) = &legend {
|
||||
ui_kit::analysis_key(ui, title, along.as_deref(), rows);
|
||||
}
|
||||
});
|
||||
for action in actions {
|
||||
if let StatusAction::Clear(id) = action
|
||||
&& let Some(effect) = effects.iter().find(|effect| identity(effect) == id)
|
||||
{
|
||||
clear(app, effect);
|
||||
app.viewport_status.open_key = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user